@sideboard-ai/core 0.1.67 → 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 (44) hide show
  1. package/dist/{agents-OTBZVTZO.js → agents-GB7XL2LI.js} +8 -7
  2. package/dist/{agents-TS3EI2YI.js → agents-TCAK3MXN.js} +8 -7
  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-WAFIF22N.js → chunk-3CY6WJ7O.js} +2 -2
  8. package/dist/{chunk-7ZOQY4A7.js → chunk-57ROTAFD.js} +62 -3
  9. package/dist/{chunk-57WWNMFP.js → chunk-7NCIHRAU.js} +22 -4
  10. package/dist/{chunk-FGDJOJ2L.js → chunk-AE5VBOFE.js} +1 -1
  11. package/dist/{chunk-DB3I75FI.js → chunk-BTL7EMGT.js} +4 -4
  12. package/dist/{chunk-4UIYN56C.js → chunk-D4DPQ552.js} +1 -1
  13. package/dist/{chunk-JWZNK3CQ.js → chunk-DGPUAZA4.js} +62 -3
  14. package/dist/{chunk-ZRFAZVGL.js → chunk-H2IXNFQ2.js} +22 -4
  15. package/dist/chunk-JAWEDVVA.js +106 -0
  16. package/dist/chunk-JT7R45JB.js +20 -0
  17. package/dist/{chunk-4BUCGNZS.js → chunk-MF2YMEGD.js} +83 -29
  18. package/dist/{chunk-K7LM4CLD.js → chunk-NG7S3OPX.js} +12 -6
  19. package/dist/chunk-NSTQ6QKD.js +23 -0
  20. package/dist/{chunk-LW5CMQGZ.js → chunk-OE7YBB5X.js} +83 -29
  21. package/dist/{chunk-BPOTVI5J.js → chunk-PTJJIQK7.js} +472 -18
  22. package/dist/{chunk-B4PRRKMA.js → chunk-S25IHL5H.js} +12 -6
  23. package/dist/{chunk-5HTTCYWA.js → chunk-UH57WVFP.js} +2 -2
  24. package/dist/{chunk-M5V4QIWF.js → chunk-VROPG6QF.js} +1 -1
  25. package/dist/{chunk-MHR4OGZ4.js → chunk-W7YOTDVO.js} +493 -18
  26. package/dist/{connected-teams-RUNMOCG5.js → connected-teams-35EIRJCP.js} +2 -2
  27. package/dist/{connected-teams-OMDHTL6A.js → connected-teams-ZRS53OAT.js} +2 -2
  28. package/dist/{coordinator-prompt-6HMTOX4G.js → coordinator-prompt-2J4PIMUF.js} +7 -4
  29. package/dist/{coordinator-prompt-3BSMKZL4.js → coordinator-prompt-QYYG2IBB.js} +7 -4
  30. package/dist/{global-workspace-YZ2NJVLL.js → global-workspace-KHIFQUUM.js} +12 -5
  31. package/dist/{global-workspace-QS7A23CB.js → global-workspace-SVRYNJZA.js} +12 -5
  32. package/dist/index.cjs +4805 -1262
  33. package/dist/index.d.cts +570 -22
  34. package/dist/index.d.ts +570 -22
  35. package/dist/index.js +3084 -340
  36. package/dist/mcp/run-stdio.cjs +2339 -617
  37. package/dist/mcp/run-stdio.js +1289 -290
  38. package/dist/{run-Y2MI3EVV.js → run-A4RHY7HH.js} +1 -1
  39. package/dist/{run-JLD2Y52J.js → run-V2WZUMJA.js} +1 -1
  40. package/dist/{workspaces-EBFW7I7S.js → workspaces-AHFTHVBT.js} +7 -6
  41. package/dist/{workspaces-AIUYUXB7.js → workspaces-SVQIEVIZ.js} +7 -6
  42. package/dist/{worktree-WMDJW2BL.js → worktree-KWXHMKRN.js} +2 -2
  43. package/dist/{worktree-7NBPIVFZ.js → worktree-VOCH3WS3.js} +2 -2
  44. 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-WAFIF22N.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-B4PRRKMA.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-7ZOQY4A7.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,
@@ -94,7 +106,7 @@ import {
94
106
  loadBrightsyConfig,
95
107
  saveBrightsyConfig,
96
108
  switchBrightsyAccount
97
- } from "./chunk-DB3I75FI.js";
109
+ } from "./chunk-BTL7EMGT.js";
98
110
  import {
99
111
  LEGACY_PLAN_FILE_REL,
100
112
  PLAN_FILE_NAME,
@@ -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-ZRFAZVGL.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-4BUCGNZS.js";
262
+ } from "./chunk-MF2YMEGD.js";
236
263
  import {
237
264
  ATTACHMENTS_DIR,
238
265
  LEGACY_ATTACHMENTS_DIR,
@@ -295,7 +322,7 @@ import {
295
322
  resolveGhAuthToken,
296
323
  run,
297
324
  withExportedPath
298
- } from "./chunk-4UIYN56C.js";
325
+ } from "./chunk-D4DPQ552.js";
299
326
 
300
327
  // src/integrations/github.ts
