@sideboard-ai/core 0.1.73 → 0.1.77

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 (32) hide show
  1. package/dist/agents/cursor-runner.cjs +1 -1
  2. package/dist/agents/cursor-runner.js +1 -1
  3. package/dist/{agents-77GE7VRW.js → agents-LKUHPPEQ.js} +3 -3
  4. package/dist/{agents-GUFUYXKP.js → agents-XJV6J3K2.js} +4 -4
  5. package/dist/{caffeinate-hold-KLC6SABD.js → caffeinate-hold-EAZNWJBE.js} +5 -1
  6. package/dist/caffeinate-hold-OHAUWOA6.js +20 -0
  7. package/dist/{chunk-AFW3M6LU.js → chunk-2FJI5JXX.js} +17 -11
  8. package/dist/{chunk-CXT2PLO7.js → chunk-2ZIPJRJE.js} +1 -1
  9. package/dist/{chunk-CYKPUTXN.js → chunk-75U65MBE.js} +1 -1
  10. package/dist/{chunk-V4ACEJX2.js → chunk-EEKSZTMU.js} +14 -12
  11. package/dist/{chunk-POW7JCB5.js → chunk-GWTLKXXP.js} +1 -1
  12. package/dist/chunk-MFF2RE3I.js +168 -0
  13. package/dist/{chunk-WSFZPOPH.js → chunk-QD37IILI.js} +17 -11
  14. package/dist/chunk-RV6DBEDQ.js +170 -0
  15. package/dist/{chunk-3WJAUKIL.js → chunk-SEOICVGB.js} +1 -1
  16. package/dist/{chunk-NF6Y4GTE.js → chunk-VERLUKN2.js} +1 -1
  17. package/dist/{chunk-6RFPKZNC.js → chunk-XJNCWYFR.js} +14 -12
  18. package/dist/{coordinator-prompt-4U2QNHGT.js → coordinator-prompt-LLFJUO2R.js} +1 -1
  19. package/dist/{coordinator-prompt-ZHBDHMZB.js → coordinator-prompt-MRCMMRSF.js} +1 -1
  20. package/dist/{global-workspace-VF56FTPY.js → global-workspace-TWHMYLTZ.js} +2 -2
  21. package/dist/{global-workspace-S3B6ESZS.js → global-workspace-YNMNO4CL.js} +2 -2
  22. package/dist/index.cjs +1203 -468
  23. package/dist/index.d.cts +177 -23
  24. package/dist/index.d.ts +177 -23
  25. package/dist/index.js +1049 -401
  26. package/dist/mcp/run-stdio.cjs +848 -306
  27. package/dist/mcp/run-stdio.js +656 -182
  28. package/dist/{workspaces-ZJ45O4CD.js → workspaces-GQ4XKBD3.js} +3 -3
  29. package/dist/{workspaces-R66324S7.js → workspaces-O3U5BENH.js} +3 -3
  30. package/package.json +1 -1
  31. package/dist/caffeinate-hold-BEJIYPJ7.js +0 -107
  32. package/dist/chunk-JAWEDVVA.js +0 -106
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  listWorkspaces,
5
5
  removeWorkspace,
6
6
  syncWorkspacesFromThreads
