@dungle-scrubs/tallow 0.9.6 → 0.9.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 (32) hide show
  1. package/dist/cli.js +1 -1
  2. package/dist/cli.js.map +1 -1
  3. package/dist/config.d.ts +1 -1
  4. package/dist/config.js +1 -1
  5. package/dist/process-cleanup.js +1 -1
  6. package/dist/process-cleanup.js.map +1 -1
  7. package/dist/sdk.d.ts +2 -2
  8. package/dist/sdk.d.ts.map +1 -1
  9. package/dist/sdk.js +32 -26
  10. package/dist/sdk.js.map +1 -1
  11. package/dist/workspace-transition-interactive.js +1 -1
  12. package/dist/workspace-transition-interactive.js.map +1 -1
  13. package/extensions/_shared/__tests__/shell-policy.test.ts +19 -0
  14. package/extensions/_shared/shell-policy.ts +121 -1
  15. package/extensions/command-expansion/index.ts +8 -2
  16. package/extensions/context-fork/frontmatter-index.ts +6 -1
  17. package/extensions/git-status/__tests__/git-status.test.ts +65 -2
  18. package/extensions/git-status/index.ts +268 -98
  19. package/extensions/minimal-skill-display/index.ts +7 -1
  20. package/extensions/read-tool-enhanced/index.ts +7 -2
  21. package/extensions/rewind/__tests__/session-files.test.ts +115 -0
  22. package/extensions/rewind/__tests__/snapshots.test.ts +23 -0
  23. package/extensions/rewind/index.ts +5 -0
  24. package/extensions/rewind/session-files.ts +138 -0
  25. package/extensions/rewind/snapshots.ts +104 -5
  26. package/extensions/skill-commands/index.ts +6 -1
  27. package/extensions/subagent-tool/schema.ts +1 -2
  28. package/extensions/teams-tool/tools/teammate-tools.ts +1 -2
  29. package/extensions/wezterm-pane-control/index.ts +1 -2
  30. package/node_modules/@mariozechner/pi-tui/package.json +1 -1
  31. package/package.json +5 -5
  32. package/skills/tallow-expert/SKILL.md +2 -2
@@ -5,12 +5,12 @@
5
5
  * - Current branch name
6
6
  * - Dirty state (* if uncommitted changes)
7
7
  * - Ahead/behind remote
8
- * - PR status (if GitHub CLI available)
8
+ * - PR status (if GitHub CLI is available and responsive)
9
9
  */
10
10
 
11
11
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
12
12
  import { getIcon } from "../_icons/index.js";
13
- import { runCommandSync, runGitCommandSync } from "../_shared/shell-policy.js";
13
+ import { runCommand, runGitCommand } from "../_shared/shell-policy.js";
14
14
 
15
15
  // Catppuccin Macchiato colors
16
16
  const C_TEAL = "\x1b[38;2;139;213;202m"; // teal #8bd5ca
@@ -21,47 +21,53 @@ const C_MAUVE = "\x1b[38;2;198;160;246m"; // mauve #c6a0f6
21
21
  const C_GRAY = "\x1b[38;2;128;135;162m"; // overlay1 #8087a2
22
22
  const C_RESET = "\x1b[0m";
23
23
 