301
328
  async function getGitHubStatus() {
@@ -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-QS7A23CB.js");
1001
+ const { isGlobalThread: isGlobalThread2 } = await import("./global-workspace-SVRYNJZA.js");
728
1002
  if (isGlobalThread2(thread)) {
729
- const { ensureGlobalCoordinatorCwd: ensureGlobalCoordinatorCwd2 } = await import("./coordinator-prompt-3BSMKZL4.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));
@@ -1512,24 +1786,25 @@ function capPatch(patch, maxHunk) {
1512
1786
 
1513
1787
  \u2026 truncated (${patch.length - maxHunk} more chars)`;
1514
1788
  }
1789
+ var mergeBaseCache = /* @__PURE__ */ new Map();
1790
+ var MERGE_BASE_TTL_MS = 45e3;
1515
1791
  async function resolveMergeBase(worktreePath, base, repoPath) {
1516
1792
  const baseRef = await resolveDiffBaseRef(worktreePath, base, repoPath);
1793
+ const cacheKey = `${worktreePath}\0${baseRef}`;
1794
+ const hit = mergeBaseCache.get(cacheKey);
1795
+ if (hit && Date.now() - hit.at < MERGE_BASE_TTL_MS) return hit.value;
1517
1796
  const { stdout } = await git(["merge-base", baseRef, "HEAD"], worktreePath, {
1518
1797
  reject: false
1519
1798
  });
1520
- const mb = stdout.trim();
1521
- return mb || baseRef;
1799
+ const mb = stdout.trim() || baseRef;
1800
+ mergeBaseCache.set(cacheKey, { at: Date.now(), value: mb });
1801
+ return mb;
1522
1802
  }
1523
1803
  async function countUnpushedCommits(worktreePath) {
1524
1804
  const head = await git(["rev-parse", "--abbrev-ref", "HEAD"], worktreePath, {
1525
1805
  reject: false
1526
1806
  });
1527
1807
  const branch = head.stdout.trim();
1528
- if (branch && branch !== "HEAD") {
1529
- await git(["fetch", "origin", branch, "--quiet"], worktreePath, {
1530
- reject: false
1531
- });
1532
- }
1533
1808
  const upstream = await git(
1534
1809
  ["rev-list", "--count", "@{upstream}..HEAD"],
1535
1810
  worktreePath,
@@ -1562,11 +1837,37 @@ function splitCombinedDiff(stdout) {
1562
1837
  }
1563
1838
  return map;
1564
1839
  }
1840
+ function syntheticAddPatch(path, content, maxHunk) {
1841
+ const lines = content.split("\n");
1842
+ const body = lines.map((l) => `+${l}`).join("\n");
1843
+ const header = `diff --git a/${path} b/${path}
1844
+ new file mode 100644
1845
+ --- /dev/null
1846
+ +++ b/${path}
1847
+ @@ -0,0 +1,${lines.length} @@
1848
+ `;
1849
+ return {
1850
+ path,
1851
+ status: "A",
1852
+ patch: capPatch(`${header}${body}`, maxHunk),
1853
+ additions: lines.length,
1854
+ deletions: 0
1855
+ };
1856
+ }
1565
1857
  async function untrackedPatch(worktreePath, path, maxHunk) {
1858
+ const abs = join4(worktreePath, path);
1859
+ try {
1860
+ const st = statSync2(abs);
1861
+ if (st.isFile() && st.size > maxHunk) {
1862
+ const buf = readFileSync4(abs).subarray(0, maxHunk);
1863
+ return syntheticAddPatch(path, buf.toString("utf8"), maxHunk);
1864
+ }
1865
+ } catch {
1866
+ }
1566
1867
  const { stdout: patch } = await git(
1567
- ["diff", "--no-index", "--", "/dev/null", path],
1868
+ ["diff", "--no-ext-diff", "--no-color", "--no-index", "--", "/dev/null", path],
1568
1869
  worktreePath,
1569
- { reject: false }
1870
+ { reject: false, timeoutMs: 4e3 }
1570
1871
  );
1571
1872
  const normalized = patch.replace(/^diff --git a\/.+? b\/.+?$/m, `diff --git a/${path} b/${path}`).replace(/^--- .*$/m, "--- /dev/null").replace(/^\+\+\+ .*$/m, `+++ b/${path}`);
1572
1873
  const addLines = normalized.split("\n").filter((l) => l.startsWith("+") && !l.startsWith("+++")).length;
@@ -1578,22 +1879,97 @@ async function untrackedPatch(worktreePath, path, maxHunk) {
1578
1879
  deletions: 0
1579
1880
  };
1580
1881
  }
1581
- async function listUntrackedPaths(worktreePath) {
1582
- const { stdout } = await git(
1583
- ["ls-files", "-z", "--others", "--exclude-standard"],
1584
- worktreePath,
1585
- { reject: false }
1586
- );
1882
+ async function listUntrackedPaths(worktreePath, path) {
1883
+ const args = ["ls-files", "-z", "--others", "--exclude-standard"];
1884
+ if (path) args.push("--", path);
1885
+ const { stdout } = await git(args, worktreePath, { reject: false });
1587
1886
  return stdout.split("\0").filter(Boolean).filter((p) => !isSideboardScratchPath(p));
1588
1887
  }
1589
- async function listUntrackedFiles(worktreePath, maxHunk) {
1590
- const paths = await listUntrackedPaths(worktreePath);
1591
- if (paths.length === 0) return [];
1592
- return Promise.all(paths.map((path) => untrackedPatch(worktreePath, path, maxHunk)));
1888
+ async function listUntrackedFiles(worktreePath, maxHunk, paths) {
1889
+ const list = paths ?? await listUntrackedPaths(worktreePath);
1890
+ if (list.length === 0) return [];
1891
+ return Promise.all(list.map((path) => untrackedPatch(worktreePath, path, maxHunk)));
1892
+ }
1893
+ function untrackedStubs(paths) {
1894
+ return paths.map((path) => ({
1895
+ path,
1896
+ status: "A",
1897
+ patch: "",
1898
+ additions: 0,
1899
+ deletions: 0
1900
+ }));
1901
+ }
1902
+ function pathspec(path) {
1903
+ return path ? ["--", path] : [];
1904
+ }
1905
+ function gitDiff(args, cwd, opts) {
1906
+ return git(["diff", "--no-ext-diff", "--no-color", ...args], cwd, {
1907
+ reject: false,
1908
+ timeoutMs: opts?.timeoutMs
1909
+ });
1910
+ }
1911
+ function fileFromPatch(path, patch, maxHunk) {
1912
+ let status = "M";
1913
+ if (/^new file mode /m.test(patch) || /^--- \/dev\/null/m.test(patch)) {
1914
+ status = "A";
1915
+ } else if (/^deleted file mode /m.test(patch) || /^\+\+\+ \/dev\/null/m.test(patch)) {
1916
+ status = "D";
1917
+ } else if (/^rename from /m.test(patch)) {
1918
+ status = "R";
1919
+ }
1920
+ const additions = patch.split("\n").filter((l) => l.startsWith("+") && !l.startsWith("+++")).length;
1921
+ const deletions = patch.split("\n").filter((l) => l.startsWith("-") && !l.startsWith("---")).length;
1922
+ return {
1923
+ path,
1924
+ status,
1925
+ patch: capPatch(patch, maxHunk),
1926
+ additions,
1927
+ deletions
1928
+ };
1929
+ }
1930
+ async function gitDiffParts(worktreePath, diffArgs, includePatches, path) {
1931
+ const extra = pathspec(path);
1932
+ if (includePatches) {
1933
+ const diff = await gitDiff([...diffArgs, ...extra], worktreePath, {
1934
+ timeoutMs: 4e3
1935
+ });
1936
+ return { nameStatus: "", numstat: "", combinedDiff: diff.stdout };
1937
+ }
1938
+ const [ns, num] = await Promise.all([
1939
+ gitDiff(["--name-status", ...diffArgs, ...extra], worktreePath),
1940
+ gitDiff(["--numstat", ...diffArgs, ...extra], worktreePath)
1941
+ ]);
1942
+ return {
1943
+ nameStatus: ns.stdout,
1944
+ numstat: num.stdout,
1945
+ combinedDiff: ""
1946
+ };
1947
+ }
1948
+ function formatStat(files) {
1949
+ if (files.length === 0) return "(no changes)";
1950
+ const additions = files.reduce((n2, f) => n2 + (f.additions ?? 0), 0);
1951
+ const deletions = files.reduce((n2, f) => n2 + (f.deletions ?? 0), 0);
1952
+ const n = files.length;
1953
+ return `${n} file${n === 1 ? "" : "s"} changed, ${additions} insertions(+), ${deletions} deletions(-)`;
1954
+ }
1955
+ function emptyScopeStats() {
1956
+ const z3 = emptyStat();
1957
+ return {
1958
+ commits: z3,
1959
+ uncommitted: z3,
1960
+ staged: z3,
1961
+ unstaged: z3,
1962
+ last_turn: z3
1963
+ };
1593
1964
  }
1594
1965
  function filesFromDiff(nameStatus, numstat, combinedDiff, maxHunk) {
1595
- const counts = parseNumstat(numstat);
1596
1966
  const patches = splitCombinedDiff(combinedDiff);
1967
+ if (!nameStatus.trim() && patches.size > 0) {
1968
+ return [...patches.entries()].map(
1969
+ ([path, patch]) => fileFromPatch(path, patch, maxHunk)
1970
+ );
1971
+ }
1972
+ const counts = parseNumstat(numstat);
1597
1973
  const files = [];
1598
1974
  for (const { status, path } of parseNameStatus(nameStatus)) {
1599
1975
  const n = counts.get(path);
@@ -1615,11 +1991,11 @@ async function computeScopeStats(worktreePath, mergeBase, lastTurnBase, untracke
1615
1991
  { stdout: unstagedNum },
1616
1992
  lastTurnNum
1617
1993
  ] = await Promise.all([
1618
- mergeBase ? git(["diff", "--numstat", mergeBase], worktreePath, { reject: false }) : Promise.resolve({ stdout: "" }),
1619
- git(["diff", "--numstat", "HEAD"], worktreePath, { reject: false }),
1620
- git(["diff", "--numstat", "--cached"], worktreePath, { reject: false }),
1621
- git(["diff", "--numstat"], worktreePath, { reject: false }),
1622
- lastTurnBase ? git(["diff", "--numstat", lastTurnBase], worktreePath, { reject: false }) : Promise.resolve({ stdout: "" })
1994
+ mergeBase ? gitDiff(["--numstat", mergeBase], worktreePath) : Promise.resolve({ stdout: "" }),
1995
+ gitDiff(["--numstat", "HEAD"], worktreePath),
1996
+ gitDiff(["--numstat", "--cached"], worktreePath),
1997
+ gitDiff(["--numstat"], worktreePath),
1998
+ lastTurnBase ? gitDiff(["--numstat", lastTurnBase], worktreePath) : Promise.resolve({ stdout: "" })
1623
1999
  ]);
1624
2000
  return {
1625
2001
  commits: mergeBase ? statFromNumstat(commitsNum, untrackedCount) : emptyStat(),
@@ -1638,7 +2014,7 @@ async function listBranchCommits(worktreePath, repoPath, opts) {
1638
2014
  if (head.exitCode !== 0) return [];
1639
2015
  let base = opts?.base;
1640
2016
  try {
1641
- base = base ?? await resolveDefaultBranch(repoPath);
2017
+ base = base ?? await resolveDefaultBranch(repoPath, { network: false });
1642
2018
  } catch {
1643
2019
  base = "HEAD";
1644
2020
  }
@@ -1668,7 +2044,121 @@ async function listBranchCommits(worktreePath, repoPath, opts) {
1668
2044
  }
1669
2045
  return commits;
1670
2046
  }
2047
+ var SHA_RE = /^[0-9a-f]{7,40}$/i;
2048
+ function emptyPathDiff(scope, base, hasLastTurnBase, commitSha = null) {
2049
+ return {
2050
+ scope,
2051
+ commitSha: scope === "commits" ? commitSha : null,
2052
+ base,
2053
+ files: [],
2054
+ stat: formatStat([]),
2055
+ dirty: false,
2056
+ unpushed: 0,
2057
+ hasLastTurnBase,
2058
+ commits: [],
2059
+ scopeStats: emptyScopeStats()
2060
+ };
2061
+ }
2062
+ async function getPathDiff(worktreePath, repoPath, opts) {
2063
+ const { path, scope, commitSha, lastTurnBase, hasLastTurnBase, includePatches, maxHunk } = opts;
2064
+ if (scope === "last_turn" && !lastTurnBase) {
2065
+ return emptyPathDiff(scope, "last turn", false);
2066
+ }
2067
+ let diffArgs;
2068
+ let labelBase;
2069
+ if (scope === "staged") {
2070
+ diffArgs = ["--cached"];
2071
+ labelBase = "staged";
2072
+ } else if (scope === "unstaged") {
2073
+ diffArgs = [];
2074
+ labelBase = "unstaged";
2075
+ } else if (scope === "uncommitted") {
2076
+ diffArgs = ["HEAD"];
2077
+ labelBase = "HEAD";
2078
+ } else if (scope === "last_turn") {
2079
+ diffArgs = [lastTurnBase];
2080
+ labelBase = "last turn";
2081
+ } else if (scope === "commits" && commitSha) {
2082
+ diffArgs = [`${commitSha}^!`];
2083
+ labelBase = commitSha.slice(0, 7);
2084
+ } else if (scope === "commits" && opts.base && SHA_RE.test(opts.base)) {
2085
+ diffArgs = [opts.base];
2086
+ labelBase = opts.base.slice(0, 7);
2087
+ } else if (scope === "commits") {
2088
+ let base = opts.base;
2089
+ try {
2090
+ base = base ?? await resolveDefaultBranch(repoPath, { network: false });
2091
+ } catch {
2092
+ base = "HEAD";
2093
+ }
2094
+ const mergeBase = await resolveMergeBase(worktreePath, base, repoPath);
2095
+ diffArgs = [mergeBase];
2096
+ labelBase = mergeBase;
2097
+ } else {
2098
+ diffArgs = ["HEAD"];
2099
+ labelBase = "HEAD";
2100
+ }
2101
+ const diff = await gitDiff(
2102
+ includePatches ? [...diffArgs, "--", path] : ["--name-status", ...diffArgs, "--", path],
2103
+ worktreePath,
2104
+ { timeoutMs: 4e3 }
2105
+ );
2106
+ const filesMap = /* @__PURE__ */ new Map();
2107
+ if (includePatches) {
2108
+ const parsed = filesFromDiff("", "", diff.stdout, maxHunk);
2109
+ for (const file of parsed) filesMap.set(file.path, file);
2110
+ if (diff.stdout.trim() && !filesMap.has(path)) {
2111
+ filesMap.set(path, fileFromPatch(path, diff.stdout, maxHunk));
2112
+ }
2113
+ } else {
2114
+ for (const file of filesFromDiff(diff.stdout, "", "", maxHunk)) {
2115
+ filesMap.set(file.path, file);
2116
+ }
2117
+ }
2118
+ if (filesMap.size === 0) {
2119
+ const maybe = await listUntrackedPaths(worktreePath, path);
2120
+ if (maybe.length > 0) {
2121
+ const extra = includePatches ? await listUntrackedFiles(worktreePath, maxHunk, maybe) : untrackedStubs(maybe);
2122
+ for (const file of extra) filesMap.set(file.path, file);
2123
+ }
2124
+ }
2125
+ const files = [...filesMap.values()].sort((a, b) => a.path.localeCompare(b.path));
2126
+ return {
2127
+ scope,
2128
+ commitSha: scope === "commits" ? commitSha : null,
2129
+ base: labelBase,
2130
+ files,
2131
+ stat: formatStat(files),
2132
+ dirty: files.length > 0,
2133
+ unpushed: 0,
2134
+ hasLastTurnBase,
2135
+ commits: [],
2136
+ scopeStats: emptyScopeStats(),
2137
+ mergeBase: SHA_RE.test(diffArgs[0] ?? "") ? diffArgs[0] : null
2138
+ };
2139
+ }
1671
2140
  async function getDiff(worktreePath, repoPath, opts) {
2141
+ const maxHunk = opts?.maxHunkChars ?? 8e3;
2142
+ const scope = opts?.scope ?? "commits";
2143
+ const lastTurnBase = opts?.lastTurnBase?.trim() || null;
2144
+ const hasLastTurnBase = Boolean(lastTurnBase);
2145
+ const commitSha = opts?.commitSha?.trim() || null;
2146
+ const includePatches = opts?.includePatches ?? true;
2147
+ const pathFilter = opts?.path?.trim() || null;
2148
+ const fileOnly = Boolean(pathFilter);
2149
+ const includeMeta = (opts?.includeMeta ?? true) && !fileOnly;
2150
+ if (fileOnly && pathFilter && !includeMeta) {
2151
+ return getPathDiff(worktreePath, repoPath, {
2152
+ path: pathFilter,
2153
+ scope,
2154
+ commitSha,
2155
+ lastTurnBase,
2156
+ hasLastTurnBase,
2157
+ base: opts?.base,
2158
+ includePatches,
2159
+ maxHunk
2160
+ });
2161
+ }
1672
2162
  const status = await inspectGitWorktree(worktreePath);
1673
2163
  if (status === "missing_worktree") {
1674
2164
  throw new Error("Worktree not found");
@@ -1676,18 +2166,19 @@ async function getDiff(worktreePath, repoPath, opts) {
1676
2166
  if (status === "not_git") {
1677
2167
  throw new Error("Not a Git repository");
1678
2168
  }
1679
- const maxHunk = opts?.maxHunkChars ?? 8e3;
1680
- const scope = opts?.scope ?? "commits";
1681
- const lastTurnBase = opts?.lastTurnBase?.trim() || null;
1682
- const hasLastTurnBase = Boolean(lastTurnBase);
1683
- const commitSha = opts?.commitSha?.trim() || null;
1684
- const head = await git(["rev-parse", "--verify", "HEAD"], worktreePath, {
2169
+ const wantUntracked = opts?.includeUntracked !== void 0 ? opts.includeUntracked : !fileOnly && (scope === "commits" && !commitSha || scope === "uncommitted" || scope === "unstaged" || scope === "last_turn");
2170
+ const needsMergeBase = scope === "commits" && !commitSha || includeMeta;
2171
+ const headP = git(["rev-parse", "--verify", "HEAD"], worktreePath, {
1685
2172
  reject: false
1686
2173
  });
2174
+ const untrackedP = wantUntracked ? listUntrackedPaths(worktreePath, pathFilter) : Promise.resolve([]);
2175
+ const head = await headP;
1687
2176
  const hasHead = head.exitCode === 0;
1688
- const untrackedPaths = await listUntrackedPaths(worktreePath);
1689
2177
  if (!hasHead) {
1690
- const files2 = scope === "staged" ? [] : (await listUntrackedFiles(worktreePath, maxHunk)).sort(
2178
+ const untrackedPaths2 = await untrackedP;
2179
+ const files2 = scope === "staged" ? [] : includePatches ? (await listUntrackedFiles(worktreePath, maxHunk, untrackedPaths2)).sort(
2180
+ (a, b) => a.path.localeCompare(b.path)
2181
+ ) : untrackedStubs(untrackedPaths2).sort(
1691
2182
  (a, b) => a.path.localeCompare(b.path)
1692
2183
  );
1693
2184
  const untrackedStat = {
@@ -1701,7 +2192,7 @@ async function getDiff(worktreePath, repoPath, opts) {
1701
2192
  commitSha: null,
1702
2193
  base: "(no commits)",
1703
2194
  files: files2,
1704
- stat: files2.length ? files2.map((f) => ` ${f.path} | untracked`).join("\n") : "(no changes)",
2195
+ stat: formatStat(files2),
1705
2196
  dirty: files2.length > 0,
1706
2197
  unpushed: 0,
1707
2198
  hasLastTurnBase,
@@ -1715,137 +2206,122 @@ async function getDiff(worktreePath, repoPath, opts) {
1715
2206
  }
1716
2207
  };
1717
2208
  }
1718
- let base = opts?.base;
1719
- try {
1720
- base = base ?? await resolveDefaultBranch(repoPath);
1721
- } catch {
1722
- base = "HEAD";
1723
- }
1724
- base = await resolveDiffBaseRef(worktreePath, base, repoPath);
1725
- if (base.startsWith("origin/")) {
1726
- const short = base.slice("origin/".length);
1727
- await git(["fetch", "origin", short, "--quiet"], worktreePath, {
1728
- reject: false
1729
- });
1730
- base = await resolveDiffBaseRef(worktreePath, short, repoPath);
2209
+ let mergeBase = "HEAD";
2210
+ let baseLabel = "HEAD";
2211
+ if (needsMergeBase) {
2212
+ let base = opts?.base;
2213
+ try {
2214
+ base = base ?? await resolveDefaultBranch(repoPath, { network: false });
2215
+ } catch {
2216
+ base = "HEAD";
2217
+ }
2218
+ mergeBase = await resolveMergeBase(worktreePath, base, repoPath);
2219
+ baseLabel = await resolveDiffBaseRef(worktreePath, base, repoPath);
2220
+ }
2221
+ const includeUntracked = wantUntracked;
2222
+ const metaPromise = includeMeta ? Promise.all([
2223
+ computeScopeStats(
2224
+ worktreePath,
2225
+ mergeBase,
2226
+ lastTurnBase,
2227
+ // Cheap: don't wait on the untracked walk just to pad the badge.
2228
+ 0
2229
+ ),
2230
+ listBranchCommits(worktreePath, repoPath, { base: baseLabel }),
2231
+ isDirty(worktreePath),
2232
+ countUnpushedCommits(worktreePath)
2233
+ ]).then(([scopeStats, commits, dirty, unpushed]) => ({
2234
+ scopeStats,
2235
+ commits,
2236
+ dirty,
2237
+ unpushed
2238
+ })) : Promise.resolve({
2239
+ scopeStats: emptyScopeStats(),
2240
+ commits: [],
2241
+ dirty: false,
2242
+ unpushed: 0
2243
+ });
2244
+ if (scope === "last_turn" && !lastTurnBase) {
2245
+ const meta2 = await metaPromise;
2246
+ return {
2247
+ scope,
2248
+ commitSha: null,
2249
+ base: "last turn",
2250
+ files: [],
2251
+ stat: "(no last agent turn)",
2252
+ dirty: meta2.dirty,
2253
+ unpushed: meta2.unpushed,
2254
+ hasLastTurnBase: false,
2255
+ commits: meta2.commits,
2256
+ scopeStats: meta2.scopeStats
2257
+ };
1731
2258
  }
1732
- const mergeBase = await resolveMergeBase(worktreePath, base, repoPath);
1733
- const scopeStats = await computeScopeStats(
1734
- worktreePath,
1735
- mergeBase,
1736
- lastTurnBase,
1737
- untrackedPaths.length
1738
- );
1739
- const includeUntracked = scope === "commits" && !commitSha || scope === "uncommitted" || scope === "unstaged" || scope === "last_turn";
1740
- let nameStatus = "";
1741
- let numstat = "";
1742
- let combinedDiff = "";
1743
- let labelBase = base;
2259
+ let diffArgs;
2260
+ let labelBase = baseLabel;
1744
2261
  if (scope === "staged") {
1745
- const [ns, num, diff] = await Promise.all([
1746
- git(["diff", "--name-status", "--cached"], worktreePath, { reject: false }),
1747
- git(["diff", "--numstat", "--cached"], worktreePath, { reject: false }),
1748
- git(["diff", "--cached"], worktreePath, { reject: false })
1749
- ]);
1750
- nameStatus = ns.stdout;
1751
- numstat = num.stdout;
1752
- combinedDiff = diff.stdout;
2262
+ diffArgs = ["--cached"];
1753
2263
  labelBase = "staged";
1754
2264
  } else if (scope === "unstaged") {
1755
- const [ns, num, diff] = await Promise.all([
1756
- git(["diff", "--name-status"], worktreePath, { reject: false }),
1757
- git(["diff", "--numstat"], worktreePath, { reject: false }),
1758
- git(["diff"], worktreePath, { reject: false })
1759
- ]);
1760
- nameStatus = ns.stdout;
1761
- numstat = num.stdout;
1762
- combinedDiff = diff.stdout;
2265
+ diffArgs = [];
1763
2266
  labelBase = "unstaged";
1764
2267
  } else if (scope === "uncommitted") {
1765
- const [ns, num, diff] = await Promise.all([
1766
- git(["diff", "--name-status", "HEAD"], worktreePath, { reject: false }),
1767
- git(["diff", "--numstat", "HEAD"], worktreePath, { reject: false }),
1768
- git(["diff", "HEAD"], worktreePath, { reject: false })
1769
- ]);
1770
- nameStatus = ns.stdout;
1771
- numstat = num.stdout;
1772
- combinedDiff = diff.stdout;
2268
+ diffArgs = ["HEAD"];
1773
2269
  labelBase = "HEAD";
1774
2270
  } else if (scope === "last_turn") {
1775
- if (!lastTurnBase) {
1776
- const commits2 = await listBranchCommits(worktreePath, repoPath, { base });
1777
- return {
1778
- scope,
1779
- commitSha: null,
1780
- base: "last turn",
1781
- files: [],
1782
- stat: "(no last agent turn)",
1783
- dirty: await isDirty(worktreePath),
1784
- unpushed: await countUnpushedCommits(worktreePath),
1785
- hasLastTurnBase: false,
1786
- commits: commits2,
1787
- scopeStats
1788
- };
1789
- }
1790
- const [ns, num, diff] = await Promise.all([
1791
- git(["diff", "--name-status", lastTurnBase], worktreePath, { reject: false }),
1792
- git(["diff", "--numstat", lastTurnBase], worktreePath, { reject: false }),
1793
- git(["diff", lastTurnBase], worktreePath, { reject: false })
1794
- ]);
1795
- nameStatus = ns.stdout;
1796
- numstat = num.stdout;
1797
- combinedDiff = diff.stdout;
2271
+ diffArgs = [lastTurnBase];
1798
2272
  labelBase = "last turn";
1799
2273
  } else if (scope === "commits" && commitSha) {
1800
- const range = `${commitSha}^!`;
1801
- const [ns, num, diff] = await Promise.all([
1802
- git(["diff", "--name-status", range], worktreePath, { reject: false }),
1803
- git(["diff", "--numstat", range], worktreePath, { reject: false }),
1804
- git(["diff", range], worktreePath, { reject: false })
1805
- ]);
1806
- nameStatus = ns.stdout;
1807
- numstat = num.stdout;
1808
- combinedDiff = diff.stdout;
2274
+ diffArgs = [`${commitSha}^!`];
1809
2275
  labelBase = commitSha.slice(0, 7);
1810
2276
  } else {
1811
- const [ns, num, diff] = await Promise.all([
1812
- git(["diff", "--name-status", mergeBase], worktreePath, { reject: false }),
1813
- git(["diff", "--numstat", mergeBase], worktreePath, { reject: false }),
1814
- git(["diff", mergeBase], worktreePath, { reject: false })
1815
- ]);
1816
- nameStatus = ns.stdout;
1817
- numstat = num.stdout;
1818
- combinedDiff = diff.stdout;
1819
- labelBase = base;
1820
- }
1821
- const commits = await listBranchCommits(worktreePath, repoPath, { base });
2277
+ diffArgs = [mergeBase];
2278
+ labelBase = baseLabel;
2279
+ }
2280
+ const partsPromise = gitDiffParts(
2281
+ worktreePath,
2282
+ diffArgs,
2283
+ includePatches,
2284
+ pathFilter
2285
+ );
2286
+ const [parts, meta, untrackedPaths] = await Promise.all([
2287
+ partsPromise,
2288
+ metaPromise,
2289
+ untrackedP
2290
+ ]);
1822
2291
  const filesMap = /* @__PURE__ */ new Map();
1823
- for (const file of filesFromDiff(nameStatus, numstat, combinedDiff, maxHunk)) {
2292
+ for (const file of filesFromDiff(
2293
+ parts.nameStatus,
2294
+ parts.numstat,
2295
+ parts.combinedDiff,
2296
+ maxHunk
2297
+ )) {
1824
2298
  filesMap.set(file.path, file);
1825
2299
  }
1826
2300
  if (includeUntracked) {
1827
- const untrackedFiles = await listUntrackedFiles(worktreePath, maxHunk);
1828
- for (const file of untrackedFiles) {
2301
+ const extra = includePatches ? await listUntrackedFiles(worktreePath, maxHunk, untrackedPaths) : untrackedStubs(untrackedPaths);
2302
+ for (const file of extra) {
1829
2303
  if (!filesMap.has(file.path)) filesMap.set(file.path, file);
1830
2304
  }
2305
+ } else if (fileOnly && pathFilter && !filesMap.has(pathFilter)) {
2306
+ const maybe = await listUntrackedPaths(worktreePath, pathFilter);
2307
+ if (maybe.length > 0) {
2308
+ const extra = includePatches ? await listUntrackedFiles(worktreePath, maxHunk, maybe) : untrackedStubs(maybe);
2309
+ for (const file of extra) filesMap.set(file.path, file);
2310
+ }
1831
2311
  }
1832
2312
  const files = [...filesMap.values()].sort((a, b) => a.path.localeCompare(b.path));
1833
- const { stdout: statOut } = await git(
1834
- scope === "staged" ? ["diff", "--stat", "--cached"] : scope === "unstaged" ? ["diff", "--stat"] : scope === "uncommitted" ? ["diff", "--stat", "HEAD"] : scope === "last_turn" && lastTurnBase ? ["diff", "--stat", lastTurnBase] : scope === "commits" && commitSha ? ["diff", "--stat", `${commitSha}^!`] : ["diff", "--stat", mergeBase],
1835
- worktreePath,
1836
- { reject: false }
1837
- );
1838
2313
  return {
1839
2314
  scope,
1840
2315
  commitSha: scope === "commits" ? commitSha : null,
1841
2316
  base: labelBase,
1842
2317
  files,
1843
- stat: statOut.trim() || (files.length ? `${files.length} file(s)` : "(no changes)"),
1844
- dirty: await isDirty(worktreePath),
1845
- unpushed: await countUnpushedCommits(worktreePath),
2318
+ stat: formatStat(files),
2319
+ dirty: fileOnly || !includeMeta ? files.length > 0 : meta.dirty,
2320
+ unpushed: meta.unpushed,
1846
2321
  hasLastTurnBase,
1847
- commits,
1848
- scopeStats
2322
+ commits: meta.commits,
2323
+ scopeStats: meta.scopeStats,
2324
+ mergeBase: needsMergeBase ? mergeBase : null
1849
2325
  };
1850
2326
  }
1851
2327
  async function listWorktreeFiles(worktreePath, opts) {
@@ -2472,16 +2948,16 @@ var PASTE_ATTACH_MIN_CHARS = 1200;
2472
2948
  var PASTE_ATTACH_MIN_LINES = 15;
2473
2949
  var PASTED_NAME_RE = /^Pasted text #(\d+)\.txt$/i;
2474
2950
  var PASTED_NAME_ALT_RE = /^pasted-(\d+)\.txt$/i;
2475
- function pastedTextStats(text) {
2476
- const chars = text.length;
2951
+ function pastedTextStats(text2) {
2952
+ const chars = text2.length;
2477
2953
  if (chars === 0) return { chars: 0, lines: 0 };
2478
- const lines = text.split(/\r\n|\r|\n/).length;
2954
+ const lines = text2.split(/\r\n|\r|\n/).length;
2479
2955
  return { chars, lines };
2480
2956
  }
2481
- function shouldAttachPastedText(text) {
2482
- const trimmed = text.trim();
2957
+ function shouldAttachPastedText(text2) {
2958
+ const trimmed = text2.trim();
2483
2959
  if (!trimmed) return false;
2484
- const { chars, lines } = pastedTextStats(text);
2960
+ const { chars, lines } = pastedTextStats(text2);
2485
2961
  return chars >= PASTE_ATTACH_MIN_CHARS || lines >= PASTE_ATTACH_MIN_LINES;
2486
2962
  }
2487
2963
  function nextPastedTextName(existing) {
@@ -2492,13 +2968,13 @@ function nextPastedTextName(existing) {
2492
2968
  }
2493
2969
  return `Pasted text #${max + 1}.txt`;
2494
2970
  }
2495
- function buildPastedTextAttachment(text, opts) {
2971
+ function buildPastedTextAttachment(text2, opts) {
2496
2972
  return {
2497
2973
  id: opts?.id ?? randomUUID2(),
2498
2974
  name: opts?.name ?? "Pasted text #1.txt",
2499
2975
  kind: "file",
2500
2976
  path: opts?.path,
2501
- content: text
2977
+ content: text2
2502
2978
  };
2503
2979
  }
2504
2980
 
@@ -2546,9 +3022,9 @@ async function tryClaudeSummary(transcript, opts) {
2546
3022
  { cwd: opts?.cwd, reject: false }
2547
3023
  );
2548
3024
  if (exitCode !== 0) return null;
2549
- const text = stdout.trim();
2550
- if (text.length < 40) return null;
2551
- return text;
3025
+ const text2 = stdout.trim();
3026
+ if (text2.length < 40) return null;
3027
+ return text2;
2552
3028
  } catch {
2553
3029
  return null;
2554
3030
  }
@@ -2597,9 +3073,9 @@ function extractiveSummary(transcript) {
2597
3073
  );
2598
3074
  return parts.join("\n");
2599
3075
  }
2600
- function clipSummary(text) {
2601
- if (text.length <= MAX_SUMMARY_CHARS) return text;
2602
- 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)}
2603
3079
 
2604
3080
  [\u2026summary truncated\u2026]`;
2605
3081
  }
@@ -2864,7 +3340,7 @@ async function previewLand(thread) {
2864
3340
  const target = await resolveDefaultBranch(thread.repoPath);
2865
3341
  if (thread.branchName === target || thread.branchName === "main" || thread.branchName === "master") {
2866
3342
  }
2867
- const { stdout: head } = await import("./run-JLD2Y52J.js").then(
3343
+ const { stdout: head } = await import("./run-V2WZUMJA.js").then(
2868
3344
  ({ git: git2 }) => git2(["rev-parse", "--abbrev-ref", "HEAD"], thread.worktreePath)
2869
3345
  );
2870
3346
  const current = head.trim();
@@ -2903,7 +3379,7 @@ async function confirmLand(thread, opts) {
2903
3379
  if (preview.dirty) {
2904
3380
  committed = await commitAll(thread.worktreePath, meta.commitMessage);
2905
3381
  }
2906
- const { git: git2 } = await import("./run-JLD2Y52J.js");
3382
+ const { git: git2 } = await import("./run-V2WZUMJA.js");
2907
3383
  const { stdout: headOut } = await git2(
2908
3384
  ["rev-parse", "--abbrev-ref", "HEAD"],
2909
3385
  thread.worktreePath,
@@ -3001,7 +3477,7 @@ async function createThread(input, _onSetupLine) {
3001
3477
  return readThread(thread.id) ?? thread;
3002
3478
  }
3003
3479
  async function listLinearIssues(agent, repoPath) {
3004
- const { getAdapter: getAdapter2 } = await import("./agents-TS3EI2YI.js");
3480
+ const { getAdapter: getAdapter2 } = await import("./agents-TCAK3MXN.js");
3005
3481
  await requireAgent(agent, { requireLinear: true });
3006
3482
  const adapter = getAdapter2(agent);
3007
3483
  if (!adapter.listLinearIssues) {
@@ -3499,13 +3975,13 @@ function resolveConductorCursorAgentId(workspacePath) {
3499
3975
  for (const hash of hashes) {
3500
3976
  const agentsFile = join7(CURSOR_SDK_STORE, hash, "agents.ndjson");
3501
3977
  if (!existsSync9(agentsFile)) continue;
3502
- let text;
3978
+ let text2;
3503
3979
  try {
3504
- text = readFileSync7(agentsFile, "utf8");
3980
+ text2 = readFileSync7(agentsFile, "utf8");
3505
3981
  } catch {
3506
3982
  continue;
3507
3983
  }
3508
- for (const line of text.split("\n")) {
3984
+ for (const line of text2.split("\n")) {
3509
3985
  const trimmed = line.trim();
3510
3986
  if (!trimmed) continue;
3511
3987
  try {
@@ -3529,7 +4005,7 @@ async function adoptThread(input) {
3529
4005
  throw new Error(`Worktree not found: ${input.worktreePath}`);
3530
4006
  }
3531
4007
  const repoPath = await resolveRepoRoot(input.worktreePath);
3532
- const { stdout: branch } = await import("./run-JLD2Y52J.js").then(
4008
+ const { stdout: branch } = await import("./run-V2WZUMJA.js").then(
3533
4009
  ({ git: git2 }) => git2(["rev-parse", "--abbrev-ref", "HEAD"], input.worktreePath)
3534
4010
  );
3535
4011
  const thread = createEmptyThread({
@@ -3544,7 +4020,7 @@ async function adoptThread(input) {
3544
4020
  messages: input.messages ?? []
3545
4021
  });
3546
4022
  writeThread(thread);
3547
- const { ensureWorkspace: ensureWorkspace2 } = await import("./workspaces-EBFW7I7S.js");
4023
+ const { ensureWorkspace: ensureWorkspace2 } = await import("./workspaces-AHFTHVBT.js");
3548
4024
  await ensureWorkspace2(repoPath);
3549
4025
  return thread;
3550
4026
  }
@@ -4233,7 +4709,7 @@ function buildReviewRequestAttachment(content, opts) {
4233
4709
  function readExistingReviewRequestFile(worktreePath) {
4234
4710
  return readTextIfPresent(join10(worktreePath, REPO_REVIEW_PATH)) ?? readTextIfPresent(join10(worktreePath, REVIEW_REQUEST_PATH)) ?? readTextIfPresent(join10(worktreePath, LEGACY_REVIEW_REQUEST_PATH));
4235
4711
  }
4236
- async function requestReview(threadRef, send) {
4712
+ async function requestReview(threadRef, send2) {
4237
4713
  const from = findThreadByRef(threadRef);
4238
4714
  if (!from) throw new Error(`Thread not found: ${threadRef}`);
4239
4715
  if (isOrchestratorThread(from)) {
@@ -4255,7 +4731,7 @@ async function requestReview(threadRef, send) {
4255
4731
  })
4256
4732
  ]
4257
4733
  });
4258
- const started = await send(tab.id, REVIEW_REQUEST_PREFILL);
4734
+ const started = await send2(tab.id, REVIEW_REQUEST_PREFILL);
4259
4735
  return { tab: started, from };
4260
4736
  }
4261
4737
 
@@ -4311,8 +4787,8 @@ function buildQuotaHandoffAttachment(from, limitText, fallbackAgent) {
4311
4787
  );
4312
4788
  const recent = from.messages.slice(-8).map((m) => {
4313
4789
  const role = m.role === "user" ? "User" : m.role === "agent" ? "Agent" : "Summary";
4314
- const text = m.text.trim().replace(/\s+/g, " ").slice(0, 280);
4315
- return text ? `- ${role}: ${text}` : null;
4790
+ const text2 = m.text.trim().replace(/\s+/g, " ").slice(0, 280);
4791
+ return text2 ? `- ${role}: ${text2}` : null;
4316
4792
  }).filter(Boolean);
4317
4793
  const body = [
4318
4794
  `# Orchestration handoff`,
@@ -4495,7 +4971,7 @@ var Orchestrator = class {
4495
4971
  orphans: orphans.map((o) => ({ path: o.path, repoPath: o.repoPath }))
4496
4972
  });
