santree 0.7.4 → 0.7.6

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 (114) hide show
  1. package/README.md +2 -3
  2. package/dist/commands/config.d.ts +12 -0
  3. package/dist/commands/config.js +444 -0
  4. package/dist/commands/dashboard.js +187 -80
  5. package/dist/commands/helpers/statusline.js +0 -30
  6. package/dist/commands/issue/index.d.ts +1 -1
  7. package/dist/commands/issue/index.js +1 -1
  8. package/dist/commands/pr/context.d.ts +7 -0
  9. package/dist/commands/pr/context.js +36 -0
  10. package/dist/commands/pr/fix.d.ts +11 -2
  11. package/dist/commands/pr/fix.js +64 -12
  12. package/dist/commands/worktree/create.js +17 -11
  13. package/dist/commands/worktree/open.js +3 -2
  14. package/dist/commands/worktree/switch.js +3 -11
  15. package/dist/lib/ai.d.ts +11 -0
  16. package/dist/lib/ai.js +71 -2
  17. package/dist/lib/cd-hint.d.ts +18 -0
  18. package/dist/lib/cd-hint.js +27 -0
  19. package/dist/lib/claude-config.d.ts +26 -0
  20. package/dist/lib/claude-config.js +121 -0
  21. package/dist/lib/config/TrackerPicker.d.ts +10 -0
  22. package/dist/lib/config/TrackerPicker.js +113 -0
  23. package/dist/lib/config/diagnostics.d.ts +37 -0
  24. package/dist/lib/config/diagnostics.js +190 -0
  25. package/dist/lib/config-store.d.ts +23 -0
  26. package/dist/lib/config-store.js +68 -0
  27. package/dist/lib/dashboard/DetailPanel.d.ts +7 -2
  28. package/dist/lib/dashboard/DetailPanel.js +54 -37
  29. package/dist/lib/dashboard/IssueList.d.ts +5 -1
  30. package/dist/lib/dashboard/IssueList.js +24 -5
  31. package/dist/lib/dashboard/MultilineTextArea.js +1 -1
  32. package/dist/lib/dashboard/Overlays.js +3 -4
  33. package/dist/lib/dashboard/ReviewDetailPanel.js +11 -2
  34. package/dist/lib/dashboard/ReviewList.js +6 -3
  35. package/dist/lib/dashboard/data.js +7 -34
  36. package/dist/lib/dashboard/external-editor.d.ts +1 -1
  37. package/dist/lib/dashboard/external-editor.js +3 -2
  38. package/dist/lib/dashboard/types.d.ts +19 -2
  39. package/dist/lib/dashboard/types.js +68 -2
  40. package/dist/lib/exec.d.ts +13 -0
  41. package/dist/lib/exec.js +11 -1
  42. package/dist/lib/fix-loop.d.ts +45 -0
  43. package/dist/lib/fix-loop.js +83 -0
  44. package/dist/lib/git.d.ts +3 -29
  45. package/dist/lib/git.js +6 -62
  46. package/dist/lib/github.d.ts +70 -0
  47. package/dist/lib/github.js +128 -0
  48. package/dist/lib/multiplexer/cmux.js +143 -17
  49. package/dist/lib/multiplexer/none.js +3 -3
  50. package/dist/lib/multiplexer/tmux.js +5 -29
  51. package/dist/lib/multiplexer/types.d.ts +34 -1
  52. package/dist/lib/open-url.d.ts +3 -2
  53. package/dist/lib/open-url.js +5 -4
  54. package/dist/lib/prompts.d.ts +35 -1
  55. package/dist/lib/prompts.js +7 -0
  56. package/dist/lib/setup/apply.d.ts +16 -0
  57. package/dist/lib/setup/apply.js +35 -0
  58. package/dist/lib/setup/gitignore.d.ts +24 -0
  59. package/dist/lib/setup/gitignore.js +95 -0
  60. package/dist/lib/setup/steps.d.ts +39 -0
  61. package/dist/lib/setup/steps.js +333 -0
  62. package/dist/lib/setup/tools.d.ts +23 -0
  63. package/dist/lib/setup/tools.js +47 -0
  64. package/dist/lib/squirrel-loader.d.ts +3 -1
  65. package/dist/lib/squirrel-loader.js +2 -2
  66. package/dist/lib/trackers/github/index.js +2 -2
  67. package/dist/lib/trackers/local/store.js +1 -1
  68. package/package.json +1 -2
  69. package/prompts/fix-context.njk +79 -0
  70. package/prompts/fix-loop.njk +18 -0
  71. package/prompts/pr.njk +1 -1
  72. package/dist/commands/doctor.d.ts +0 -2
  73. package/dist/commands/doctor.js +0 -618
  74. package/dist/commands/github/auth.d.ts +0 -2
  75. package/dist/commands/github/auth.js +0 -56
  76. package/dist/commands/github/index.d.ts +0 -1
  77. package/dist/commands/github/index.js +0 -1
  78. package/dist/commands/helpers/english-tutor/index.d.ts +0 -1
  79. package/dist/commands/helpers/english-tutor/index.js +0 -1
  80. package/dist/commands/helpers/english-tutor/install.d.ts +0 -8
  81. package/dist/commands/helpers/english-tutor/install.js +0 -24
  82. package/dist/commands/helpers/english-tutor/prompt.d.ts +0 -2
  83. package/dist/commands/helpers/english-tutor/prompt.js +0 -16
  84. package/dist/commands/helpers/english-tutor/session-start.d.ts +0 -2
  85. package/dist/commands/helpers/english-tutor/session-start.js +0 -34
  86. package/dist/commands/helpers/english-tutor/uninstall.d.ts +0 -2
  87. package/dist/commands/helpers/english-tutor/uninstall.js +0 -15
  88. package/dist/commands/helpers/session-signal/end.d.ts +0 -2
  89. package/dist/commands/helpers/session-signal/end.js +0 -13
  90. package/dist/commands/helpers/session-signal/index.d.ts +0 -1
  91. package/dist/commands/helpers/session-signal/index.js +0 -1
  92. package/dist/commands/helpers/session-signal/install.d.ts +0 -8
  93. package/dist/commands/helpers/session-signal/install.js +0 -24
  94. package/dist/commands/helpers/session-signal/notification.d.ts +0 -2
  95. package/dist/commands/helpers/session-signal/notification.js +0 -13
  96. package/dist/commands/helpers/session-signal/prompt.d.ts +0 -2
  97. package/dist/commands/helpers/session-signal/prompt.js +0 -13
  98. package/dist/commands/helpers/session-signal/stop.d.ts +0 -2
  99. package/dist/commands/helpers/session-signal/stop.js +0 -13
  100. package/dist/commands/helpers/shell-init.d.ts +0 -11
  101. package/dist/commands/helpers/shell-init.js +0 -122
  102. package/dist/commands/issue/setup.d.ts +0 -2
  103. package/dist/commands/issue/setup.js +0 -108
  104. package/dist/commands/issue/switch.d.ts +0 -12
  105. package/dist/commands/issue/switch.js +0 -38
  106. package/dist/commands/linear/switch.d.ts +0 -2
  107. package/dist/commands/linear/switch.js +0 -75
  108. package/dist/lib/english-tutor.d.ts +0 -13
  109. package/dist/lib/english-tutor.js +0 -125
  110. package/dist/lib/session-signal.d.ts +0 -16
  111. package/dist/lib/session-signal.js +0 -104
  112. package/prompts/english-tutor-prompt.njk +0 -15
  113. package/shell/init.bash.njk +0 -129
  114. package/shell/init.zsh.njk +0 -204