7
- } from "./chunk-CYKPUTXN.js";
7
+ } from "./chunk-75U65MBE.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-WSFZPOPH.js";
61
+ } from "./chunk-QD37IILI.js";
62
62
  import {
63
63
  CLOUD_COORDINATOR_BUSY_REPLY,
64
64
  CLOUD_COORDINATOR_STOPPED_REPLY,
@@ -85,7 +85,7 @@ import {
85
85
  parseForceStopMessage,
86
86
  slackCoordinatorSourceRef,
87
87
  takenTeamSlugsForOrchestration
88
- } from "./chunk-NF6Y4GTE.js";
88
+ } from "./chunk-VERLUKN2.js";
89
89
  import {
90
90
  COORDINATOR_TOOL_PLAYBOOK,
91
91
  SLACK_REPLY_FORMATTING,
@@ -94,7 +94,7 @@ import {
94
94
  enrichWorkspacesWithGithub,
95
95
  ensureGlobalCoordinatorCwd,
96
96
  formatWorkspaceInventory
97
- } from "./chunk-V4ACEJX2.js";
97
+ } from "./chunk-EEKSZTMU.js";
98
98
  import {
99
99
  brightsyConfigPath,
100
100
  brightsyMcpServerName,
@@ -128,7 +128,7 @@ import {
128
128
  parseCursorRunnerLine,
129
129
  pushTurnStderr,
130
130
  summarizeTurnStderr
131
- } from "./chunk-3WJAUKIL.js";
131
+ } from "./chunk-SEOICVGB.js";
132
132
  import {
133
133
  HARNESS_ENV_KEYS,
134
134
  appSettingsPath,
@@ -192,8 +192,10 @@ import {
192
192
  import {
193
193
  caffeinateHoldPath,
194
194
  getCaffeinateHold,
195
+ isThreadCaffeinated,
196
+ releaseCaffeinateHoldForThread,
195
197
  setCaffeinateHold
196
- } from "./chunk-JAWEDVVA.js";
198
+ } from "./chunk-MFF2RE3I.js";
197
199
  import {
198
200
  writePrivateFile
199
201
  } from "./chunk-JT7R45JB.js";
@@ -324,6 +326,31 @@ import {
324
326
  withExportedPath
325
327
  } from "./chunk-D4DPQ552.js";
326
328
 
329
+ // src/git/agent-git-actions.ts
330
+ var AGENT_GIT_ACTIONS = [
331
+ "commit-push",
332
+ "create-draft",
333
+ "create-web",
334
+ "resolve-conflicts",
335
+ "merge"
336
+ ];
337
+ function agentGitPrompt(action, opts) {
338
+ switch (action) {
339
+ case "commit-push":
340
+ return "Commit and push.";
341
+ case "create-draft":
342
+ return "Commit, push, and open a draft PR.";
343
+ case "create-web":
344
+ return "Commit, push, and open a PR in the browser.";
345
+ case "resolve-conflicts": {
346
+ const base = opts?.prBase?.trim().replace(/^refs\/heads\//, "");
347
+ return base ? `Merge origin/${base} into this branch. Then push.` : "Fix merge conflicts.";
348
+ }
349
+ case "merge":
350
+ return "Merge PR.";
351
+ }
352
+ }
353
+
327
354
  // src/integrations/github.ts
328
355
  async function getGitHubStatus() {
329
356
  const which = await run("which", ["gh"], { reject: false });
@@ -349,9 +376,9 @@ async function getGitHubStatus() {
349
376
  };
350
377
  }
351
378
  const status = await run("gh", ["auth", "status"], { reject: false });
352
- const text2 = `${status.stdout}
379
+ const text3 = `${status.stdout}
353
380
  ${status.stderr}`;
354
- const loginMatch = text2.match(/Logged in to ([^\s]+) account (\S+)/i) ?? text2.match(/Logged in to ([^\s]+) as (\S+)/i);
381
+ const loginMatch = text3.match(/Logged in to ([^\s]+) account (\S+)/i) ?? text3.match(/Logged in to ([^\s]+) as (\S+)/i);
355
382
  if (loginMatch) {
356
383
  return {
357
384
  connected: true,
@@ -371,13 +398,39 @@ async function refreshGitHubAuth() {
371
398
  return getGitHubStatus();
372
399
  }
373
400
 
401
+ // src/http/fetch.ts
402
+ var injected = null;
403
+ function setHttpFetchImpl(fetchImpl) {
404
+ injected = fetchImpl;
405
+ }
406
+ function formatFetchError(err, url) {
407
+ if (!(err instanceof Error)) return `${String(err)} (${url})`;
408
+ const cause = err.cause;
409
+ let detail = "";
410
+ if (cause instanceof Error) {
411
+ const code = typeof cause.code === "string" ? cause.code : void 0;
412
+ detail = code ? ` [${code}: ${cause.message}]` : ` [${cause.message}]`;
413
+ } else if (cause != null) {
414
+ detail = ` [${String(cause)}]`;
415
+ }
416
+ return `${err.message}${detail} (${url})`;
417
+ }
418
+ async function httpFetch(input, init) {
419
+ const url = typeof input === "string" ? input : input.href;
420
+ const fn = injected ?? globalThis.fetch.bind(globalThis);
421
+ try {
422
+ return await fn(url, init);
423
+ } catch (err) {
424
+ throw new Error(formatFetchError(err, url));
425
+ }
426
+ }
427
+
374
428
  // src/integrations/linear-oauth.ts
375
429
  import { createHash, randomBytes } from "crypto";
376
430
  import { createServer } from "http";
377
431
 
378
432
  // src/integrations/linear-app.ts
379
433
  var BAKED_LINEAR_CLIENT_ID = "39a15abc7ea5bea17c47f47f85ff5fd0";
380
- var BAKED_LINEAR_CLIENT_SECRET = "";
381
434
  function hasBakedLinearOAuth() {
382
435
  return Boolean(BAKED_LINEAR_CLIENT_ID.trim());
383
436
  }
@@ -389,12 +442,12 @@ var LINEAR_AUTHORIZE = "https://linear.app/oauth/authorize";
389
442
  var LINEAR_TOKEN = "https://api.linear.app/oauth/token";
390
443
  var LINEAR_REVOKE = "https://api.linear.app/oauth/revoke";
391
444
  var LINEAR_GRAPHQL = "https://api.linear.app/graphql";
392
- var LINEAR_OAUTH_SCOPES = "read";
445
+ var LINEAR_OAUTH_SCOPES = "read,write";
393
446
  var REFRESH_SKEW_MS = 5 * 6e4;
394
447
  function linearOAuthCredentials() {
395
448
  const settings = loadAppSettings();
396
449
  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();
450
+ const clientSecret = process.env.SIDEBOARD_LINEAR_CLIENT_SECRET?.trim() || settings.integrations.linearClientSecret?.trim() || "";
398
451
  if (!clientId) {
399
452
  throw new Error(
400
453
  `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.`
@@ -434,7 +487,7 @@ function htmlPage(title, body) {
434
487
  h1{font-size:1.25rem}p{line-height:1.5;color:#444}</style></head><body>${body}</body></html>`;
435
488
  }
436
489
  async function exchangeLinearToken(body) {
437
- const res = await fetch(LINEAR_TOKEN, {
490
+ const res = await httpFetch(LINEAR_TOKEN, {
438
491
  method: "POST",
439
492
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
440
493
  body
@@ -456,7 +509,7 @@ async function persistLinearTokens(data, viewer) {
456
509
  });
457
510
  }
458
511
  async function fetchLinearViewer(accessToken) {
459
- const res = await fetch(LINEAR_GRAPHQL, {
512
+ const res = await httpFetch(LINEAR_GRAPHQL, {
460
513
  method: "POST",
461
514
  headers: {
462
515
  "Content-Type": "application/json",
@@ -627,7 +680,7 @@ async function revokeLinearToken(token, tokenTypeHint) {
627
680
  token,
628
681
  token_type_hint: tokenTypeHint
629
682
  });
630
- await fetch(LINEAR_REVOKE, {
683
+ await httpFetch(LINEAR_REVOKE, {
631
684
  method: "POST",
632
685
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
633
686
  body
@@ -644,6 +697,18 @@ async function disconnectLinear() {
644
697
 
645
698
  // src/integrations/linear.ts
646
699
  var LINEAR_GRAPHQL2 = "https://api.linear.app/graphql";
700
+ var ISSUE_FIELDS = `
701
+ id
702
+ identifier
703
+ title
704
+ description
705
+ url
706
+ priority
707
+ state { id name type }
708
+ assignee { id name }
709
+ team { id key name states { nodes { id name type } } }
710
+ labels { nodes { name } }
711
+ `;
647
712
  var ASSIGNED_ISSUES_QUERY = `
648
713
  query SideboardAssignedIssues($first: Int!) {
649
714
  viewer {
@@ -652,53 +717,280 @@ query SideboardAssignedIssues($first: Int!) {
652
717
  orderBy: updatedAt
653
718
  filter: { state: { type: { nin: ["completed", "canceled"] } } }
654
719
  ) {
655
- nodes {
656
- id
657
- identifier
658
- title
659
- url
660
- labels { nodes { name } }
661
- }
720
+ nodes { ${ISSUE_FIELDS} }
662
721
  }
663
722
  }
664
723
  }
665
724
  `;
666
- async function listLinearIssuesDirect(opts) {
667
- const apiKey = (opts?.apiKey ?? await getLinearAuthToken())?.trim();
668
- if (!apiKey) {
725
+ var TEAMS_QUERY = `
726
+ query SideboardTeams {
727
+ viewer { id name }
728
+ teams(first: 50) {
729
+ nodes {
730
+ id
731
+ key
732
+ name
733
+ states { nodes { id name type } }
734
+ }
735
+ }
736
+ }
737
+ `;
738
+ var ISSUE_QUERY = `
739
+ query SideboardIssue($id: String!) {
740
+ issue(id: $id) { ${ISSUE_FIELDS} }
741
+ }
742
+ `;
743
+ var ISSUE_CREATE = `
744
+ mutation SideboardIssueCreate($input: IssueCreateInput!) {
745
+ issueCreate(input: $input) {
746
+ success
747
+ issue { ${ISSUE_FIELDS} }
748
+ }
749
+ }
750
+ `;
751
+ var ISSUE_UPDATE = `
752
+ mutation SideboardIssueUpdate($id: String!, $input: IssueUpdateInput!) {
753
+ issueUpdate(id: $id, input: $input) {
754
+ success
755
+ issue { ${ISSUE_FIELDS} }
756
+ }
757
+ }
758
+ `;
759
+ var COMMENT_CREATE = `
760
+ mutation SideboardCommentCreate($input: CommentCreateInput!) {
761
+ commentCreate(input: $input) {
762
+ success
763
+ comment { id body url }
764
+ }
765
+ }
766
+ `;
767
+ function rewriteLinearError(message) {
768
+ if (/scope|permission|not (authorized|allowed)|unauthorized|insufficient/i.test(message)) {
769
+ return `${message} \u2014 Disconnect and Connect Linear in Account settings to grant write access.`;
770
+ }
771
+ return message;
772
+ }
773
+ async function requireLinearToken(apiKey) {
774
+ const token = (apiKey ?? await getLinearAuthToken())?.trim();
775
+ if (!token) {
669
776
  throw new Error("Linear is not connected \u2014 sign in from Account settings");
670
777
  }
671
- const first = Math.max(1, Math.min(100, opts?.limit ?? 50));
672
- const res = await fetch(LINEAR_GRAPHQL2, {
778
+ return token;
779
+ }
780
+ async function linearGraphql(query, variables, opts) {
781
+ const apiKey = await requireLinearToken(opts?.apiKey);
782
+ const res = await httpFetch(LINEAR_GRAPHQL2, {
673
783
  method: "POST",
674
784
  headers: {
675
785
  "Content-Type": "application/json",
676
786
  Authorization: linearAuthorizationHeader(apiKey)
677
787
  },
678
- body: JSON.stringify({
679
- query: ASSIGNED_ISSUES_QUERY,
680
- variables: { first }
681
- })
788
+ body: JSON.stringify({ query, variables })
682
789
  });
683
790
  if (!res.ok) {
684
791
  const body = await res.text().catch(() => "");
685
792
  throw new Error(
686
- `Linear API error ${res.status}${body ? `: ${body.slice(0, 200)}` : ""}`
793
+ rewriteLinearError(
794
+ `Linear API error ${res.status}${body ? `: ${body.slice(0, 200)}` : ""}`
795
+ )
687
796
  );
688
797
  }
689
798
  const json = await res.json();
690
799
  if (json.errors?.length) {
691
- throw new Error(json.errors.map((e) => e.message ?? "Linear error").join("; "));
800
+ throw new Error(
801
+ rewriteLinearError(json.errors.map((e) => e.message ?? "Linear error").join("; "))
802
+ );
692
803
  }
693
- const nodes = json.data?.viewer?.assignedIssues?.nodes ?? [];
694
- return nodes.map((node) => ({
804
+ if (json.data == null) {
805
+ throw new Error("Linear API returned no data");
806
+ }
807
+ return json.data;
808
+ }
809
+ function mapState(node) {
810
+ if (!node?.id) return void 0;
811
+ return {
812
+ id: String(node.id),
813
+ name: String(node.name ?? ""),
814
+ type: String(node.type ?? "")
815
+ };
816
+ }
817
+ function mapIssue(node) {
818
+ const team = node.team;
819
+ return {
695
820
  id: String(node.id ?? node.identifier ?? ""),
696
821
  identifier: String(node.identifier ?? node.id ?? ""),
697
822
  title: String(node.title ?? ""),
698
823
  url: String(node.url ?? ""),
699
- labels: (node.labels?.nodes ?? []).map((l) => l.name).filter((n) => Boolean(n)),
824
+ description: node.description?.trim() || void 0,
825
+ priority: typeof node.priority === "number" ? node.priority : void 0,
826
+ state: mapState(node.state),
827
+ assignee: node.assignee?.id ? { id: String(node.assignee.id), name: String(node.assignee.name ?? "") } : void 0,
828
+ team: team?.id ? {
829
+ id: String(team.id),
830
+ key: String(team.key ?? ""),
831
+ name: String(team.name ?? ""),
832
+ states: (team.states?.nodes ?? []).map((s) => mapState(s)).filter((s) => Boolean(s))
833
+ } : void 0,
834
+ labels: (node.labels?.nodes ?? []).map((l) => l.name).filter((n) => Boolean(n))
835
+ };
836
+ }
837
+ function toIssueInfo(issue) {
838
+ return {
839
+ id: issue.id,
840
+ identifier: issue.identifier,
841
+ title: issue.title,
842
+ url: issue.url,
843
+ labels: issue.labels,
700
844
  provider: "linear"
701
- }));
845
+ };
846
+ }
847
+ function mapTeam(node) {
848
+ return {
849
+ id: String(node.id ?? ""),
850
+ key: String(node.key ?? ""),
851
+ name: String(node.name ?? ""),
852
+ states: (node.states?.nodes ?? []).map((s) => mapState(s)).filter((s) => Boolean(s))
853
+ };
854
+ }
855
+ function resolveLinearTeam(teams, team) {
856
+ const t = team.trim();
857
+ if (!t) throw new Error("Linear team is required (id, key, or name from linear_list_teams)");
858
+ const lower = t.toLowerCase();
859
+ const found = teams.find(
860
+ (x) => x.id === t || x.key.toLowerCase() === lower || x.name.toLowerCase() === lower
861
+ );
862
+ if (!found) {
863
+ const keys = teams.map((x) => x.key).filter(Boolean).join(", ") || "(none)";
864
+ throw new Error(`Linear team not found: ${team}. Known keys: ${keys}`);
865
+ }
866
+ return found;
867
+ }
868
+ function resolveLinearState(team, state) {
869
+ const s = state.trim();
870
+ if (!s) throw new Error("Linear state is empty");
871
+ const lower = s.toLowerCase();
872
+ const found = team.states.find((x) => x.id === s) || team.states.find((x) => x.name.toLowerCase() === lower) || team.states.find((x) => x.type.toLowerCase() === lower);
873
+ if (!found) {
874
+ const available = team.states.map((x) => `${x.name} (${x.type})`).join(", ") || "(none)";
875
+ throw new Error(`Linear state not found on ${team.key}: ${state}. Available: ${available}`);
876
+ }
877
+ return found;
878
+ }
879
+ function normalizePriority(priority) {
880
+ if (priority == null) return void 0;
881
+ if (!Number.isInteger(priority) || priority < 0 || priority > 4) {
882
+ throw new Error("Linear priority must be 0 (none), 1 (urgent), 2 (high), 3 (medium), or 4 (low)");
883
+ }
884
+ return priority;
885
+ }
886
+ async function listLinearIssuesDirect(opts) {
887
+ const first = Math.max(1, Math.min(100, opts?.limit ?? 50));
888
+ const json = await linearGraphql(ASSIGNED_ISSUES_QUERY, { first }, opts);
889
+ return (json.viewer?.assignedIssues?.nodes ?? []).map((node) => toIssueInfo(mapIssue(node)));
890
+ }
891
+ async function listLinearTeams(opts) {
892
+ const json = await linearGraphql(TEAMS_QUERY, void 0, opts);
893
+ return {
894
+ viewer: {
895
+ id: String(json.viewer?.id ?? ""),
896
+ name: String(json.viewer?.name ?? "")
897
+ },
898
+ teams: (json.teams?.nodes ?? []).map(mapTeam)
899
+ };
900
+ }
901
+ async function getLinearIssue(id, opts) {
902
+ const issueId = id.trim();
903
+ if (!issueId) throw new Error("Linear issue id is required (uuid or ENG-123)");
904
+ const json = await linearGraphql(
905
+ ISSUE_QUERY,
906
+ { id: issueId },
907
+ opts
908
+ );
909
+ if (!json.issue) {
910
+ throw new Error(`Linear issue not found: ${issueId}`);
911
+ }
912
+ return mapIssue(json.issue);
913
+ }
914
+ async function createLinearIssue(input, opts) {
915
+ const title = input.title.trim();
916
+ if (!title) throw new Error("Linear issue title is required");
917
+ const { viewer, teams } = await listLinearTeams(opts);
918
+ const team = resolveLinearTeam(teams, input.team);
919
+ const mutationInput = {
920
+ teamId: team.id,
921
+ title
922
+ };
923
+ const description = input.description?.trim();
924
+ if (description) mutationInput.description = description;
925
+ if (input.state?.trim()) {
926
+ mutationInput.stateId = resolveLinearState(team, input.state).id;
927
+ }
928
+ const assignee = input.assignee === void 0 ? void 0 : input.assignee?.trim() || null;
929
+ if (assignee === "me") mutationInput.assigneeId = viewer.id;
930
+ else if (assignee) mutationInput.assigneeId = assignee;
931
+ const priority = normalizePriority(input.priority);
932
+ if (priority != null) mutationInput.priority = priority;
933
+ const json = await linearGraphql(ISSUE_CREATE, { input: mutationInput }, opts);
934
+ if (!json.issueCreate?.success || !json.issueCreate.issue) {
935
+ throw new Error("Linear issueCreate failed");
936
+ }
937
+ return mapIssue(json.issueCreate.issue);
938
+ }
939
+ async function updateLinearIssue(input, opts) {
940
+ const issueId = input.id.trim();
941
+ if (!issueId) throw new Error("Linear issue id is required (uuid or ENG-123)");
942
+ const mutationInput = {};
943
+ if (input.title !== void 0) {
944
+ const title = input.title.trim();
945
+ if (!title) throw new Error("Linear issue title cannot be empty");
946
+ mutationInput.title = title;
947
+ }
948
+ if (input.description !== void 0) {
949
+ mutationInput.description = input.description;
950
+ }
951
+ if (input.state?.trim()) {
952
+ const existing = await getLinearIssue(issueId, opts);
953
+ const team = existing.team;
954
+ if (!team?.states.length) {
955
+ throw new Error(`Linear issue ${existing.identifier} has no workflow states to resolve "${input.state}"`);
956
+ }
957
+ mutationInput.stateId = resolveLinearState(team, input.state).id;
958
+ }
959
+ if (input.assignee !== void 0) {
960
+ const assignee = input.assignee?.trim() || null;
961
+ if (assignee === "me") {
962
+ const { viewer } = await listLinearTeams(opts);
963
+ mutationInput.assigneeId = viewer.id;
964
+ } else {
965
+ mutationInput.assigneeId = assignee;
966
+ }
967
+ }
968
+ if (input.priority !== void 0) {
969
+ mutationInput.priority = normalizePriority(input.priority);
970
+ }
971
+ if (Object.keys(mutationInput).length === 0) {
972
+ throw new Error("linear_update_issue needs at least one of title, description, state, assignee, priority");
973
+ }
974
+ const json = await linearGraphql(ISSUE_UPDATE, { id: issueId, input: mutationInput }, opts);
975
+ if (!json.issueUpdate?.success || !json.issueUpdate.issue) {
976
+ throw new Error("Linear issueUpdate failed");
977
+ }
978
+ return mapIssue(json.issueUpdate.issue);
979
+ }
980
+ async function commentLinearIssue(input, opts) {
981
+ const issueId = input.id.trim();
982
+ const body = input.body.trim();
983
+ if (!issueId) throw new Error("Linear issue id is required (uuid or ENG-123)");
984
+ if (!body) throw new Error("Linear comment body is required");
985
+ const json = await linearGraphql(COMMENT_CREATE, { input: { issueId, body } }, opts);
986
+ if (!json.commentCreate?.success || !json.commentCreate.comment?.id) {
987
+ throw new Error("Linear commentCreate failed");
988
+ }
989
+ return {
990
+ id: String(json.commentCreate.comment.id),
991
+ body: String(json.commentCreate.comment.body ?? body),
992
+ url: json.commentCreate.comment.url?.trim() || void 0
993
+ };
702
994
  }
703
995
  async function validateLinearApiKey(apiKey) {
704
996
  const key = apiKey.trim();
@@ -843,9 +1135,9 @@ function toolFilePath(input) {
843
1135
  if (!input) return void 0;
844
1136
  return str(input.file_path) ?? str(input.path) ?? str(input.filePath) ?? str(input.filename);
845
1137
  }
846
- function countLines(text2) {
847
- if (!text2) return 0;
848
- return text2.split("\n").length;
1138
+ function countLines(text3) {
1139
+ if (!text3) return 0;
1140
+ return text3.split("\n").length;
849
1141
  }
850
1142
  function diffFromInput(input) {
851
1143
  if (!input) return {};
@@ -972,9 +1264,9 @@ function applyAgentEvent(parts, event) {
972
1264
  function partsToAssistantText(parts) {
973
1265
  return parts.filter((p) => p.type === "text").map((p) => p.text).join("").trim();
974
1266
  }
975
- function stripBrightsyNdjsonNoise(text2) {
976
- if (!text2 || !text2.includes('"type"')) return text2;
977
- let out = text2;
1267
+ function stripBrightsyNdjsonNoise(text3) {
1268
+ if (!text3 || !text3.includes('"type"')) return text3;
1269
+ let out = text3;
978
1270
  out = out.replace(
979
1271
  /\{"type":"(?:tool_use|tool_result|tool|thinking|usage|done|error)"[\s\S]*?\}\s*(?=\{"type":"|$|(?=[A-Za-z*#]))/g,
980
1272
  ""
@@ -1002,17 +1294,35 @@ function sumOptional(a, b) {
1002
1294
  if (a == null && b == null) return void 0;
1003
1295
  return (a ?? 0) + (b ?? 0);
1004
1296
  }
1297
+ function requestOccupancy(u) {
1298
+ return u.inputTokens + (u.cacheReadTokens ?? 0) + (u.cacheWriteTokens ?? 0);
1299
+ }
1005
1300
  function mergeUsage(a, b) {
1006
1301
  return {
1007
1302
  inputTokens: (a?.inputTokens ?? 0) + b.inputTokens,
1008
1303
  outputTokens: (a?.outputTokens ?? 0) + b.outputTokens,
1009
1304
  cacheReadTokens: sumOptional(a?.cacheReadTokens, b.cacheReadTokens),
1010
- cacheWriteTokens: sumOptional(a?.cacheWriteTokens, b.cacheWriteTokens)
1305
+ cacheWriteTokens: sumOptional(a?.cacheWriteTokens, b.cacheWriteTokens),
1306
+ lastRequestTokens: b.lastRequestTokens ?? a?.lastRequestTokens
1011
1307
  };
1012
1308
  }
1309
+ function applyTurnUsage(current, incoming, scope = "request") {
1310
+ if (scope === "turn") {
1311
+ return {
1312
+ ...incoming,
1313
+ lastRequestTokens: current?.lastRequestTokens ?? requestOccupancy(incoming)
1314
+ };
1315
+ }
1316
+ const merged = mergeUsage(current, incoming);
1317
+ return { ...merged, lastRequestTokens: requestOccupancy(incoming) };
1318
+ }
1013
1319
  function totalTokens(u) {
1014
1320
  return u.inputTokens + u.outputTokens + (u.cacheReadTokens ?? 0) + (u.cacheWriteTokens ?? 0);
1015
1321
  }
1322
+ function contextTokens(u) {
1323
+ if (u.lastRequestTokens != null && u.lastRequestTokens > 0) return u.lastRequestTokens;
1324
+ return requestOccupancy(u);
1325
+ }
1016
1326
 
1017
1327
  // src/agents/spawn.ts
1018
1328
  async function spawnAgentTurn(thread, input, onEvent) {
@@ -1022,9 +1332,9 @@ async function spawnAgentTurn(thread, input, onEvent) {
1022
1332
  `Cannot spawn ${thread.agent}: thread ${thread.id} has no worktreePath`
1023
1333
  );
1024
1334
  }
1025
- const { isGlobalThread: isGlobalThread2 } = await import("./global-workspace-S3B6ESZS.js");
1335
+ const { isGlobalThread: isGlobalThread2 } = await import("./global-workspace-YNMNO4CL.js");
1026
1336
  if (isGlobalThread2(thread)) {
1027
- const { ensureGlobalCoordinatorCwd: ensureGlobalCoordinatorCwd2 } = await import("./coordinator-prompt-4U2QNHGT.js");
1337
+ const { ensureGlobalCoordinatorCwd: ensureGlobalCoordinatorCwd2 } = await import("./coordinator-prompt-LLFJUO2R.js");
1028
1338
  ensureGlobalCoordinatorCwd2(
1029
1339
  isOrchestratorThread(thread) ? { orchestratorThreadId: thread.id } : void 0
1030
1340
  );
@@ -1086,7 +1396,7 @@ async function spawnAgentTurn(thread, input, onEvent) {
1086
1396
  continue;
1087
1397
  }
1088
1398
  if (parsed.type === "usage") {
1089
- usage = mergeUsage(usage, parsed.data);
1399
+ usage = applyTurnUsage(usage, parsed.data, parsed.scope ?? "request");
1090
1400
  onEvent(parsed);
1091
1401
  continue;
1092
1402
  }
@@ -1111,8 +1421,8 @@ async function spawnAgentTurn(thread, input, onEvent) {
1111
1421
  onEvent({ type: "exit", data: exitCode });
1112
1422
  const finalized = finalizeParts(parts);
1113
1423
  const rawText = assistantText.trim() || partsToAssistantText(finalized);
1114
- const text2 = thread.agent === "brightsy" ? stripBrightsyNdjsonNoise(rawText) : rawText;
1115
- return { exitCode, sessionId, assistantText: text2, parts: finalized, usage };
1424
+ const text3 = thread.agent === "brightsy" ? stripBrightsyNdjsonNoise(rawText) : rawText;
1425
+ return { exitCode, sessionId, assistantText: text3, parts: finalized, usage };
1116
1426
  });
1117
1427
  return {
1118
1428
  pid: child.pid,
@@ -1141,7 +1451,7 @@ function formatRenameBranchDirective(thread, opts) {
1141
1451
  "- Rename the git branch to a short kebab-case name that describes this task (what you are changing), e.g. `fix/panel-width` or `feat/dark-mode`:",
1142
1452
  " `git branch -m <new-name>`",
1143
1453
  "- Prefer Conventional Commits style prefixes when they fit (`fix/`, `feat/`, `chore/`, `docs/`).",
1144
- "- Never push or merge to main/master from here."
1454
+ "- Never push this placeholder to main/master."
1145
1455
  ];
1146
1456
  const custom = opts?.customPrompt?.trim();
1147
1457
  if (custom) {
@@ -1184,7 +1494,7 @@ function formatWorktreeDirective(thread, opts) {
1184
1494
  "- Prefer a concise imperative title (Conventional Commits style when it fits: feat:/fix:/chore:/docs:). Body should summarize intent, key changes, and test notes."
1185
1495
  );
1186
1496
  lines.push(
1187
- "- Commit with messages that state the purpose of the change (same standard as the PR). Stay on this thread branch; never push or merge to main/master from here."
1497
+ "- Commit with messages that state the purpose of the change (same standard as the PR). Stay on this thread branch. Never push directly to main/master or merge locally into the main checkout. When asked to merge the PR, use GitHub from this worktree (`gh pr merge` / `gh stack merge`)."
1188
1498
  );
1189
1499
  if (thread.prUrl) {
1190
1500
  lines.push(
@@ -1216,13 +1526,13 @@ function formatWorktreeDirective(thread, opts) {
1216
1526
  '- "Fix CI: <name>." \u2192 investigate that failing check, fix it, commit, and push.'
1217
1527
  );
1218
1528
  lines.push(
1219
- '- "Update the branch." / "Fix merge conflicts." \u2192 sync with the PR base (merge or rebase), resolve conflicts carefully, commit, and push until the PR is mergeable.'
1529
+ '- "Update the branch." / "Fix merge conflicts." / "Merge origin/<base> into this branch. Then push." \u2192 sync with the PR base (merge or rebase), resolve conflicts carefully, commit, and push until the PR is mergeable.'
1220
1530
  );
1221
1531
  lines.push(
1222
1532
  '- "Address review comments." \u2192 read PR review feedback, make the requested changes, commit, and push.'
1223
1533
  );
1224
1534
  lines.push(
1225
- '- "Merge PR." \u2192 merge this thread\'s open pull request with `gh pr merge` (respect repo defaults / squash vs merge); do not force-push main/master.'
1535
+ '- "Merge PR." \u2192 merge this thread\'s open pull request on GitHub. If `gh stack view` shows a stack, use `gh stack merge`; otherwise `gh pr merge` (respect repo defaults / squash vs merge). Do not force-push main/master or merge locally into the main checkout.'
1226
1536
  );
1227
1537
  return lines.join("\n");
1228
1538
  }
@@ -1977,13 +2287,13 @@ function formatStat(files) {
1977
2287
  return `${n} file${n === 1 ? "" : "s"} changed, ${additions} insertions(+), ${deletions} deletions(-)`;
1978
2288
  }
1979
2289
  function emptyScopeStats() {
1980
- const z3 = emptyStat();
2290
+ const z4 = emptyStat();
1981
2291
  return {
1982
- commits: z3,
1983
- uncommitted: z3,
1984
- staged: z3,
1985
- unstaged: z3,
1986
- last_turn: z3
2292
+ commits: z4,
2293
+ uncommitted: z4,
2294
+ staged: z4,
2295
+ unstaged: z4,
2296
+ last_turn: z4
1987
2297
  };
1988
2298
  }
1989
2299
  function filesFromDiff(nameStatus, numstat, combinedDiff, maxHunk) {
@@ -2254,10 +2564,10 @@ async function getDiff(worktreePath, repoPath, opts) {
2254
2564
  listBranchCommits(worktreePath, repoPath, { base: baseLabel }),
2255
2565
  isDirty(worktreePath),
2256
2566
  countUnpushedCommits(worktreePath)
2257
- ]).then(([scopeStats, commits, dirty, unpushed]) => ({
2567
+ ]).then(([scopeStats, commits, dirty2, unpushed]) => ({
2258
2568
  scopeStats,
2259
2569
  commits,
2260
- dirty,
2570
+ dirty: dirty2,
2261
2571
  unpushed
2262
2572
  })) : Promise.resolve({
2263
2573
  scopeStats: emptyScopeStats(),
@@ -2334,13 +2644,14 @@ async function getDiff(worktreePath, repoPath, opts) {
2334
2644
  }
2335
2645
  }
2336
2646
  const files = [...filesMap.values()].sort((a, b) => a.path.localeCompare(b.path));
2647
+ const dirty = includeMeta ? meta.dirty : scope === "commits" ? false : files.length > 0;
2337
2648
  return {
2338
2649
  scope,
2339
2650
  commitSha: scope === "commits" ? commitSha : null,
2340
2651
  base: labelBase,
2341
2652
  files,
2342
2653
  stat: formatStat(files),
2343
- dirty: fileOnly || !includeMeta ? files.length > 0 : meta.dirty,
2654
+ dirty,
2344
2655
  unpushed: meta.unpushed,
2345
2656
  hasLastTurnBase,
2346
2657
  commits: meta.commits,
@@ -2972,16 +3283,16 @@ var PASTE_ATTACH_MIN_CHARS = 1200;
2972
3283
  var PASTE_ATTACH_MIN_LINES = 15;
2973
3284
  var PASTED_NAME_RE = /^Pasted text #(\d+)\.txt$/i;
2974
3285
  var PASTED_NAME_ALT_RE = /^pasted-(\d+)\.txt$/i;
2975
- function pastedTextStats(text2) {
2976
- const chars = text2.length;
3286
+ function pastedTextStats(text3) {
3287
+ const chars = text3.length;
2977
3288
  if (chars === 0) return { chars: 0, lines: 0 };
2978
- const lines = text2.split(/\r\n|\r|\n/).length;
3289
+ const lines = text3.split(/\r\n|\r|\n/).length;
2979
3290
  return { chars, lines };
2980
3291
  }
2981
- function shouldAttachPastedText(text2) {
2982
- const trimmed = text2.trim();
3292
+ function shouldAttachPastedText(text3) {
3293
+ const trimmed = text3.trim();
2983
3294
  if (!trimmed) return false;
2984
- const { chars, lines } = pastedTextStats(text2);
3295
+ const { chars, lines } = pastedTextStats(text3);
2985
3296
  return chars >= PASTE_ATTACH_MIN_CHARS || lines >= PASTE_ATTACH_MIN_LINES;
2986
3297
  }
2987
3298
  function nextPastedTextName(existing) {
@@ -2992,13 +3303,13 @@ function nextPastedTextName(existing) {
2992
3303
  }
2993
3304
  return `Pasted text #${max + 1}.txt`;
2994
3305
  }
2995
- function buildPastedTextAttachment(text2, opts) {
3306
+ function buildPastedTextAttachment(text3, opts) {
2996
3307
  return {
2997
3308
  id: opts?.id ?? randomUUID2(),
2998
3309
  name: opts?.name ?? "Pasted text #1.txt",
2999
3310
  kind: "file",
3000
3311
  path: opts?.path,
3001
- content: text2
3312
+ content: text3
3002
3313
  };
3003
3314
  }
3004
3315
 
@@ -3046,9 +3357,9 @@ async function tryClaudeSummary(transcript, opts) {
3046
3357
  { cwd: opts?.cwd, reject: false }
3047
3358
  );
3048
3359
  if (exitCode !== 0) return null;
3049
- const text2 = stdout.trim();
3050
- if (text2.length < 40) return null;
3051
- return text2;
3360
+ const text3 = stdout.trim();
3361
+ if (text3.length < 40) return null;
3362
+ return text3;
3052
3363
  } catch {
3053
3364
  return null;
3054
3365
  }
@@ -3097,9 +3408,9 @@ function extractiveSummary(transcript) {
3097
3408
  );
3098
3409
  return parts.join("\n");
3099
3410
  }
3100
- function clipSummary(text2) {
3101
- if (text2.length <= MAX_SUMMARY_CHARS) return text2;
3102
- return `${text2.slice(0, MAX_SUMMARY_CHARS)}
3411
+ function clipSummary(text3) {
3412
+ if (text3.length <= MAX_SUMMARY_CHARS) return text3;
3413
+ return `${text3.slice(0, MAX_SUMMARY_CHARS)}
3103
3414
 
3104
3415
  [\u2026summary truncated\u2026]`;
3105
3416
  }
@@ -3501,7 +3812,7 @@ async function createThread(input, _onSetupLine) {
3501
3812
  return readThread(thread.id) ?? thread;
3502
3813
  }
3503
3814
  async function listLinearIssues(agent, repoPath) {
3504
- const { getAdapter: getAdapter2 } = await import("./agents-GUFUYXKP.js");
3815
+ const { getAdapter: getAdapter2 } = await import("./agents-XJV6J3K2.js");
3505
3816
  await requireAgent(agent, { requireLinear: true });
3506
3817
  const adapter = getAdapter2(agent);
3507
3818
  if (!adapter.listLinearIssues) {
@@ -3999,13 +4310,13 @@ function resolveConductorCursorAgentId(workspacePath) {
3999
4310
  for (const hash of hashes) {
4000
4311
  const agentsFile = join7(CURSOR_SDK_STORE, hash, "agents.ndjson");
4001
4312
  if (!existsSync9(agentsFile)) continue;
4002
- let text2;
4313
+ let text3;
4003
4314
  try {
4004
- text2 = readFileSync7(agentsFile, "utf8");
4315
+ text3 = readFileSync7(agentsFile, "utf8");
4005
4316
  } catch {
4006
4317
  continue;
4007
4318
  }
4008
- for (const line of text2.split("\n")) {
4319
+ for (const line of text3.split("\n")) {
4009
4320
  const trimmed = line.trim();
4010
4321
  if (!trimmed) continue;
4011
4322
  try {
@@ -4044,7 +4355,7 @@ async function adoptThread(input) {
4044
4355
  messages: input.messages ?? []
4045
4356
  });
4046
4357
  writeThread(thread);
4047
- const { ensureWorkspace: ensureWorkspace2 } = await import("./workspaces-R66324S7.js");
4358
+ const { ensureWorkspace: ensureWorkspace2 } = await import("./workspaces-O3U5BENH.js");
4048
4359
  await ensureWorkspace2(repoPath);
4049
4360
  return thread;
4050
4361
  }
@@ -4495,8 +4806,8 @@ ${input.permalink}` : "";
4495
4806
 
4496
4807
  ${body}${link}`;
4497
4808
  }
4498
- function isSlackExternalReplyPrompt(text2) {
4499
- return text2.startsWith("Slack reply from ") && text2.includes("not a command");
4809
+ function isSlackExternalReplyPrompt(text3) {
4810
+ return text3.startsWith("Slack reply from ") && text3.includes("not a command");
4500
4811
  }
4501
4812
  function pendingSlackExternalReplies(messages) {
4502
4813
  let i = messages.length - 1;
@@ -4520,9 +4831,9 @@ function formatSlackRepliesForTurn(replies) {
4520
4831
  function listSlackOutboundWatches() {
4521
4832
  return pruneWatches(readStore3());
4522
4833
  }
4523
- function formatOwnerSlackFyi(userName, text2) {
4834
+ function formatOwnerSlackFyi(userName, text3) {
4524
4835
  const who = userName.trim() || "Someone";
4525
- const body = text2.trim() || "(no text)";
4836
+ const body = text3.trim() || "(no text)";
4526
4837
  return `${who} replied in Slack:
4527
4838
  ${body}`;
4528
4839
  }
@@ -4537,7 +4848,7 @@ async function relayExternalReply(opts) {
4537
4848
  const thread = readThread(threadId);
4538
4849
  if (!thread || thread.status === "archived") return true;
4539
4850
  try {
4540
- const text2 = formatSlackExternalReplyPrompt({
4851
+ const text3 = formatSlackExternalReplyPrompt({
4541
4852
  userName: opts.reply.userName,
4542
4853
  kind: opts.watch.kind,
4543
4854
  toLabel: opts.watch.toLabel,
@@ -4546,7 +4857,7 @@ async function relayExternalReply(opts) {
4546
4857
  });
4547
4858
  appendMessage(threadId, {
4548
4859
  role: "agent",
4549
- text: text2,
4860
+ text: text3,
4550
4861
  ts: (/* @__PURE__ */ new Date()).toISOString()
4551
4862
  });
4552
4863
  } catch {
@@ -4743,7 +5054,7 @@ async function refreshSlackReplyBadges(opts) {
4743
5054
  const messages = await fetchMessages(token, watch, opts?.fetchImpl);
4744
5055
  const replies = messages.filter((m) => isHumanReply(m, watch)).sort((a, b) => Number(a.ts) - Number(b.ts));
4745
5056
  if (replies.length === 0) continue;
4746
- const injected = new Set(watch.injectedReplyTs ?? []);
5057
+ const injected2 = new Set(watch.injectedReplyTs ?? []);
4747
5058
  const collected = [...watch.replies ?? []];
4748
5059
  let lastSeenTs = watch.lastSeenTs;
4749
5060
  let latestUser;
@@ -4777,7 +5088,7 @@ async function refreshSlackReplyBadges(opts) {
4777
5088
  latestName = replyUserName;
4778
5089
  latestText = reply.text;
4779
5090
  latestPermalink = permalink;
4780
- if (injected.has(ts)) {
5091
+ if (injected2.has(ts)) {
4781
5092
  lastSeenTs = ts;
4782
5093
  continue;
4783
5094
  }
@@ -4788,7 +5099,7 @@ async function refreshSlackReplyBadges(opts) {
4788
5099
  fetchImpl: opts?.fetchImpl
4789
5100
  });
4790
5101
  if (!delivered) break;
4791
- injected.add(ts);
5102
+ injected2.add(ts);
4792
5103
  lastSeenTs = ts;
4793
5104
  }
4794
5105
  watches[i] = {
@@ -4800,7 +5111,7 @@ async function refreshSlackReplyBadges(opts) {
4800
5111
  replyTs: lastSeenTs,
4801
5112
  replyPreview: latestText.slice(0, 140),
4802
5113
  permalink: latestPermalink,
4803
- injectedReplyTs: [...injected],
5114
+ injectedReplyTs: [...injected2],
4804
5115
  replies: collected.slice(-MAX_REPLIES_PER_WATCH)
4805
5116
  };
4806
5117
  changed = true;
@@ -5377,8 +5688,8 @@ function buildQuotaHandoffAttachment(from, limitText, fallbackAgent) {
5377
5688
  );
5378
5689
  const recent = from.messages.slice(-8).map((m) => {
5379
5690
  const role = m.role === "user" ? "User" : m.role === "agent" ? "Agent" : "Summary";
5380
- const text2 = m.text.trim().replace(/\s+/g, " ").slice(0, 280);
5381
- return text2 ? `- ${role}: ${text2}` : null;
5691
+ const text3 = m.text.trim().replace(/\s+/g, " ").slice(0, 280);
5692
+ return text3 ? `- ${role}: ${text3}` : null;
5382
5693
  }).filter(Boolean);
5383
5694
  const body = [
5384
5695
  `# Orchestration handoff`,
@@ -5798,11 +6109,11 @@ var Orchestrator = class {
5798
6109
  return results;
5799
6110
  }
5800
6111
  /** Edit the text of a not-yet-started queued message. */
5801
- async editQueuedMessage(threadRef, index, text2) {
6112
+ async editQueuedMessage(threadRef, index, text3) {
5802
6113
  const thread = this.requireThread(threadRef);
5803
6114
  return withThreadLock(thread.id, async () => {
5804
6115
  const current = this.requireThread(thread.id);
5805
- const trimmed = text2.trim();
6116
+ const trimmed = text3.trim();
5806
6117
  if (!trimmed || index < 0 || index >= current.queue.length) {
5807
6118
  return current;
5808
6119
  }
@@ -6562,6 +6873,14 @@ var Orchestrator = class {
6562
6873
  throw new Error(`${action} is not available on the global coordinator`);
6563
6874
  }
6564
6875
  }
6876
+ /** MCP set_caffeinate is a detached hold — closing the chat must not leave the Mac awake. */
6877
+ releaseOrchestratorCaffeinate(thread) {
6878
+ if (!isOrchestratorThread(thread)) return;
6879
+ try {
6880
+ releaseCaffeinateHoldForThread(thread.id);
6881
+ } catch {
6882
+ }
6883
+ }
6565
6884
  async diff(threadRef, opts) {
6566
6885
  const thread = this.requireThread(threadRef);
6567
6886
  this.assertNotGlobal(thread, "Diff");
@@ -6831,6 +7150,36 @@ var Orchestrator = class {
6831
7150
  this.emit({ type: "status_changed", threadId: tab.id, status: tab.status });
6832
7151
  return tab;
6833
7152
  }
7153
+ /**
7154
+ * Queue a desktop-git-button prompt on a worktree agent (commit/push/PR/merge).
7155
+ * Orchestrators use this instead of running git/gh from the synthetic home.
7156
+ */
7157
+ async askGit(threadRef, action) {
7158
+ if (!AGENT_GIT_ACTIONS.includes(action)) {
7159
+ throw new Error(`Unknown git action: ${action}`);
7160
+ }
7161
+ const thread = this.requireThread(threadRef);
7162
+ this.assertNotGlobal(thread, "ask_git");
7163
+ if (isOrchestratorThread(thread)) {
7164
+ throw new Error(
7165
+ "ask_git targets a worktree agent thread (not the orchestrator). Pass a child/worktree thread ref."
7166
+ );
7167
+ }
7168
+ if (action === "merge" && !thread.prUrl) {
7169
+ throw new Error(
7170
+ "No pull request linked. Ask the worktree agent to open a draft PR first (ask_git create-draft)."
7171
+ );
7172
+ }
7173
+ let prBase;
7174
+ if (action === "resolve-conflicts") {
7175
+ try {
7176
+ const details = await this.getPrDetails(threadRef);
7177
+ prBase = details?.baseRefName?.trim() || void 0;
7178
+ } catch {
7179
+ }
7180
+ }
7181
+ return this.send(threadRef, agentGitPrompt(action, { prBase }));
7182
+ }
6834
7183
  setThreadOptions(threadRef, patch) {
6835
7184
  const thread = this.requireThread(threadRef);
6836
7185
  const next = {};
@@ -6901,6 +7250,7 @@ var Orchestrator = class {
6901
7250
  async archive(threadRef) {
6902
7251
  const thread = this.requireThread(threadRef);
6903
7252
  this.stop(thread.id);
7253
+ this.releaseOrchestratorCaffeinate(thread);
6904
7254
  if (isGlobalThread(thread)) {
6905
7255
  const archived2 = setStatus(thread.id, "archived");
6906
7256
  this.emit({ type: "status_changed", threadId: archived2.id, status: "archived" });
@@ -6925,7 +7275,7 @@ var Orchestrator = class {
6925
7275
  this.emit({ type: "status_changed", threadId: archived.id, status: "archived" });
6926
7276
  if (thread.repoPath && !isGlobalRepoPath(thread.repoPath)) {
6927
7277
  try {
6928
- const { ensureWorkspace: ensureWorkspace2 } = await import("./workspaces-R66324S7.js");
7278
+ const { ensureWorkspace: ensureWorkspace2 } = await import("./workspaces-O3U5BENH.js");
6929
7279
  await ensureWorkspace2(thread.repoPath);
6930
7280
  } catch {
6931
7281
  }
@@ -6935,6 +7285,7 @@ var Orchestrator = class {
6935
7285
  async purge(threadRef, opts) {
6936
7286
  const thread = this.requireThread(threadRef);
6937
7287
  this.stop(thread.id);
7288
+ this.releaseOrchestratorCaffeinate(thread);
6938
7289
  if (isGlobalThread(thread)) {
6939
7290
  deleteThreadRecord(thread.id);
6940
7291
  return;
@@ -7198,7 +7549,7 @@ function formatPlanQuestionsForChat(questions) {
7198
7549
  // src/mcp/server.ts
7199
7550
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7200
7551
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7201
- import { z as z2 } from "zod";
7552
+ import { z as z3 } from "zod";
7202
7553
  import { basename as basename4 } from "path";
7203
7554
 
7204
7555
  // src/mcp/archive-guard.ts
@@ -7426,8 +7777,8 @@ function labelGithubUrl(url) {
7426
7777
  }
7427
7778
  return { kind: "other", label: "GitHub", url: raw };
7428
7779
  }
7429
- function appendGithubLink(text2, githubUrl) {
7430
- const body = text2.trimEnd();
7780
+ function appendGithubLink(text3, githubUrl) {
7781
+ const body = text3.trimEnd();
7431
7782
  if (!githubUrl?.trim()) return body;
7432
7783
  const labeled = labelGithubUrl(githubUrl);
7433
7784
  if (!labeled) {
@@ -7710,6 +8061,101 @@ function registerSlackTools(server) {
7710
8061
  );
7711
8062
  }
7712
8063
 
8064
+ // src/mcp/linear-tools.ts
8065
+ import { z as z2 } from "zod";
8066
+ function text2(payload, isError = false) {
8067
+ return {
8068
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
8069
+ ...isError ? { isError: true } : {}
8070
+ };
8071
+ }
8072
+ function fail2(err) {
8073
+ return text2(
8074
+ { error: err instanceof Error ? err.message : String(err) },
8075
+ true
8076
+ );
8077
+ }
8078
+ var prioritySchema = z2.number().int().min(0).max(4).optional().describe("0 none, 1 urgent, 2 high, 3 medium, 4 low");
8079
+ function registerLinearTools(server) {
8080
+ server.tool(
8081
+ "linear_list_teams",
8082
+ "List Linear teams (id, key, name) and workflow states for the connected Account. Use team key (e.g. ENG) on linear_create_issue; use state name or type (started, completed) on create/update. Viewer id is for assignee=me.",
8083
+ {},
8084
+ async () => {
8085
+ try {
8086
+ return text2(await listLinearTeams());
8087
+ } catch (err) {
8088
+ return fail2(err);
8089
+ }
8090
+ }
8091
+ );
8092
+ server.tool(
8093
+ "linear_get_issue",
8094
+ "Get a Linear issue by uuid or identifier (ENG-123).",
8095
+ { id: z2.string() },
8096
+ async ({ id }) => {
8097
+ try {
8098
+ return text2(await getLinearIssue(id));
8099
+ } catch (err) {
8100
+ return fail2(err);
8101
+ }
8102
+ }
8103
+ );
8104
+ server.tool(
8105
+ "linear_create_issue",
8106
+ 'Create a Linear issue. Call linear_list_teams first. team is id, key, or name. state is name, type (unstarted/started/completed/canceled/backlog), or id. assignee is "me" or a user id.',
8107
+ {
8108
+ team: z2.string(),
8109
+ title: z2.string(),
8110
+ description: z2.string().optional(),
8111
+ state: z2.string().optional(),
8112
+ assignee: z2.string().nullable().optional(),
8113
+ priority: prioritySchema
8114
+ },
8115
+ async (args) => {
8116
+ try {
8117
+ return text2(await createLinearIssue(args));
8118
+ } catch (err) {
8119
+ return fail2(err);
8120
+ }
8121
+ }
8122
+ );
8123
+ server.tool(
8124
+ "linear_update_issue",
8125
+ 'Update a Linear issue (uuid or ENG-123). Pass at least one of title, description, state, assignee, priority. state is name, type, or id. assignee is "me", a user id, or null to unassign.',
8126
+ {
8127
+ id: z2.string(),
8128
+ title: z2.string().optional(),
8129
+ description: z2.string().optional(),
8130
+ state: z2.string().optional(),
8131
+ assignee: z2.string().nullable().optional(),
8132
+ priority: prioritySchema
8133
+ },
8134
+ async (args) => {
8135
+ try {
8136
+ return text2(await updateLinearIssue(args));
8137
+ } catch (err) {
8138
+ return fail2(err);
8139
+ }
8140
+ }
8141
+ );
8142
+ server.tool(
8143
+ "linear_comment",
8144
+ "Add a markdown comment on a Linear issue (uuid or ENG-123).",
8145
+ {
8146
+ id: z2.string(),
8147
+ body: z2.string()
8148
+ },
8149
+ async (args) => {
8150
+ try {
8151
+ return text2(await commentLinearIssue(args));
8152
+ } catch (err) {
8153
+ return fail2(err);
8154
+ }
8155
+ }
8156
+ );
8157
+ }
8158
+
7713
8159
  // src/mcp/server.ts
7714
8160
  var MAX_ORCH_THREADS = 5;
7715
8161
  var CREATE_THREAD_TIMEOUT_MS = 9e4;
@@ -7786,7 +8232,7 @@ async function startMcpServer() {
7786
8232
  server.tool(
7787
8233
  "get_thread",
7788
8234
  "Get a compact thread summary by id/ref",
7789
- { ref: z2.string() },
8235
+ { ref: z3.string() },
7790
8236
  async ({ ref }) => {
7791
8237
  const t = orch.getThread(ref);
7792
8238
  if (!t) {
@@ -7815,14 +8261,14 @@ async function startMcpServer() {
7815
8261
  "present_artifact",
7816
8262
  "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.",
7817
8263
  {
7818
- title: z2.string().describe("Short title shown in the artifact pane header"),
7819
- type: z2.enum(["html", "svg", "markdown", "react"]).describe(
8264
+ title: z3.string().describe("Short title shown in the artifact pane header"),
8265
+ type: z3.enum(["html", "svg", "markdown", "react"]).describe(
7820
8266
  "Artifact kind \u2014 html opens an iframe preview; react transpiles and renders a default-exported component in a sandboxed iframe"
7821
8267
  ),
7822
- content: z2.string().describe(
8268
+ content: z3.string().describe(
7823
8269
  "Full document body (complete HTML page, SVG markup, markdown, or a React component module with a default export)"
7824
8270
  ),
7825
- artifact_id: z2.string().optional().describe("Stable id when updating the same artifact across turns")
8271
+ artifact_id: z3.string().optional().describe("Stable id when updating the same artifact across turns")
7826
8272
  },
7827
8273
  async ({ title, type, artifact_id }) => {
7828
8274
  const id = artifact_id?.trim() || `artifact_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
@@ -7840,15 +8286,15 @@ async function startMcpServer() {
7840
8286
  "ask_user",
7841
8287
  "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.",
7842
8288
  {
7843
- questions: z2.array(
7844
- z2.object({
7845
- question: z2.string().describe("Full question text ending with ?"),
7846
- header: z2.string().max(24).optional().describe("Short label shown above the question"),
7847
- multiSelect: z2.boolean().optional().describe("Allow selecting multiple options"),
7848
- options: z2.array(
7849
- z2.object({
7850
- label: z2.string(),
7851
- description: z2.string().optional().describe("What this option means / when to choose it (strongly preferred)")
8289
+ questions: z3.array(
8290
+ z3.object({
8291
+ question: z3.string().describe("Full question text ending with ?"),
8292
+ header: z3.string().max(24).optional().describe("Short label shown above the question"),
8293
+ multiSelect: z3.boolean().optional().describe("Allow selecting multiple options"),
8294
+ options: z3.array(
8295
+ z3.object({
8296
+ label: z3.string(),
8297
+ description: z3.string().optional().describe("What this option means / when to choose it (strongly preferred)")
7852
8298
  })
7853
8299
  ).min(2).max(6).describe("2\u20136 choices (Sideboard also offers Other)")
7854
8300
  })
@@ -7867,9 +8313,9 @@ async function startMcpServer() {
7867
8313
  "present_plan",
7868
8314
  "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.",
7869
8315
  {
7870
- title: z2.string().optional().describe("Short plan title (defaults to Plan)"),
7871
- content: z2.string().min(1).describe("Full plan markdown (headings, steps, risks, open questions)"),
7872
- thread_id: z2.string().optional().describe("Sideboard thread id when cwd is not the worktree")
8316
+ title: z3.string().optional().describe("Short plan title (defaults to Plan)"),
8317
+ content: z3.string().min(1).describe("Full plan markdown (headings, steps, risks, open questions)"),
8318
+ thread_id: z3.string().optional().describe("Sideboard thread id when cwd is not the worktree")
7873
8319
  },
7874
8320
  async ({ title, content, thread_id }) => {
7875
8321
  const { writePlanFile: writePlanFile2 } = await import("./plan-file-6O7G4VPQ.js");
@@ -7892,15 +8338,15 @@ async function startMcpServer() {
7892
8338
  "present_schema",
7893
8339
  "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.",
7894
8340
  {
7895
- title: z2.string().describe("Pane title"),
7896
- mode: z2.enum(["table", "form"]).optional().describe("table = list/filter records; form = edit one record"),
7897
- datasource: z2.enum(["brightsy", "inline"]).optional().describe("brightsy resolves via login; inline uses embedded resource/records"),
7898
- resource_id: z2.string().optional().describe("Brightsy record type UUID (or generic resource id)"),
7899
- record_id: z2.string().optional().describe("Record id when opening form mode"),
7900
- resource: z2.record(z2.unknown()).optional().describe("Inline { id, title, schema, schemaUi?, slug? }"),
7901
- record: z2.record(z2.unknown()).optional().describe("Inline record { id, data, published_at? }"),
7902
- records: z2.array(z2.record(z2.unknown())).optional().describe("Inline records for table mode"),
7903
- pane_id: z2.string().optional().describe("Stable pane id across updates")
8341
+ title: z3.string().describe("Pane title"),
8342
+ mode: z3.enum(["table", "form"]).optional().describe("table = list/filter records; form = edit one record"),
8343
+ datasource: z3.enum(["brightsy", "inline"]).optional().describe("brightsy resolves via login; inline uses embedded resource/records"),
8344
+ resource_id: z3.string().optional().describe("Brightsy record type UUID (or generic resource id)"),
8345
+ record_id: z3.string().optional().describe("Record id when opening form mode"),
8346
+ resource: z3.record(z3.unknown()).optional().describe("Inline { id, title, schema, schemaUi?, slug? }"),
8347
+ record: z3.record(z3.unknown()).optional().describe("Inline record { id, data, published_at? }"),
8348
+ records: z3.array(z3.record(z3.unknown())).optional().describe("Inline records for table mode"),
8349
+ pane_id: z3.string().optional().describe("Stable pane id across updates")
7904
8350
  },
7905
8351
  async (args) => {
7906
8352
  const id = args.pane_id?.trim() || `schema_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
@@ -7921,10 +8367,10 @@ async function startMcpServer() {
7921
8367
  "present_files",
7922
8368
  "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.",
7923
8369
  {
7924
- title: z2.string().optional().describe("Pane title (default: Files)"),
7925
- datasource: z2.enum(["brightsy", "memory"]).optional().describe("brightsy = account storage via login; memory = session demo store"),
7926
- path: z2.string().optional().describe("Initial folder path (e.g. public)"),
7927
- pane_id: z2.string().optional().describe("Stable pane id across updates")
8370
+ title: z3.string().optional().describe("Pane title (default: Files)"),
8371
+ datasource: z3.enum(["brightsy", "memory"]).optional().describe("brightsy = account storage via login; memory = session demo store"),
8372
+ path: z3.string().optional().describe("Initial folder path (e.g. public)"),
8373
+ pane_id: z3.string().optional().describe("Stable pane id across updates")
7928
8374
  },
7929
8375
  async (args) => {
7930
8376
  const id = args.pane_id?.trim() || `files_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
@@ -7940,19 +8386,21 @@ async function startMcpServer() {
7940
8386
  }
7941
8387
  );
7942
8388
  registerSlackTools(server);
8389
+ registerLinearTools(server);
7943
8390
  if (sideboardMcpProfile() !== "worktree") {
7944
- const { getCaffeinateHold: getCaffeinateHold2, setCaffeinateHold: setCaffeinateHold2 } = await import("./caffeinate-hold-KLC6SABD.js");
8391
+ const { getCaffeinateHold: getCaffeinateHold2, setCaffeinateHold: setCaffeinateHold2 } = await import("./caffeinate-hold-EAZNWJBE.js");
7945
8392
  const { resolveNewThreadOptions: resolveNewThreadOptions2, resolveThreadDefaults: resolveThreadDefaults2 } = await import("./app-settings-3TLA2EJ7.js");
7946
8393
  const accountDefaults = resolveThreadDefaults2();
7947
8394
  const accountDefaultsHint = `Account defaults: agent=${accountDefaults.agent}, model=${accountDefaults.model?.trim() || "Auto"}, effort=${accountDefaults.effort}`;
7948
8395
  server.tool(
7949
8396
  "set_caffeinate",
7950
- "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.",
8397
+ "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. Closing or archiving this orchestration chat also releases the hold. macOS only.",
7951
8398
  {
7952
- enabled: z2.boolean().describe("true = hold caffeinate on; false = release and let the Mac sleep")
8399
+ enabled: z3.boolean().describe("true = hold caffeinate on; false = release and let the Mac sleep")
7953
8400
  },
7954
8401
  async ({ enabled }) => {
7955
- const state = setCaffeinateHold2(enabled);
8402
+ const threadId = process.env.SIDEBOARD_ORCHESTRATOR_THREAD_ID?.trim() || null;
8403
+ const state = setCaffeinateHold2(enabled, { threadId });
7956
8404
  if (enabled && !state.held) {
7957
8405
  return {
7958
8406
  content: [
@@ -7975,7 +8423,7 @@ async function startMcpServer() {
7975
8423
  text: JSON.stringify({
7976
8424
  ...getCaffeinateHold2(),
7977
8425
  ok: true,
7978
- 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)."
8426
+ message: state.held ? "Mac will stay awake until you call set_caffeinate with enabled=false, the user says they are done, or this orchestration chat is closed." : "Caffeinate hold released. The Mac can sleep (unless Settings caffeinate toggles are on)."
7979
8427
  })
7980
8428
  }
7981
8429
  ]
@@ -7986,15 +8434,15 @@ async function startMcpServer() {
7986
8434
  "create_thread",
7987
8435
  `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.`,
7988
8436
  {
7989
- sourceType: z2.enum(["branch", "pr", "ticket"]),
7990
- sourceRef: z2.string(),
7991
- agent: z2.enum(["claude", "codex", "opencode", "brightsy", "cursor"]).optional().describe(`Omit to use Account default agent (${accountDefaults.agent})`),
7992
- model: z2.string().nullable().optional().describe(
8437
+ sourceType: z3.enum(["branch", "pr", "ticket"]),
8438
+ sourceRef: z3.string(),
8439
+ agent: z3.enum(["claude", "codex", "opencode", "brightsy", "cursor"]).optional().describe(`Omit to use Account default agent (${accountDefaults.agent})`),
8440
+ model: z3.string().nullable().optional().describe(
7993
8441
  `Usually omit to use Account default model (${accountDefaults.model?.trim() || "Auto"}). Pass null only to force Auto / agent-default.`
7994
8442
  ),
7995
- repoPath: z2.string(),
7996
- title: z2.string().optional(),
7997
- parentThreadId: z2.string().optional().describe(
8443
+ repoPath: z3.string(),
8444
+ title: z3.string().optional(),
8445
+ parentThreadId: z3.string().optional().describe(
7998
8446
  "Orchestration: omit (preferred) or pass YOUR chat id from the turn reminder / AGENTS.md. Do not invent uuids."
7999
8447
  )
8000
8448
  },
@@ -8099,11 +8547,11 @@ async function startMcpServer() {
8099
8547
  );
8100
8548
  server.tool(
8101
8549
  "send_to_thread",
8102
- "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.",
8550
+ "Queue a prompt on a worktree thread chat (runs under concurrency cap). Use after create_thread to start or continue a conversation. For commit/push/PR/merge, prefer ask_git (canonical desktop-button phrases). 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.",
8103
8551
  {
8104
- ref: z2.string(),
8105
- prompt: z2.string(),
8106
- force_stop: z2.boolean().optional()
8552
+ ref: z3.string(),
8553
+ prompt: z3.string(),
8554
+ force_stop: z3.boolean().optional()
8107
8555
  },
8108
8556
  async ({ ref, prompt, force_stop }) => {
8109
8557
  if (force_stop) {
@@ -8130,10 +8578,10 @@ async function startMcpServer() {
8130
8578
  );
8131
8579
  server.tool(
8132
8580
  "wait_for_turn",
8133
- "Block until the thread finishes its current/queued turn (avoids polling). Use after send_to_thread to read the agent reply.",
8581
+ "Block until the thread finishes its current/queued turn (avoids polling). Use after send_to_thread or ask_git to read the agent reply.",
8134
8582
  {
8135
- ref: z2.string(),
8136
- timeoutMs: z2.number().optional()
8583
+ ref: z3.string(),
8584
+ timeoutMs: z3.number().optional()
8137
8585
  },
8138
8586
  async ({ ref, timeoutMs }) => {
8139
8587
  const thread = await orch.waitForTurn(ref, timeoutMs ?? 6e5);
@@ -8155,7 +8603,7 @@ async function startMcpServer() {
8155
8603
  server.tool(
8156
8604
  "get_turn_result",
8157
8605
  "Final assistant message only (not full transcript)",
8158
- { ref: z2.string() },
8606
+ { ref: z3.string() },
8159
8607
  async ({ ref }) => {
8160
8608
  const result = orch.getTurnResult(ref);
8161
8609
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
@@ -8165,8 +8613,8 @@ async function startMcpServer() {
8165
8613
  "stop_thread",
8166
8614
  "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.",
8167
8615
  {
8168
- ref: z2.string(),
8169
- force: z2.boolean().optional()
8616
+ ref: z3.string(),
8617
+ force: z3.boolean().optional()
8170
8618
  },
8171
8619
  async ({ ref, force }) => {
8172
8620
  const t = orch.getThread(ref);
@@ -8195,8 +8643,8 @@ async function startMcpServer() {
8195
8643
  );
8196
8644
  server.tool(
8197
8645
  "archive_thread",
8198
- "Archive a thread (stops agent/dev, runs archive script, removes worktree when last chat tab). Coordinators open PRs only by asking the worktree agent.",
8199
- { ref: z2.string() },
8646
+ "Archive a thread (stops agent/dev, runs archive script, removes worktree when last chat tab). Coordinators commit, push, open PRs, and merge only by asking the worktree agent (ask_git).",
8647
+ { ref: z3.string() },
8200
8648
  async ({ ref }) => {
8201
8649
  const t = orch.getThread(ref);
8202
8650
  if (!t) {
@@ -8229,7 +8677,7 @@ async function startMcpServer() {
8229
8677
  server.tool(
8230
8678
  "restore_thread",
8231
8679
  "Restore an archived thread (recreates worktree from branch when needed)",
8232
- { ref: z2.string() },
8680
+ { ref: z3.string() },
8233
8681
  async ({ ref }) => {
8234
8682
  try {
8235
8683
  const restored = await orch.restore(ref);
@@ -8255,8 +8703,8 @@ async function startMcpServer() {
8255
8703
  "get_diff",
8256
8704
  "Compact diff summary (capped hunks, paginated)",
8257
8705
  {
8258
- ref: z2.string(),
8259
- maxFiles: z2.number().optional()
8706
+ ref: z3.string(),
8707
+ maxFiles: z3.number().optional()
8260
8708
  },
8261
8709
  async ({ ref, maxFiles }) => {
8262
8710
  const summary = await orch.diffSummary(ref);
@@ -8276,7 +8724,7 @@ async function startMcpServer() {
8276
8724
  server.tool(
8277
8725
  "request_review",
8278
8726
  '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.',
8279
- { ref: z2.string().describe("Worktree thread id/ref to review") },
8727
+ { ref: z3.string().describe("Worktree thread id/ref to review") },
8280
8728
  async ({ ref }) => {
8281
8729
  try {
8282
8730
  const tab = await orch.requestReview(ref);
@@ -8301,7 +8749,39 @@ async function startMcpServer() {
8301
8749
  }
8302
8750
  }
8303
8751
  );
8304
- const agentEnum = z2.enum(["claude", "codex", "opencode", "brightsy", "cursor"]);
8752
+ server.tool(
8753
+ "ask_git",
8754
+ "Tell a worktree agent to commit & push, open a draft PR, resolve conflicts, or merge the linked PR \u2014 same short prompts as the desktop git buttons. The worktree agent runs git/gh (`gh pr merge`); this only queues the prompt. Pass a worktree thread ref (not the orchestrator). Then wait_for_turn / get_turn_result. Do not run git or gh from the orchestration cwd.",
8755
+ {
8756
+ ref: z3.string().describe("Worktree thread id/ref"),
8757
+ action: z3.enum(AGENT_GIT_ACTIONS).describe(
8758
+ "commit-push | create-draft | create-web | resolve-conflicts | merge"
8759
+ )
8760
+ },
8761
+ async ({ ref, action }) => {
8762
+ try {
8763
+ const thread = await orch.askGit(ref, action);
8764
+ return {
8765
+ content: [
8766
+ {
8767
+ type: "text",
8768
+ text: JSON.stringify({
8769
+ id: thread.id,
8770
+ status: thread.status,
8771
+ queueLength: thread.queue.length,
8772
+ action,
8773
+ link: `sideboard://thread/${thread.id}`
8774
+ })
8775
+ }
8776
+ ]
8777
+ };
8778
+ } catch (err) {
8779
+ const message = err instanceof Error ? err.message : String(err);
8780
+ return { content: [{ type: "text", text: message }], isError: true };
8781
+ }
8782
+ }
8783
+ );
8784
+ const agentEnum = z3.enum(["claude", "codex", "opencode", "brightsy", "cursor"]);
8305
8785
  server.tool(
8306
8786
  "list_models",
8307
8787
  "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.",
@@ -8324,11 +8804,11 @@ async function startMcpServer() {
8324
8804
  "fork_worktree",
8325
8805
  "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.",
8326
8806
  {
8327
- ref: z2.string().describe("Worktree thread id/ref to fork"),
8328
- through_index: z2.number().optional().describe("Inclusive message index to include in the transcript (default: all)"),
8807
+ ref: z3.string().describe("Worktree thread id/ref to fork"),
8808
+ through_index: z3.number().optional().describe("Inclusive message index to include in the transcript (default: all)"),
8329
8809
  agent: agentEnum.optional().describe("Agent for the forked chat (default: same as source)"),
8330
- model: z2.string().nullable().optional().describe("Usually omit (Auto). Only set from list_models when you need a specific model"),
8331
- title: z2.string().optional()
8810
+ model: z3.string().nullable().optional().describe("Usually omit (Auto). Only set from list_models when you need a specific model"),
8811
+ title: z3.string().optional()
8332
8812
  },
8333
8813
  async ({ ref, through_index, agent, model, title }) => {
8334
8814
  try {
@@ -8369,11 +8849,11 @@ async function startMcpServer() {
8369
8849
  "fork_chat",
8370
8850
  "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.",
8371
8851
  {
8372
- ref: z2.string().describe("Thread id/ref to fork (worktree agent or orchestration chat)"),
8373
- through_index: z2.number().optional().describe("Inclusive message index to include in the transcript (default: all)"),
8852
+ ref: z3.string().describe("Thread id/ref to fork (worktree agent or orchestration chat)"),
8853
+ through_index: z3.number().optional().describe("Inclusive message index to include in the transcript (default: all)"),
8374
8854
  agent: agentEnum.optional().describe("Agent for the forked chat (default: same as source)"),
8375
- model: z2.string().nullable().optional().describe("Usually omit (Auto). Only set from list_models when you need a specific model"),
8376
- title: z2.string().optional()
8855
+ model: z3.string().nullable().optional().describe("Usually omit (Auto). Only set from list_models when you need a specific model"),
8856
+ title: z3.string().optional()
8377
8857
  },
8378
8858
  async ({ ref, through_index, agent, model, title }) => {
8379
8859
  try {
@@ -8419,8 +8899,8 @@ async function startMcpServer() {
8419
8899
  "run_dev_script",
8420
8900
  "Start a .sideboard/.conductor run script for a thread (default script if name omitted); returns port",
8421
8901
  {
8422
- ref: z2.string(),
8423
- name: z2.string().optional()
8902
+ ref: z3.string(),
8903
+ name: z3.string().optional()
8424
8904
  },
8425
8905
  async ({ ref, name }) => {
8426
8906
  const result = await orch.startDev(ref, name);
@@ -8442,7 +8922,7 @@ async function startMcpServer() {
8442
8922
  server.tool(
8443
8923
  "list_run_scripts",
8444
8924
  "List named run scripts available for a thread",
8445
- { ref: z2.string() },
8925
+ { ref: z3.string() },
8446
8926
  async ({ ref }) => {
8447
8927
  const scripts = orch.listThreadRunScripts(ref);
8448
8928
  const active = orch.getActiveRuns(ref);
@@ -8460,8 +8940,8 @@ async function startMcpServer() {
8460
8940
  "stop_dev_script",
8461
8941
  "Stop a running script for a thread (all scripts if name omitted)",
8462
8942
  {
8463
- ref: z2.string(),
8464
- name: z2.string().optional()
8943
+ ref: z3.string(),
8944
+ name: z3.string().optional()
8465
8945
  },
8466
8946
  async ({ ref, name }) => {
8467
8947
  orch.stopDev(ref, name);
@@ -8471,7 +8951,7 @@ async function startMcpServer() {
8471
8951
  server.tool(
8472
8952
  "run_setup",
8473
8953
  "Re-run workspace setup (Sideboard/Conductor settings or .cursor/worktrees.json)",
8474
- { ref: z2.string() },
8954
+ { ref: z3.string() },
8475
8955
  async ({ ref }) => {
8476
8956
  const result = await orch.runSetup(ref);
8477
8957
  return {
@@ -8482,7 +8962,7 @@ async function startMcpServer() {
8482
8962
  server.tool(
8483
8963
  "add_workspace",
8484
8964
  "Register a git repo as a Sideboard workspace",
8485
- { repoPath: z2.string() },
8965
+ { repoPath: z3.string() },
8486
8966
  async ({ repoPath }) => {
8487
8967
  const ws = await orch.addWorkspace(repoPath);
8488
8968
  return { content: [{ type: "text", text: JSON.stringify(ws) }] };
@@ -8491,7 +8971,7 @@ async function startMcpServer() {
8491
8971
  server.tool(
8492
8972
  "remove_workspace",
8493
8973
  "Unregister a Sideboard workspace (does not archive threads)",
8494
- { repoPath: z2.string() },
8974
+ { repoPath: z3.string() },
8495
8975
  async ({ repoPath }) => {
8496
8976
  orch.removeWorkspace(repoPath);
8497
8977
  return { content: [{ type: "text", text: "ok" }] };
@@ -8501,12 +8981,12 @@ async function startMcpServer() {
8501
8981
  "fanout",
8502
8982
  "Best-of-n: create one thread per agent with the same prompt (parallel attempts)",
8503
8983
  {
8504
- prompt: z2.string(),
8505
- agents: z2.array(z2.enum(["claude", "codex", "opencode", "brightsy", "cursor"])),
8506
- repoPath: z2.string(),
8507
- sourceType: z2.enum(["branch", "pr", "ticket"]).optional(),
8508
- sourceRef: z2.string().optional(),
8509
- title: z2.string().optional()
8984
+ prompt: z3.string(),
8985
+ agents: z3.array(z3.enum(["claude", "codex", "opencode", "brightsy", "cursor"])),
8986
+ repoPath: z3.string(),
8987
+ sourceType: z3.enum(["branch", "pr", "ticket"]).optional(),
8988
+ sourceRef: z3.string().optional(),
8989
+ title: z3.string().optional()
8510
8990
  },
8511
8991
  async (args) => {
8512
8992
  const threads = await orch.bestOfN(args);
@@ -8533,8 +9013,8 @@ async function startMcpServer() {
8533
9013
  "list_branches",
8534
9014
  "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).",
8535
9015
  {
8536
- repoPath: z2.string(),
8537
- unmergedOnly: z2.boolean().optional()
9016
+ repoPath: z3.string(),
9017
+ unmergedOnly: z3.boolean().optional()
8538
9018
  },
8539
9019
  async ({ repoPath, unmergedOnly }) => {
8540
9020
  const root = await resolveRepoRoot(repoPath);
@@ -8554,7 +9034,7 @@ async function startMcpServer() {
8554
9034
  server.tool(
8555
9035
  "list_prs",
8556
9036
  "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.",
8557
- { repoPath: z2.string() },
9037
+ { repoPath: z3.string() },
8558
9038
  async ({ repoPath }) => {
8559
9039
  const root = await resolveRepoRoot(repoPath);
8560
9040
  const prs = await listPrs(root);
@@ -8572,8 +9052,8 @@ async function startMcpServer() {
8572
9052
  );
8573
9053
  server.tool(
8574
9054
  "get_pr_stack",
8575
- "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.",
8576
- { ref: z2.string() },
9055
+ "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 ask_git merge on stacked PRs.",
9056
+ { ref: z3.string() },
8577
9057
  async ({ ref }) => {
8578
9058
  const stack = await orch.getPrStack(ref);
8579
9059
  return {
@@ -8585,8 +9065,8 @@ async function startMcpServer() {
8585
9065
  "open_pr_stack_layers",
8586
9066
  "Materialize one worktree+thread per stack layer (or a single 1-based layer). Pass a thread ref already on the stack.",
8587
9067
  {
8588
- ref: z2.string(),
8589
- layer: z2.number().int().positive().optional()
9068
+ ref: z3.string(),
9069
+ layer: z3.number().int().positive().optional()
8590
9070
  },
8591
9071
  async ({ ref, layer }) => {
8592
9072
  const result = await orch.openPrStackLayers(ref, { layer });
@@ -8620,9 +9100,9 @@ async function startMcpServer() {
8620
9100
  "add_stack_layer",
8621
9101
  "Add a branch on top of the current stack (`gh stack add`) and open a worktree+thread for it.",
8622
9102
  {
8623
- ref: z2.string(),
8624
- branchName: z2.string(),
8625
- title: z2.string().optional()
9103
+ ref: z3.string(),
9104
+ branchName: z3.string(),
9105
+ title: z3.string().optional()
8626
9106
  },
8627
9107
  async ({ ref, branchName, title }) => {
8628
9108
  const result = await orch.addStackLayer(ref, branchName, { title });
@@ -8651,11 +9131,11 @@ async function startMcpServer() {
8651
9131
  "create_pr_stack",
8652
9132
  "Create a new GitHub PR stack with one Sideboard worktree per layer (bottom\u2192top branch names). Requires `gh extension install github/gh-stack`.",
8653
9133
  {
8654
- repoPath: z2.string(),
8655
- branches: z2.array(z2.string()).min(1),
8656
- agent: z2.enum(["claude", "codex", "opencode", "brightsy", "cursor"]),
8657
- base: z2.string().optional(),
8658
- title: z2.string().optional()
9134
+ repoPath: z3.string(),
9135
+ branches: z3.array(z3.string()).min(1),
9136
+ agent: z3.enum(["claude", "codex", "opencode", "brightsy", "cursor"]),
9137
+ base: z3.string().optional(),
9138
+ title: z3.string().optional()
8659
9139
  },
8660
9140
  async (args) => {
8661
9141
  const result = await orch.createPrStack({
@@ -8693,7 +9173,7 @@ async function startMcpServer() {
8693
9173
  server.tool(
8694
9174
  "list_issues",
8695
9175
  "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.",
8696
- { repoPath: z2.string() },
9176
+ { repoPath: z3.string() },
8697
9177
  async ({ repoPath }) => {
8698
9178
  const root = await resolveRepoRoot(repoPath);
8699
9179
  const result = await listIssues(root);
@@ -8704,8 +9184,8 @@ async function startMcpServer() {
8704
9184
  "list_linear_issues",
8705
9185
  "Deprecated: prefer list_issues. Lists assigned Linear issues via the chosen agent MCP connector",
8706
9186
  {
8707
- agent: z2.enum(["claude", "codex", "opencode"]),
8708
- repoPath: z2.string()
9187
+ agent: z3.enum(["claude", "codex", "opencode"]),
9188
+ repoPath: z3.string()
8709
9189
  },
8710
9190
  async ({ agent, repoPath }) => {
8711
9191
  const issues = await listLinearIssues(agent, repoPath);
@@ -8718,18 +9198,7 @@ async function startMcpServer() {
8718
9198
  }
8719
9199
 
8720
9200
  // src/brightsy/api.ts
8721
- function formatBrightsyFetchError(err, url) {
8722
- if (!(err instanceof Error)) return `${String(err)} (${url})`;
8723
- const cause = err.cause;
8724
- let detail = "";
8725
- if (cause instanceof Error) {
8726
- const code = typeof cause.code === "string" ? cause.code : void 0;
8727
- detail = code ? ` [${code}: ${cause.message}]` : ` [${cause.message}]`;
8728
- } else if (cause != null) {
8729
- detail = ` [${String(cause)}]`;
8730
- }
8731
- return `${err.message}${detail} (${url})`;
8732
- }
9201
+ var formatBrightsyFetchError = formatFetchError;
8733
9202
  var BrightsySideboardApi = class {
8734
9203
  cfg;
8735
9204
  fetchImpl;
@@ -8849,8 +9318,8 @@ var BrightsySideboardApi = class {
8849
9318
  };
8850
9319
  function taskMessageText(task) {
8851
9320
  const parts = task.message?.parts ?? [];
8852
- const text2 = parts.find((p) => p.kind === "text")?.text ?? "";
8853
- return text2.trim();
9321
+ const text3 = parts.find((p) => p.kind === "text")?.text ?? "";
9322
+ return text3.trim();
8854
9323
  }
8855
9324
 
8856
9325
  // src/brightsy/cloud-connect.ts
@@ -9046,63 +9515,128 @@ async function runCloudConnect(opts) {
9046
9515
 
9047
9516
  // src/slack/oauth.ts
9048
9517
  import { randomBytes as randomBytes2 } from "crypto";
9049
- import { createServer as createServer3 } from "http";
9050
9518
 
9051
9519
  // src/slack/baked-app.ts
9052
9520
  var BAKED_SLACK_CLIENT_ID = "7592788819232.11813536272562";
9053
- var BAKED_SLACK_CLIENT_SECRET = "10290abf6ad0dc11fe6fc321a6215299";
9054
- var BAKED_SLACK_RELAY_URL = "wss://slack-relay.sideboard.cloud/desktop";
9521
+ var BAKED_SLACK_RELAY_URL = "wss://relay.sideboard.cloud/slack/desktop";
9055
9522
  function hasBakedSlackOAuth() {
9056
- return Boolean(BAKED_SLACK_CLIENT_ID.trim() && BAKED_SLACK_CLIENT_SECRET.trim());
9523
+ return Boolean(BAKED_SLACK_CLIENT_ID.trim());
9057
9524
  }
9058
9525
  function slackRelayUrl() {
9059
9526
  return process.env.SIDEBOARD_SLACK_RELAY_URL?.trim() || BAKED_SLACK_RELAY_URL.trim();
9060
9527
  }
9061
9528
 
9062
9529
  // src/slack/oauth-redirect.ts
9063
- var SLACK_OAUTH_PORT = 19847;
9064
- var SLACK_OAUTH_LOCAL_CALLBACK = `http://127.0.0.1:${SLACK_OAUTH_PORT}/callback`;
9065
- var SLACK_OAUTH_REDIRECT = "https://slack-relay.sideboard.cloud/callback";
9066
- var SLACK_OAUTH_BOUNCE_PATH = "/callback";
9067
- var BOUNCE_PARAMS = ["code", "state", "error", "error_description"];
9530
+ var SLACK_OAUTH_CALLBACK_PATH = "/slack/callback";
9531
+ var SLACK_OAUTH_RESULT_PATH = "/slack/oauth/result";
9532
+ var SLACK_RELAY_DESKTOP_PATH = "/slack/desktop";
9533
+ var SLACK_OAUTH_LOCAL_PORT = 19847;
9534
+ var SLACK_OAUTH_LOCAL_CALLBACK = `http://127.0.0.1:${SLACK_OAUTH_LOCAL_PORT}${SLACK_OAUTH_CALLBACK_PATH}`;
9535
+ var SLACK_OAUTH_REDIRECT = `https://relay.sideboard.cloud${SLACK_OAUTH_CALLBACK_PATH}`;
9068
9536
  function slackOAuthRedirectUri() {
9069
9537
  return process.env.SIDEBOARD_SLACK_OAUTH_REDIRECT?.trim() || SLACK_OAUTH_REDIRECT;
9070
9538
  }
9071
- function slackOAuthLocalBounceTarget(params) {
9072
- const dest = new URL(SLACK_OAUTH_LOCAL_CALLBACK);
9073
- for (const key of BOUNCE_PARAMS) {
9074
- const value = params.get(key);
9075
- if (value) dest.searchParams.set(key, value);
9076
- }
9077
- return dest.toString();
9078
- }
9079
9539
  function escapeHtml(value) {
9080
9540
  return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
9081
9541
  }
9082
- function slackOAuthBounceHtml(target) {
9083
- const safe = escapeHtml(target);
9084
- return `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=${safe}"><title>Connecting Slack</title>
9542
+ function slackOAuthHtmlPage(title, body) {
9543
+ return `<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title>
9085
9544
  <style>body{font-family:ui-sans-serif,system-ui,sans-serif;padding:48px 24px;max-width:36rem;margin:0 auto;color:#1a1a1a}
9086
- h1{font-size:1.25rem}p{line-height:1.5;color:#444}a{color:#111}</style>
9087
- <script>location.replace(${JSON.stringify(target)})</script>
9088
- </head><body><h1>Connecting Slack</h1><p>Returning to Sideboard\u2026 <a href="${safe}">Continue</a></p></body></html>`;
9545
+ h1{font-size:1.25rem}p{line-height:1.5;color:#444}</style></head><body>${body}</body></html>`;
9089
9546
  }
9090
- function slackOAuthBounceResponse(reqUrl) {
9547
+ function parseSlackOAuthCallbackUrl(reqUrl) {
9091
9548
  let url;
9092
9549
  try {
9093
9550
  url = new URL(reqUrl, SLACK_OAUTH_REDIRECT);
9094
9551
  } catch {
9095
9552
  return null;
9096
9553
  }
9097
- if (url.pathname !== SLACK_OAUTH_BOUNCE_PATH) return null;
9098
- const target = slackOAuthLocalBounceTarget(url.searchParams);
9554
+ if (url.pathname !== SLACK_OAUTH_CALLBACK_PATH) return null;
9555
+ return url;
9556
+ }
9557
+ function parseSlackOAuthResultUrl(reqUrl) {
9558
+ let url;
9559
+ try {
9560
+ url = new URL(reqUrl, SLACK_OAUTH_REDIRECT);
9561
+ } catch {
9562
+ return null;
9563
+ }
9564
+ if (url.pathname !== SLACK_OAUTH_RESULT_PATH) return null;
9565
+ return url;
9566
+ }
9567
+
9568
+ // src/slack/oauth-exchange.ts
9569
+ var PENDING_TTL_MS = 5 * 6e4;
9570
+ var SlackOAuthPendingStore = class {
9571
+ items = /* @__PURE__ */ new Map();
9572
+ put(state, value) {
9573
+ const id = state.trim();
9574
+ if (!id) return;
9575
+ this.sweep();
9576
+ const existing = this.items.get(id);
9577
+ if (existing && existing.value.ok && !value.ok) return;
9578
+ this.items.set(id, { expiresAt: Date.now() + PENDING_TTL_MS, value });
9579
+ }
9580
+ /** Consume a pending result. Missing/expired → null (desktop should keep polling). */
9581
+ take(state) {
9582
+ const id = state.trim();
9583
+ if (!id) return null;
9584
+ this.sweep();
9585
+ const row = this.items.get(id);
9586
+ if (!row) return null;
9587
+ this.items.delete(id);
9588
+ return row.value;
9589
+ }
9590
+ sweep() {
9591
+ const now = Date.now();
9592
+ for (const [key, row] of this.items) {
9593
+ if (row.expiresAt <= now) this.items.delete(key);
9594
+ }
9595
+ }
9596
+ };
9597
+ function slackRelayHttpOrigin(relayWsUrl) {
9598
+ const raw = (relayWsUrl ?? slackRelayUrl()).trim();
9599
+ const http = raw.replace(/^ws/i, "http");
9600
+ const u = new URL(http);
9601
+ return `${u.protocol}//${u.host}`;
9602
+ }
9603
+ function slackOAuthResultUrl(state, relayWsUrl) {
9604
+ const u = new URL(SLACK_OAUTH_RESULT_PATH, slackRelayHttpOrigin(relayWsUrl));
9605
+ u.searchParams.set("state", state);
9606
+ return u.toString();
9607
+ }
9608
+ function slackOAuthRelayClientId() {
9609
+ return process.env.SIDEBOARD_SLACK_CLIENT_ID?.trim() || BAKED_SLACK_CLIENT_ID.trim();
9610
+ }
9611
+ function slackOAuthRelayRedirectUri() {
9612
+ return slackOAuthRedirectUri() || SLACK_OAUTH_REDIRECT;
9613
+ }
9614
+ async function exchangeSlackOAuthCode(opts) {
9615
+ const body = new URLSearchParams({
9616
+ client_id: opts.clientId,
9617
+ client_secret: opts.clientSecret,
9618
+ code: opts.code,
9619
+ redirect_uri: opts.redirectUri
9620
+ });
9621
+ const fetchImpl = opts.fetchImpl ?? fetch;
9622
+ const res = await fetchImpl("https://slack.com/api/oauth.v2.access", {
9623
+ method: "POST",
9624
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
9625
+ body
9626
+ });
9627
+ const data = await res.json();
9628
+ if (!data.ok) {
9629
+ throw new Error(`Slack OAuth exchange failed: ${data.error || res.status}`);
9630
+ }
9631
+ const teamId = data.team?.id?.trim();
9632
+ if (!teamId) throw new Error("Slack OAuth did not return a team id");
9099
9633
  return {
9100
- status: 302,
9101
- headers: {
9102
- Location: target,
9103
- "Content-Type": "text/html; charset=utf-8"
9104
- },
9105
- body: slackOAuthBounceHtml(target)
9634
+ team_id: teamId,
9635
+ team_name: data.team?.name?.trim() || teamId,
9636
+ user_id: data.authed_user?.id?.trim() || void 0,
9637
+ bot_token: data.access_token?.trim() || void 0,
9638
+ user_token: data.authed_user?.access_token?.trim() || void 0,
9639
+ scopes: [data.scope, data.authed_user?.scope].filter(Boolean).join(",")
9106
9640
  };
9107
9641
  }
9108
9642
 
@@ -9138,13 +9672,13 @@ var SLACK_USER_SCOPES = [
9138
9672
  function slackOAuthCredentials() {
9139
9673
  const settings = loadAppSettings();
9140
9674
  const clientId = process.env.SIDEBOARD_SLACK_CLIENT_ID?.trim() || settings.integrations.slackClientId?.trim() || BAKED_SLACK_CLIENT_ID.trim();
9141
- const clientSecret = process.env.SIDEBOARD_SLACK_CLIENT_SECRET?.trim() || settings.integrations.slackClientSecret?.trim() || BAKED_SLACK_CLIENT_SECRET.trim();
9142
- if (!clientId || !clientSecret) {
9675
+ const clientSecret = process.env.SIDEBOARD_SLACK_CLIENT_SECRET?.trim() || settings.integrations.slackClientSecret?.trim() || "";
9676
+ if (!clientId) {
9143
9677
  throw new Error(
9144
- "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."
9678
+ "Slack browser sign-in needs a Slack app Client ID (Account \u2192 Slack, or SIDEBOARD_SLACK_CLIENT_ID). You can still paste an xoxb- or xoxp- token."
9145
9679
  );
9146
9680
  }
9147
- return { clientId, clientSecret };
9681
+ return { clientId, clientSecret: clientSecret || null };
9148
9682
  }
9149
9683
  var SLACK_OAUTH_CANCELLED = "Slack sign-in cancelled";
9150
9684
  var SlackOAuthCancelledError = class extends Error {
@@ -9169,127 +9703,100 @@ function slackOAuthAuthorizeUrl(clientId, state) {
9169
9703
  });
9170
9704
  return `https://slack.com/oauth/v2/authorize?${params.toString()}`;
9171
9705
  }
9172
- function htmlPage2(title, body) {
9173
- return `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title>
9174
- <style>body{font-family:ui-sans-serif,system-ui,sans-serif;padding:48px 24px;max-width:36rem;margin:0 auto;color:#1a1a1a}
9175
- h1{font-size:1.25rem}p{line-height:1.5;color:#444}</style></head><body>${body}</body></html>`;
9706
+ function sleep(ms, signal) {
9707
+ return new Promise((resolve, reject) => {
9708
+ if (signal?.aborted) {
9709
+ reject(new SlackOAuthCancelledError());
9710
+ return;
9711
+ }
9712
+ const timer = setTimeout(() => {
9713
+ signal?.removeEventListener("abort", onAbort);
9714
+ resolve();
9715
+ }, ms);
9716
+ const onAbort = () => {
9717
+ clearTimeout(timer);
9718
+ reject(new SlackOAuthCancelledError());
9719
+ };
9720
+ signal?.addEventListener("abort", onAbort, { once: true });
9721
+ });
9722
+ }
9723
+ function upsertFromPayload(payload) {
9724
+ return upsertSlackWorkspace({
9725
+ team_id: payload.team_id,
9726
+ team_name: payload.team_name,
9727
+ user_id: payload.user_id,
9728
+ bot_token: payload.bot_token,
9729
+ user_token: payload.user_token,
9730
+ scopes: payload.scopes,
9731
+ connected_at: (/* @__PURE__ */ new Date()).toISOString()
9732
+ });
9176
9733
  }
9177
9734
  async function startSlackOAuth(opts) {
9178
9735
  if (opts?.signal?.aborted) {
9179
9736
  throw new SlackOAuthCancelledError();
9180
9737
  }
9181
- const { clientId, clientSecret } = slackOAuthCredentials();
9738
+ const { clientId } = slackOAuthCredentials();
9182
9739
  const state = randomBytes2(16).toString("hex");
9183
9740
  const authorizeUrl = slackOAuthAuthorizeUrl(clientId, state);
9184
9741
  const timeoutMs = opts?.timeoutMs ?? 5 * 6e4;
9185
- const code = await new Promise((resolve, reject) => {
9186
- let settled = false;
9187
- const finish = (err, value) => {
9188
- if (settled) return;
9189
- settled = true;
9190
- cleanup();
9191
- if (err) reject(err);
9192
- else resolve(value);
9193
- };
9194
- const server = createServer3((req, res2) => {
9195
- try {
9196
- const url = new URL(req.url || "/", `http://127.0.0.1:${SLACK_OAUTH_PORT}`);
9197
- if (url.pathname !== "/callback") {
9198
- res2.writeHead(404);
9199
- res2.end("Not found");
9200
- return;
9201
- }
9202
- const err = url.searchParams.get("error");
9203
- const gotState = url.searchParams.get("state");
9204
- const gotCode = url.searchParams.get("code");
9205
- if (err) {
9206
- res2.writeHead(400, { "Content-Type": "text/html" });
9207
- res2.end(htmlPage2("Slack", `<h1>Authorization cancelled</h1><p>${err}</p>`));
9208
- finish(new SlackOAuthCancelledError(`Slack OAuth: ${err}`));
9209
- return;
9210
- }
9211
- if (gotState !== state || !gotCode) {
9212
- res2.writeHead(400, { "Content-Type": "text/html" });
9213
- res2.end(htmlPage2("Slack", "<h1>Invalid callback</h1><p>State or code missing.</p>"));
9214
- finish(new Error("Slack OAuth callback was invalid"));
9215
- return;
9216
- }
9217
- res2.writeHead(200, { "Content-Type": "text/html" });
9218
- res2.end(
9219
- htmlPage2(
9220
- "Slack connected",
9221
- "<h1>Slack workspace connected</h1><p>You can close this tab and return to Sideboard.</p>"
9222
- )
9223
- );
9224
- finish(void 0, gotCode);
9225
- } catch (e) {
9226
- finish(e instanceof Error ? e : new Error(String(e)));
9227
- }
9228
- });
9229
- const timer = setTimeout(() => {
9230
- finish(new Error("Slack sign-in timed out \u2014 try again"));
9231
- }, timeoutMs);
9232
- const onAbort = () => finish(new SlackOAuthCancelledError());
9233
- const cleanup = () => {
9234
- clearTimeout(timer);
9235
- opts?.signal?.removeEventListener("abort", onAbort);
9236
- server.close();
9237
- };
9238
- opts?.signal?.addEventListener("abort", onAbort, { once: true });
9742
+ const pollIntervalMs = opts?.pollIntervalMs ?? 400;
9743
+ const fetchImpl = opts?.fetchImpl ?? fetch;
9744
+ const resultUrl = opts?.resultUrlForState?.(state) ?? slackOAuthResultUrl(state);
9745
+ await Promise.resolve(opts?.openUrl?.(authorizeUrl));
9746
+ if (opts?.signal?.aborted) {
9747
+ throw new SlackOAuthCancelledError();
9748
+ }
9749
+ const deadline = Date.now() + timeoutMs;
9750
+ while (Date.now() < deadline) {
9239
9751
  if (opts?.signal?.aborted) {
9240
- onAbort();
9241
- return;
9752
+ throw new SlackOAuthCancelledError();
9242
9753
  }
9243
- server.on("error", (e) => {
9244
- finish(
9245
- e instanceof Error && e.code === "EADDRINUSE" ? new Error(
9246
- `Port ${SLACK_OAUTH_PORT} is in use. Close whatever is bound there, or paste a Slack token instead.`
9247
- ) : e instanceof Error ? e : new Error(String(e))
9248
- );
9249
- });
9250
- server.listen(SLACK_OAUTH_PORT, "127.0.0.1", () => {
9251
- void Promise.resolve(opts?.openUrl?.(authorizeUrl)).catch((e) => {
9252
- finish(e instanceof Error ? e : new Error(String(e)));
9754
+ let res;
9755
+ try {
9756
+ res = await fetchImpl(resultUrl);
9757
+ } catch {
9758
+ if (opts?.signal?.aborted) throw new SlackOAuthCancelledError();
9759
+ await sleep(pollIntervalMs, opts?.signal);
9760
+ continue;
9761
+ }
9762
+ let data;
9763
+ try {
9764
+ data = await res.json();
9765
+ } catch {
9766
+ await sleep(pollIntervalMs, opts?.signal);
9767
+ continue;
9768
+ }
9769
+ if (data.ok && data.team_id?.trim()) {
9770
+ return upsertFromPayload({
9771
+ team_id: data.team_id.trim(),
9772
+ team_name: data.team_name?.trim() || data.team_id.trim(),
9773
+ user_id: data.user_id?.trim() || void 0,
9774
+ bot_token: data.bot_token?.trim() || void 0,
9775
+ user_token: data.user_token?.trim() || void 0,
9776
+ scopes: data.scopes?.trim() || ""
9253
9777
  });
9254
- });
9255
- });
9256
- const body = new URLSearchParams({
9257
- client_id: clientId,
9258
- client_secret: clientSecret,
9259
- code,
9260
- redirect_uri: slackOAuthRedirectUri()
9261
- });
9262
- const res = await fetch("https://slack.com/api/oauth.v2.access", {
9263
- method: "POST",
9264
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
9265
- body
9266
- });
9267
- const data = await res.json();
9268
- if (!data.ok) {
9269
- throw new Error(`Slack OAuth exchange failed: ${data.error || res.status}`);
9778
+ }
9779
+ const err = data.error?.trim();
9780
+ if (err && err !== "pending") {
9781
+ if (/cancel/i.test(err) || /access_denied/i.test(err)) {
9782
+ throw new SlackOAuthCancelledError(`Slack OAuth: ${err}`);
9783
+ }
9784
+ throw new Error(err.startsWith("Slack") ? err : `Slack OAuth: ${err}`);
9785
+ }
9786
+ await sleep(pollIntervalMs, opts?.signal);
9270
9787
  }
9271
- const teamId = data.team?.id?.trim();
9272
- if (!teamId) throw new Error("Slack OAuth did not return a team id");
9273
- return upsertSlackWorkspace({
9274
- team_id: teamId,
9275
- team_name: data.team?.name?.trim() || teamId,
9276
- user_id: data.authed_user?.id,
9277
- bot_token: data.access_token,
9278
- user_token: data.authed_user?.access_token,
9279
- scopes: [data.scope, data.authed_user?.scope].filter(Boolean).join(","),
9280
- connected_at: (/* @__PURE__ */ new Date()).toISOString()
9281
- });
9788
+ throw new Error("Slack sign-in timed out \u2014 try again");
9282
9789
  }
9283
9790
 
9284
9791
  // src/slack/socket-mode.ts
9285
9792
  import { WebSocket as WsWebSocket } from "ws";
9286
9793
  var BACKOFF_START_MS = 1e3;
9287
9794
  var BACKOFF_MAX_MS = 3e4;
9288
- function stripSlackMentions(text2) {
9289
- return text2.replace(/<@[^>]+>/g, "").replace(/\s+/g, " ").trim();
9795
+ function stripSlackMentions(text3) {
9796
+ return text3.replace(/<@[^>]+>/g, "").replace(/\s+/g, " ").trim();
9290
9797
  }
9291
- function isSlackStopCommand(text2) {
9292
- const t = stripSlackMentions(text2).toLowerCase();
9798
+ function isSlackStopCommand(text3) {
9799
+ const t = stripSlackMentions(text3).toLowerCase();
9293
9800
  return t === "stop" || t === "sideboard_force_stop";
9294
9801
  }
9295
9802
  function parseSlackSocketFrame(raw) {
@@ -9311,8 +9818,8 @@ function inboundFromSocketFrame(frame) {
9311
9818
  const ts = event.ts?.trim();
9312
9819
  if (!channelId || !ts) return null;
9313
9820
  const rawText = event.text?.trim() ?? "";
9314
- const text2 = stripSlackMentions(rawText);
9315
- if (!text2) return null;
9821
+ const text3 = stripSlackMentions(rawText);
9822
+ if (!text3) return null;
9316
9823
  const teamId = (frame.payload?.team_id || event.team || "").trim();
9317
9824
  if (!teamId) return null;
9318
9825
  const isDm = event.type === "message" && (event.channel_type === "im" || event.channel_type === "mpim" || !event.channel_type && channelId.startsWith("D"));
@@ -9324,7 +9831,7 @@ function inboundFromSocketFrame(frame) {
9324
9831
  ts,
9325
9832
  threadTs: event.thread_ts?.trim() || void 0,
9326
9833
  userId: event.user?.trim(),
9327
- text: text2,
9834
+ text: text3,
9328
9835
  kind: isMention ? "mention" : "dm"
9329
9836
  };
9330
9837
  }
@@ -9575,7 +10082,40 @@ function parseSlackRelayServerMessage(raw) {
9575
10082
  }
9576
10083
  }
9577
10084
 
10085
+ // src/slack/public-dns.ts
10086
+ import { lookup as systemLookup, Resolver } from "dns";
10087
+ import { request as httpRequest } from "http";
10088
+ import { request as httpsRequest } from "https";
10089
+ var PUBLIC_DNS = ["1.1.1.1", "8.8.8.8"];
10090
+ function finishLookup(options, callback, address, family) {
10091
+ if (options.all) {
10092
+ callback(null, [{ address, family }]);
10093
+ return;
10094
+ }
10095
+ callback(null, address, family);
10096
+ }
10097
+ var lookupPreferPublicDns = (hostname, options, callback) => {
10098
+ if (options.family === 6) {
10099
+ systemLookup(hostname, options, (err, address, family) => {
10100
+ callback(err, address, family);
10101
+ });
10102
+ return;
10103
+ }
10104
+ const resolver = new Resolver();
10105
+ resolver.setServers(PUBLIC_DNS);
10106
+ resolver.resolve4(hostname, (err, addresses) => {
10107
+ if (!err && addresses[0]) {
10108
+ finishLookup(options, callback, addresses[0], 4);
10109
+ return;
10110
+ }
10111
+ systemLookup(hostname, options, (sysErr, address, family) => {
10112
+ callback(sysErr, address, family);
10113
+ });
10114
+ });
10115
+ };
10116
+
9578
10117
  // src/slack/relay-client.ts
10118
+ import { WebSocket as WsWebSocket2 } from "ws";
9579
10119
  var BACKOFF_START_MS2 = 1e3;
9580
10120
  var BACKOFF_MAX_MS2 = 3e4;
9581
10121
  function wait2(ms, signal) {
@@ -9600,7 +10140,11 @@ function send(ws, msg) {
9600
10140
  }
9601
10141
  async function runSlackRelayClient(opts) {
9602
10142
  const log = opts.onLog ?? (() => void 0);
9603
- const Ws = resolveWebSocket(opts.WebSocketImpl);
10143
+ const Ws = opts.WebSocketImpl ? resolveWebSocket(opts.WebSocketImpl) : class {
10144
+ constructor(url2) {
10145
+ return new WsWebSocket2(url2, { lookup: lookupPreferPublicDns });
10146
+ }
10147
+ };
9604
10148
  const url = opts.url.trim();
9605
10149
  const deviceId = opts.deviceId.trim();
9606
10150
  if (!url) throw new Error("Slack relay URL is empty");
@@ -9763,12 +10307,12 @@ function formatSlackInboundPrompt(msg) {
9763
10307
 
9764
10308
  ${msg.text}`;
9765
10309
  }
9766
- function isSlackInboundUserPrompt(text2) {
9767
- return text2.startsWith("Slack DM\n") || text2.startsWith("Slack @mention\n");
10310
+ function isSlackInboundUserPrompt(text3) {
10311
+ return text3.startsWith("Slack DM\n") || text3.startsWith("Slack @mention\n");
9768
10312
  }
9769
- function formatSlackSignedReply(deviceLabel, text2) {
10313
+ function formatSlackSignedReply(deviceLabel, text3) {
9770
10314
  const label = deviceLabel.trim();
9771
- const body = text2.trim();
10315
+ const body = text3.trim();
9772
10316
  if (!body) return body;
9773
10317
  if (!label) return body;
9774
10318
  const head = `${label}:`;
@@ -9777,8 +10321,8 @@ function formatSlackSignedReply(deviceLabel, text2) {
9777
10321
  }
9778
10322
  return `${label}: ${body}`;
9779
10323
  }
9780
- function signForThisMac(text2) {
9781
- return formatSlackSignedReply(ensureSlackDeviceIdentity().deviceLabel, text2);
10324
+ function signForThisMac(text3) {
10325
+ return formatSlackSignedReply(ensureSlackDeviceIdentity().deviceLabel, text3);
9782
10326
  }
9783
10327
  function slackReplyThreadTs(msg) {
9784
10328
  if (msg.threadTs && msg.threadTs !== msg.ts) return msg.threadTs;
@@ -9821,7 +10365,7 @@ async function ackSlackInboundSeen(msg, opts) {
9821
10365
  log(`react error: ${errMsg}${hint}`);
9822
10366
  }
9823
10367
  }
9824
- async function postSlackText(target, text2, opts) {
10368
+ async function postSlackText(target, text3, opts) {
9825
10369
  const stub = {
9826
10370
  teamId: target.teamId,
9827
10371
  channelId: target.channelId,
@@ -9831,7 +10375,7 @@ async function postSlackText(target, text2, opts) {
9831
10375
  kind: "dm"
9832
10376
  };
9833
10377
  if (opts.postReply) {
9834
- await opts.postReply(stub, text2);
10378
+ await opts.postReply(stub, text3);
9835
10379
  return;
9836
10380
  }
9837
10381
  const token = writeTokenForTeam(target.teamId);
@@ -9840,29 +10384,29 @@ async function postSlackText(target, text2, opts) {
9840
10384
  "chat.postMessage",
9841
10385
  {
9842
10386
  channel: target.channelId,
9843
- text: text2,
10387
+ text: text3,
9844
10388
  thread_ts: target.threadTs
9845
10389
  },
9846
10390
  opts.fetchImpl
9847
10391
  );
9848
10392
  }
9849
- async function postSlackReply(msg, text2, opts) {
10393
+ async function postSlackReply(msg, text3, opts) {
9850
10394
  await postSlackText(
9851
10395
  {
9852
10396
  teamId: msg.teamId,
9853
10397
  channelId: msg.channelId,
9854
10398
  threadTs: slackReplyThreadTs(msg)
9855
10399
  },
9856
- signForThisMac(text2),
10400
+ signForThisMac(text3),
9857
10401
  opts
9858
10402
  );
9859
10403
  }
9860
10404
  var lastRelayed = /* @__PURE__ */ new Map();
9861
- function markRelayed(threadId, text2) {
9862
- lastRelayed.set(threadId, `${threadId}:${text2}`);
10405
+ function markRelayed(threadId, text3) {
10406
+ lastRelayed.set(threadId, `${threadId}:${text3}`);
9863
10407
  }
9864
- function alreadyRelayed(threadId, text2) {
9865
- return lastRelayed.get(threadId) === `${threadId}:${text2}`;
10408
+ function alreadyRelayed(threadId, text3) {
10409
+ return lastRelayed.get(threadId) === `${threadId}:${text3}`;
9866
10410
  }
9867
10411
  async function relayCoordinatorReplyToSlack(threadId, opts) {
9868
10412
  const target = getSlackReplyTarget(threadId);
@@ -9871,14 +10415,14 @@ async function relayCoordinatorReplyToSlack(threadId, opts) {
9871
10415
  const lastUser = thread ? [...thread.messages].reverse().find((m) => m.role === "user") : void 0;
9872
10416
  if (lastUser && isSlackInboundUserPrompt(lastUser.text)) return;
9873
10417
  const result = getOrchestrator().getTurnResult(threadId);
9874
- const text2 = result.text.trim();
9875
- if (!text2) return;
9876
- if (alreadyRelayed(threadId, text2)) return;
9877
- markRelayed(threadId, text2);
10418
+ const text3 = result.text.trim();
10419
+ if (!text3) return;
10420
+ if (alreadyRelayed(threadId, text3)) return;
10421
+ markRelayed(threadId, text3);
9878
10422
  const log = opts.onLog ?? (() => void 0);
9879
10423
  try {
9880
- await postSlackText(target, signForThisMac(text2), opts);
9881
- log(`replied ${target.threadTs ?? target.channelId} (${text2.length} chars)`);
10424
+ await postSlackText(target, signForThisMac(text3), opts);
10425
+ log(`replied ${target.threadTs ?? target.channelId} (${text3.length} chars)`);
9882
10426
  } catch (err) {
9883
10427
  lastRelayed.delete(threadId);
9884
10428
  const errMsg = err instanceof Error ? err.message : String(err);
@@ -10042,10 +10586,10 @@ function resolveSlackListenMode(opts) {
10042
10586
  }
10043
10587
 
10044
10588
  // src/slack/relay-hub.ts
10045
- function parseSlackDeviceDestination(text2) {
10046
- const m = text2.match(/^\s*(?:to\s+)?(?:@|#)?([A-Za-z][\w-]{0,63})\s*[::]\s*/);
10047
- if (!m) return { label: null, rest: text2 };
10048
- return { label: m[1], rest: text2.slice(m[0].length) };
10589
+ function parseSlackDeviceDestination(text3) {
10590
+ const m = text3.match(/^\s*(?:to\s+)?(?:@|#)?([A-Za-z][\w-]{0,63})\s*[::]\s*/);
10591
+ if (!m) return { label: null, rest: text3 };
10592
+ return { label: m[1], rest: text3.slice(m[0].length) };
10049
10593
  }
10050
10594
  var SlackRelayHub = class {
10051
10595
  sessions = /* @__PURE__ */ new Map();
@@ -10263,40 +10807,126 @@ var SlackRelayHub = class {
10263
10807
  };
10264
10808
 
10265
10809
  // src/slack/relay-server.ts
10266
- import { createServer as createServer4 } from "http";
10810
+ import { createServer as createServer3 } from "http";
10267
10811
  import { WebSocketServer } from "ws";
10812
+ function sendHtml(res, status, title, body) {
10813
+ res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
10814
+ res.end(slackOAuthHtmlPage(title, body));
10815
+ }
10816
+ function sendJson(res, status, body) {
10817
+ res.writeHead(status, { "Content-Type": "application/json" });
10818
+ res.end(JSON.stringify(body));
10819
+ }
10268
10820
  async function startSlackRelayServer(opts) {
10269
10821
  const log = opts.onLog ?? console.log;
10270
10822
  const appToken = opts.appToken.trim();
10271
10823
  if (!appToken.startsWith("xapp-")) {
10272
10824
  throw new Error("SIDEBOARD_SLACK_APP_TOKEN must be an xapp-\u2026 app-level token");
10273
10825
  }
10826
+ const clientId = (opts.clientId ?? slackOAuthRelayClientId()).trim();
10827
+ const clientSecret = (opts.clientSecret ?? process.env.SIDEBOARD_SLACK_CLIENT_SECRET ?? "").trim();
10828
+ const redirectUri = (opts.oauthRedirectUri ?? slackOAuthRelayRedirectUri()).trim();
10829
+ const pending = new SlackOAuthPendingStore();
10274
10830
  const hub = opts.hub ?? new SlackRelayHub({
10275
10831
  fetchImpl: opts.fetchImpl,
10276
10832
  onLog: log
10277
10833
  });
10278
- const httpServer = createServer4((req, res) => {
10279
- const bounce = slackOAuthBounceResponse(req.url || "/");
10280
- if (bounce) {
10281
- res.writeHead(bounce.status, bounce.headers);
10282
- res.end(bounce.body);
10283
- return;
10834
+ const handleCallback = async (reqUrl, res) => {
10835
+ const url2 = parseSlackOAuthCallbackUrl(reqUrl);
10836
+ if (!url2) return false;
10837
+ const error = url2.searchParams.get("error")?.trim();
10838
+ const state = url2.searchParams.get("state")?.trim() ?? "";
10839
+ const code = url2.searchParams.get("code")?.trim() ?? "";
10840
+ if (error) {
10841
+ if (state) pending.put(state, { ok: false, error: `Slack OAuth: ${error}` });
10842
+ sendHtml(
10843
+ res,
10844
+ 400,
10845
+ "Slack",
10846
+ `<h1>Authorization cancelled</h1><p>${escapeHtml(error)}</p>`
10847
+ );
10848
+ return true;
10284
10849
  }
10285
- if (req.url === "/health" || req.url === "/") {
10286
- res.writeHead(200, { "Content-Type": "application/json" });
10287
- res.end(
10288
- JSON.stringify({
10289
- ok: true,
10290
- service: "sideboard-slack-relay",
10291
- sessions: hub.listSessions().length
10292
- })
10850
+ if (!state || !code) {
10851
+ sendHtml(res, 400, "Slack", "<h1>Invalid callback</h1><p>State or code missing.</p>");
10852
+ return true;
10853
+ }
10854
+ if (!clientSecret) {
10855
+ pending.put(state, {
10856
+ ok: false,
10857
+ error: "Relay is missing SIDEBOARD_SLACK_CLIENT_SECRET"
10858
+ });
10859
+ sendHtml(
10860
+ res,
10861
+ 500,
10862
+ "Slack",
10863
+ "<h1>Relay misconfigured</h1><p>OAuth client secret is not set on the relay.</p>"
10293
10864
  );
10294
- return;
10865
+ return true;
10866
+ }
10867
+ try {
10868
+ const payload = await exchangeSlackOAuthCode({
10869
+ clientId,
10870
+ clientSecret,
10871
+ code,
10872
+ redirectUri,
10873
+ fetchImpl: opts.fetchImpl
10874
+ });
10875
+ pending.put(state, { ok: true, payload });
10876
+ sendHtml(
10877
+ res,
10878
+ 200,
10879
+ "Slack connected",
10880
+ "<h1>Slack workspace connected</h1><p>You can close this tab and return to Sideboard.</p>"
10881
+ );
10882
+ } catch (err) {
10883
+ const message = err instanceof Error ? err.message : String(err);
10884
+ pending.put(state, { ok: false, error: message });
10885
+ sendHtml(res, 400, "Slack", `<h1>Could not connect Slack</h1><p>${escapeHtml(message)}</p>`);
10886
+ }
10887
+ return true;
10888
+ };
10889
+ const handleResult = (reqUrl, res) => {
10890
+ const url2 = parseSlackOAuthResultUrl(reqUrl);
10891
+ if (!url2) return false;
10892
+ const state = url2.searchParams.get("state")?.trim() ?? "";
10893
+ const value = pending.take(state);
10894
+ if (!value) {
10895
+ sendJson(res, 404, { ok: false, error: "pending" });
10896
+ return true;
10897
+ }
10898
+ if (!value.ok) {
10899
+ sendJson(res, 400, { ok: false, error: value.error });
10900
+ return true;
10295
10901
  }
10296
- res.writeHead(404);
10297
- res.end("Not found");
10902
+ sendJson(res, 200, { ok: true, ...value.payload });
10903
+ return true;
10904
+ };
10905
+ const httpServer = createServer3((req, res) => {
10906
+ void (async () => {
10907
+ const reqUrl = req.url || "/";
10908
+ if (await handleCallback(reqUrl, res)) return;
10909
+ if (handleResult(reqUrl, res)) return;
10910
+ if (req.url === "/health" || req.url === "/") {
10911
+ sendJson(res, 200, {
10912
+ ok: true,
10913
+ service: "sideboard-slack-relay",
10914
+ sessions: hub.listSessions().length,
10915
+ oauth: Boolean(clientSecret)
10916
+ });
10917
+ return;
10918
+ }
10919
+ res.writeHead(404);
10920
+ res.end("Not found");
10921
+ })().catch((err) => {
10922
+ log(`http error: ${err instanceof Error ? err.message : String(err)}`);
10923
+ if (!res.headersSent) {
10924
+ res.writeHead(500);
10925
+ res.end("Internal error");
10926
+ }
10927
+ });
10298
10928
  });
10299
- const wss = new WebSocketServer({ server: httpServer, path: "/desktop" });
10929
+ const wss = new WebSocketServer({ server: httpServer, path: SLACK_RELAY_DESKTOP_PATH });
10300
10930
  wss.on("connection", (ws, _req) => {
10301
10931
  const socket = {
10302
10932
  send: (data) => {
@@ -10334,12 +10964,12 @@ async function startSlackRelayServer(opts) {
10334
10964
  });
10335
10965
  const address = httpServer.address();
10336
10966
  const boundPort = typeof address === "object" && address ? address.port : typeof port === "number" ? port : 0;
10337
- const url = `ws://${host === "0.0.0.0" ? "127.0.0.1" : host}:${boundPort}/desktop`;
10967
+ const url = `ws://${host === "0.0.0.0" ? "127.0.0.1" : host}:${boundPort}${SLACK_RELAY_DESKTOP_PATH}`;
10338
10968
  log(`relay listening on ${url}`);
10339
10969
  const ac = new AbortController();
10340
10970
  const onAbort = () => ac.abort();
10341
10971
  opts.signal?.addEventListener("abort", onAbort, { once: true });
10342
- const socketModeDone = runSlackSocketMode({
10972
+ const socketModeDone = opts.skipSocketMode ? Promise.resolve() : runSlackSocketMode({
10343
10973
  appToken,
10344
10974
  signal: ac.signal,
10345
10975
  fetchImpl: opts.fetchImpl,
@@ -10370,6 +11000,7 @@ async function startSlackRelayServer(opts) {
10370
11000
  };
10371
11001
  }
10372
11002
  export {
11003
+ AGENT_GIT_ACTIONS,
10373
11004
  ATTACHMENTS_DIR,
10374
11005
  BAKED_SLACK_RELAY_URL,
10375
11006
  BRIGHTSY_MCP_ALLOWED_TOOLS,
@@ -10415,8 +11046,6 @@ export {
10415
11046
  SLACK_LISTEN_STOPPED_REPLY,
10416
11047
  SLACK_LISTEN_TIMEOUT_REPLY,
10417
11048
  SLACK_OAUTH_CANCELLED,
10418
- SLACK_OAUTH_LOCAL_CALLBACK,
10419
- SLACK_OAUTH_PORT,
10420
11049
  SLACK_OAUTH_REDIRECT,
10421
11050
  SLACK_REPLY_FORMATTING,
10422
11051
  SLACK_SEEN_REACTION,
@@ -10428,6 +11057,7 @@ export {
10428
11057
  addStackLayerFromThread,
10429
11058
  addWorkspace,
10430
11059
  adoptThread,
11060
+ agentGitPrompt,
10431
11061
  allAdapters,
10432
11062
  allocatePort,
10433
11063
  allocatePortRange,
@@ -10440,6 +11070,7 @@ export {
10440
11070
  applyAppEnvironment,
10441
11071
  applyCompaction,
10442
11072
  applyThreadIntoMain,
11073
+ applyTurnUsage,
10443
11074
  assertOrchestratorCapableAgent,
10444
11075
  attachmentFromAbsolutePath,
10445
11076
  attachmentsFromBuffers,
@@ -10481,12 +11112,14 @@ export {
10481
11112
  codexAdapter,
10482
11113
  coerceOrchestratorAgent,
10483
11114
  collectTakenTeamSlugs,
11115
+ commentLinearIssue,
10484
11116
  commitAll,
10485
11117
  conductorBundledBinDir,
10486
11118
  conductorDbPath,
10487
11119
  confirmLand,
10488
11120
  connectBrightsyTeam,
10489
11121
  connectSlackToken,
11122
+ contextTokens,
10490
11123
  coordinatorSystemPrompt,
10491
11124
  coordinatorTurnReminder,
10492
11125
  copyConfiguredFiles,
@@ -10495,6 +11128,7 @@ export {
10495
11128
  createEmptyThread,
10496
11129
  createExistingBranchWorktree,
10497
11130
  createGlobalChat,
11131
+ createLinearIssue,
10498
11132
  createLinearPkce,
10499
11133
  createOrUpdatePr,
10500
11134
  createPrStack,
@@ -10547,6 +11181,7 @@ export {
10547
11181
  formatAgentInstructions,
10548
11182
  formatArtifactDirective,
10549
11183
  formatBrightsyFetchError,
11184
+ formatFetchError,
10550
11185
  formatGhLandError,
10551
11186
  formatIpcInvokeError,
10552
11187
  formatMessagesAsTranscript,
@@ -10577,6 +11212,7 @@ export {
10577
11212
  getIssueSource,
10578
11213
  getLinearApiKey,
10579
11214
  getLinearAuthToken,
11215
+ getLinearIssue,
10580
11216
  getOrchestrator,
10581
11217
  getPr,
10582
11218
  getPrChecks,
@@ -10601,6 +11237,7 @@ export {
10601
11237
  hasRepoHook,
10602
11238
  hasWorkspaceHook,
10603
11239
  healOrchestrationSoccerTitles,
11240
+ httpFetch,
10604
11241
  importConductorWorkspace,
10605
11242
  importConductorWorkspaceAsync,
10606
11243
  initPrStack,
@@ -10634,8 +11271,10 @@ export {
10634
11271
  isSlackExternalReplyPrompt,
10635
11272
  isSlackOAuthCancelled,
10636
11273
  isThinkingEffort,
11274
+ isThreadCaffeinated,
10637
11275
  isWorkspaceScratchPath,
10638
11276
  linearAuthorizationHeader,
11277
+ linearGraphql,
10639
11278
  linearOAuthAuthorizeUrl,
10640
11279
  linearOAuthCredentials,
10641
11280
  listAgentSetupInfo,
@@ -10652,6 +11291,7 @@ export {
10652
11291
  listIssues,
10653
11292
  listLinearIssues,
10654
11293
  listLinearIssuesDirect,
11294
+ listLinearTeams,
10655
11295
  listModelsForAgent,
10656
11296
  listOpencodeModels,
10657
11297
  listPrs,
@@ -10727,9 +11367,11 @@ export {
10727
11367
  recordSlackOutboundWatch,
10728
11368
  refreshGitHubAuth,
10729
11369
  refreshSlackReplyBadges,
11370
+ releaseCaffeinateHoldForThread,
10730
11371
  removeWorkspace,
10731
11372
  removeWorktree,
10732
11373
  repoSlug,
11374
+ requestOccupancy,
10733
11375
  requestReview,
10734
11376
  requireAgent,
10735
11377
  resetGhStackDetectCache,
@@ -10744,6 +11386,8 @@ export {
10744
11386
  resolveFilesToCopy,
10745
11387
  resolveGhAuthToken,
10746
11388
  resolveGithubRepoSlug,
11389
+ resolveLinearState,
11390
+ resolveLinearTeam,
10747
11391
  resolveLoginCommand,
10748
11392
  resolveNewThreadOptions,
10749
11393
  resolvePlanMarkdown,
@@ -10756,6 +11400,7 @@ export {
10756
11400
  resolveThreadEffort,
10757
11401
  resolveVaultKey,
10758
11402
  resolveWorktreeStartPoint,
11403
+ rewriteLinearError,
10759
11404
  run,
10760
11405
  runArchiveScript,
10761
11406
  runCloudConnect,
@@ -10769,6 +11414,7 @@ export {
10769
11414
  saveLinearOAuth,
10770
11415
  secureFileUnlocksWith,
10771
11416
  setCaffeinateHold,
11417
+ setHttpFetchImpl,
10772
11418
  setStatus,
10773
11419
  setVaultMasterKey,
10774
11420
  settingsSourceLabel,
@@ -10786,6 +11432,7 @@ export {
10786
11432
  slackCoordinatorSourceRef,
10787
11433
  slackListenEnabled,
10788
11434
  slackOAuthCredentials,
11435
+ slackOAuthResultUrl,
10789
11436
  slackRelayUrl,
10790
11437
  slugify,
10791
11438
  spawnAgentTurn,
@@ -10831,6 +11478,7 @@ export {
10831
11478
  updateCodexSettings,
10832
11479
  updateDefaultsSettings,
10833
11480
  updateIntegrationsSettings,
11481
+ updateLinearIssue,
10834
11482
  updateOpencodeSettings,
10835
11483
  updateThread,
10836
11484
  validateLinearApiKey,