4497
4973
  }
4498
- const { autoCleanupOrphansEnabled: autoCleanupOrphansEnabled2 } = await import("./app-settings-ONKMFSEJ.js");
4974
+ const { autoCleanupOrphansEnabled: autoCleanupOrphansEnabled2 } = await import("./app-settings-3TLA2EJ7.js");
4499
4975
  if (autoCleanupOrphansEnabled2() && shouldRunWorktreeCleanup() && orphans.length > 0) {
4500
4976
  await cleanupOrphanWorktrees({ repoPaths });
4501
4977
  }
@@ -4652,7 +5128,7 @@ var Orchestrator = class {
4652
5128
  }
4653
5129
  async finishCreateThread(threadId, prompt) {
4654
5130
  await this.runSetupAfterCreate(threadId);
4655
- const { autoRunAfterSetupEnabled: autoRunAfterSetupEnabled2 } = await import("./app-settings-ONKMFSEJ.js");
5131
+ const { autoRunAfterSetupEnabled: autoRunAfterSetupEnabled2 } = await import("./app-settings-3TLA2EJ7.js");
4656
5132
  if (autoRunAfterSetupEnabled2()) {
4657
5133
  try {
4658
5134
  await this.startDev(threadId);
@@ -4732,11 +5208,11 @@ var Orchestrator = class {
4732
5208
  return results;
4733
5209
  }
4734
5210
  /** Edit the text of a not-yet-started queued message. */
4735
- async editQueuedMessage(threadRef, index, text) {
5211
+ async editQueuedMessage(threadRef, index, text2) {
4736
5212
  const thread = this.requireThread(threadRef);
4737
5213
  return withThreadLock(thread.id, async () => {
4738
5214
  const current = this.requireThread(thread.id);
4739
- const trimmed = text.trim();
5215
+ const trimmed = text2.trim();
4740
5216
  if (!trimmed || index < 0 || index >= current.queue.length) {
4741
5217
  return current;
4742
5218
  }
@@ -4881,10 +5357,19 @@ var Orchestrator = class {
4881
5357
  attachments: sentAttachments ?? thread.attachments
4882
5358
  }
4883
5359
  );
4884
- const orchestrationReminder = isOrchestratorThread(thread) ? coordinatorTurnReminder({
4885
- parentId: threadId,
4886
- goal: thread.sourceRef || thread.title
4887
- }) : 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;
4888
5373
  const artifactReminder = thread.agent !== "brightsy" ? [
4889
5374
  "Sideboard side column (important):",
4890
5375
  "There is no claude.ai Artifact tool here \u2014 that is normal.",
@@ -4924,7 +5409,7 @@ var Orchestrator = class {
4924
5409
  });
4925
5410
  const artifactDirective = isBrightsy ? null : formatArtifactDirective();
4926
5411
  const settings = loadWorkspaceSettings(fresh.worktreePath, fresh.repoPath);
4927
- const { autoRenameBranchEnabled: autoRenameBranchEnabled2 } = await import("./app-settings-ONKMFSEJ.js");
5412
+ const { autoRenameBranchEnabled: autoRenameBranchEnabled2 } = await import("./app-settings-3TLA2EJ7.js");
4928
5413
  const renameBranchDirective = !isBrightsy && !isOrchestration && autoRenameBranchEnabled2() ? formatRenameBranchDirective(fresh, {
4929
5414
  customPrompt: settings?.prompts?.renameBranch
4930
5415
  }) : null;
@@ -4944,7 +5429,9 @@ var Orchestrator = class {
4944
5429
  goal: fresh.sourceRef || fresh.title || "Orchestration",
4945
5430
  parentId: fresh.id,
4946
5431
  workspaces: inventory,
4947
- audience: "desktop"
5432
+ audience: /^(Slack DM|Slack @mention)(?:\n|$)/.test(
5433
+ expandedPrompt.trim()
5434
+ ) ? "slack" : "desktop"
4948
5435
  });
4949
5436
  }
4950
5437
  }
@@ -5487,6 +5974,11 @@ var Orchestrator = class {
5487
5974
  return getDiff(thread.worktreePath, thread.repoPath, {
5488
5975
  scope: opts?.scope,
5489
5976
  commitSha: opts?.commitSha,
5977
+ base: opts?.base,
5978
+ includePatches: opts?.includePatches,
5979
+ includeMeta: opts?.includeMeta,
5980
+ includeUntracked: opts?.includeUntracked,
5981
+ path: opts?.path,
5490
5982
  lastTurnBase: this.turnBaselines.get(thread.id) ?? null
5491
5983
  });
5492
5984
  }
@@ -5564,7 +6056,8 @@ var Orchestrator = class {
5564
6056
  isDraft: false,
5565
6057
  reviewDecision: null,
5566
6058
  baseRefName: "",
5567
- headRefName: ""
6059
+ headRefName: "",
6060
+ isInMergeQueue: false
5568
6061
  };
5569
6062
  await this.persistPrMetaAndMaybeArchive(thread, metaLike);
5570
6063
  return { url: metaLike.url, state };
@@ -5614,7 +6107,7 @@ var Orchestrator = class {
5614
6107
  updateThread(thread.id, { title: meta.title });
5615
6108
  }
5616
6109
  }
5617
- const { autoArchiveOnMergeEnabled: autoArchiveOnMergeEnabled2 } = await import("./app-settings-ONKMFSEJ.js");
6110
+ const { autoArchiveOnMergeEnabled: autoArchiveOnMergeEnabled2 } = await import("./app-settings-3TLA2EJ7.js");
5618
6111
  const latest = this.requireThread(thread.id);
5619
6112
  if (!shouldAutoArchiveOnPrMerge({
5620
6113
  previousPrState: prevState || null,
@@ -5838,7 +6331,7 @@ var Orchestrator = class {
5838
6331
  this.emit({ type: "status_changed", threadId: archived.id, status: "archived" });
5839
6332
  if (thread.repoPath && !isGlobalRepoPath(thread.repoPath)) {
5840
6333
  try {
5841
- const { ensureWorkspace: ensureWorkspace2 } = await import("./workspaces-EBFW7I7S.js");
6334
+ const { ensureWorkspace: ensureWorkspace2 } = await import("./workspaces-AHFTHVBT.js");
5842
6335
  await ensureWorkspace2(thread.repoPath);
5843
6336
  } catch {
5844
6337
  }
@@ -5859,7 +6352,7 @@ var Orchestrator = class {
5859
6352
  await runArchiveScript(thread.repoPath, thread.worktreePath);
5860
6353
  } catch {
5861
6354
  }
5862
- const { deleteBranchOnPurgeEnabled: deleteBranchOnPurgeEnabled2 } = await import("./app-settings-ONKMFSEJ.js");
6355
+ const { deleteBranchOnPurgeEnabled: deleteBranchOnPurgeEnabled2 } = await import("./app-settings-3TLA2EJ7.js");
5863
6356
  const deleteBranch = opts?.deleteBranch ?? deleteBranchOnPurgeEnabled2();
5864
6357
  await removeWorktree(thread.repoPath, thread.worktreePath, {
5865
6358
  deleteBranch: deleteBranch ? thread.branchName : void 0
@@ -5880,7 +6373,7 @@ var Orchestrator = class {
5880
6373
  return restored2;
5881
6374
  }
5882
6375
  if (!existsSync13(thread.worktreePath)) {
5883
- const { createThreadWorktree: createThreadWorktree2 } = await import("./worktree-7NBPIVFZ.js");
6376
+ const { createThreadWorktree: createThreadWorktree2 } = await import("./worktree-VOCH3WS3.js");
5884
6377
  const { execa: execa6 } = await import("execa");
5885
6378
  const slug = thread.worktreePath.split("/").pop();
5886
6379
  const dest = thread.worktreePath;
@@ -5989,7 +6482,7 @@ async function startOrchestration(opts) {
5989
6482
  sourceRef: "default",
5990
6483
  ...createOpts
5991
6484
  }).catch(async () => {
5992
- const { resolveDefaultBranch: resolveDefaultBranch2, resolveRepoRoot: resolveRepoRoot2 } = await import("./worktree-7NBPIVFZ.js");
6485
+ const { resolveDefaultBranch: resolveDefaultBranch2, resolveRepoRoot: resolveRepoRoot2 } = await import("./worktree-VOCH3WS3.js");
5993
6486
  const repo = await resolveRepoRoot2(repoPath);
5994
6487
  const def = await resolveDefaultBranch2(repo);
5995
6488
  return createThread({
@@ -6111,7 +6604,7 @@ function formatPlanQuestionsForChat(questions) {
6111
6604
  // src/mcp/server.ts
6112
6605
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6113
6606
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6114
- import { z } from "zod";
6607
+ import { z as z2 } from "zod";
6115
6608
  import { basename as basename4 } from "path";
6116
6609
 
6117
6610
  // src/mcp/archive-guard.ts
@@ -6122,35 +6615,907 @@ function mcpArchiveBlockedReason(thread) {
6122
6615
  return null;
6123
6616
  }
6124
6617
 
6125
- // src/mcp/server.ts
6126
- var MAX_ORCH_THREADS = 5;
6127
- var CREATE_THREAD_TIMEOUT_MS = 9e4;
6128
- function withTimeout(promise, ms, label) {
6129
- return new Promise((resolve, reject) => {
6130
- const timer = setTimeout(() => {
6131
- reject(new Error(`${label} timed out after ${Math.round(ms / 1e3)}s`));
6132
- }, ms);
6133
- promise.then(
6134
- (value) => {
6135
- clearTimeout(timer);
6136
- resolve(value);
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
6649
+ });
6650
+ const json = await res.json();
6651
+ if (!json.ok) {
6652
+ throw new SlackApiError(method, json.error || `HTTP ${res.status}`);
6653
+ }
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
6137
6691
  },
6138
- (err) => {
6139
- clearTimeout(timer);
6140
- reject(err);
6141
- }
6692
+ opts?.fetchImpl
6142
6693
  );
6143
- });
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);
6144
6712
  }
6145
- async function startMcpServer() {
6146
- const orch = getOrchestrator();
6147
- try {
6148
- const { maxConcurrentAgents: maxConcurrentAgents2 } = await import("./app-settings-ONKMFSEJ.js");
6149
- orch.setMaxConcurrent(maxConcurrentAgents2());
6150
- } catch {
6713
+ async function findUserId(token, handle, fetchImpl) {
6714
+ if (isUserId(handle)) {
6715
+ return { id: handle, name: handle };
6151
6716
  }
6152
- try {
6153
- await orch.reconcile(void 0, { reclaimStaleTurns: false, drainQueues: false });
6717
+ if (isEmail(handle)) {
6718
+ try {
6719
+ const data = await slackApi(
6720
+ token,
6721
+ "users.lookupByEmail",
6722
+ { email: handle },
6723
+ fetchImpl
6724
+ );
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 {
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()
6740
+ );
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.`
6748
+ );
6749
+ }
6750
+ async function openDmChannel(token, userId, fetchImpl) {
6751
+ const data = await slackApi(
6752
+ token,
6753
+ "conversations.open",
6754
+ { users: userId },
6755
+ fetchImpl
6756
+ );
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",
6765
+ {
6766
+ types: "public_channel,private_channel",
6767
+ exclude_archived: true,
6768
+ limit: 200
6769
+ },
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
6798
+ };
6799
+ }
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
6824
+ };
6825
+ } catch {
6826
+ throw channelErr;
6827
+ }
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
+ });
7290
+ }
7291
+ );
7292
+ server.tool(
7293
+ "slack_list_channels",
7294
+ "List channels in a connected Slack workspace. Pass team_id from list_teams.",
7295
+ {
7296
+ team_id: z.string(),
7297
+ limit: z.number().optional()
7298
+ },
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
+ }
7321
+ }
7322
+ );
7323
+ server.tool(
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.",
7326
+ {
7327
+ team_id: z.string(),
7328
+ query: z.string().optional(),
7329
+ limit: z.number().optional()
7330
+ },
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
+ }
7345
+ }
7346
+ );
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
+ });
7409
+ }
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)"));
7441
+ }
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 {
7472
+ }
7473
+ }
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
7482
+ });
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 });
6154
7519
  } catch (err) {
6155
7520
  console.error(
6156
7521
  "[sideboard-mcp] reconcile on boot failed (continuing):",
@@ -6198,7 +7563,7 @@ async function startMcpServer() {
6198
7563
  server.tool(
6199
7564
  "get_thread",
6200
7565
  "Get a compact thread summary by id/ref",
6201
- { ref: z.string() },
7566
+ { ref: z2.string() },
6202
7567
  async ({ ref }) => {
6203
7568
  const t = orch.getThread(ref);
6204
7569
  if (!t) {
@@ -6227,14 +7592,14 @@ async function startMcpServer() {
6227
7592
  "present_artifact",
6228
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.",
6229
7594
  {
6230
- title: z.string().describe("Short title shown in the artifact pane header"),
6231
- type: z.enum(["html", "svg", "markdown", "react"]).describe(
7595
+ title: z2.string().describe("Short title shown in the artifact pane header"),
7596
+ type: z2.enum(["html", "svg", "markdown", "react"]).describe(
6232
7597
  "Artifact kind \u2014 html opens an iframe preview; react transpiles and renders a default-exported component in a sandboxed iframe"
6233
7598
  ),
6234
- content: z.string().describe(
7599
+ content: z2.string().describe(
6235
7600
  "Full document body (complete HTML page, SVG markup, markdown, or a React component module with a default export)"
6236
7601
  ),
6237
- artifact_id: z.string().optional().describe("Stable id when updating the same artifact across turns")
7602
+ artifact_id: z2.string().optional().describe("Stable id when updating the same artifact across turns")
6238
7603
  },
6239
7604
  async ({ title, type, artifact_id }) => {
6240
7605
  const id = artifact_id?.trim() || `artifact_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
@@ -6252,15 +7617,15 @@ async function startMcpServer() {
6252
7617
  "ask_user",
6253
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.",
6254
7619
  {
6255
- questions: z.array(
6256
- z.object({
6257
- question: z.string().describe("Full question text ending with ?"),
6258
- header: z.string().max(24).optional().describe("Short label shown above the question"),
6259
- multiSelect: z.boolean().optional().describe("Allow selecting multiple options"),
6260
- options: z.array(
6261
- z.object({
6262
- label: z.string(),
6263
- description: z.string().optional().describe("What this option means / when to choose it (strongly preferred)")
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)")
6264
7629
  })
6265
7630
  ).min(2).max(6).describe("2\u20136 choices (Sideboard also offers Other)")
6266
7631
  })
@@ -6279,9 +7644,9 @@ async function startMcpServer() {
6279
7644
  "present_plan",
6280
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.",
6281
7646
  {
6282
- title: z.string().optional().describe("Short plan title (defaults to Plan)"),
6283
- content: z.string().min(1).describe("Full plan markdown (headings, steps, risks, open questions)"),
6284
- thread_id: z.string().optional().describe("Sideboard thread id when cwd is not the worktree")
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")
6285
7650
  },
6286
7651
  async ({ title, content, thread_id }) => {
6287
7652
  const { writePlanFile: writePlanFile2 } = await import("./plan-file-6O7G4VPQ.js");
@@ -6302,17 +7667,17 @@ async function startMcpServer() {
6302
7667
  );
6303
7668
  server.tool(
6304
7669
  "present_schema",
6305
- "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.",
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.",
6306
7671
  {
6307
- title: z.string().describe("Pane title"),
6308
- mode: z.enum(["table", "form"]).optional().describe("table = list/filter records; form = edit one record"),
6309
- datasource: z.enum(["brightsy", "inline"]).optional().describe("brightsy resolves via login; inline uses embedded resource/records"),
6310
- resource_id: z.string().optional().describe("Brightsy record type UUID (or generic resource id)"),
6311
- record_id: z.string().optional().describe("Record id when opening form mode"),
6312
- resource: z.record(z.unknown()).optional().describe("Inline { id, title, schema, schemaUi?, slug? }"),
6313
- record: z.record(z.unknown()).optional().describe("Inline record { id, data, published_at? }"),
6314
- records: z.array(z.record(z.unknown())).optional().describe("Inline records for table mode"),
6315
- pane_id: z.string().optional().describe("Stable pane id across updates")
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")
6316
7681
  },
6317
7682
  async (args) => {
6318
7683
  const id = args.pane_id?.trim() || `schema_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
@@ -6333,10 +7698,10 @@ async function startMcpServer() {
6333
7698
  "present_files",
6334
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.",
6335
7700
  {
6336
- title: z.string().optional().describe("Pane title (default: Files)"),
6337
- datasource: z.enum(["brightsy", "memory"]).optional().describe("brightsy = account storage via login; memory = session demo store"),
6338
- path: z.string().optional().describe("Initial folder path (e.g. public)"),
6339
- pane_id: z.string().optional().describe("Stable pane id across updates")
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")
6340
7705
  },
6341
7706
  async (args) => {
6342
7707
  const id = args.pane_id?.trim() || `files_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
@@ -6351,23 +7716,62 @@ async function startMcpServer() {
6351
7716
  return { content: [{ type: "text", text: JSON.stringify(payload) }] };
6352
7717
  }
6353
7718
  );
7719
+ registerSlackTools(server);
6354
7720
  if (sideboardMcpProfile() !== "worktree") {
6355
- const { resolveNewThreadOptions: resolveNewThreadOptions2, resolveThreadDefaults: resolveThreadDefaults2 } = await import("./app-settings-ONKMFSEJ.js");
7721
+ const { getCaffeinateHold: getCaffeinateHold2, setCaffeinateHold: setCaffeinateHold2 } = await import("./caffeinate-hold-KLC6SABD.js");
7722
+ const { resolveNewThreadOptions: resolveNewThreadOptions2, resolveThreadDefaults: resolveThreadDefaults2 } = await import("./app-settings-3TLA2EJ7.js");
6356
7723
  const accountDefaults = resolveThreadDefaults2();
6357
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) {
7734
+ return {
7735
+ content: [
7736
+ {
7737
+ type: "text",
7738
+ text: JSON.stringify({
7739
+ ...state,
7740
+ ok: false,
7741
+ message: state.platform === "darwin" ? "Could not start caffeinate." : "Caffeinate is macOS only."
7742
+ })
7743
+ }
7744
+ ],
7745
+ isError: true
7746
+ };
7747
+ }
7748
+ return {
7749
+ content: [
7750
+ {
7751
+ type: "text",
7752
+ text: JSON.stringify({
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)."
7756
+ })
7757
+ }
7758
+ ]
7759
+ };
7760
+ }
7761
+ );
6358
7762
  server.tool(
6359
7763
  "create_thread",
6360
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.`,
6361
7765
  {
6362
- sourceType: z.enum(["branch", "pr", "ticket"]),
6363
- sourceRef: z.string(),
6364
- agent: z.enum(["claude", "codex", "opencode", "brightsy", "cursor"]).optional().describe(`Omit to use Account default agent (${accountDefaults.agent})`),
6365
- model: z.string().nullable().optional().describe(
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(
6366
7770
  `Usually omit to use Account default model (${accountDefaults.model?.trim() || "Auto"}). Pass null only to force Auto / agent-default.`
6367
7771
  ),
6368
- repoPath: z.string(),
6369
- title: z.string().optional(),
6370
- parentThreadId: z.string().optional().describe(
7772
+ repoPath: z2.string(),
7773
+ title: z2.string().optional(),
7774
+ parentThreadId: z2.string().optional().describe(
6371
7775
  "Orchestration: omit (preferred) or pass YOUR chat id from the turn reminder / AGENTS.md. Do not invent uuids."
6372
7776
  )
6373
7777
  },
@@ -6474,9 +7878,9 @@ async function startMcpServer() {
6474
7878
  "send_to_thread",
6475
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.",
6476
7880
  {
6477
- ref: z.string(),
6478
- prompt: z.string(),
6479
- force_stop: z.boolean().optional()
7881
+ ref: z2.string(),
7882
+ prompt: z2.string(),
7883
+ force_stop: z2.boolean().optional()
6480
7884
  },
6481
7885
  async ({ ref, prompt, force_stop }) => {
6482
7886
  if (force_stop) {
@@ -6505,8 +7909,8 @@ async function startMcpServer() {
6505
7909
  "wait_for_turn",
6506
7910
  "Block until the thread finishes its current/queued turn (avoids polling). Use after send_to_thread to read the agent reply.",
6507
7911
  {
6508
- ref: z.string(),
6509
- timeoutMs: z.number().optional()
7912
+ ref: z2.string(),
7913
+ timeoutMs: z2.number().optional()
6510
7914
  },
6511
7915
  async ({ ref, timeoutMs }) => {
6512
7916
  const thread = await orch.waitForTurn(ref, timeoutMs ?? 6e5);
@@ -6528,7 +7932,7 @@ async function startMcpServer() {
6528
7932
  server.tool(
6529
7933
  "get_turn_result",
6530
7934
  "Final assistant message only (not full transcript)",
6531
- { ref: z.string() },
7935
+ { ref: z2.string() },
6532
7936
  async ({ ref }) => {
6533
7937
  const result = orch.getTurnResult(ref);
6534
7938
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
@@ -6538,8 +7942,8 @@ async function startMcpServer() {
6538
7942
  "stop_thread",
6539
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.",
6540
7944
  {
6541
- ref: z.string(),
6542
- force: z.boolean().optional()
7945
+ ref: z2.string(),
7946
+ force: z2.boolean().optional()
6543
7947
  },
6544
7948
  async ({ ref, force }) => {
6545
7949
  const t = orch.getThread(ref);
@@ -6568,8 +7972,8 @@ async function startMcpServer() {
6568
7972
  );
6569
7973
  server.tool(
6570
7974
  "archive_thread",
6571
- "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.",
6572
- { 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() },
6573
7977
  async ({ ref }) => {
6574
7978
  const t = orch.getThread(ref);
6575
7979
  if (!t) {
@@ -6602,7 +8006,7 @@ async function startMcpServer() {
6602
8006
  server.tool(
6603
8007
  "restore_thread",
6604
8008
  "Restore an archived thread (recreates worktree from branch when needed)",
6605
- { ref: z.string() },
8009
+ { ref: z2.string() },
6606
8010
  async ({ ref }) => {
6607
8011
  try {
6608
8012
  const restored = await orch.restore(ref);
@@ -6628,8 +8032,8 @@ async function startMcpServer() {
6628
8032
  "get_diff",
6629
8033
  "Compact diff summary (capped hunks, paginated)",
6630
8034
  {
6631
- ref: z.string(),
6632
- maxFiles: z.number().optional()
8035
+ ref: z2.string(),
8036
+ maxFiles: z2.number().optional()
6633
8037
  },
6634
8038
  async ({ ref, maxFiles }) => {
6635
8039
  const summary = await orch.diffSummary(ref);
@@ -6649,7 +8053,7 @@ async function startMcpServer() {
6649
8053
  server.tool(
6650
8054
  "request_review",
6651
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.',
6652
- { ref: z.string().describe("Worktree thread id/ref to review") },
8056
+ { ref: z2.string().describe("Worktree thread id/ref to review") },
6653
8057
  async ({ ref }) => {
6654
8058
  try {
6655
8059
  const tab = await orch.requestReview(ref);
@@ -6674,7 +8078,7 @@ async function startMcpServer() {
6674
8078
  }
6675
8079
  }
6676
8080
  );
6677
- const agentEnum = z.enum(["claude", "codex", "opencode", "brightsy", "cursor"]);
8081
+ const agentEnum = z2.enum(["claude", "codex", "opencode", "brightsy", "cursor"]);
6678
8082
  server.tool(
6679
8083
  "list_models",
6680
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.",
@@ -6697,11 +8101,11 @@ async function startMcpServer() {
6697
8101
  "fork_worktree",
6698
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.",
6699
8103
  {
6700
- ref: z.string().describe("Worktree thread id/ref to fork"),
6701
- 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)"),
6702
8106
  agent: agentEnum.optional().describe("Agent for the forked chat (default: same as source)"),
6703
- model: z.string().nullable().optional().describe("Usually omit (Auto). Only set from list_models when you need a specific model"),
6704
- 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()
6705
8109
  },
6706
8110
  async ({ ref, through_index, agent, model, title }) => {
6707
8111
  try {
@@ -6740,13 +8144,13 @@ async function startMcpServer() {
6740
8144
  );
6741
8145
  server.tool(
6742
8146
  "fork_chat",
6743
- "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.",
6744
8148
  {
6745
- ref: z.string().describe("Thread id/ref to fork (worktree agent or orchestration chat)"),
6746
- 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)"),
6747
8151
  agent: agentEnum.optional().describe("Agent for the forked chat (default: same as source)"),
6748
- model: z.string().nullable().optional().describe("Usually omit (Auto). Only set from list_models when you need a specific model"),
6749
- 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()
6750
8154
  },
6751
8155
  async ({ ref, through_index, agent, model, title }) => {
6752
8156
  try {
@@ -6792,8 +8196,8 @@ async function startMcpServer() {
6792
8196
  "run_dev_script",
6793
8197
  "Start a .sideboard/.conductor run script for a thread (default script if name omitted); returns port",
6794
8198
  {
6795
- ref: z.string(),
6796
- name: z.string().optional()
8199
+ ref: z2.string(),
8200
+ name: z2.string().optional()
6797
8201
  },
6798
8202
  async ({ ref, name }) => {
6799
8203
  const result = await orch.startDev(ref, name);
@@ -6815,7 +8219,7 @@ async function startMcpServer() {
6815
8219
  server.tool(
6816
8220
  "list_run_scripts",
6817
8221
  "List named run scripts available for a thread",
6818
- { ref: z.string() },
8222
+ { ref: z2.string() },
6819
8223
  async ({ ref }) => {
6820
8224
  const scripts = orch.listThreadRunScripts(ref);
6821
8225
  const active = orch.getActiveRuns(ref);
@@ -6833,8 +8237,8 @@ async function startMcpServer() {
6833
8237
  "stop_dev_script",
6834
8238
  "Stop a running script for a thread (all scripts if name omitted)",
6835
8239
  {
6836
- ref: z.string(),
6837
- name: z.string().optional()
8240
+ ref: z2.string(),
8241
+ name: z2.string().optional()
6838
8242
  },
6839
8243
  async ({ ref, name }) => {
6840
8244
  orch.stopDev(ref, name);
@@ -6844,7 +8248,7 @@ async function startMcpServer() {
6844
8248
  server.tool(
6845
8249
  "run_setup",
6846
8250
  "Re-run workspace setup (Sideboard/Conductor settings or .cursor/worktrees.json)",
6847
- { ref: z.string() },
8251
+ { ref: z2.string() },
6848
8252
  async ({ ref }) => {
6849
8253
  const result = await orch.runSetup(ref);
6850
8254
  return {
@@ -6855,7 +8259,7 @@ async function startMcpServer() {
6855
8259
  server.tool(
6856
8260
  "add_workspace",
6857
8261
  "Register a git repo as a Sideboard workspace",
6858
- { repoPath: z.string() },
8262
+ { repoPath: z2.string() },
6859
8263
  async ({ repoPath }) => {
6860
8264
  const ws = await orch.addWorkspace(repoPath);
6861
8265
  return { content: [{ type: "text", text: JSON.stringify(ws) }] };
@@ -6864,7 +8268,7 @@ async function startMcpServer() {
6864
8268
  server.tool(
6865
8269
  "remove_workspace",
6866
8270
  "Unregister a Sideboard workspace (does not archive threads)",
6867
- { repoPath: z.string() },
8271
+ { repoPath: z2.string() },
6868
8272
  async ({ repoPath }) => {
6869
8273
  orch.removeWorkspace(repoPath);
6870
8274
  return { content: [{ type: "text", text: "ok" }] };
@@ -6874,12 +8278,12 @@ async function startMcpServer() {
6874
8278
  "fanout",
6875
8279
  "Best-of-n: create one thread per agent with the same prompt (parallel attempts)",
6876
8280
  {
6877
- prompt: z.string(),
6878
- agents: z.array(z.enum(["claude", "codex", "opencode", "brightsy", "cursor"])),
6879
- repoPath: z.string(),
6880
- sourceType: z.enum(["branch", "pr", "ticket"]).optional(),
6881
- sourceRef: z.string().optional(),
6882
- 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()
6883
8287
  },
6884
8288
  async (args) => {
6885
8289
  const threads = await orch.bestOfN(args);
@@ -6906,8 +8310,8 @@ async function startMcpServer() {
6906
8310
  "list_branches",
6907
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).",
6908
8312
  {
6909
- repoPath: z.string(),
6910
- unmergedOnly: z.boolean().optional()
8313
+ repoPath: z2.string(),
8314
+ unmergedOnly: z2.boolean().optional()
6911
8315
  },
6912
8316
  async ({ repoPath, unmergedOnly }) => {
6913
8317
  const root = await resolveRepoRoot(repoPath);
@@ -6927,7 +8331,7 @@ async function startMcpServer() {
6927
8331
  server.tool(
6928
8332
  "list_prs",
6929
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.",
6930
- { repoPath: z.string() },
8334
+ { repoPath: z2.string() },
6931
8335
  async ({ repoPath }) => {
6932
8336
  const root = await resolveRepoRoot(repoPath);
6933
8337
  const prs = await listPrs(root);
@@ -6946,7 +8350,7 @@ async function startMcpServer() {
6946
8350
  server.tool(
6947
8351
  "get_pr_stack",
6948
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.",
6949
- { ref: z.string() },
8353
+ { ref: z2.string() },
6950
8354
  async ({ ref }) => {
6951
8355
  const stack = await orch.getPrStack(ref);
6952
8356
  return {
@@ -6958,8 +8362,8 @@ async function startMcpServer() {
6958
8362
  "open_pr_stack_layers",
6959
8363
  "Materialize one worktree+thread per stack layer (or a single 1-based layer). Pass a thread ref already on the stack.",
6960
8364
  {
6961
- ref: z.string(),
6962
- layer: z.number().int().positive().optional()
8365
+ ref: z2.string(),
8366
+ layer: z2.number().int().positive().optional()
6963
8367
  },
6964
8368
  async ({ ref, layer }) => {
6965
8369
  const result = await orch.openPrStackLayers(ref, { layer });
@@ -6993,9 +8397,9 @@ async function startMcpServer() {
6993
8397
  "add_stack_layer",
6994
8398
  "Add a branch on top of the current stack (`gh stack add`) and open a worktree+thread for it.",
6995
8399
  {
6996
- ref: z.string(),
6997
- branchName: z.string(),
6998
- title: z.string().optional()
8400
+ ref: z2.string(),
8401
+ branchName: z2.string(),
8402
+ title: z2.string().optional()
6999
8403
  },
7000
8404
  async ({ ref, branchName, title }) => {
7001
8405
  const result = await orch.addStackLayer(ref, branchName, { title });
@@ -7024,11 +8428,11 @@ async function startMcpServer() {
7024
8428
  "create_pr_stack",
7025
8429
  "Create a new GitHub PR stack with one Sideboard worktree per layer (bottom\u2192top branch names). Requires `gh extension install github/gh-stack`.",
7026
8430
  {
7027
- repoPath: z.string(),
7028
- branches: z.array(z.string()).min(1),
7029
- agent: z.enum(["claude", "codex", "opencode", "brightsy", "cursor"]),
7030
- base: z.string().optional(),
7031
- 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()
7032
8436
  },
7033
8437
  async (args) => {
7034
8438
  const result = await orch.createPrStack({
@@ -7066,7 +8470,7 @@ async function startMcpServer() {
7066
8470
  server.tool(
7067
8471
  "list_issues",
7068
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.",
7069
- { repoPath: z.string() },
8473
+ { repoPath: z2.string() },
7070
8474
  async ({ repoPath }) => {
7071
8475
  const root = await resolveRepoRoot(repoPath);
7072
8476
  const result = await listIssues(root);
@@ -7077,8 +8481,8 @@ async function startMcpServer() {
7077
8481
  "list_linear_issues",
7078
8482
  "Deprecated: prefer list_issues. Lists assigned Linear issues via the chosen agent MCP connector",
7079
8483
  {
7080
- agent: z.enum(["claude", "codex", "opencode"]),
7081
- repoPath: z.string()
8484
+ agent: z2.enum(["claude", "codex", "opencode"]),
8485
+ repoPath: z2.string()
7082
8486
  },
7083
8487
  async ({ agent, repoPath }) => {
7084
8488
  const issues = await listLinearIssues(agent, repoPath);
@@ -7222,8 +8626,8 @@ var BrightsySideboardApi = class {
7222
8626
  };
7223
8627
  function taskMessageText(task) {
7224
8628
  const parts = task.message?.parts ?? [];
7225
- const text = parts.find((p) => p.kind === "text")?.text ?? "";
7226
- return text.trim();
8629
+ const text2 = parts.find((p) => p.kind === "text")?.text ?? "";
8630
+ return text2.trim();
7227
8631
  }
7228
8632
 
7229
8633
  // src/brightsy/cloud-connect.ts
@@ -7416,8 +8820,1286 @@ async function runCloudConnect(opts) {
7416
8820
  });
7417
8821
  }
7418
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;
9187
+ }
9188
+ if (frame.type === "disconnect") {
9189
+ log(`socket disconnect: ${frame.reason || "refresh"}`);
9190
+ try {
9191
+ ws.close();
9192
+ } catch {
9193
+ }
9194
+ return;
9195
+ }
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
+ });
9204
+ }
9205
+
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 {};
9220
+ }
9221
+ }
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
+ };
9270
+ }
9271
+ return null;
9272
+ } catch {
9273
+ return null;
9274
+ }
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 };
9305
+ }
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;
9320
+ }
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
+ );
9346
+ }
9347
+ let backoff = BACKOFF_START_MS2;
9348
+ while (!opts.signal?.aborted) {
9349
+ let ws = null;
9350
+ try {
9351
+ ws = new Ws(url);
9352
+ await connectSession2(ws, opts, log);
9353
+ backoff = BACKOFF_START_MS2;
9354
+ } catch (err) {
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
+ }
9364
+ }
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;
9371
+ }
9372
+ backoff = Math.min(backoff * 2, BACKOFF_MAX_MS2);
9373
+ }
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()}`)
9381
+ );
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
+ });
9411
+ }
9412
+ });
9413
+ ws.addEventListener("error", () => {
9414
+ finish(new Error("WebSocket error"));
9415
+ });
9416
+ ws.addEventListener("close", () => {
9417
+ finish();
9418
+ });
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
+ }
9467
+ });
9468
+ });
9469
+ }
9470
+
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(
9486
+ () => void 0,
9487
+ () => void 0
9488
+ );
9489
+ return run2;
9490
+ }
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}`);
9597
+ }
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);
9612
+ const log = opts.onLog ?? (() => void 0);
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}`);
9620
+ }
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();
9627
+ for (const ws of workspaces) {
9628
+ await getOrchestrator().reconcile(ws.path).catch(() => void 0);
9629
+ }
9630
+ const inventory = await enrichWorkspacesWithGithub(workspaces);
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)) {
9639
+ try {
9640
+ getOrchestrator().stop(fresh.id);
9641
+ log(`stop ${msg.kind} ${msg.ts} \u2192 coordinator ${fresh.id.slice(0, 8)}`);
9642
+ } catch (err) {
9643
+ const errMsg = err instanceof Error ? err.message : String(err);
9644
+ log(`stop ${msg.ts}: ${errMsg}`);
9645
+ }
9646
+ await postSlackReply(msg, SLACK_LISTEN_STOPPED_REPLY, opts);
9647
+ log(`replied stopped ${msg.ts}`);
9648
+ return;
9649
+ }
9650
+ if (fresh.status === "running" || fresh.status === "queued") {
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}`);
9654
+ return;
9655
+ }
9656
+ log(
9657
+ `run ${msg.kind} ${msg.ts} user ${userId} \u2192 coordinator ${fresh.id.slice(0, 8)} (${inventory.length} workspace${inventory.length === 1 ? "" : "s"})`
9658
+ );
9659
+ const prompt = formatSlackInboundPrompt(msg);
9660
+ setSlackReplyTarget({
9661
+ threadId: fresh.id,
9662
+ teamId: msg.teamId,
9663
+ channelId: msg.channelId,
9664
+ threadTs: slackReplyThreadTs(msg)
9665
+ });
9666
+ const orch = getOrchestrator();
9667
+ let reply;
9668
+ try {
9669
+ await orch.send(fresh.id, prompt);
9670
+ await orch.waitForTurn(fresh.id, 14 * 60 * 1e3);
9671
+ reply = orch.getTurnResult(fresh.id).text.trim();
9672
+ } catch (err) {
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}`);
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}`);
9699
+ }
9700
+ }
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 = {}) {
9717
+ const log = opts.onLog ?? console.log;
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) {
9742
+ throw new Error(
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."
9744
+ );
9745
+ }
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();
9767
+ }
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
+ }
9838
+ }
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
+ }
9853
+ try {
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;
9890
+ }
9891
+ } catch (err) {
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;
9951
+ }
9952
+ outbound = { ...message, text: rest };
9953
+ }
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
+ )
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");
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
+ };
10099
+ }
7419
10100
  export {
7420
10101
  ATTACHMENTS_DIR,
10102
+ BAKED_SLACK_RELAY_URL,
7421
10103
  BRIGHTSY_MCP_ALLOWED_TOOLS,
7422
10104
  BrightsySideboardApi,
7423
10105
  CLAUDE_MODEL_CATALOG,
@@ -7436,6 +10118,9 @@ export {
7436
10118
  LEGACY_ATTACHMENTS_DIR,
7437
10119
  LEGACY_PLAN_FILE_REL,
7438
10120
  LEGACY_REVIEW_REQUEST_PATH,
10121
+ LINEAR_OAUTH_PORT,
10122
+ LINEAR_OAUTH_REDIRECT,
10123
+ LINEAR_OAUTH_SCOPES,
7439
10124
  MAX_ANTHROPIC_CACHE_CONTROL_BLOCKS,
7440
10125
  ORCHESTRATOR_AGENT_KINDS,
7441
10126
  Orchestrator,
@@ -7452,7 +10137,16 @@ export {
7452
10137
  SIDEBOARD_FORCE_STOP,
7453
10138
  SIDEBOARD_MCP_ALLOWED_TOOLS,
7454
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,
7455
10148
  THINKING_EFFORTS,
10149
+ ackSlackInboundSeen,
7456
10150
  addPrStackLayer,
7457
10151
  addStackLayerFromThread,
7458
10152
  addWorkspace,
@@ -7494,8 +10188,10 @@ export {
7494
10188
  buildReviewRequestAttachment,
7495
10189
  buildSessionSeed,
7496
10190
  buildWorkspaceScriptEnv,
10191
+ caffeinateHoldPath,
7497
10192
  caffeinateWhileCloudConnectEnabled,
7498
10193
  caffeinateWhileRunningEnabled,
10194
+ caffeinateWhileSlackListenEnabled,
7499
10195
  captureLoginEnv,
7500
10196
  captureTurnBaseline,
7501
10197
  checkoutPrStackLayer,
@@ -7513,6 +10209,7 @@ export {
7513
10209
  conductorDbPath,
7514
10210
  confirmLand,
7515
10211
  connectBrightsyTeam,
10212
+ connectSlackToken,
7516
10213
  coordinatorSystemPrompt,
7517
10214
  coordinatorTurnReminder,
7518
10215
  copyConfiguredFiles,
@@ -7521,6 +10218,7 @@ export {
7521
10218
  createEmptyThread,
7522
10219
  createExistingBranchWorktree,
7523
10220
  createGlobalChat,
10221
+ createLinearPkce,
7524
10222
  createOrUpdatePr,
7525
10223
  createPrStack,
7526
10224
  createThread,
@@ -7535,7 +10233,11 @@ export {
7535
10233
  detectGhStack,
7536
10234
  detectLocalMergeConflicts,
7537
10235
  disconnectBrightsyTeam,
10236
+ disconnectLinear,
10237
+ disconnectLinearConnection,
10238
+ disconnectSlackWorkspace,
7538
10239
  discoverSkills,
10240
+ dismissSlackReplyBadge,
7539
10241
  dropCachedPrefixOnResume,
7540
10242
  encodeBrightsyTarget,
7541
10243
  enrichPathWithNpmGlobalBin,
@@ -7545,6 +10247,8 @@ export {
7545
10247
  ensureGhPreferOrigin,
7546
10248
  ensureGlobalCoordinatorCwd,
7547
10249
  ensureReviewRequestFile,
10250
+ ensureSlackCoordinator,
10251
+ ensureSlackDeviceIdentity,
7548
10252
  ensureWorkspace,
7549
10253
  estimateMessageChars,
7550
10254
  estimateThreadChars,
@@ -7573,6 +10277,8 @@ export {
7573
10277
  formatPlanQuestionsForChat,
7574
10278
  formatRateLimitResetHint,
7575
10279
  formatRenameBranchDirective,
10280
+ formatSlackInboundPrompt,
10281
+ formatSlackSignedReply,
7576
10282
  formatTranscriptMarkdown,
7577
10283
  formatWorkspaceInventory,
7578
10284
  formatWorktreeDirective,
@@ -7580,6 +10286,7 @@ export {
7580
10286
  getAdapter,
7581
10287
  getAgentSetupInfo,
7582
10288
  getBrightsySession,
10289
+ getCaffeinateHold,
7583
10290
  getDefaultAgent,
7584
10291
  getDefaultEffort,
7585
10292
  getDefaultFast,
@@ -7590,6 +10297,7 @@ export {
7590
10297
  getGitHubStatus,
7591
10298
  getIssueSource,
7592
10299
  getLinearApiKey,
10300
+ getLinearAuthToken,
7593
10301
  getOrchestrator,
7594
10302
  getPr,
7595
10303
  getPrChecks,
@@ -7599,12 +10307,16 @@ export {
7599
10307
  getRepoSetupInfo,
7600
10308
  getRunMode,
7601
10309
  getRunScript,
10310
+ getSlackWorkspace,
7602
10311
  gh,
7603
10312
  ghHeadRef,
7604
10313
  ghRepoSelectArgs,
7605
10314
  git,
7606
10315
  globalAgentCwd,
10316
+ handleSlackInbound,
7607
10317
  harnessEnvKey,
10318
+ hasBakedLinearOAuth,
10319
+ hasBakedSlackOAuth,
7608
10320
  hasConductorHook,
7609
10321
  hasCursorWorktreeSetup,
7610
10322
  hasRepoHook,
@@ -7629,6 +10341,7 @@ export {
7629
10341
  isGlobalThread,
7630
10342
  isImageFilePath,
7631
10343
  isInPrStack,
10344
+ isInboundForThisDesktop,
7632
10345
  isLinearConnected,
7633
10346
  isOrchestratorCapableAgent,
7634
10347
  isOrchestratorThread,
@@ -7637,8 +10350,12 @@ export {
7637
10350
  isPresentPlanToolName,
7638
10351
  isSessionQuotaLimit,
7639
10352
  isSideboardScratchPath,
10353
+ isSlackCoordinatorThread,
7640
10354
  isThinkingEffort,
7641
10355
  isWorkspaceScratchPath,
10356
+ linearAuthorizationHeader,
10357
+ linearOAuthAuthorizeUrl,
10358
+ linearOAuthCredentials,
7642
10359
  listAgentSetupInfo,
7643
10360
  listBranchCommits,
7644
10361
  listBranches,
@@ -7657,6 +10374,8 @@ export {
7657
10374
  listOpencodeModels,
7658
10375
  listPrs,
7659
10376
  listRunScripts,
10377
+ listSlackReplyBadges,
10378
+ listSlackWorkspaces,
7660
10379
  listThreads,
7661
10380
  listWorkspaces,
7662
10381
  listWorktreeFiles,
@@ -7700,9 +10419,13 @@ export {
7700
10419
  parseMcpList,
7701
10420
  parsePlanQuestionsInput,
7702
10421
  parseSessionQuotaResetAt,
10422
+ parseSlackRelayClientMessage,
10423
+ parseSlackRelayServerMessage,
7703
10424
  partsToAssistantText,
7704
10425
  pastedTextStats,
10426
+ permalinkForSlackReplyBadge,
7705
10427
  permissionMode,
10428
+ persistVaultKeyInKeychain,
7706
10429
  planFileAbs,
7707
10430
  posixShellSingleQuote,
7708
10431
  prepareTerminalCommand,
@@ -7710,13 +10433,16 @@ export {
7710
10433
  promptMentionsBrightsy,
7711
10434
  pushBranch,
7712
10435
  readExistingReviewRequestFile,
10436
+ readKeychainVaultKey,
7713
10437
  readPlanFile,
7714
10438
  readSkillBody,
7715
10439
  readThread,
7716
10440
  readWorktreeFile,
7717
10441
  readWorktreeFileForUpload,
7718
10442
  readWorktreeInclude,
10443
+ recordSlackOutboundWatch,
7719
10444
  refreshGitHubAuth,
10445
+ refreshSlackReplyBadges,
7720
10446
  removeWorkspace,
7721
10447
  removeWorktree,
7722
10448
  repoSlug,
@@ -7741,18 +10467,26 @@ export {
7741
10467
  resolveQuotaFallbackAgent,
7742
10468
  resolveRepoRoot,
7743
10469
  resolveReviewGuidelines,
10470
+ resolveSlackListenMode,
7744
10471
  resolveThreadDefaults,
7745
10472
  resolveThreadEffort,
10473
+ resolveVaultKey,
7746
10474
  resolveWorktreeStartPoint,
7747
10475
  run,
7748
10476
  runArchiveScript,
7749
10477
  runCloudConnect,
7750
10478
  runCursorWorktreeSetup,
7751
10479
  runSetupScript,
10480
+ runSlackListen,
10481
+ runSlackRelayClient,
7752
10482
  sameWorktreePath,
7753
10483
  sanitizeMcpServerName,
7754
10484
  saveAppSettings,
10485
+ saveLinearOAuth,
10486
+ secureFileUnlocksWith,
10487
+ setCaffeinateHold,
7755
10488
  setStatus,
10489
+ setVaultMasterKey,
7756
10490
  settingsSourceLabel,
7757
10491
  shouldAttachPastedText,
7758
10492
  shouldCompactContext,
@@ -7763,6 +10497,12 @@ export {
7763
10497
  sideboardMcpProfile,
7764
10498
  sideboardReposDir,
7765
10499
  sideboardWorkspacesDir,
10500
+ slackAppLevelToken,
10501
+ slackArchiveUrl,
10502
+ slackCoordinatorSourceRef,
10503
+ slackListenEnabled,
10504
+ slackOAuthCredentials,
10505
+ slackRelayUrl,
7766
10506
  slugify,
7767
10507
  spawnAgentTurn,
7768
10508
  splitForCompaction,
@@ -7772,8 +10512,11 @@ export {
7772
10512
  stageAbsolutePathsAsAttachments,
7773
10513
  stageBuffersAsAttachments,
7774
10514
  startDevServer,
10515
+ startLinearOAuth,
7775
10516
  startMcpServer,
7776
10517
  startOrchestration,
10518
+ startSlackOAuth,
10519
+ startSlackRelayServer,
7777
10520
  stripBrightsyNdjsonNoise,
7778
10521
  submitPrStack,
7779
10522
  suggestSlug,
@@ -7791,6 +10534,7 @@ export {
7791
10534
  threadRequestsBrightsyMcp,
7792
10535
  threadsDir,
7793
10536
  threadsSharingWorktree,
10537
+ toPublicAppSettings,
7794
10538
  toolDescription,
7795
10539
  toolDetail,
7796
10540
  toolFilePath,