package/dist/lib/git.d.ts CHANGED
@@ -1,10 +1,4 @@
1
1
  export { getSantreeDir, readAllMetadata, writeAllMetadata } from "./metadata.js";
2
- export interface SessionState {
3
- state: "waiting" | "idle" | "active" | "exited";
4
- message: string | null;
5
- session_id: string;
6
- at: string;
7
- }
8
2
  export interface Worktree {
9
3
  path: string;
10
4
  branch: string;
@@ -191,17 +185,14 @@ export declare function getCommitsAheadAsync(cwd: string, baseBranch: string): P
191
185
  */
192
186
  export declare function getCommitsBehindAsync(cwd: string, baseBranch: string): Promise<number>;
193
187
  /**
194
- * Read the SANTREE_DIFF_TOOL env var, returning the configured diff pager
195
- * command (e.g. "delta", "diff-so-fancy") or null if unset/invalid.
188
+ * Resolve the configured diff pager command (e.g. "delta", "diff-so-fancy"),
189
+ * or null if unset/invalid. Source order: `SANTREE_DIFF_TOOL` env override →
190
+ * santree's config file (set from `santree config`). See `lib/config-store.ts`.
196
191
  *
197
192
  * The CLI `worktree diff` flow lets the pager do its full job (render +
198
193
  * scroll) via git's `core.pager`. The dashboard's `[v]` overlay only uses
199
194
  * the rendering half — it captures the pager's stdout as a string and
200
195
  * handles scrolling itself in Ink.
201
- *
202
- * The value is restricted to a safe shell-token character set since it ends
203
- * up in arguments passed to spawn() — even though we never use shell:true,
204
- * keeping the surface tight defends against accidental misconfigurations.
205
196
  */
206
197
  export declare function getDiffTool(): string | null;
207
198
  export interface DiffShortstat {
@@ -315,20 +306,3 @@ export declare function unstageAll(cwd: string): Promise<void>;
315
306
  * - Untracked: deletes the file from disk via fs.unlink.
316
307
  */
317
308
  export declare function discardFile(cwd: string, filePath: string, isUntracked: boolean): Promise<void>;
318
- /**
319
- * Read the session state file for a given ticket.
320
- * Returns null if missing or "exited".
321
- */
322
- export declare function readSessionState(repoRoot: string, ticketId: string): SessionState | null;
323
- /**
324
- * Check if a session for the given ticket is still alive in the active multiplexer.
325
- * Delegates to the configured multiplexer (tmux: pane_pid + pgrep claude;
326
- * cmux: workspace lookup; none: always false). Callers should also consult the
327
- * .santree/session-states/<ticketId>.json file — that's the authoritative signal
328
- * written by Claude Code hooks, while this is the "is the terminal still up" backstop.
329
- */
330
- export declare function isSessionAlive(ticketId: string): boolean;
331
- /**
332
- * Delete the session state file for a given ticket.
333
- */
334
- export declare function clearSessionState(repoRoot: string, ticketId: string): void;
package/dist/lib/git.js CHANGED
@@ -3,7 +3,7 @@ import { promisify } from "util";
3
3
  import * as path from "path";
4
4
  import * as fs from "fs";
5
5
  import { run, runAsync, spawnAsync } from "./exec.js";
6
- import { getMultiplexer } from "./multiplexer/index.js";
6
+ import { getConfiguredDiffTool } from "./config-store.js";
7
7
  import { getSantreeDir, readAllMetadata, writeAllMetadata } from "./metadata.js";
8
8
  import { getIssueTracker } from "./trackers/index.js";
9
9
  export { getSantreeDir, readAllMetadata, writeAllMetadata } from "./metadata.js";
@@ -217,7 +217,7 @@ export async function removeWorktree(branchName, repoRoot, force = false, onProg
217
217
  }
218
218
  fs.rmSync(worktreePath, { recursive: true, force: true });
219
219
  }
220
- // Clean up centralized metadata entry and session state
220
+ // Clean up centralized metadata entry
221
221
  const ticketId = extractTicketId(branchName);
222
222
  if (ticketId) {
223
223
  const all = readAllMetadata(repoRoot);
@@ -225,7 +225,6 @@ export async function removeWorktree(branchName, repoRoot, force = false, onProg
225
225
  delete all[ticketId];
226
226
  writeAllMetadata(repoRoot, all);
227
227
  }
228
- clearSessionState(repoRoot, ticketId);
229
228
  }
230
229
  // Also delete the branch
231
230
  report("Deleting branch…");
@@ -457,26 +456,17 @@ export async function getCommitsBehindAsync(cwd, baseBranch) {
457
456
  return output ? parseInt(output, 10) || 0 : 0;
458
457
  }
459
458
  /**
460
- * Read the SANTREE_DIFF_TOOL env var, returning the configured diff pager
461
- * command (e.g. "delta", "diff-so-fancy") or null if unset/invalid.
459
+ * Resolve the configured diff pager command (e.g. "delta", "diff-so-fancy"),
460
+ * or null if unset/invalid. Source order: `SANTREE_DIFF_TOOL` env override →
461
+ * santree's config file (set from `santree config`). See `lib/config-store.ts`.
462
462
  *
463
463
  * The CLI `worktree diff` flow lets the pager do its full job (render +
464
464
  * scroll) via git's `core.pager`. The dashboard's `[v]` overlay only uses
465
465
  * the rendering half — it captures the pager's stdout as a string and
466
466
  * handles scrolling itself in Ink.
467
- *
468
- * The value is restricted to a safe shell-token character set since it ends
469
- * up in arguments passed to spawn() — even though we never use shell:true,
470
- * keeping the surface tight defends against accidental misconfigurations.
471
467
  */
472
468
  export function getDiffTool() {
473
- const raw = process.env["SANTREE_DIFF_TOOL"];
474
- if (!raw || !raw.trim())
475
- return null;
476
- const tool = raw.trim();
477
- if (!/^[a-zA-Z0-9_\-/.+]+$/.test(tool))
478
- return null;
479
- return tool;
469
+ return getConfiguredDiffTool() ?? null;
480
470
  }
481
471
  /**
482
472
  * Compute branch-only diff stats vs base (uses merge-base, like a GitHub PR
@@ -704,49 +694,3 @@ export async function discardFile(cwd, filePath, isUntracked) {
704
694
  throw new Error(`git checkout HEAD failed: ${output.trim()}`);
705
695
  }
706
696
  }
707
- /**
708
- * Get the path to the .santree/session-states directory.
709
- */
710
- function getSessionStatesDir(repoRoot) {
711
- return path.join(getSantreeDir(repoRoot), "session-states");
712
- }
713
- /**
714
- * Read the session state file for a given ticket.
715
- * Returns null if missing or "exited".
716
- */
717
- export function readSessionState(repoRoot, ticketId) {
718
- const filePath = path.join(getSessionStatesDir(repoRoot), `${ticketId}.json`);
719
- if (!fs.existsSync(filePath))
720
- return null;
721
- try {
722
- const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
723
- if (data.state === "exited")
724
- return null;
725
- return data;
726
- }
727
- catch {
728
- return null;
729
- }
730
- }
731
- /**
732
- * Check if a session for the given ticket is still alive in the active multiplexer.
733
- * Delegates to the configured multiplexer (tmux: pane_pid + pgrep claude;
734
- * cmux: workspace lookup; none: always false). Callers should also consult the
735
- * .santree/session-states/<ticketId>.json file — that's the authoritative signal
736
- * written by Claude Code hooks, while this is the "is the terminal still up" backstop.
737
- */
738
- export function isSessionAlive(ticketId) {
739
- return getMultiplexer().isSessionAlive(ticketId);
740
- }
741
- /**
742
- * Delete the session state file for a given ticket.
743
- */
744
- export function clearSessionState(repoRoot, ticketId) {
745
- const filePath = path.join(getSessionStatesDir(repoRoot), `${ticketId}.json`);
746
- try {
747
- fs.unlinkSync(filePath);
748
- }
749
- catch {
750
- // Ignore if file doesn't exist
751
- }
752
- }
@@ -10,6 +10,34 @@ export interface PRInfo {
10
10
  * Returns null if no PR exists for the branch or gh CLI fails.
11
11
  */
12
12
  export declare function getPRInfoAsync(branchName: string): Promise<PRInfo | null>;
13
+ /** GitHub's mergeability signals for a PR (subset we act on). */
14
+ export interface PRMergeState {
15
+ /** "MERGEABLE" | "CONFLICTING" | "UNKNOWN" */
16
+ mergeable: string;
17
+ /** "BEHIND" | "BLOCKED" | "CLEAN" | "DIRTY" | "DRAFT" | "HAS_HOOKS" | "UNKNOWN" | "UNSTABLE" */
18
+ mergeStateStatus: string;
19
+ /** True when the branch has conflicts with its base that need resolving. */
20
+ hasConflicts: boolean;
21
+ }
22
+ /**
23
+ * Fetch a PR's mergeability for a branch. `CONFLICTING` (or a `DIRTY` merge-state)
24
+ * means the branch conflicts with its base and needs a merge/resolve before CI can
25
+ * pass. Returns null if no PR exists or gh fails. GitHub computes mergeability
26
+ * asynchronously, so `mergeable` is `UNKNOWN` briefly after a push — callers should
27
+ * treat `UNKNOWN` as "don't know yet", not "no conflicts".
28
+ */
29
+ export declare function getPRMergeStateAsync(branchName: string): Promise<PRMergeState | null>;
30
+ /**
31
+ * Classify a CI check as something an AI agent can plausibly fix on its own
32
+ * ("fixable" — tests, types, lint, format, coverage) vs. one that needs a human or
33
+ * infra ("manual" — deploys, releases, image builds, e2e, security scans). Matched
34
+ * case-insensitively against the check's name + workflow. **Unknown ⇒ "manual"**:
35
+ * the fix loop should only touch failures it's confident about and stop otherwise.
36
+ * Extend the keyword lists as new check conventions appear (cf. HIDDEN_STATE_NAMES).
37
+ */
38
+ export declare const FIXABLE_CHECK_PATTERN: RegExp;
39
+ export declare const MANUAL_CHECK_PATTERN: RegExp;
40
+ export declare function classifyCheck(check: Pick<PRCheck, "name" | "workflow">): "fixable" | "manual";
13
41
  /**
14
42
  * Check if the GitHub CLI (gh) is available on PATH.
15
43
  * Runs: `which gh`
@@ -50,6 +78,48 @@ export declare function getPRReviewsAsync(prNumber: string): Promise<PRReview[]
50
78
  * Fetch inline review comments for a pull request via the GitHub API (async).
51
79
  */
52
80
  export declare function getPRReviewCommentsAsync(prNumber: string): Promise<PRReviewComment[] | null>;
81
+ /**
82
+ * The authenticated GitHub user's login (GraphQL `viewer`). Cached per process.
83
+ * Used to gate auto-applying a review comment on the PR owner's OWN 👍 — a
84
+ * reviewer can't self-approve their comment for the fix loop.
85
+ */
86
+ export declare function getViewerLoginAsync(): Promise<string | null>;
87
+ export interface PRReviewThreadComment {
88
+ author: string;
89
+ body: string;
90
+ path: string | null;
91
+ /** Current line, falling back to the original line for outdated comments. */
92
+ line: number | null;
93
+ diffHunk: string;
94
+ createdAt: string;
95
+ /** Logins of users who reacted 👍 (THUMBS_UP) to this comment. */
96
+ thumbsUpBy: string[];
97
+ }
98
+ /** A PR review thread (an inline comment + its replies) with resolution state. */
99
+ export interface PRReviewThread {
100
+ /** GraphQL node id — used to resolve the thread after the fix is applied. */
101
+ id: string;
102
+ isResolved: boolean;
103
+ isOutdated: boolean;
104
+ path: string | null;
105
+ /** Anchor line in the current diff, falling back to the original line. */
106
+ line: number | null;
107
+ comments: PRReviewThreadComment[];
108
+ }
109
+ /**
110
+ * Fetch a PR's review threads with resolution state and per-comment 👍 reactions.
111
+ * The REST comments endpoint exposes neither, so this goes through GraphQL.
112
+ * Returns null if the repo can't be resolved or gh fails.
113
+ */
114
+ export declare function getPRReviewThreadsAsync(prNumber: string): Promise<PRReviewThread[] | null>;
115
+ /**
116
+ * Filter review threads to the ones the fix loop should act on: **unresolved**
117
+ * AND whose **last comment carries a 👍 from `approver`** (the viewer's own
118
+ * approval). Resolved threads are dropped entirely — resolved means the ask was
119
+ * already applied or deliberately declined. The 👍 gate stops the loop from
120
+ * applying a comment the moment it's left, before the PR owner has vetted it.
121
+ */
122
+ export declare function getApprovedReviewThreads(threads: PRReviewThread[], approver: string): PRReviewThread[];
53
123
  /**
54
124
  * Fetch structured conversation comments on a pull request (async).
55
125
  */
@@ -22,6 +22,50 @@ export async function getPRInfoAsync(branchName) {
22
22
  return null;
23
23
  }
24
24
  }
25
+ /**
26
+ * Fetch a PR's mergeability for a branch. `CONFLICTING` (or a `DIRTY` merge-state)
27
+ * means the branch conflicts with its base and needs a merge/resolve before CI can
28
+ * pass. Returns null if no PR exists or gh fails. GitHub computes mergeability
29
+ * asynchronously, so `mergeable` is `UNKNOWN` briefly after a push — callers should
30
+ * treat `UNKNOWN` as "don't know yet", not "no conflicts".
31
+ */
32
+ export async function getPRMergeStateAsync(branchName) {
33
+ try {
34
+ const { stdout } = await execAsync(`gh pr view "${branchName}" --json mergeable,mergeStateStatus`);
35
+ const data = JSON.parse(stdout);
36
+ const mergeable = String(data.mergeable ?? "UNKNOWN");
37
+ const mergeStateStatus = String(data.mergeStateStatus ?? "UNKNOWN");
38
+ return {
39
+ mergeable,
40
+ mergeStateStatus,
41
+ hasConflicts: mergeable === "CONFLICTING" || mergeStateStatus === "DIRTY",
42
+ };
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
48
+ /**
49
+ * Classify a CI check as something an AI agent can plausibly fix on its own
50
+ * ("fixable" — tests, types, lint, format, coverage) vs. one that needs a human or
51
+ * infra ("manual" — deploys, releases, image builds, e2e, security scans). Matched
52
+ * case-insensitively against the check's name + workflow. **Unknown ⇒ "manual"**:
53
+ * the fix loop should only touch failures it's confident about and stop otherwise.
54
+ * Extend the keyword lists as new check conventions appear (cf. HIDDEN_STATE_NAMES).
55
+ */
56
+ // Trailing \b only (no leading boundary) so test-runner prefixes and plurals
57
+ // match: "pytest", "tests", "rspec", "codecov" all count as fixable.
58
+ export const FIXABLE_CHECK_PATTERN = /(tests?|specs?|jest|vitest|mocha|junit|phpunit|lint|format|prettier|eslint|ruff|black|gofmt|rustfmt|type[\s-]?check|tsc|mypy|pyright|coverage|cov)\b/i;
59
+ export const MANUAL_CHECK_PATTERN = /\b(deploy|release|publish|build[\s-]?image|docker|e2e|end[\s-]?to[\s-]?end|integration|smoke|terraform|infra|migrat|codeql|security|scan|sign|notariz)\b/i;
60
+ export function classifyCheck(check) {
61
+ const haystack = `${check.name} ${check.workflow}`;
62
+ // Manual wins ties: a "deploy tests" job is safer treated as manual.
63
+ if (MANUAL_CHECK_PATTERN.test(haystack))
64
+ return "manual";
65
+ if (FIXABLE_CHECK_PATTERN.test(haystack))
66
+ return "fixable";
67
+ return "manual";
68
+ }
25
69
  /**
26
70
  * Check if the GitHub CLI (gh) is available on PATH.
27
71
  * Runs: `which gh`
@@ -137,6 +181,90 @@ export async function getPRReviewCommentsAsync(prNumber) {
137
181
  return null;
138
182
  }
139
183
  }
184
+ /** Per-process cache of the authenticated GitHub login (the "viewer"). */
185
+ let viewerLoginCache = null;
186
+ /**
187
+ * The authenticated GitHub user's login (GraphQL `viewer`). Cached per process.
188
+ * Used to gate auto-applying a review comment on the PR owner's OWN 👍 — a
189
+ * reviewer can't self-approve their comment for the fix loop.
190
+ */
191
+ export function getViewerLoginAsync() {
192
+ if (viewerLoginCache)
193
+ return viewerLoginCache;
194
+ viewerLoginCache = (async () => {
195
+ try {
196
+ const { stdout } = await execAsync(`gh api graphql -f query='{ viewer { login } }' --jq .data.viewer.login`);
197
+ return stdout.trim() || null;
198
+ }
199
+ catch {
200
+ return null;
201
+ }
202
+ })();
203
+ return viewerLoginCache;
204
+ }
205
+ /**
206
+ * Fetch a PR's review threads with resolution state and per-comment 👍 reactions.
207
+ * The REST comments endpoint exposes neither, so this goes through GraphQL.
208
+ * Returns null if the repo can't be resolved or gh fails.
209
+ */
210
+ export async function getPRReviewThreadsAsync(prNumber) {
211
+ const repo = await getRepoNameAsync();
212
+ if (!repo)
213
+ return null;
214
+ const [owner, name] = repo.split("/");
215
+ if (!owner || !name)
216
+ return null;
217
+ // Single-line query (GraphQL ignores whitespace); single-quoted for the shell.
218
+ // `first` bounds keep us well under GitHub's query-complexity cap.
219
+ const query = `query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){` +
220
+ `pullRequest(number:$number){reviewThreads(first:100){nodes{id isResolved isOutdated path line originalLine ` +
221
+ `comments(first:50){nodes{author{login} body path line originalLine diffHunk createdAt ` +
222
+ `reactions(first:50){nodes{content user{login}}}}}}}}}}`;
223
+ const output = await runAsync(`gh api graphql -f query='${query}' -f owner='${owner}' -f name='${name}' -F number=${Number(prNumber)}`);
224
+ if (!output)
225
+ return null;
226
+ try {
227
+ const data = JSON.parse(output);
228
+ const nodes = data?.data?.repository?.pullRequest?.reviewThreads?.nodes ?? [];
229
+ return nodes.map((t) => ({
230
+ id: String(t.id),
231
+ isResolved: !!t.isResolved,
232
+ isOutdated: !!t.isOutdated,
233
+ path: t.path ?? null,
234
+ line: t.line ?? t.originalLine ?? null,
235
+ comments: (t.comments?.nodes ?? []).map((c) => ({
236
+ author: c.author?.login ?? "unknown",
237
+ body: c.body ?? "",
238
+ path: c.path ?? null,
239
+ line: c.line ?? c.originalLine ?? null,
240
+ diffHunk: c.diffHunk ?? "",
241
+ createdAt: c.createdAt ?? "",
242
+ thumbsUpBy: (c.reactions?.nodes ?? [])
243
+ .filter((r) => r.content === "THUMBS_UP")
244
+ .map((r) => r.user?.login)
245
+ .filter(Boolean),
246
+ })),
247
+ }));
248
+ }
249
+ catch {
250
+ return null;
251
+ }
252
+ }
253
+ /**
254
+ * Filter review threads to the ones the fix loop should act on: **unresolved**
255
+ * AND whose **last comment carries a 👍 from `approver`** (the viewer's own
256
+ * approval). Resolved threads are dropped entirely — resolved means the ask was
257
+ * already applied or deliberately declined. The 👍 gate stops the loop from
258
+ * applying a comment the moment it's left, before the PR owner has vetted it.
259
+ */
260
+ export function getApprovedReviewThreads(threads, approver) {
261
+ return threads.filter((t) => {
262
+ if (t.isResolved)
263
+ return false;
264
+ const last = t.comments[t.comments.length - 1];
265
+ return !!last && last.thumbsUpBy.includes(approver);
266
+ });
267
+ }
140
268
  /**
141
269
  * Fetch structured conversation comments on a pull request (async).
142
270
  */
@@ -28,16 +28,112 @@ function findWorkspaceByTitle(title) {
28
28
  return null;
29
29
  }
30
30
  }
31
+ // `list-pane-surfaces --workspace X` reports one pane (the active one) and its
32
+ // surfaces (tabs). Enough for our two flows: the work workspace has a single
33
+ // pane, and a freshly-created temp workspace has a single pane + single surface.
34
+ function paneSurfaces(workspaceRef) {
35
+ const r = cmuxRun(`cmux --json list-pane-surfaces --workspace ${shellEscape(workspaceRef)}`);
36
+ if (!r.ok)
37
+ return null;
38
+ try {
39
+ return JSON.parse(r.stdout);
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ function renameTab(workspaceRef, surfaceRef, title) {
46
+ cmuxRun(`cmux rename-tab --workspace ${shellEscape(workspaceRef)} --surface ${shellEscape(surfaceRef)} ${shellEscape(title)}`);
47
+ }
48
+ // cmux exposes workspace-group (sidebar folder) operations only over the RPC
49
+ // socket, not as first-class subcommands. `cmux rpc <method> '<json>'`.
50
+ function cmuxRpc(method, params) {
51
+ const arg = params ? ` ${shellEscape(JSON.stringify(params))}` : "";
52
+ const result = cmuxRun(`cmux rpc ${method}${arg}`);
53
+ if (!result.ok)
54
+ return { ok: false };
55
+ try {
56
+ return { ok: true, data: JSON.parse(result.stdout) };
57
+ }
58
+ catch {
59
+ return { ok: false };
60
+ }
61
+ }
62
+ function findGroupIdByName(name) {
63
+ const r = cmuxRpc("workspace.group.list");
64
+ if (!r.ok)
65
+ return null;
66
+ const groups = r.data?.groups ?? [];
67
+ return groups.find((g) => g.name === name)?.id ?? null;
68
+ }
69
+ // Snapshot every workspace's current group membership (ref -> group id), so we
70
+ // can detect and undo the side effect described in `createGroup` below.
71
+ function snapshotMemberships() {
72
+ const map = new Map();
73
+ const r = cmuxRpc("workspace.group.list");
74
+ if (!r.ok)
75
+ return map;
76
+ for (const g of r.data?.groups ?? []) {
77
+ if (!g.id)
78
+ continue;
79
+ for (const ref of g.member_workspace_refs ?? [])
80
+ map.set(ref, g.id);
81
+ }
82
+ return map;
83
+ }
84
+ // Create a sidebar group (folder) named `name` and return its id.
85
+ //
86
+ // Two cmux quirks shape this:
87
+ // 1. A group renders as a folder whose header IS its "anchor" workspace
88
+ // (auto-titled with the group name); members nest under it. We must keep
89
+ // that anchor — closing it removes the folder label and cmux flattens the
90
+ // members back to top-level rows (visually ungrouped).
91
+ // 2. `workspace.group.create` also pulls the *currently-selected* workspace
92
+ // into the new group as a stray member. We undo that: restore each stray to
93
+ // whatever group it was in before (or detach it if none), so creating a
94
+ // project folder never disturbs the user's existing folders or their
95
+ // dashboard window.
96
+ function createGroup(name) {
97
+ const before = snapshotMemberships();
98
+ const created = cmuxRpc("workspace.group.create", { name });
99
+ if (!created.ok)
100
+ return null;
101
+ const group = created.data?.group;
102
+ if (!group?.id)
103
+ return null;
104
+ const anchor = group.anchor_workspace_ref;
105
+ for (const ref of group.member_workspace_refs ?? []) {
106
+ if (ref === anchor)
107
+ continue;
108
+ const prev = before.get(ref);
109
+ if (prev && prev !== group.id) {
110
+ cmuxRpc("workspace.group.add", { group_id: prev, workspace_id: ref });
111
+ }
112
+ else {
113
+ cmuxRpc("workspace.group.remove", { group_id: group.id, workspace_id: ref });
114
+ }
115
+ }
116
+ return group.id;
117
+ }
118
+ // Place an already-created workspace into a sidebar group named `groupName`,
119
+ // creating the group on first use. Best-effort: any failure leaves the
120
+ // workspace ungrouped but otherwise intact.
121
+ function placeWorkspaceInGroup(groupName, workspaceRef) {
122
+ const groupId = findGroupIdByName(groupName) ?? createGroup(groupName);
123
+ if (!groupId)
124
+ return;
125
+ cmuxRpc("workspace.group.add", { group_id: groupId, workspace_id: workspaceRef });
126
+ }
31
127
  export const cmuxMultiplexer = {
32
128
  kind: "cmux",
33
129
  isActive() {
34
130
  return !!process.env["CMUX_SURFACE_ID"];
35
131
  },
36
- async createWindow({ name, cwd, command }) {
132
+ async createWindow({ name, cwd, command, group, tabName, }) {
37
133
  // `new-workspace` accepts --name, --cwd, --command in a single atomic call.
38
- // `--command` sends "<text>\n" to the new surface after creation. cmux #1472 means
39
- // programmatically created workspaces have dead PTYs, so the command may not actually
40
- // execute but the workspace + name are created, which is the visible win.
134
+ // `--command` sends "<text>\n" to the new surface after creation, which IS
135
+ // the one reliable way to get a live PTY (cmux #1472 only breaks commands on
136
+ // surfaces created *after* the workspace exists).
41
137
  const parts = [`cmux new-workspace --name ${shellEscape(name)} --cwd ${shellEscape(cwd)}`];
42
138
  if (command)
43
139
  parts.push(`--command ${shellEscape(command)}`);
@@ -45,6 +141,49 @@ export const cmuxMultiplexer = {
45
141
  if (!created.ok) {
46
142
  return { ok: false, reason: "failed", message: "cmux new-workspace failed" };
47
143
  }
144
+ // Grouping / tab-naming are best-effort and must never fail the launch.
145
+ // `new-workspace` prints the new workspace ref (`OK workspace:N`) to stdout.
146
+ const ref = created.stdout.match(/workspace:\d+/)?.[0];
147
+ if (group && ref)
148
+ placeWorkspaceInGroup(group, ref);
149
+ if (tabName && ref) {
150
+ const surface = paneSurfaces(ref)?.surfaces?.[0]?.ref;
151
+ if (surface)
152
+ renameTab(ref, surface, tabName);
153
+ }
154
+ return { ok: true };
155
+ },
156
+ async addTab({ windowName, tabName, cwd, command, group }) {
157
+ const target = findWorkspaceByTitle(windowName);
158
+ // No existing workspace for this ticket — create one named after it, with
159
+ // the command as its (renamed) tab.
160
+ if (!target?.ref) {
161
+ return this.createWindow({ name: windowName, cwd, command, group, tabName });
162
+ }
163
+ const targetPane = paneSurfaces(target.ref)?.pane_ref;
164
+ if (!targetPane) {
165
+ return { ok: false, reason: "failed", message: "could not resolve target pane" };
166
+ }
167
+ // A surface only gets a live PTY when its command is baked in at workspace
168
+ // creation. So we spawn the command in a throwaway workspace, then relocate
169
+ // the (already-running) surface into the target pane and discard the husk.
170
+ const tmpName = `__santree_tab_${windowName}`;
171
+ const tmp = cmuxRun(`cmux new-workspace --name ${shellEscape(tmpName)} --cwd ${shellEscape(cwd)} --command ${shellEscape(command)}`);
172
+ if (!tmp.ok) {
173
+ return { ok: false, reason: "failed", message: "could not launch tab command" };
174
+ }
175
+ const tmpRef = tmp.stdout.match(/workspace:\d+/)?.[0];
176
+ const surface = tmpRef ? paneSurfaces(tmpRef)?.surfaces?.[0]?.ref : undefined;
177
+ if (!tmpRef || !surface) {
178
+ return { ok: false, reason: "failed", message: "could not resolve new tab surface" };
179
+ }
180
+ const moved = cmuxRun(`cmux move-surface --surface ${shellEscape(surface)} --pane ${shellEscape(targetPane)}`);
181
+ if (!moved.ok) {
182
+ // Leave the command running in its own workspace rather than losing it.
183
+ return { ok: false, reason: "failed", message: "could not move tab into workspace" };
184
+ }
185
+ cmuxRun(`cmux workspace close --workspace ${shellEscape(tmpRef)}`);
186
+ renameTab(target.ref, surface, tabName);
48
187
  return { ok: true };
49
188
  },
50
189
  async selectWindow(name) {
@@ -66,17 +205,4 @@ export const cmuxMultiplexer = {
66
205
  message: "blocked by manaflow-ai/cmux#1472",
67
206
  };
68
207
  },
69
- isSessionAlive(ticketId) {
70
- const result = cmuxRun("cmux --json list-workspaces");
71
- if (!result.ok)
72
- return false;
73
- try {
74
- const parsed = JSON.parse(result.stdout);
75
- const items = parsed.workspaces ?? [];
76
- return items.some((w) => typeof w.title === "string" && w.title.startsWith(ticketId));
77
- }
78
- catch {
79
- return false;
80
- }
81
- },
82
208
  };
@@ -7,13 +7,13 @@ export const noneMultiplexer = {
7
7
  async createWindow() {
8
8
  return NOT_ACTIVE;
9
9
  },
10
+ async addTab() {
11
+ return NOT_ACTIVE;
12
+ },
10
13
  async selectWindow() {
11
14
  return NOT_ACTIVE;
12
15
  },
13
16
  sendCommand() {
14
17
  return NOT_ACTIVE;
15
18
  },
16
- isSessionAlive() {
17
- return false;
18
- },
19
19
  };
@@ -30,6 +30,11 @@ export const tmuxMultiplexer = {
30
30
  }
31
31
  return { ok: true };
32
32
  },
33
+ async addTab({ windowName, tabName, cwd, command }) {
34
+ // tmux has no in-window tab concept, so a "tab" becomes its own window named
35
+ // `<tabName>-<windowName>` (e.g. fix-loop-TEAM-123).
36
+ return this.createWindow({ name: `${tabName}-${windowName}`, cwd, command });
37
+ },
33
38
  async selectWindow(name) {
34
39
  if (!this.isActive())
35
40
  return { ok: false, reason: "not-active" };
@@ -42,33 +47,4 @@ export const tmuxMultiplexer = {
42
47
  const ok = tmuxSync(`tmux send-keys -t ${shellEscape(name)} ${shellEscape(command)} Enter`);
43
48
  return ok ? { ok: true } : { ok: false, reason: "failed" };
44
49
  },
45
- isSessionAlive(ticketId) {
46
- try {
47
- const output = execSync('tmux list-windows -F "#{window_name}\t#{pane_pid}"', {
48
- encoding: "utf-8",
49
- stdio: ["pipe", "pipe", "ignore"],
50
- }).trim();
51
- for (const line of output.split("\n")) {
52
- const [name, pidStr] = line.split("\t");
53
- if (!name?.startsWith(ticketId))
54
- continue;
55
- if (!pidStr)
56
- return false;
57
- try {
58
- const ps = execSync(`pgrep -P ${pidStr} -a`, {
59
- encoding: "utf-8",
60
- stdio: ["pipe", "pipe", "ignore"],
61
- }).trim();
62
- return ps.split("\n").some((proc) => proc.includes("claude"));
63
- }
64
- catch {
65
- return false;
66
- }
67
- }
68
- }
69
- catch {
70
- // tmux not available
71
- }
72
- return false;
73
- },
74
50
  };
@@ -10,13 +10,46 @@ export interface CreateWindowOpts {
10
10
  name: string;
11
11
  cwd: string;
12
12
  command?: string;
13
+ /**
14
+ * Optional sidebar group/folder to place the new window in. Backends that
15
+ * have no grouping concept (tmux, none) ignore it; cmux files the workspace
16
+ * under a group of this name (creating it on first use). Used to cluster all
17
+ * windows for one Linear project together.
18
+ */
19
+ group?: string;
20
+ /**
21
+ * Optional name for the window's initial tab/surface. cmux renames the tab
22
+ * after creation; backends without an in-window tab concept ignore it.
23
+ */
24
+ tabName?: string;
25
+ }
26
+ export interface AddTabOpts {
27
+ /** Name of the existing window/workspace to add the tab to. */
28
+ windowName: string;
29
+ /** Name for the new tab. */
30
+ tabName: string;
31
+ cwd: string;
32
+ command: string;
33
+ /**
34
+ * Group to use only if the target window doesn't exist yet and a fresh one
35
+ * has to be created as a fallback (cmux only).
36
+ */
37
+ group?: string;
13
38
  }
14
39
  export interface Multiplexer {
15
40
  readonly kind: MultiplexerKind;
16
41
  isActive(): boolean;
17
42
  createWindow(opts: CreateWindowOpts): Promise<SessionResult>;
43
+ /**
44
+ * Add a tab running `command` to an existing window/workspace named
45
+ * `windowName`. cmux relocates a live surface into that workspace so the two
46
+ * tabs (e.g. `work` + `fix-loop`) share one ticket workspace. Backends with
47
+ * no in-window tab concept (tmux, none) create a separate window named
48
+ * `<tabName>-<windowName>` instead. Falls back to creating the window when it
49
+ * doesn't exist yet.
50
+ */
51
+ addTab(opts: AddTabOpts): Promise<SessionResult>;
18
52
  selectWindow(name: string): Promise<SessionResult>;
19
53
  sendCommand(name: string, command: string): SessionResult;
20
- isSessionAlive(ticketId: string): boolean;
21
54
  }
22
55
  export declare function shellEscape(s: string): string;