santree 0.7.5 → 0.7.7

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 (109) 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 +165 -61
  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 +10 -2
  11. package/dist/commands/pr/fix.js +61 -12
  12. package/dist/commands/worktree/create.js +6 -1
  13. package/dist/commands/worktree/open.js +3 -2
  14. package/dist/lib/ai.d.ts +11 -0
  15. package/dist/lib/ai.js +71 -2
  16. package/dist/lib/claude-config.d.ts +18 -4
  17. package/dist/lib/claude-config.js +58 -23
  18. package/dist/lib/config/TrackerPicker.d.ts +10 -0
  19. package/dist/lib/config/TrackerPicker.js +113 -0
  20. package/dist/lib/config/diagnostics.d.ts +37 -0
  21. package/dist/lib/config/diagnostics.js +190 -0
  22. package/dist/lib/config-store.d.ts +23 -0
  23. package/dist/lib/config-store.js +68 -0
  24. package/dist/lib/dashboard/DetailPanel.d.ts +4 -2
  25. package/dist/lib/dashboard/DetailPanel.js +44 -37
  26. package/dist/lib/dashboard/IssueList.d.ts +5 -1
  27. package/dist/lib/dashboard/IssueList.js +24 -5
  28. package/dist/lib/dashboard/MultilineTextArea.js +1 -1
  29. package/dist/lib/dashboard/Overlays.js +3 -4
  30. package/dist/lib/dashboard/ReviewDetailPanel.js +11 -2
  31. package/dist/lib/dashboard/ReviewList.js +6 -3
  32. package/dist/lib/dashboard/data.js +7 -34
  33. package/dist/lib/dashboard/external-editor.d.ts +1 -1
  34. package/dist/lib/dashboard/external-editor.js +3 -2
  35. package/dist/lib/dashboard/types.d.ts +13 -2
  36. package/dist/lib/dashboard/types.js +33 -2
  37. package/dist/lib/exec.d.ts +13 -0
  38. package/dist/lib/exec.js +11 -1
  39. package/dist/lib/fix-loop.d.ts +45 -0
  40. package/dist/lib/fix-loop.js +83 -0
  41. package/dist/lib/git.d.ts +3 -29
  42. package/dist/lib/git.js +6 -62
  43. package/dist/lib/github.d.ts +70 -0
  44. package/dist/lib/github.js +128 -0
  45. package/dist/lib/multiplexer/cmux.js +143 -17
  46. package/dist/lib/multiplexer/none.js +3 -3
  47. package/dist/lib/multiplexer/tmux.js +5 -29
  48. package/dist/lib/multiplexer/types.d.ts +34 -1
  49. package/dist/lib/open-url.d.ts +3 -2
  50. package/dist/lib/open-url.js +5 -4
  51. package/dist/lib/prompts.d.ts +35 -1
  52. package/dist/lib/prompts.js +7 -0
  53. package/dist/lib/setup/apply.d.ts +2 -2
  54. package/dist/lib/setup/apply.js +2 -2
  55. package/dist/lib/setup/gitignore.d.ts +7 -0
  56. package/dist/lib/setup/gitignore.js +39 -1
  57. package/dist/lib/setup/steps.d.ts +6 -2
  58. package/dist/lib/setup/steps.js +65 -60
  59. package/dist/lib/setup/tools.d.ts +1 -1
  60. package/dist/lib/setup/tools.js +1 -1
  61. package/dist/lib/trackers/github/index.js +2 -2
  62. package/dist/lib/trackers/local/store.js +1 -1
  63. package/package.json +1 -1
  64. package/prompts/fix-context.njk +79 -0
  65. package/prompts/fix-loop.njk +18 -0
  66. package/prompts/pr.njk +1 -1
  67. package/dist/commands/doctor.d.ts +0 -2
  68. package/dist/commands/doctor.js +0 -545
  69. package/dist/commands/github/auth.d.ts +0 -2
  70. package/dist/commands/github/auth.js +0 -56
  71. package/dist/commands/github/index.d.ts +0 -1
  72. package/dist/commands/github/index.js +0 -1
  73. package/dist/commands/helpers/english-tutor/index.d.ts +0 -1
  74. package/dist/commands/helpers/english-tutor/index.js +0 -1
  75. package/dist/commands/helpers/english-tutor/install.d.ts +0 -8
  76. package/dist/commands/helpers/english-tutor/install.js +0 -24
  77. package/dist/commands/helpers/english-tutor/prompt.d.ts +0 -2
  78. package/dist/commands/helpers/english-tutor/prompt.js +0 -16
  79. package/dist/commands/helpers/english-tutor/session-start.d.ts +0 -2
  80. package/dist/commands/helpers/english-tutor/session-start.js +0 -34
  81. package/dist/commands/helpers/english-tutor/uninstall.d.ts +0 -2
  82. package/dist/commands/helpers/english-tutor/uninstall.js +0 -15
  83. package/dist/commands/helpers/session-signal/end.d.ts +0 -2
  84. package/dist/commands/helpers/session-signal/end.js +0 -13
  85. package/dist/commands/helpers/session-signal/index.d.ts +0 -1
  86. package/dist/commands/helpers/session-signal/index.js +0 -1
  87. package/dist/commands/helpers/session-signal/install.d.ts +0 -8
  88. package/dist/commands/helpers/session-signal/install.js +0 -24
  89. package/dist/commands/helpers/session-signal/notification.d.ts +0 -2
  90. package/dist/commands/helpers/session-signal/notification.js +0 -13
  91. package/dist/commands/helpers/session-signal/prompt.d.ts +0 -2
  92. package/dist/commands/helpers/session-signal/prompt.js +0 -13
  93. package/dist/commands/helpers/session-signal/stop.d.ts +0 -2
  94. package/dist/commands/helpers/session-signal/stop.js +0 -13
  95. package/dist/commands/issue/setup.d.ts +0 -2
  96. package/dist/commands/issue/setup.js +0 -108
  97. package/dist/commands/issue/switch.d.ts +0 -12
  98. package/dist/commands/issue/switch.js +0 -38
  99. package/dist/commands/linear/switch.d.ts +0 -2
  100. package/dist/commands/linear/switch.js +0 -75
  101. package/dist/commands/setup.d.ts +0 -11
  102. package/dist/commands/setup.js +0 -194
  103. package/dist/lib/english-tutor.d.ts +0 -13
  104. package/dist/lib/english-tutor.js +0 -125
  105. package/dist/lib/session-signal.d.ts +0 -16
  106. package/dist/lib/session-signal.js +0 -104
  107. package/dist/lib/setup/shell-config.d.ts +0 -38
  108. package/dist/lib/setup/shell-config.js +0 -131
  109. package/prompts/english-tutor-prompt.njk +0 -15