24
- /** Represents the current state of a git repository */
25
- interface GitState {
24
+ const STATUS_REFRESH_INTERVAL_MS = 10_000;
25
+ const PR_REFRESH_INTERVAL_MS = 60_000;
26
+ const PR_TIMEOUT_MS = 1_500;
27
+ const PR_ERROR_COOLDOWN_MS = 5 * 60_000;
28
+
29
+ type PullRequestState = "open" | "merged" | "closed" | "draft" | null;
30
+
31
+ /** Represents the current state of a git repository. */
32
+ export interface GitState {
26
33
  branch: string | null;
27
34
  dirty: boolean;
28
35
  ahead: number;
29
36
  behind: number;
30
- prState: "open" | "merged" | "closed" | "draft" | null;
37
+ prState: PullRequestState;
38
+ prNumber: number | null;
39
+ }
40
+
41
+ interface PullRequestInfo {
42
+ prState: PullRequestState;
31
43
  prNumber: number | null;
32
44
  }
33
45
 
34
- // Store interval on globalThis to clear across reloads
35
- const G = globalThis;
46
+ interface GitStatusGlobals {
47
+ __piGitStatusInterval?: ReturnType<typeof setInterval> | null;
48
+ }
49
+
50
+ const G = globalThis as typeof globalThis & GitStatusGlobals;
36
51
  if (G.__piGitStatusInterval) {
37
52
  clearInterval(G.__piGitStatusInterval);
38
53
  G.__piGitStatusInterval = null;
39
54
  }
55
+
40
56
  let lastCwd = "";
41
57
  let cachedState: GitState | null = null;
58
+ let activeRefresh: Promise<void> | null = null;
59
+ let queuedRefresh: { ctx: ExtensionContext; revision: number } | null = null;
60
+ let sessionRevision = 0;
61
+ let lastPrRefreshAt = 0;
62
+ let prCooldownUntil = 0;
42
63
 
43
64
  /**
44
- * Executes a git command in the specified directory via arg-array spawn.
65
+ * Parse `git status --porcelain=v2 --branch` output into a base git state.
45
66
  *
46
- * @param args - Git subcommand and arguments as an array
47
- * @param cwd - The working directory to run the command in
48
- * @returns The trimmed stdout output, or null if the command failed
49
- */
50
- function runGit(args: string[], cwd: string): string | null {
51
- return runGitCommandSync(args, cwd, 5000);
52
- }
53
-
54
- /**
55
- * Retrieves the current git state for a directory.
56
- * Includes branch name, dirty status, ahead/behind counts, and PR info.
57
- * @param cwd - The working directory to check
58
- * @returns The git state object, or null if not a git repository
67
+ * @param raw - Raw porcelain-v2 status output
68
+ * @returns Parsed git state without PR metadata, or null when no branch is found
59
69
  */
60
- function getGitState(cwd: string): GitState | null {
61
- // Single command: branch, ahead/behind, and porcelain status
62
- const raw = runGit(["status", "--porcelain=v2", "--branch"], cwd);
63
- if (raw === null) return null;
64
-
70
+ export function parseGitStatus(raw: string): GitState | null {
65
71
  let branch: string | null = null;
66
72
  let ahead = 0;
67
73
  let behind = 0;
@@ -70,10 +76,6 @@ function getGitState(cwd: string): GitState | null {
70
76
  for (const line of raw.split("\n")) {
71
77
  if (line.startsWith("# branch.head ")) {
72
78
  branch = line.slice("# branch.head ".length);
73
- if (branch === "(detached)") {
74
- const sha = runGit(["rev-parse", "--short", "HEAD"], cwd);
75
- branch = sha ? `(${sha})` : branch;
76
- }
77
79
  } else if (line.startsWith("# branch.ab ")) {
78
80
  const match = line.match(/\+(\d+) -(\d+)/);
79
81
  if (match) {
@@ -86,58 +88,161 @@ function getGitState(cwd: string): GitState | null {
86
88
  }
87
89
 
88
90
  if (!branch) return null;
91
+ return { branch, dirty, ahead, behind, prState: null, prNumber: null };
92
+ }
89
93
 
90
- // Try to get PR status using GitHub CLI
91
- let prState: GitState["prState"] = null;
92
- let prNumber: number | null = null;
93
-
94
- try {
95
- const prResult = runCommandSync({
96
- command: "gh",
97
- args: ["pr", "view", "--json", "state,number,isDraft"],
98
- cwd,
99
- source: "git-helper",
100
- timeoutMs: 5000,
101
- });
102
-
103
- if (prResult.ok && prResult.stdout) {
104
- const pr = JSON.parse(prResult.stdout) as {
105
- number?: number;
106
- isDraft?: boolean;
107
- state?: string;
108
- };
109
- if (pr.number) {
110
- prNumber = pr.number;
111
- if (pr.isDraft) {
112
- prState = "draft";
113
- } else if (pr.state) {
114
- prState = pr.state.toLowerCase() as GitState["prState"];
115
- }
116
- }
94
+ /**
95
+ * Parse `gh pr view --json state,number,isDraft` output.
96
+ *
97
+ * @param raw - Raw gh JSON output
98
+ * @returns Parsed pull-request metadata, or null when unavailable
99
+ */
100
+ export function parsePullRequestInfo(raw: string): PullRequestInfo | null {
101
+ const parsed = JSON.parse(raw) as {
102
+ number?: number;
103
+ isDraft?: boolean;
104
+ state?: string;
105
+ };
106
+ if (!parsed.number) return null;
107
+ if (parsed.isDraft) {
108
+ return { prState: "draft", prNumber: parsed.number };
109
+ }
110
+ if (!parsed.state) return null;
111
+ return {
112
+ prState: parsed.state.toLowerCase() as Exclude<PullRequestState, null>,
113
+ prNumber: parsed.number,
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Returns whether a gh stderr payload means “no PR” rather than a broken CLI.
119
+ *
120
+ * @param stderr - gh stderr text
121
+ * @returns True when the branch simply has no associated pull request
122
+ */
123
+ function isNoPullRequestError(stderr: string): boolean {
124
+ return /no pull requests? found/i.test(stderr);
125
+ }
126
+
127
+ /**
128
+ * Returns whether a gh failure should trigger a long retry cooldown.
129
+ *
130
+ * @param result - Process result from the gh invocation
131
+ * @returns True when failures are likely environmental or timeout-related
132
+ */
133
+ function shouldCooldownPullRequestChecks(result: {
134
+ reason?: string;
135
+ stderr: string;
136
+ exitCode: number | null;
137
+ }): boolean {
138
+ if (result.reason?.includes("timed out")) return true;
139
+ if (result.reason?.includes("ENOENT")) return true;
140
+ if (result.exitCode === null && result.reason) return true;
141
+ return /could not resolve to a repository|no git remotes found|not a git repository/i.test(
142
+ result.stderr
143
+ );
144
+ }
145
+
146
+ /**
147
+ * Execute a git command asynchronously.
148
+ *
149
+ * @param args - Git subcommand and arguments
150
+ * @param cwd - Working directory
151
+ * @param timeoutMs - Optional timeout override
152
+ * @returns Trimmed stdout output, or null on failure
153
+ */
154
+ async function runGit(
155
+ args: readonly string[],
156
+ cwd: string,
157
+ timeoutMs = 3_000
158
+ ): Promise<string | null> {
159
+ return await runGitCommand(args, cwd, timeoutMs);
160
+ }
161
+
162
+ /**
163
+ * Read branch, ahead/behind, and dirty state without blocking the UI thread.
164
+ *
165
+ * @param cwd - Working directory to inspect
166
+ * @returns Base git state, or null when not inside a git repository
167
+ */
168
+ async function getBaseGitState(cwd: string): Promise<GitState | null> {
169
+ const raw = await runGit(["status", "--porcelain=v2", "--branch"], cwd);
170
+ if (raw === null) return null;
171
+
172
+ const state = parseGitStatus(raw);
173
+ if (!state) return null;
174
+ if (state.branch !== "(detached)") return state;
175
+
176
+ const sha = await runGit(["rev-parse", "--short", "HEAD"], cwd);
177
+ if (sha) {
178
+ state.branch = `(${sha})`;
179
+ }
180
+ return state;
181
+ }
182
+
183
+ /**
184
+ * Resolve pull-request metadata for the current branch.
185
+ *
186
+ * @param cwd - Working directory to inspect
187
+ * @returns PR metadata, null for no PR / unavailable data, and cooldown on repeated failures
188
+ */
189
+ async function getPullRequestInfoForBranch(cwd: string): Promise<PullRequestInfo | null> {
190
+ const result = await runCommand({
191
+ command: "gh",
192
+ args: ["pr", "view", "--json", "state,number,isDraft"],
193
+ cwd,
194
+ source: "git-helper",
195
+ timeoutMs: PR_TIMEOUT_MS,
196
+ });
197
+ if (result.ok && result.stdout) {
198
+ try {
199
+ return parsePullRequestInfo(result.stdout);
200
+ } catch {
201
+ prCooldownUntil = Date.now() + PR_ERROR_COOLDOWN_MS;
202
+ return null;
117
203
  }
118
- } catch {
119
- // gh CLI not available, parse failure, or not in a GitHub repo
120
204
  }
121
205
 
122
- return { branch, dirty, ahead, behind, prState, prNumber };
206
+ if (isNoPullRequestError(result.stderr)) {
207
+ return null;
208
+ }
209
+
210
+ if (shouldCooldownPullRequestChecks(result)) {
211
+ prCooldownUntil = Date.now() + PR_ERROR_COOLDOWN_MS;
212
+ }
213
+ return null;
214
+ }
215
+
216
+ /**
217
+ * Returns whether PR metadata should be refreshed for the current branch.
218
+ *
219
+ * @param baseState - Freshly computed base git state
220
+ * @param previousState - Previously cached state before the current refresh
221
+ * @returns True when a PR refresh is worth attempting
222
+ */
223
+ function shouldRefreshPullRequest(baseState: GitState, previousState: GitState | null): boolean {
224
+ if (baseState.branch === null) return false;
225
+ if (Date.now() < prCooldownUntil) return false;
226
+ if (!previousState) return true;
227
+ if (previousState.branch !== baseState.branch) return true;
228
+ return Date.now() - lastPrRefreshAt >= PR_REFRESH_INTERVAL_MS;
123
229
  }
124
230
 
125
231
  /**
126
232
  * Formats the git state into a colored status string for display.
233
+ *
127
234
  * @param state - The git state to format
128
235
  * @returns A formatted string with ANSI color codes
129
236
  */
130
- function formatStatus(state: GitState): string {
237
+ export function formatStatus(state: GitState): string {
131
238
  const parts: string[] = [];
132
239
 
133
- // Branch name with dirty indicator
134
240
  let branchDisplay = `${C_TEAL}${state.branch}${C_RESET}`;
135
241
  if (state.dirty) {
136
242
  branchDisplay += `${C_YELLOW}*${C_RESET}`;
137
243
  }
138
244
  parts.push(branchDisplay);
139
245
 
140
- // Ahead/behind
141
246
  if (state.ahead > 0 || state.behind > 0) {
142
247
  const arrows: string[] = [];
143
248
  if (state.ahead > 0) arrows.push(`${C_GREEN}↑${state.ahead}${C_RESET}`);
@@ -145,7 +250,6 @@ function formatStatus(state: GitState): string {
145
250
  parts.push(arrows.join(""));
146
251
  }
147
252
 
148
- // PR status
149
253
  if (state.prState && state.prNumber) {
150
254
  let prDisplay: string;
151
255
  switch (state.prState) {
@@ -171,63 +275,129 @@ function formatStatus(state: GitState): string {
171
275
  }
172
276
 
173
277
  /**
174
- * Updates the git status in the UI status bar.
175
- * Caches the state to avoid redundant git calls.
176
- * @param ctx - The extension context providing UI access
278
+ * Push the current cached status into the UI.
279
+ *
280
+ * @param ctx - Extension context providing the UI surface
281
+ * @param revision - Session revision that must still be current
282
+ * @returns Nothing
283
+ */
284
+ function renderCachedStatus(ctx: ExtensionContext, revision: number): void {
285
+ if (revision !== sessionRevision) return;
286
+ if (ctx.cwd !== lastCwd || !cachedState) {
287
+ ctx.ui.setStatus("git", undefined);
288
+ return;
289
+ }
290
+ ctx.ui.setStatus("git", formatStatus(cachedState));
291
+ }
292
+
293
+ /**
294
+ * Refresh the git status cache without blocking terminal input.
295
+ *
296
+ * Concurrent refreshes are coalesced so timer ticks, agent-end hooks, and bash
297
+ * results cannot stack multiple in-flight `git`/`gh` subprocesses.
298
+ *
299
+ * @param ctx - Extension context providing cwd + ui access
300
+ * @param revision - Session revision that must remain current while refreshing
301
+ * @returns Promise resolving after the latest queued refresh finishes
177
302
  */
178
- async function updateStatus(ctx: ExtensionContext): Promise<void> {
303
+ async function refreshStatus(ctx: ExtensionContext, revision: number): Promise<void> {
304
+ if (activeRefresh) {
305
+ queuedRefresh = { ctx, revision };
306
+ return;
307
+ }
308
+
179
309
  const cwd = ctx.cwd;
310
+ activeRefresh = (async () => {
311
+ const baseState = await getBaseGitState(cwd);
312
+ if (revision !== sessionRevision || ctx.cwd !== cwd) return;
180
313
 
181
- // Only update if cwd changed or no cache
182
- if (cwd !== lastCwd || !cachedState) {
183
314
  lastCwd = cwd;
184
- cachedState = getGitState(cwd);
185
- } else {
186
- // Refresh state periodically
187
- cachedState = getGitState(cwd);
188
- }
315
+ if (!baseState) {
316
+ cachedState = null;
317
+ renderCachedStatus(ctx, revision);
318
+ return;
319
+ }
189
320
 
190
- if (cachedState) {
191
- ctx.ui.setStatus("git", formatStatus(cachedState));
192
- } else {
193
- ctx.ui.setStatus("git", undefined);
194
- }
321
+ const previousState = cachedState;
322
+ cachedState = {
323
+ ...baseState,
324
+ prState: previousState?.branch === baseState.branch ? previousState.prState : null,
325
+ prNumber: previousState?.branch === baseState.branch ? previousState.prNumber : null,
326
+ };
327
+ renderCachedStatus(ctx, revision);
328
+
329
+ if (!shouldRefreshPullRequest(baseState, previousState)) {
330
+ return;
331
+ }
332
+
333
+ const prInfo = await getPullRequestInfoForBranch(cwd);
334
+ if (revision !== sessionRevision || ctx.cwd !== cwd) return;
335
+ if (!cachedState || cachedState.branch !== baseState.branch) return;
336
+
337
+ cachedState = {
338
+ ...cachedState,
339
+ prState: prInfo?.prState ?? null,
340
+ prNumber: prInfo?.prNumber ?? null,
341
+ };
342
+ lastPrRefreshAt = Date.now();
343
+ renderCachedStatus(ctx, revision);
344
+ })().finally(() => {
345
+ activeRefresh = null;
346
+ const nextRefresh = queuedRefresh;
347
+ queuedRefresh = null;
348
+ if (nextRefresh) {
349
+ void refreshStatus(nextRefresh.ctx, nextRefresh.revision);
350
+ }
351
+ });
352
+
353
+ await activeRefresh;
354
+ }
355
+
356
+ /**
357
+ * Invalidate caches that should be recomputed on the next refresh.
358
+ *
359
+ * @returns Nothing
360
+ */
361
+ function invalidateStatusCache(): void {
362
+ cachedState = null;
363
+ lastPrRefreshAt = 0;
195
364
  }
196
365
 
197
366
  /**
198
367
  * Registers the git status extension with Pi.
199
- * Sets up event handlers for session lifecycle and git state updates.
368
+ *
200
369
  * @param pi - The Pi extension API
370
+ * @returns Nothing
201
371
  */
202
372
  export default function gitStatus(pi: ExtensionAPI): void {
203
- pi.on("session_start", async (_event, ctx) => {
204
- await updateStatus(ctx);
373
+ pi.on("session_start", (_event, ctx) => {
374
+ const revision = ++sessionRevision;
375
+ renderCachedStatus(ctx, revision);
376
+ void refreshStatus(ctx, revision);
205
377
 
206
- // Update every 10 seconds
207
378
  if (G.__piGitStatusInterval) clearInterval(G.__piGitStatusInterval);
208
- G.__piGitStatusInterval = setInterval(() => updateStatus(ctx), 10_000);
379
+ G.__piGitStatusInterval = setInterval(() => {
380
+ void refreshStatus(ctx, revision);
381
+ }, STATUS_REFRESH_INTERVAL_MS);
209
382
  });
210
383
 
211
- pi.on("session_shutdown", async () => {
384
+ pi.on("session_shutdown", () => {
385
+ sessionRevision += 1;
386
+ queuedRefresh = null;
212
387
  if (G.__piGitStatusInterval) {
213
388
  clearInterval(G.__piGitStatusInterval);
214
389
  G.__piGitStatusInterval = null;
215
390
  }
216
391
  });
217
392
 
218
- // Update after each agent turn (files may have changed)
219
- pi.on("agent_end", async (_event, ctx) => {
220
- // Clear cache to force refresh
221
- cachedState = null;
222
- await updateStatus(ctx);
393
+ pi.on("agent_end", (_event, ctx) => {
394
+ invalidateStatusCache();
395
+ void refreshStatus(ctx, sessionRevision);
223
396
  });
224
397
 
225
- // Update when directory changes
226
- pi.on("tool_result", async (event, ctx) => {
227
- if (event.toolName === "bash") {
228
- // Might have changed directory or git state
229
- cachedState = null;
230
- await updateStatus(ctx);
231
- }
398
+ pi.on("tool_result", (event, ctx) => {
399
+ if (event.toolName !== "bash") return;
400
+ invalidateStatusCache();
401
+ void refreshStatus(ctx, sessionRevision);
232
402
  });
233
403
  }
@@ -12,6 +12,7 @@ import * as fs from "node:fs";
12
12
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
13
13
  import { loadSkills, stripFrontmatter } from "@mariozechner/pi-coding-agent";
14
14
  import { Text } from "@mariozechner/pi-tui";
15
+ import { getTallowHomeDir } from "../_shared/tallow-paths.js";
15
16
  import {
16
17
  DEFAULT_SKILL_ICON,
17
18
  getPackageSkillPaths,
@@ -38,7 +39,12 @@ export default function (pi: ExtensionAPI) {
38
39
 
39
40
  function reloadSkills() {
40
41
  try {
41
- const loaded = loadSkills({ skillPaths: getPackageSkillPaths() });
42
+ const loaded = loadSkills({
43
+ cwd: process.cwd(),
44
+ agentDir: getTallowHomeDir(),
45
+ skillPaths: getPackageSkillPaths(),
46
+ includeDefaults: false,
47
+ });
42
48
  skills = loaded.skills.map((s) => ({
43
49
  name: s.name,
44
50
  filePath: s.filePath,
@@ -38,7 +38,7 @@ import {
38
38
  formatImageDimensions,
39
39
  imageFormatToMime,
40
40
  } from "../_shared/image-metadata.js";
41
- import { getTallowSettingsPath } from "../_shared/tallow-paths.js";
41
+ import { getTallowHomeDir, getTallowSettingsPath } from "../_shared/tallow-paths.js";
42
42
  import { fileLink } from "../_shared/terminal-links.js";
43
43
  import {
44
44
  appendSection,
@@ -151,7 +151,12 @@ function getSkillPathMap(): Map<string, SkillCacheEntry> {
151
151
  if (!skillPathMap) {
152
152
  skillPathMap = new Map();
153
153
  try {
154
- const { skills } = loadSkills({ skillPaths: getPackageSkillPaths() });
154
+ const { skills } = loadSkills({
155
+ cwd: process.cwd(),
156
+ agentDir: getTallowHomeDir(),
157
+ skillPaths: getPackageSkillPaths(),
158
+ includeDefaults: false,
159
+ });
155
160
  for (const s of skills) {
156
161
  skillPathMap.set(s.name, {
157
162
  path: s.filePath,
@@ -0,0 +1,115 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { listLiveSessionIdsForCwd } from "../session-files.js";
6
+
7
+ /**
8
+ * Encode a cwd into the session directory name used by tallow.
9
+ *
10
+ * @param cwd - Absolute working directory path
11
+ * @returns Encoded directory name
12
+ */
13
+ function encodeSessionDirName(cwd: string): string {
14
+ const withoutLeadingSlash = cwd.startsWith("/") || cwd.startsWith("\\") ? cwd.slice(1) : cwd;
15
+ const safeName = withoutLeadingSlash
16
+ .replaceAll("/", "-")
17
+ .replaceAll("\\", "-")
18
+ .replaceAll(":", "-");
19
+ return `--${safeName}--`;
20
+ }
21
+
22
+ /**
23
+ * Create a minimal JSONL session file for a test home/cwd pair.
24
+ *
25
+ * @param homeDir - Tallow home directory
26
+ * @param cwd - Session working directory
27
+ * @param sessionId - Session id to persist in the header
28
+ * @param fileName - Session filename to create
29
+ * @returns Absolute path to the created session file
30
+ */
31
+ function createSessionFile(
32
+ homeDir: string,
33
+ cwd: string,
34
+ sessionId: string,
35
+ fileName: string
36
+ ): string {
37
+ const sessionDir = join(homeDir, "sessions", encodeSessionDirName(cwd));
38
+ mkdirSync(sessionDir, { recursive: true });
39
+ const filePath = join(sessionDir, fileName);
40
+ writeFileSync(
41
+ filePath,
42
+ `${JSON.stringify({ cwd, id: sessionId, timestamp: new Date().toISOString(), type: "session", version: 3 })}\n`
43
+ );
44
+ return filePath;
45
+ }
46
+
47
+ describe("listLiveSessionIdsForCwd", () => {
48
+ const originalHome = process.env.HOME;
49
+ const originalTallowHome = process.env.TALLOW_CODING_AGENT_DIR;
50
+ const originalPiHome = process.env.PI_CODING_AGENT_DIR;
51
+ let tmpRoot: string;
52
+
53
+ beforeEach(() => {
54
+ tmpRoot = mkdtempSync(join(tmpdir(), "rewind-session-files-"));
55
+ process.env.HOME = tmpRoot;
56
+ delete process.env.PI_CODING_AGENT_DIR;
57
+ delete process.env.TALLOW_CODING_AGENT_DIR;
58
+ });
59
+
60
+ afterEach(() => {
61
+ if (originalHome === undefined) {
62
+ delete process.env.HOME;
63
+ } else {
64
+ process.env.HOME = originalHome;
65
+ }
66
+
67
+ if (originalTallowHome === undefined) {
68
+ delete process.env.TALLOW_CODING_AGENT_DIR;
69
+ } else {
70
+ process.env.TALLOW_CODING_AGENT_DIR = originalTallowHome;
71
+ }
72
+
73
+ if (originalPiHome === undefined) {
74
+ delete process.env.PI_CODING_AGENT_DIR;
75
+ } else {
76
+ process.env.PI_CODING_AGENT_DIR = originalPiHome;
77
+ }
78
+
79
+ rmSync(tmpRoot, { force: true, recursive: true });
80
+ });
81
+
82
+ it("finds live session ids across the default and active tallow homes", () => {
83
+ const cwd = "/Users/kevin/dev/tallow";
84
+ const defaultHome = join(tmpRoot, ".tallow");
85
+ const activeHome = join(tmpRoot, ".tallow-project");
86
+ process.env.TALLOW_CODING_AGENT_DIR = activeHome;
87
+
88
+ createSessionFile(defaultHome, cwd, "default-session", "2026-04-20_default-session.jsonl");
89
+ createSessionFile(activeHome, cwd, "active-session", "2026-04-20_active-session.jsonl");
90
+ createSessionFile(
91
+ activeHome,
92
+ "/Users/kevin/dev/other",
93
+ "other-project",
94
+ "2026-04-20_other.jsonl"
95
+ );
96
+
97
+ const ids = listLiveSessionIdsForCwd(cwd, [defaultHome, activeHome]);
98
+ expect([...ids].sort((a, b) => a.localeCompare(b))).toEqual([
99
+ "active-session",
100
+ "default-session",
101
+ ]);
102
+ });
103
+
104
+ it("ignores unreadable or corrupt session files", () => {
105
+ const cwd = "/Users/kevin/dev/tallow";
106
+ const defaultHome = join(tmpRoot, ".tallow");
107
+ const sessionDir = join(defaultHome, "sessions", encodeSessionDirName(cwd));
108
+ mkdirSync(sessionDir, { recursive: true });
109
+ writeFileSync(join(sessionDir, "corrupt.jsonl"), "not-json\n");
110
+ createSessionFile(defaultHome, cwd, "valid-session", "2026-04-20_valid-session.jsonl");
111
+
112
+ const ids = listLiveSessionIdsForCwd(cwd, [defaultHome]);
113
+ expect([...ids]).toEqual(["valid-session"]);
114
+ });
115
+ });
@@ -247,6 +247,29 @@ describe("SnapshotManager", () => {
247
247
  otherMgr.cleanup();
248
248
  });
249
249
 
250
+ it("should prune stale session refs while preserving live sessions", () => {
251
+ const otherMgr = new SnapshotManager(tmpDir, "other-session");
252
+ const staleMgr = new SnapshotManager(tmpDir, "stale-session");
253
+
254
+ writeFileSync(join(tmpDir, "a.txt"), "live-1");
255
+ mgr.createSnapshot(1);
256
+ writeFileSync(join(tmpDir, "a.txt"), "live-2");
257
+ otherMgr.createSnapshot(1);
258
+ writeFileSync(join(tmpDir, "a.txt"), "stale");
259
+ staleMgr.createSnapshot(1);
260
+
261
+ const deletedRefs = mgr.cleanupStaleSessions(new Set(["other-session", "test-session"]));
262
+ expect(deletedRefs).toBe(1);
263
+
264
+ const staleRefs = git(["for-each-ref", "refs/tallow/rewind/stale-session/"], tmpDir);
265
+ expect(staleRefs.trim()).toBe("");
266
+
267
+ const liveRefs = git(["for-each-ref", "refs/tallow/rewind/test-session/"], tmpDir);
268
+ expect(liveRefs.trim()).not.toBe("");
269
+ const otherRefs = git(["for-each-ref", "refs/tallow/rewind/other-session/"], tmpDir);
270
+ expect(otherRefs.trim()).not.toBe("");
271
+ });
272
+
250
273
  it("should return empty list when no snapshots exist", () => {
251
274
  expect(mgr.listSnapshots()).toHaveLength(0);
252
275
  });
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import type { CustomEntry, ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
17
+ import { listLiveSessionIdsForCwd } from "./session-files.js";
17
18
  import { SnapshotManager } from "./snapshots.js";
18
19
  import type { RewindSnapshotEntry } from "./tracker.js";
19
20
  import { FileTracker } from "./tracker.js";
@@ -50,6 +51,10 @@ export default function rewind(pi: ExtensionAPI): void {
50
51
  snapshots = mgr;
51
52
  tracker.reset();
52
53
 
54
+ const liveSessionIds = listLiveSessionIdsForCwd(context.cwd);
55
+ liveSessionIds.add(sessionId);
56
+ mgr.cleanupStaleSessions(liveSessionIds);
57
+
53
58
  // Restore tracker state from persisted session entries
54
59
  const entries = context.sessionManager.getEntries();
55
60
  const snapshotEntries = entries