santree 0.7.5 → 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 (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 +11 -2
  11. package/dist/commands/pr/fix.js +64 -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
@@ -1,5 +1,6 @@
1
- import { listWorktrees, extractTicketId, getBaseBranch, getDefaultBranch, readAllMetadata, readSessionState, isSessionAlive, clearSessionState, clearSessionId, getGitStatusAsync, getCommitsAheadAsync, getCommitsBehindAsync, getDiffShortstatAsync, } from "../git.js";
1
+ import { listWorktrees, extractTicketId, getBaseBranch, getDefaultBranch, readAllMetadata, clearSessionId, getGitStatusAsync, getCommitsAheadAsync, getCommitsBehindAsync, getDiffShortstatAsync, } from "../git.js";
2
2
  import { runAsync } from "../exec.js";
3
+ import { readFixLoopRuntime } from "../fix-loop.js";
3
4
  import { readMainAgentTodos, findClaudeSessionCwd } from "../claude-todos.js";
4
5
  import { getPRInfoAsync, getPRChecksAsync, getPRReviewsAsync, getPRConversationCommentsAsync, getPRViewAsync, getReviewRequestedPRsAsync, getRepoNameAsync, getGitHubUserNameAsync, } from "../github.js";
5
6
  import { getIssueTracker, getCandidateTrackers } from "../trackers/index.js";
@@ -46,13 +47,6 @@ export async function loadDashboardData(repoRoot) {
46
47
  getPRInfoAsync(wt.branch),
47
48
  getDiffShortstatAsync(wt.path, base),
48
49
  ]);
49
- let sessState = readSessionState(repoRoot, issue.identifier);
50
- // Validate against the active multiplexer — if the session has gone, clear stale state
51
- if (sessState && !isSessionAlive(issue.identifier)) {
52
- clearSessionState(repoRoot, issue.identifier);
53
- sessState = null;
54
- }
55
- const ss = sessState?.state ?? null;
56
50
  const storedId = metadata[issue.identifier]?.session_id ?? null;
57
51
  // Verify the session is still resumable. Claude Code clears old
58
52
  // transcript files (or `/clear` mints a new ID), leaving our stored
@@ -69,10 +63,7 @@ export async function loadDashboardData(repoRoot) {
69
63
  clearSessionId(repoRoot, issue.identifier);
70
64
  sessionId = null;
71
65
  }
72
- // Hide stale todos when the session has exited or its file is gone —
73
- // the on-disk todos file outlives the process and showing them
74
- // would lie about state.
75
- const claudeTodos = sessionId && ss !== "exited" ? readMainAgentTodos(sessionId) : null;
66
+ const claudeTodos = sessionId ? readMainAgentTodos(sessionId) : null;
76
67
  worktreeInfo = {
77
68
  path: wt.path,
78
69
  branch: wt.branch,
@@ -82,8 +73,6 @@ export async function loadDashboardData(repoRoot) {
82
73
  sessionId,
83
74
  sessionCwd,
84
75
  gitStatus: gitStatusOutput,
85
- sessionState: ss === "exited" ? null : ss,
86
- sessionMessage: sessState?.message ?? null,
87
76
  diffStats: shortstat,
88
77
  claudeTodos,
89
78
  };
@@ -101,6 +90,7 @@ export async function loadDashboardData(repoRoot) {
101
90
  pr: prInfo,
102
91
  checks: checksInfo,
103
92
  reviews: reviewsInfo,
93
+ fixLoop: readFixLoopRuntime(repoRoot, issue.identifier),
104
94
  };
105
95
  }));
106
96
  // Build orphan DashboardIssue objects for worktrees not matched to any fetched issue