@@ -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;
@@ -4,7 +4,8 @@
4
4
  * else → `xdg-open <url>`
5
5
  *
6
6
  * Returns true on apparent success, false on failure. Callers decide how to
7
- * surface failures (e.g. a dashboard action message). Uses execSync with
8
- * `stdio: "ignore"` fast, no output leak into the dashboard's alt screen.
7
+ * surface failures (e.g. a dashboard action message). Uses execFileSync (array
8
+ * args, no shell) so a `"` in a tracker/PR-supplied URL can't break or inject;
9
+ * `stdio: "ignore"` keeps output out of the dashboard's alt screen.
9
10
  */
10
11
  export declare function openUrl(url: string): boolean;
@@ -1,17 +1,18 @@
1
- import { execSync } from "child_process";
1
+ import { execFileSync } from "child_process";
2
2
  /**
3
3
  * Open a URL in the platform's default browser.
4
4
  * macOS → `open <url>`
5
5
  * else → `xdg-open <url>`
6
6
  *
7
7
  * Returns true on apparent success, false on failure. Callers decide how to
8
- * surface failures (e.g. a dashboard action message). Uses execSync with
9
- * `stdio: "ignore"` fast, no output leak into the dashboard's alt screen.
8
+ * surface failures (e.g. a dashboard action message). Uses execFileSync (array
9
+ * args, no shell) so a `"` in a tracker/PR-supplied URL can't break or inject;
10
+ * `stdio: "ignore"` keeps output out of the dashboard's alt screen.
10
11
  */