@@ -132,12 +122,6 @@ export async function loadDashboardData(repoRoot) {
132
122
  : wt.branch.replace(/^[^/]+\//, ""))
133
123
  .replace(/-/g, " ")
134
124
  .trim() || tid;
135
- let sessState = readSessionState(repoRoot, tid);
136
- if (sessState && !isSessionAlive(tid)) {
137
- clearSessionState(repoRoot, tid);
138
- sessState = null;
139
- }
140
- const ss = sessState?.state ?? null;
141
125
  const storedId = metadata[tid]?.session_id ?? null;
142
126
  const sessionCwd = storedId ? findClaudeSessionCwd(wt.path, storedId) : null;
143
127
  let sessionId = storedId;
@@ -145,7 +129,7 @@ export async function loadDashboardData(repoRoot) {
145
129
  clearSessionId(repoRoot, tid);
146
130
  sessionId = null;
147
131
  }
148
- const claudeTodos = sessionId && ss !== "exited" ? readMainAgentTodos(sessionId) : null;
132
+ const claudeTodos = sessionId ? readMainAgentTodos(sessionId) : null;
149
133
  return {
150
134
  issue: {
151
135
  identifier: tid,
@@ -168,14 +152,13 @@ export async function loadDashboardData(repoRoot) {
168
152
  sessionId,
169
153
  sessionCwd,
170
154
  gitStatus: gitStatusOutput,
171
- sessionState: ss === "exited" ? null : ss,
172
- sessionMessage: sessState?.message ?? null,
173
155
  diffStats: shortstat,
174
156
  claudeTodos,
175
157
  },
176
158
  pr,
177
159
  checks: checksInfo,
178
160
  reviews: reviewsInfo,
161
+ fixLoop: readFixLoopRuntime(repoRoot, tid),
179
162
  };
180
163
  }));
181
164
  // ── Compute parent-child relationships ──────────────────────────
@@ -367,8 +350,6 @@ async function buildMainEntry(repoRoot) {
367
350
  sessionId: null,
368
351
  sessionCwd: null,
369
352
  gitStatus: gitStatusOutput,
370
- sessionState: null,
371
- sessionMessage: null,
372
353
  diffStats: shortstat,
373
354
  claudeTodos: null,
374
355
  },
@@ -421,12 +402,6 @@ export async function loadReviewsData(repoRoot) {
421
402
  getCommitsAheadAsync(wt.path, base),
422
403
  getDiffShortstatAsync(wt.path, base),
423
404
  ]);
424
- let sessState = ticketId ? readSessionState(repoRoot, ticketId) : null;
425
- if (sessState && ticketId && !isSessionAlive(ticketId)) {
426
- clearSessionState(repoRoot, ticketId);
427
- sessState = null;
428
- }
429
- const ss = sessState?.state ?? null;
430
405
  const storedId = ticketId ? (metadata[ticketId]?.session_id ?? null) : null;
431
406
  const sessionCwd = storedId ? findClaudeSessionCwd(wt.path, storedId) : null;
432
407
  let sessionId = storedId;
@@ -434,7 +409,7 @@ export async function loadReviewsData(repoRoot) {
434
409
  clearSessionId(repoRoot, ticketId);
435
410
  sessionId = null;
436
411
  }
437
- const claudeTodos = sessionId && ss !== "exited" ? readMainAgentTodos(sessionId) : null;
412
+ const claudeTodos = sessionId ? readMainAgentTodos(sessionId) : null;
438
413
  worktreeInfo = {
439
414
  path: wt.path,
440
415
  branch: wt.branch,
@@ -444,8 +419,6 @@ export async function loadReviewsData(repoRoot) {
444
419
  sessionId,
445
420
  sessionCwd,
446
421
  gitStatus: gitStatusOutput,
447
- sessionState: ss === "exited" ? null : ss,
448
- sessionMessage: sessState?.message ?? null,
449
422
  diffStats: shortstat,
450
423
  claudeTodos,
451
424
  };
@@ -7,6 +7,6 @@ export interface EditExternallyResult {
7
7
  * Open the user's editor on a temp file seeded with `initial`, then return the
8
8
  * saved content. Empty buffer is treated as cancel (matches `git commit`).
9
9
  *
10
- * Editor resolution: SANTREE_EDITOR > VISUAL > EDITOR > "vim".
10
+ * Editor resolution: santree config editor (or SANTREE_EDITOR) > VISUAL > EDITOR > "vim".
11
11
  */
12
12
  export declare function editExternally(initial: string, ext?: string): EditExternallyResult;
@@ -2,6 +2,7 @@ import { spawnSync } from "child_process";
2
2
  import * as fs from "fs";
3
3
  import * as os from "os";
4
4
  import * as path from "path";
5
+ import { getConfiguredEditor } from "../config-store.js";
5
6
  // GUI editors that detach by default and need a `--wait` flag to make spawnSync
6
7
  // block until the file is closed. Terminal editors (vim, nvim, nano, emacs -nw)
7
8
  // already block, so they aren't listed here.
@@ -17,10 +18,10 @@ const GUI_EDITORS_NEEDING_WAIT = new Set([
17
18
  * Open the user's editor on a temp file seeded with `initial`, then return the
18
19
  * saved content. Empty buffer is treated as cancel (matches `git commit`).
19
20
  *
20
- * Editor resolution: SANTREE_EDITOR > VISUAL > EDITOR > "vim".
21
+ * Editor resolution: santree config editor (or SANTREE_EDITOR) > VISUAL > EDITOR > "vim".
21
22
  */
22
23
  export function editExternally(initial, ext = "md") {
23
- const editorRaw = process.env["SANTREE_EDITOR"] || process.env["VISUAL"] || process.env["EDITOR"] || "vim";
24
+ const editorRaw = getConfiguredEditor() || process.env["VISUAL"] || process.env["EDITOR"] || "vim";
24
25
  const filePath = path.join(os.tmpdir(), `santree-edit-${Date.now()}.${ext.replace(/^\./, "")}`);
25
26
  try {
26
27
  fs.writeFileSync(filePath, initial);
@@ -1,6 +1,8 @@
1
1
  import type { PRInfo, PRCheck, PRReview, PRConversationComment, SearchPR } from "../github.js";
2
2
  import type { ClaudeTodo } from "../claude-todos.js";
3
+ import type { FixLoopRuntime } from "../fix-loop.js";
3
4
  import type { AssignedIssue, Comment, Issue, TriageSchedule } from "../trackers/types.js";
5
+ export type { FixLoopRuntime } from "../fix-loop.js";
4
6
  export type { AssignedIssue, Comment, Issue, TriageSchedule } from "../trackers/types.js";
5
7
  export interface WorktreeInfo {
6
8
  path: string;
@@ -22,8 +24,6 @@ export interface WorktreeInfo {
22
24
  * sessionId is null or the underlying file can't be located. */
23
25
  sessionCwd: string | null;
24
26
  gitStatus: string;
25
- sessionState: "waiting" | "idle" | "active" | null;
26
- sessionMessage: string | null;
27
27
  diffStats: {
28
28
  filesChanged: number;
29
29
  insertions: number;
@@ -37,6 +37,8 @@ export interface DashboardIssue {
37
37
  pr: PRInfo | null;
38
38
  checks: PRCheck[] | null;
39
39
  reviews: PRReview[] | null;
40
+ /** Active/recent auto-fix loop for this ticket (from .santree/fix-loops/). */
41
+ fixLoop?: FixLoopRuntime | null;
40
42
  parentTicketId?: string;
41
43
  children?: DashboardIssue[];
42
44
  }
@@ -169,6 +171,12 @@ export interface DashboardState {
169
171
  * surfaces the real PR), and are reconciled away by SET_DATA.
170
172
  */
171
173
  pendingPrs: Record<string, PRInfo>;
174
+ /**
175
+ * Active/recent auto-fix loops keyed by ticket id, used to render the per-row
176
+ * ⟳ badge + detail line. Seeded optimistically by FIXLOOP_START and rebuilt
177
+ * from the `.santree/fix-loops/` markers on every SET_DATA.
178
+ */
179
+ fixLoopTickets: Record<string, FixLoopRuntime>;
172
180
  commitPhase: CommitPhase;
173
181
  commitMessage: string;
174
182
  commitError: string | null;
@@ -361,6 +369,9 @@ export type DashboardAction = {
361
369
  type: "DELETE_ERROR";
362
370
  ticketId: string;
363
371
  error: string;
372
+ } | {
373
+ type: "FIXLOOP_START";
374
+ ticketId: string;
364
375
  } | {
365
376
  type: "COMMIT_START";
366
377
  /** Null when committing on a non-ticket branch (e.g. the main
@@ -49,6 +49,7 @@ export const initialState = {
49
49
  creationError: null,
50
50
  deletingTickets: {},
51
51
  pendingPrs: {},
52
+ fixLoopTickets: {},
52
53
  commitPhase: "idle",
53
54
  commitMessage: "",
54
55
  commitError: null,
@@ -132,6 +133,14 @@ export function reducer(state, action) {
132
133
  reconciledTrees = reconciledTrees.map((d, i) => (i === idx ? { ...d, pr: optimistic } : d));
133
134
  pendingPrs[id] = optimistic; // still pending → keep showing it
134
135
  }
136
+ // Rebuild the fix-loop badge map authoritatively from the freshly-read
137
+ // markers (drops loops whose marker was cleared); optimistic FIXLOOP_START
138
+ // entries are superseded here once the marker file is picked up.
139
+ const fixLoopTickets = {};
140
+ for (const d of reconciledTrees) {
141
+ if (d.fixLoop)
142
+ fixLoopTickets[d.issue.identifier] = d.fixLoop;
143
+ }
135
144
  return {
136
145
  ...state,
137
146
  groups: action.groups,
@@ -139,6 +148,7 @@ export function reducer(state, action) {
139
148
  treeGroups: action.treeGroups,
140
149
  flatTrees: reconciledTrees,
141
150
  pendingPrs,
151
+ fixLoopTickets,
142
152
  triageGroups: action.triageGroups,
143
153
  flatTriage: action.flatTriage,
144
154
  deletingTickets,
@@ -360,6 +370,23 @@ export function reducer(state, action) {
360
370
  },
361
371
  };
362
372
  }
373
+ case "FIXLOOP_START": {
374
+ // Optimistic running badge until SET_DATA reads the marker the launcher wrote.
375
+ const now = new Date().toISOString();
376
+ return {
377
+ ...state,
378
+ fixLoopTickets: {
379
+ ...state.fixLoopTickets,
380
+ [action.ticketId]: {
381
+ phase: "running",
382
+ status: "running",
383
+ startedAt: now,
384
+ at: now,
385
+ ageMs: 0,
386
+ },
387
+ },
388
+ };
389
+ }
363
390
  case "COMMIT_START":
364
391
  return {
365
392
  ...state,
@@ -431,9 +458,13 @@ export function reducer(state, action) {
431
458
  const ticketId = state.prCreateTicketId;
432
459
  let flatTrees = state.flatTrees;
433
460
  let pendingPrs = state.pendingPrs;
434
- if (ticketId) {
461
+ // Only attach the optimistic PR if we can parse a real number from the
462
+ // returned URL — a `"?"` placeholder would break later `gh pr view <n>`
463
+ // calls. If it doesn't match, the next refresh surfaces the real PR.
464
+ const prNumber = action.url.match(/\/pull\/(\d+)/)?.[1];
465
+ if (ticketId && prNumber) {
435
466
  const optimistic = {
436
- number: action.url.match(/\/pull\/(\d+)/)?.[1] ?? "?",
467
+ number: prNumber,
437
468
  state: "OPEN",
438
469
  isDraft: state.prCreateDraft,
439
470
  url: action.url,
@@ -1,3 +1,16 @@
1
+ /**
2
+ * Run a command with array args (NO shell) and return `{ stdout, stderr }`.
3
+ * Rejects on non-zero exit with an error carrying `.stdout`/`.stderr`/`.message`,
4
+ * matching promisified `exec` semantics. Use this — not a `exec(\`… ${x}\`)`
5
+ * template — whenever any argument is user-supplied, to avoid shell injection.
6
+ */
7
+ export declare function execFileAsync(file: string, args: string[], options?: {
8
+ cwd?: string;
9
+ env?: NodeJS.ProcessEnv;
10
+ }): Promise<{
11
+ stdout: string;
12
+ stderr: string;
13
+ }>;
1
14
  /**
2
15
  * Run a shell command and return trimmed stdout, or null on failure.
3
16
  */
package/dist/lib/exec.js CHANGED
@@ -1,6 +1,16 @@
1
- import { execSync, exec, spawn } from "child_process";
1
+ import { execSync, exec, execFile, spawn } from "child_process";
2
2
  import { promisify } from "util";
3
3
  const execPromise = promisify(exec);
4
+ const execFilePromise = promisify(execFile);
5
+ /**
6
+ * Run a command with array args (NO shell) and return `{ stdout, stderr }`.
7
+ * Rejects on non-zero exit with an error carrying `.stdout`/`.stderr`/`.message`,
8
+ * matching promisified `exec` semantics. Use this — not a `exec(\`… ${x}\`)`
9
+ * template — whenever any argument is user-supplied, to avoid shell injection.
10
+ */
11
+ export async function execFileAsync(file, args, options) {
12
+ return execFilePromise(file, args, { encoding: "utf-8", ...options });
13
+ }
4
14
  /**
5
15
  * Run a shell command and return trimmed stdout, or null on failure.
6
16
  */
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Marker store for the auto-fix `/loop` (see `santree pr fix --loop`).
3
+ *
4
+ * Each running loop writes `.santree/fix-loops/<ticketId>.json` and heartbeats it
5
+ * every iteration. The dashboard reads these markers to render a per-ticket fix-loop
6
+ * badge — kept as files (not in dashboard memory) because the loop runs in a separate
7
+ * process/window and must survive dashboard restarts. Staleness is timestamp-based:
8
+ * the loop has a known cadence, so a "running" marker that hasn't heartbeat in a
9
+ * while is treated as stalled.
10
+ *
11
+ * Standalone module (only `fs`/`path`/`metadata`) so both commands and the dashboard
12
+ * data layer can import it without pulling in git/tracker cycles.
13
+ */
14
+ /** Lifecycle status written by the loop. `stopped:*` are terminal. */
15
+ export type FixLoopStatus = "running" | "merging" | "fixing" | "waiting-ci" | "stopped:clean" | "stopped:stuck";
16
+ export interface FixLoopMarker {
17
+ status: FixLoopStatus;
18
+ /** Loop interval in minutes (used for staleness math). */
19
+ intervalMin: number;
20
+ /** ISO timestamp the loop first launched. */
21
+ startedAt: string;
22
+ /** ISO timestamp of the last heartbeat / status change. */
23
+ at: string;
24
+ }
25
+ /** Display phase derived from a marker + the current time. */
26
+ export type FixLoopPhase = "running" | "stalled" | "stopped-clean" | "stopped-stuck";
27
+ export interface FixLoopRuntime {
28
+ phase: FixLoopPhase;
29
+ status: FixLoopStatus;
30
+ startedAt: string;
31
+ at: string;
32
+ ageMs: number;
33
+ }
34
+ /** Create/overwrite a marker as a freshly-started running loop. */
35
+ export declare function startFixLoop(repoRoot: string, ticketId: string, intervalMin: number): void;
36
+ /**
37
+ * Update an existing marker's status and heartbeat. If no marker exists yet (e.g. a
38
+ * signal arrives before the launcher wrote one), a minimal one is created so the
39
+ * dashboard still reflects the loop.
40
+ */
41
+ export declare function signalFixLoop(repoRoot: string, ticketId: string, status: FixLoopStatus): void;
42
+ export declare function readFixLoopMarker(repoRoot: string, ticketId: string): FixLoopMarker | null;
43
+ export declare function clearFixLoop(repoRoot: string, ticketId: string): void;
44
+ /** Derive the dashboard-facing phase from a marker. Returns null if no marker. */
45
+ export declare function readFixLoopRuntime(repoRoot: string, ticketId: string, now?: number): FixLoopRuntime | null;
@@ -0,0 +1,83 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { getSantreeDir } from "./metadata.js";
4
+ function getFixLoopsDir(repoRoot) {
5
+ return path.join(getSantreeDir(repoRoot), "fix-loops");
6
+ }
7
+ function markerPath(repoRoot, ticketId) {
8
+ return path.join(getFixLoopsDir(repoRoot), `${ticketId}.json`);
9
+ }
10
+ /** Create/overwrite a marker as a freshly-started running loop. */
11
+ export function startFixLoop(repoRoot, ticketId, intervalMin) {
12
+ const now = new Date().toISOString();
13
+ writeMarker(repoRoot, ticketId, {
14
+ status: "running",
15
+ intervalMin,
16
+ startedAt: now,
17
+ at: now,
18
+ });
19
+ }
20
+ /**
21
+ * Update an existing marker's status and heartbeat. If no marker exists yet (e.g. a
22
+ * signal arrives before the launcher wrote one), a minimal one is created so the
23
+ * dashboard still reflects the loop.
24
+ */
25
+ export function signalFixLoop(repoRoot, ticketId, status) {
26
+ const prev = readFixLoopMarker(repoRoot, ticketId);
27
+ const now = new Date().toISOString();
28
+ writeMarker(repoRoot, ticketId, {
29
+ status,
30
+ intervalMin: prev?.intervalMin ?? 5,
31
+ startedAt: prev?.startedAt ?? now,
32
+ at: now,
33
+ });
34
+ }
35
+ function writeMarker(repoRoot, ticketId, marker) {
36
+ const dir = getFixLoopsDir(repoRoot);
37
+ fs.mkdirSync(dir, { recursive: true });
38
+ fs.writeFileSync(markerPath(repoRoot, ticketId), JSON.stringify(marker, null, 2) + "\n");
39
+ }
40
+ export function readFixLoopMarker(repoRoot, ticketId) {
41
+ const filePath = markerPath(repoRoot, ticketId);
42
+ if (!fs.existsSync(filePath))
43
+ return null;
44
+ try {
45
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ export function clearFixLoop(repoRoot, ticketId) {
52
+ try {
53
+ fs.unlinkSync(markerPath(repoRoot, ticketId));
54
+ }
55
+ catch {
56
+ // Ignore if missing.
57
+ }
58
+ }
59
+ /**
60
+ * A heartbeating loop that hasn't checked in for ~2.5 intervals is treated as
61
+ * stalled (its window was probably closed). Floor of 10 min so a slow first
62
+ * iteration isn't mistaken for a dead loop.
63
+ */
64
+ function staleThresholdMs(intervalMin) {
65
+ return Math.max(intervalMin * 2.5, 10) * 60_000;
66
+ }
67
+ /** Derive the dashboard-facing phase from a marker. Returns null if no marker. */
68
+ export function readFixLoopRuntime(repoRoot, ticketId, now = Date.now()) {
69
+ const marker = readFixLoopMarker(repoRoot, ticketId);
70
+ if (!marker)
71
+ return null;
72
+ const ageMs = now - new Date(marker.at).getTime();
73
+ let phase;
74
+ if (marker.status === "stopped:clean")
75
+ phase = "stopped-clean";
76
+ else if (marker.status === "stopped:stuck")
77
+ phase = "stopped-stuck";
78
+ else if (ageMs > staleThresholdMs(marker.intervalMin))
79
+ phase = "stalled";
80
+ else
81
+ phase = "running";
82
+ return { phase, status: marker.status, startedAt: marker.startedAt, at: marker.at, ageMs };
83
+ }
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
- }