11
12
  export function openUrl(url) {
12
13
  try {
13
14
  const cmd = process.platform === "darwin" ? "open" : "xdg-open";
14
- execSync(`${cmd} "${url}"`, { stdio: "ignore" });
15
+ execFileSync(cmd, [url], { stdio: "ignore" });
15
16
  return true;
16
17
  }
17
18
  catch {
@@ -1,5 +1,5 @@
1
1
  import type { Issue } from "./trackers/types.js";
2
- import type { PRCheck, PRReview, PRReviewComment, FailedCheckDetail, PRConversationComment } from "./github.js";
2
+ import type { PRCheck, PRReview, PRReviewComment, PRReviewThread, FailedCheckDetail, PRConversationComment } from "./github.js";
3
3
  /**
4
4
  * Render a nunjucks template from the prompts/ directory.
5
5
  * @param template - Template name without extension (e.g. "fill-pr")
@@ -35,3 +35,37 @@ export interface PRData {
35
35
  * Render PR feedback data into formatted markdown using the pr template.
36
36
  */
37
37
  export declare function renderPR(data: PRData): string;
38
+ /** A failed check annotated with whether the fix loop should attempt it. */
39
+ export interface FixContextCheck extends FailedCheckDetail {
40
+ fixable: boolean;
41
+ }
42
+ export interface FixContextData {
43
+ pr_number: string;
44
+ pr_url: string;
45
+ branch: string;
46
+ base_branch: string;
47
+ conflicts: boolean;
48
+ mergeable: string;
49
+ merge_state: string;
50
+ failed_checks: FixContextCheck[];
51
+ fixable_count: number;
52
+ manual_count: number;
53
+ pending_count: number;
54
+ reviews: PRReview[] | null;
55
+ /** Unresolved review threads the viewer has 👍-approved for auto-apply. */
56
+ approved_comments: PRReviewThread[];
57
+ /**
58
+ * The single recommended action for this iteration, computed from state so
59
+ * the loop doesn't have to: `merge` (conflicts) | `work` (fixable CI and/or
60
+ * approved comments) | `wait` (nothing to do, CI still running) |
61
+ * `stop-stuck` (only manual failures left) | `stop-clean` (all done).
62
+ */
63
+ directive: "merge" | "work" | "wait" | "stop-stuck" | "stop-clean";
64
+ /** Absolute santree invocation, for the `--signal` / resolve commands. */
65
+ santree_cmd: string;
66
+ }
67
+ /**
68
+ * Render the compact per-iteration brief the auto-fix loop consumes: conflict
69
+ * status, failing checks tagged fixable/manual, and open review asks.
70
+ */
71
+ export declare function renderFixContext(data: FixContextData): string;
@@ -51,3 +51,10 @@ export function renderDiff(data) {
51
51
  export function renderPR(data) {
52
52
  return promptsEnv.render("pr.njk", data);
53
53
  }
54
+ /**
55
+ * Render the compact per-iteration brief the auto-fix loop consumes: conflict
56
+ * status, failing checks tagged fixable/manual, and open review asks.
57
+ */
58
+ export function renderFixContext(data) {
59
+ return promptsEnv.render("fix-context.njk", data);
60
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * TTY hand-off for setup steps that run an interactive subprocess (brew install,
3
- * gh auth login, `santree issue setup`). Mirrors lib/dashboard/external-editor.ts:
2
+ * TTY hand-off for config steps that run an interactive subprocess (brew install,
3
+ * gh auth login, Claude install). Mirrors lib/dashboard/external-editor.ts:
4
4
  * drop raw mode so the child owns the terminal, run it synchronously (Ink can't
5
5
  * repaint while the event loop is blocked), then restore raw mode.
6
6
  *
@@ -1,7 +1,7 @@
1
1
  import { spawnSync } from "child_process";
2
2
  /**
3
- * TTY hand-off for setup steps that run an interactive subprocess (brew install,
4
- * gh auth login, `santree issue setup`). Mirrors lib/dashboard/external-editor.ts:
3
+ * TTY hand-off for config steps that run an interactive subprocess (brew install,
4
+ * gh auth login, Claude install). Mirrors lib/dashboard/external-editor.ts:
5
5
  * drop raw mode so the child owns the terminal, run it synchronously (Ink can't
6
6
  * repaint while the event loop is blocked), then restore raw mode.
7
7
  *
@@ -15,3 +15,10 @@ export declare function missingIgnoreEntries(repoRoot: string): string[];
15
15
  * the entries actually written (skips any already present as literal lines).
16
16
  */
17
17
  export declare function addIgnoreEntries(repoRoot: string, target: IgnoreTarget): string[];
18
+ /**
19
+ * Remove santree's ignore entries (and an orphaned `# santree` header) from both
20
+ * candidate files. Returns the entries actually removed. Leaves every other line
21
+ * — including user-authored ignores — untouched, and collapses the blank lines
22
+ * left behind so the file doesn't accumulate gaps across enable/disable cycles.
23
+ */
24
+ export declare function removeIgnoreEntries(repoRoot: string): string[];