@stevecastaneda/paseo-progress 1.14.0 → 1.14.1

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.
package/README.md CHANGED
@@ -80,7 +80,7 @@ The choice covers every worktree of the repo and is kept in the repo's local git
80
80
 
81
81
  ### Upgrading from 0.1.x
82
82
 
83
- Earlier versions kept progress in `.scratch/` in each worktree, hidden by a `.scratch/.gitignore` they wrote. The first time the command or the panel touches a worktree after the upgrade, the plugin moves its files out of `.scratch/` and removes that `.gitignore` if it's exactly the one the plugin wrote. If `.scratch/` is then empty, it goes too; anything else you keep there stays. If git tracks the old progress file (for example, an agent committed it), the plugin leaves it where it is and keeps using it, so nothing changes in that repo.
83
+ Versions before 0.2.0 kept progress in `.scratch/` in each worktree. From 1.14.1, Progress never reads, moves or changes the files those versions left there, so such a run no longer shows in the panel. The file stays where it is. Versions 0.2.0 to 1.14.0 moved such a run outside the repo the first time they opened the worktree.
84
84
 
85
85
  ## Agent tools
86
86
 
@@ -178,6 +178,12 @@ The progress file, `progress.jsonl`, holds one JSON event per line, each with a
178
178
 
179
179
  ## Changelog
180
180
 
181
+ ### 1.14.1
182
+
183
+ - [Fixed] **Run history stays in its folder.** This fixes a security issue in saving run history. Progress now saves a run only when the dashboard can read the line that started it, and only into a folder inside the repo and outside `.git`.
184
+ - [Fixed] **HTML previews keep ignored files and `.git` out.** This fixes a security issue in the HTML preview. No preview gets files in `.git` written into it, and a page that git tracks no longer gets files git ignores, like a `.env`. A page in an ignored folder, like an agent's report in `.scratch`, still shows the files in its own folder and the folders below it. If git can't run in the repo, a preview shows the page without the files it loads from the repo.
185
+ - [Fixed] **Progress no longer reads its old files in `.scratch`.** This fixes a security issue in how Progress handled a run that version 0.1.x left in `.scratch`. Progress now keeps its files outside the repo for every worktree and never reads, moves or changes the files 0.1.x left there. A 0.1.x run that was never opened since 0.2.0 stays there and no longer shows in the panel.
186
+
181
187
  ### 1.14.0
182
188
 
183
189
  - [New] **Agents can record progress with Paseo tools.** On Paseo 0.14 and later, Progress hands agents a tool for each common command, like `progress_ticket_update` and `progress_question_ask`. Nothing needs installing, and every update names the agent that sent it. Agents get them once you turn on **Settings → your host → Agents → Enable Paseo tools**, which also lets them start, message and stop other agents. **Agent tools** in Progress settings shows whether it's on and how to turn it on. The skill now uses the tools when an agent has them and the `paseo-progress` command otherwise, so Paseo 0.13 and agents without the tools work as before.
@@ -19706,7 +19706,6 @@ var QUESTION_MODE_RULES = {
19706
19706
  // shared/events.ts
19707
19707
  var PROGRESS_FILE_NAME = "progress.jsonl";
19708
19708
  var PANEL_OPENED_NAME = "progress-panel-opened";
19709
- var LEGACY_FOLDER = ".scratch";
19710
19709
  var EVENT_VERSION = 1;
19711
19710
  var TICKET_STATUSES = ["not_started", "working", "blocked", "done", "skipped"];
19712
19711
  var ticketStatusSchema = external_exports.enum(TICKET_STATUSES);
@@ -20270,10 +20269,16 @@ import { dirname, isAbsolute, join as join2, resolve, sep } from "node:path";
20270
20269
  function outside(inside) {
20271
20270
  return inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside);
20272
20271
  }
20272
+ function inGitFolder(inside) {
20273
+ return inside.split(/[\\/]/).some((name) => name.toLowerCase() === ".git");
20274
+ }
20275
+ function hasGitEntry(directory) {
20276
+ return existsSync(join2(directory, ".git"));
20277
+ }
20273
20278
  function findRoot(cwd) {
20274
20279
  let directory = resolve(cwd);
20275
20280
  while (true) {
20276
- if (existsSync(join2(directory, ".git"))) return directory;
20281
+ if (hasGitEntry(directory)) return directory;
20277
20282
  const parent = dirname(directory);
20278
20283
  if (parent === directory) return resolve(cwd);
20279
20284
  directory = parent;
@@ -20385,33 +20390,33 @@ async function installLauncher(path, platform = process.platform) {
20385
20390
  import { execFile } from "node:child_process";
20386
20391
  import { createHash, randomUUID } from "node:crypto";
20387
20392
  import { realpathSync } from "node:fs";
20388
- import { appendFile as appendFile2, mkdir as mkdir4, readdir, readFile as readFile3, rename, rm as rm2, rmdir, stat as stat3, writeFile as writeFile3 } from "node:fs/promises";
20393
+ import { lstat, mkdir as mkdir4, readdir, realpath, rename, rm as rm2, stat as stat3, writeFile as writeFile3 } from "node:fs/promises";
20389
20394
  import { homedir } from "node:os";
20390
- import { basename as basename2, dirname as dirname3, join as join4, normalize, resolve as resolve2 } from "node:path";
20395
+ import { basename as basename2, dirname as dirname3, join as join4, normalize, resolve as resolve2, sep as sep2 } from "node:path";
20391
20396
  import { promisify } from "node:util";
20392
- var LOCK_NAME2 = `${PROGRESS_FILE_NAME}.lock`;
20393
20397
  var SAVE_RUNS_KEY = "paseo-progress.saveRuns";
20394
- var LEGACY_GITIGNORE = `# Added by the progress plugin for Paseo so its files stay out of git.
20395
- ${PROGRESS_FILE_NAME}
20396
- ${LOCK_NAME2}/
20397
- ${PANEL_OPENED_NAME}
20398
- .gitignore
20399
- `;
20400
- async function git(cwd, args) {
20398
+ async function git(root, args) {
20401
20399
  try {
20402
- const { stdout } = await promisify(execFile)("git", args, { cwd });
20400
+ const { stdout } = await promisify(execFile)("git", ["-C", resolve2(root), ...args], { cwd: dirname3(process.execPath) });
20403
20401
  return { code: 0, stdout };
20404
20402
  } catch (error62) {
20405
20403
  const failed = error62;
20406
20404
  return { code: typeof failed.code === "number" ? failed.code : -1, stdout: failed.stdout ?? "" };
20407
20405
  }
20408
20406
  }
20409
- async function exists2(path) {
20410
- return Boolean(await stat3(path).catch(() => null));
20411
- }
20412
20407
  function stateRoot(home = homedir()) {
20413
20408
  return join4(home, ".local", "state", "paseo-progress");
20414
20409
  }
20410
+ async function writeAtomic(path, text2) {
20411
+ await mkdir4(dirname3(path), { recursive: true });
20412
+ const temp = `${path}.${randomUUID()}.tmp`;
20413
+ try {
20414
+ await writeFile3(temp, text2);
20415
+ await rename(temp, path);
20416
+ } finally {
20417
+ await rm2(temp, { force: true });
20418
+ }
20419
+ }
20415
20420
  function outsideDirectory(root, home = homedir()) {
20416
20421
  let real = root;
20417
20422
  try {
@@ -20421,51 +20426,18 @@ function outsideDirectory(root, home = homedir()) {
20421
20426
  const hash2 = createHash("sha1").update(real).digest("hex").slice(0, 8);
20422
20427
  return join4(stateRoot(home), `${basename2(real)}-${hash2}`);
20423
20428
  }
20424
- var trackedLegacy = /* @__PURE__ */ new Map();
20425
- async function legacyTracked(root, mtimeMs) {
20426
- const cached2 = trackedLegacy.get(root);
20427
- if (cached2 && cached2.mtimeMs === mtimeMs) return cached2.tracked;
20428
- const { code } = await git(root, ["ls-files", "--error-unmatch", "--", `${LEGACY_FOLDER}/${PROGRESS_FILE_NAME}`]);
20429
- trackedLegacy.set(root, { mtimeMs, tracked: code === 0 });
20430
- return code === 0;
20431
- }
20432
- async function findStore(root, home = homedir()) {
20429
+ function findStore(root, home = homedir()) {
20433
20430
  const directory = outsideDirectory(root, home);
20434
- const store = { directory, shown: directory.startsWith(home) ? `~${directory.slice(home.length)}` : directory };
20435
- const legacy = join4(root, LEGACY_FOLDER);
20436
- const info = await stat3(join4(legacy, PROGRESS_FILE_NAME)).catch(() => null);
20437
- if (!info) return store;
20438
- if (await legacyTracked(root, info.mtimeMs)) return { directory: legacy, shown: LEGACY_FOLDER };
20439
- await moveLegacy(legacy, directory);
20440
- return store;
20441
- }
20442
- async function moveLegacy(legacy, directory) {
20443
- await mkdir4(directory, { recursive: true });
20444
- await withLock(join4(legacy, LOCK_NAME2), async () => {
20445
- const text2 = await readFile3(join4(legacy, PROGRESS_FILE_NAME), "utf8").catch(() => null);
20446
- if (text2 === null) return;
20447
- await withLock(join4(directory, LOCK_NAME2), async () => {
20448
- const current = await readFile3(join4(directory, PROGRESS_FILE_NAME), "utf8").catch(() => "");
20449
- const lead = current && !current.endsWith("\n") ? "\n" : "";
20450
- await appendFile2(join4(directory, PROGRESS_FILE_NAME), `${lead}${text2}`);
20451
- });
20452
- await rm2(join4(legacy, PROGRESS_FILE_NAME));
20453
- if (await exists2(join4(legacy, PANEL_OPENED_NAME))) {
20454
- await writeFile3(join4(directory, PANEL_OPENED_NAME), "");
20455
- await rm2(join4(legacy, PANEL_OPENED_NAME));
20456
- }
20457
- });
20458
- const ignore = await readFile3(join4(legacy, ".gitignore"), "utf8").catch(() => null);
20459
- if (ignore === LEGACY_GITIGNORE) await rm2(join4(legacy, ".gitignore"));
20460
- await rmdir(legacy).catch(() => {
20461
- });
20431
+ return { directory, shown: directory.startsWith(home) ? `~${directory.slice(home.length)}` : directory };
20462
20432
  }
20463
20433
  var configFiles = /* @__PURE__ */ new Map();
20464
20434
  var savedFolders = /* @__PURE__ */ new Map();
20465
20435
  async function configFile(root) {
20436
+ if (!hasGitEntry(root)) return null;
20466
20437
  if (!configFiles.has(root)) {
20467
20438
  const { code, stdout } = await git(root, ["rev-parse", "--git-common-dir"]);
20468
- configFiles.set(root, code === 0 ? join4(resolve2(root, stdout.trim()), "config") : null);
20439
+ if (code !== 0) return null;
20440
+ configFiles.set(root, join4(resolve2(root, stdout.trim()), "config"));
20469
20441
  }
20470
20442
  return configFiles.get(root);
20471
20443
  }
@@ -20476,10 +20448,19 @@ async function historyFolder(root) {
20476
20448
  const cached2 = savedFolders.get(file2);
20477
20449
  if (cached2 && cached2.mtimeMs === mtimeMs) return cached2.folder;
20478
20450
  const { stdout } = await git(root, ["config", "--get", SAVE_RUNS_KEY]);
20479
- const folder = stdout.trim() || null;
20451
+ const checked = checkFolder(stdout);
20452
+ const folder = "folder" in checked ? checked.folder : null;
20480
20453
  savedFolders.set(file2, { mtimeMs, folder });
20481
20454
  return folder;
20482
20455
  }
20456
+ function checkFolder(raw) {
20457
+ const trimmed = raw.trim().replace(/\/+$/, "");
20458
+ if (!trimmed) return { error: "Choose a folder" };
20459
+ const folder = normalize(trimmed);
20460
+ if (outside(folder) || folder === ".") return { error: "Choose a folder inside the repo" };
20461
+ if (inGitFolder(folder)) return { error: "Choose a folder outside .git" };
20462
+ return { folder };
20463
+ }
20483
20464
  function runFileName(startedAt, title) {
20484
20465
  const stamp = startedAt.replace(/\.\d+Z$/, "").replace(/[-:]/g, "").replace("T", "-");
20485
20466
  const slug = title.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60);
@@ -20488,29 +20469,27 @@ function runFileName(startedAt, title) {
20488
20469
  async function saveRunCopy(root, text2) {
20489
20470
  const folder = await historyFolder(root);
20490
20471
  if (!folder) return;
20491
- const lines = text2.split("\n").filter((line) => line.trim());
20492
- let start = -1;
20493
- let event = null;
20494
- for (let index = lines.length - 1; index >= 0; index--) {
20495
- try {
20496
- const parsed = JSON.parse(lines[index]);
20497
- if (parsed?.type === "run.start") {
20498
- start = index;
20499
- event = parsed;
20500
- break;
20501
- }
20502
- } catch {
20503
- }
20504
- }
20505
- if (start < 0 || !event?.ts || !event.title) return;
20506
- const directory = join4(root, folder);
20507
- await mkdir4(directory, { recursive: true });
20508
- await writeFile3(join4(directory, runFileName(event.ts, event.title)), `${lines.slice(start).join("\n")}
20472
+ const [start] = latestRun(parseProgress(text2).events);
20473
+ if (start?.event.type !== "run.start") return;
20474
+ const lines = text2.split("\n").slice(start.line - 1).filter((line) => line.trim());
20475
+ const directory = await runsFolder(root, folder);
20476
+ if (!directory) return;
20477
+ await writeAtomic(join4(directory, runFileName(start.event.ts, start.event.title)), `${lines.join("\n")}
20509
20478
  `);
20510
20479
  }
20480
+ async function runsFolder(root, folder) {
20481
+ let directory = await realpath(root);
20482
+ for (const name of folder.split(sep2)) {
20483
+ directory = join4(directory, name);
20484
+ const info = await lstat(directory).catch(() => null);
20485
+ if (!info) await mkdir4(directory);
20486
+ else if (!info.isDirectory()) return null;
20487
+ }
20488
+ return directory;
20489
+ }
20511
20490
 
20512
20491
  // server/question-mode.ts
20513
- import { readFile as readFile4, rm as rm3 } from "node:fs/promises";
20492
+ import { readFile as readFile3, rm as rm3 } from "node:fs/promises";
20514
20493
  import { homedir as homedir2 } from "node:os";
20515
20494
  import { join as join5 } from "node:path";
20516
20495
  var mirrorSchema = external_exports.object({ questionMode: external_exports.enum(QUESTION_MODES) });
@@ -20519,7 +20498,7 @@ function questionModePath(home = homedir2()) {
20519
20498
  }
20520
20499
  async function readQuestionMode(home = homedir2()) {
20521
20500
  try {
20522
- return mirrorSchema.parse(JSON.parse(await readFile4(questionModePath(home), "utf8"))).questionMode;
20501
+ return mirrorSchema.parse(JSON.parse(await readFile3(questionModePath(home), "utf8"))).questionMode;
20523
20502
  } catch {
20524
20503
  return DEFAULT_QUESTION_MODE;
20525
20504
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stevecastaneda/paseo-progress",
3
- "version": "1.14.0",
3
+ "version": "1.14.1",
4
4
  "description": "A live progress dashboard per worktree for Paseo, drawn from a file agents write with the Progress tools or the paseo-progress command.",
5
5
  "license": "MIT",
6
6
  "author": "Steve Castaneda",
package/server/open.ts CHANGED
@@ -8,8 +8,8 @@ import { promisify } from "node:util";
8
8
  import { findAttachment, type FolderEntry, imageMimeType, isPieced, type PiecedKind, previewKind, splitRef, textFormat, videoMimeType } from "../shared/attachments.ts";
9
9
  import { prepareMarkdown, previewText } from "../shared/markdown.ts";
10
10
  import { readProgress } from "./progress-file.ts";
11
- import { findRoot, outside } from "./paths.ts";
12
- import { findStore } from "./store.ts";
11
+ import { findRoot, hasGitEntry, inGitFolder, outside } from "./paths.ts";
12
+ import { findStore, git } from "./store.ts";
13
13
 
14
14
  export type Opener = (path: string) => Promise<void>;
15
15
 
@@ -127,7 +127,7 @@ export async function previewDeliverable(directory: string, id: string): Promise
127
127
  }
128
128
  }
129
129
  if (size > MAX_PREVIEW_BYTES) throw new Error(tooLarge(path, size));
130
- if (kind === "html") return { kind, html: await inlineLocalFiles(await readFile(realTarget, "utf8"), dirname(realTarget), realRoot), bytes: size };
130
+ if (kind === "html") return { kind, html: await inlineLocalFiles(await readFile(realTarget, "utf8"), realTarget, realRoot), bytes: size };
131
131
  const data = await readFile(realTarget);
132
132
  return { kind, dataUri: `data:${imageMimeType(realTarget)};base64,${data.toString("base64")}`, bytes: size };
133
133
  }
@@ -146,8 +146,29 @@ async function listFolder(target: string): Promise<{ entries: FolderEntry[]; tru
146
146
  // The preview gets only the page, so the files beside it that it loads (its
147
147
  // screenshots, a script, a stylesheet) are written into it. Only files inside the
148
148
  // worktree, up to MAX_PREVIEW_BYTES in all; anything else is left as written and
149
- // fails to load. Web addresses are left alone.
150
- async function inlineLocalFiles(html: string, directory: string, root: string): Promise<string> {
149
+ // fails to load. Web addresses are left alone. A cloned repo's page runs here,
150
+ // so it must not read and send .env or .git files: it gets a file git says it
151
+ // keeps, or, when git ignores the page, like an agent's report in .scratch, a
152
+ // file in the page's own folder or below. Never one in a .git folder. When git
153
+ // can't answer, as for a file in a submodule or when git fails, the file stays out.
154
+ async function inlineLocalFiles(html: string, page: string, root: string): Promise<string> {
155
+ const directory = dirname(page);
156
+ const inRepo = hasGitEntry(root);
157
+ // -v -n prints a line for every path git answers for, so a git that fails
158
+ // with no answer never counts as "not ignored". The "./" stops git reading a
159
+ // name like ":(glob)x" as a pattern.
160
+ const ignoreCheck = async (path: string): Promise<"ignored" | "kept" | "unknown"> => {
161
+ if (!inRepo) return "kept";
162
+ const { code, stdout } = await git(root, ["check-ignore", "-v", "-n", "--", `./${relative(root, path).split(nodePath.sep).join("/")}`]);
163
+ if (!stdout.trim()) return "unknown";
164
+ return code === 0 ? "ignored" : code === 1 ? "kept" : "unknown";
165
+ };
166
+ const pageIgnored = (await ignoreCheck(page)) === "ignored";
167
+ const allowed = async (file: string) => {
168
+ if (inGitFolder(relative(root, file))) return false;
169
+ if ((await ignoreCheck(file)) === "kept") return true;
170
+ return pageIgnored && !outside(relative(directory, file));
171
+ };
151
172
  let room = MAX_PREVIEW_BYTES - Buffer.byteLength(html);
152
173
  const inline = async (url: string, encode: (data: Buffer, file: string) => string): Promise<string | null> => {
153
174
  if (!url || /^([a-z][a-z0-9+.-]*:|[/\\#?])/i.test(url)) return null;
@@ -155,6 +176,7 @@ async function inlineLocalFiles(html: string, directory: string, root: string):
155
176
  const file = await realpath(resolve(directory, decodeURI(url.replace(/[?#].*$/, ""))));
156
177
  const stats = await stat(file);
157
178
  if (outside(relative(root, file)) || !stats.isFile() || stats.size > room) return null;
179
+ if (!(await allowed(file))) return null;
158
180
  const encoded = encode(await readFile(file), file);
159
181
  if (encoded.length > room) return null;
160
182
  room -= encoded.length;
package/server/paths.ts CHANGED
@@ -7,14 +7,26 @@ export function outside(inside: string): boolean {
7
7
  return inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside);
8
8
  }
9
9
 
10
- // The worktree root is the nearest directory with a `.git` entry (a folder in
11
- // a checkout, a file in a linked worktree). Outside git, use the directory.
12
- // The command and the panel both start from here, so a Paseo project set at a
13
- // subfolder of the repo still reads the file the agent writes.
10
+ // Whether a path from relative(root, target) is in a .git folder, a nested
11
+ // checkout's included. Windows writes either separator, and Windows and macOS
12
+ // match names in any case.
13
+ export function inGitFolder(inside: string): boolean {
14
+ return inside.split(/[\\/]/).some((name) => name.toLowerCase() === ".git");
15
+ }
16
+
17
+ // A `.git` entry: a folder in a checkout, a file in a linked worktree.
18
+ export function hasGitEntry(directory: string): boolean {
19
+ return existsSync(join(directory, ".git"));
20
+ }
21
+
22
+ // The worktree root is the nearest directory with a `.git` entry. Outside git,
23
+ // use the directory. The command and the panel both start from here, so a
24
+ // Paseo project set at a subfolder of the repo still reads the file the agent
25
+ // writes.
14
26
  export function findRoot(cwd: string): string {
15
27
  let directory = resolve(cwd);
16
28
  while (true) {
17
- if (existsSync(join(directory, ".git"))) return directory;
29
+ if (hasGitEntry(directory)) return directory;
18
30
  const parent = dirname(directory);
19
31
  if (parent === directory) return resolve(cwd);
20
32
  directory = parent;
package/server/store.ts CHANGED
@@ -5,30 +5,24 @@
5
5
  import { execFile } from "node:child_process";
6
6
  import { createHash, randomUUID } from "node:crypto";
7
7
  import { realpathSync } from "node:fs";
8
- import { appendFile, mkdir, readdir, readFile, rename, rm, rmdir, stat, writeFile } from "node:fs/promises";
8
+ import { lstat, mkdir, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
9
9
  import { homedir } from "node:os";
10
- import { basename, dirname, join, normalize, resolve } from "node:path";
10
+ import { basename, dirname, join, normalize, resolve, sep } from "node:path";
11
11
  import { promisify } from "node:util";
12
- import { LEGACY_FOLDER, PANEL_OPENED_NAME, PROGRESS_FILE_NAME } from "../shared/events.ts";
13
- import { withLock } from "./lock.ts";
14
- import { outside } from "./paths.ts";
12
+ import { latestRun, parseProgress } from "../shared/dashboard.ts";
13
+ import { hasGitEntry, inGitFolder, outside } from "./paths.ts";
15
14
 
16
- const LOCK_NAME = `${PROGRESS_FILE_NAME}.lock`;
17
15
  const SAVE_RUNS_KEY = "paseo-progress.saveRuns";
18
- // Exactly what 0.1.x wrote into .scratch/.gitignore. Only this is ever removed.
19
- const LEGACY_GITIGNORE = `# Added by the progress plugin for Paseo so its files stay out of git.
20
- ${PROGRESS_FILE_NAME}
21
- ${LOCK_NAME}/
22
- ${PANEL_OPENED_NAME}
23
- .gitignore
24
- `;
25
16
 
26
17
  // `directory` holds the working files. `shown` names it for people.
27
18
  export type Store = { directory: string; shown: string };
28
19
 
29
- export async function git(cwd: string, args: string[]): Promise<{ code: number; stdout: string }> {
20
+ // Git runs in the worktree through -C, started from the folder Node itself
21
+ // lives in: Windows looks for a program in the folder it starts in before
22
+ // PATH, and a cloned repo can commit a git.exe.
23
+ export async function git(root: string, args: string[]): Promise<{ code: number; stdout: string }> {
30
24
  try {
31
- const { stdout } = await promisify(execFile)("git", args, { cwd });
25
+ const { stdout } = await promisify(execFile)("git", ["-C", resolve(root), ...args], { cwd: dirname(process.execPath) });
32
26
  return { code: 0, stdout };
33
27
  } catch (error) {
34
28
  const failed = error as { code?: number | string; stdout?: string };
@@ -36,10 +30,6 @@ export async function git(cwd: string, args: string[]): Promise<{ code: number;
36
30
  }
37
31
  }
38
32
 
39
- async function exists(path: string): Promise<boolean> {
40
- return Boolean(await stat(path).catch(() => null));
41
- }
42
-
43
33
  // The plugin's per-user folder on this computer.
44
34
  export function stateRoot(home = homedir()): string {
45
35
  return join(home, ".local", "state", "paseo-progress");
@@ -68,76 +58,37 @@ export function outsideDirectory(root: string, home = homedir()): string {
68
58
  return join(stateRoot(home), `${basename(real)}-${hash}`);
69
59
  }
70
60
 
71
- // Whether git tracks a 0.1.x progress file, per worktree, until the file changes.
72
- const trackedLegacy = new Map<string, { mtimeMs: number; tracked: boolean }>();
73
-
74
- async function legacyTracked(root: string, mtimeMs: number): Promise<boolean> {
75
- const cached = trackedLegacy.get(root);
76
- if (cached && cached.mtimeMs === mtimeMs) return cached.tracked;
77
- const { code } = await git(root, ["ls-files", "--error-unmatch", "--", `${LEGACY_FOLDER}/${PROGRESS_FILE_NAME}`]);
78
- trackedLegacy.set(root, { mtimeMs, tracked: code === 0 });
79
- return code === 0;
80
- }
81
-
82
- // The worktree's working files. A run 0.1.x left in .scratch moves outside the
83
- // first time anything asks, unless git tracks it: then it stays and is used in
84
- // place, so nothing changes for a repo that commits it.
85
- export async function findStore(root: string, home = homedir()): Promise<Store> {
61
+ // The worktree's working files, always outside the repo. Progress never reads
62
+ // a repo's .scratch, where 0.1.x kept them: a cloned repo could put anything there.
63
+ export function findStore(root: string, home = homedir()): Store {
86
64
  const directory = outsideDirectory(root, home);
87
- const store = { directory, shown: directory.startsWith(home) ? `~${directory.slice(home.length)}` : directory };
88
- const legacy = join(root, LEGACY_FOLDER);
89
- const info = await stat(join(legacy, PROGRESS_FILE_NAME)).catch(() => null);
90
- if (!info) return store;
91
- if (await legacyTracked(root, info.mtimeMs)) return { directory: legacy, shown: LEGACY_FOLDER };
92
- await moveLegacy(legacy, directory);
93
- return store;
94
- }
95
-
96
- // Appends the old file's lines to the new one rather than replacing it, so a
97
- // command that wrote to the old place mid-move loses nothing: the next move
98
- // picks those lines up. Then tidies what 0.1.x left behind.
99
- async function moveLegacy(legacy: string, directory: string): Promise<void> {
100
- await mkdir(directory, { recursive: true });
101
- await withLock(join(legacy, LOCK_NAME), async () => {
102
- const text = await readFile(join(legacy, PROGRESS_FILE_NAME), "utf8").catch(() => null);
103
- if (text === null) return;
104
- await withLock(join(directory, LOCK_NAME), async () => {
105
- const current = await readFile(join(directory, PROGRESS_FILE_NAME), "utf8").catch(() => "");
106
- const lead = current && !current.endsWith("\n") ? "\n" : "";
107
- await appendFile(join(directory, PROGRESS_FILE_NAME), `${lead}${text}`);
108
- });
109
- await rm(join(legacy, PROGRESS_FILE_NAME));
110
- if (await exists(join(legacy, PANEL_OPENED_NAME))) {
111
- await writeFile(join(directory, PANEL_OPENED_NAME), "");
112
- await rm(join(legacy, PANEL_OPENED_NAME));
113
- }
114
- });
115
- const ignore = await readFile(join(legacy, ".gitignore"), "utf8").catch(() => null);
116
- if (ignore === LEGACY_GITIGNORE) await rm(join(legacy, ".gitignore"));
117
- // Only if nothing else is in there; the user's own files keep the folder.
118
- await rmdir(legacy).catch(() => {});
65
+ return { directory, shown: directory.startsWith(home) ? `~${directory.slice(home.length)}` : directory };
119
66
  }
120
67
 
121
68
  // The repo's saved-history folder, kept in its local git config, which every
122
69
  // worktree of the repo shares and nothing commits. Polls cost one stat; git
123
- // runs again only when the config file changes.
124
- const configFiles = new Map<string, string | null>();
70
+ // runs again only when the config file changes. A worktree with no .git
71
+ // entry is no repo; when git fails in one that has it, the next poll asks again.
72
+ const configFiles = new Map<string, string>();
125
73
  const savedFolders = new Map<string, { mtimeMs: number; folder: string | null }>();
126
74
 
127
75
  async function configFile(root: string): Promise<string | null> {
76
+ if (!hasGitEntry(root)) return null;
128
77
  if (!configFiles.has(root)) {
129
78
  const { code, stdout } = await git(root, ["rev-parse", "--git-common-dir"]);
130
- configFiles.set(root, code === 0 ? join(resolve(root, stdout.trim()), "config") : null);
79
+ if (code !== 0) return null;
80
+ configFiles.set(root, join(resolve(root, stdout.trim()), "config"));
131
81
  }
132
82
  return configFiles.get(root)!;
133
83
  }
134
84
 
135
- // Whether the worktree is in a git repo, which saving run history needs.
85
+ // Whether git answers for the worktree's repo, which saving run history needs.
136
86
  export async function inGitRepo(root: string): Promise<boolean> {
137
87
  return (await configFile(root)) !== null;
138
88
  }
139
89
 
140
- // The folder runs are saved to, relative to the worktree root, or null.
90
+ // The folder runs are saved to, relative to the worktree root, or null. A
91
+ // folder set outside Paseo gets the same check as one picked in it.
141
92
  export async function historyFolder(root: string): Promise<string | null> {
142
93
  const file = await configFile(root);
143
94
  if (file === null) return null;
@@ -145,7 +96,8 @@ export async function historyFolder(root: string): Promise<string | null> {
145
96
  const cached = savedFolders.get(file);
146
97
  if (cached && cached.mtimeMs === mtimeMs) return cached.folder;
147
98
  const { stdout } = await git(root, ["config", "--get", SAVE_RUNS_KEY]);
148
- const folder = stdout.trim() || null;
99
+ const checked = checkFolder(stdout);
100
+ const folder = "folder" in checked ? checked.folder : null;
149
101
  savedFolders.set(file, { mtimeMs, folder });
150
102
  return folder;
151
103
  }
@@ -156,7 +108,7 @@ export function checkFolder(raw: string): { folder: string } | { error: string }
156
108
  if (!trimmed) return { error: "Choose a folder" };
157
109
  const folder = normalize(trimmed);
158
110
  if (outside(folder) || folder === ".") return { error: "Choose a folder inside the repo" };
159
- if (folder === ".git" || folder.startsWith(".git/")) return { error: "Choose a folder outside .git" };
111
+ if (inGitFolder(folder)) return { error: "Choose a folder outside .git" };
160
112
  return { folder };
161
113
  }
162
114
 
@@ -189,27 +141,32 @@ export function runFileName(startedAt: string, title: string): string {
189
141
  export async function saveRunCopy(root: string, text: string): Promise<void> {
190
142
  const folder = await historyFolder(root);
191
143
  if (!folder) return;
192
- const lines = text.split("\n").filter((line) => line.trim());
193
- let start = -1;
194
- let event: { ts?: string; title?: string } | null = null;
195
- for (let index = lines.length - 1; index >= 0; index--) {
196
- try {
197
- const parsed = JSON.parse(lines[index]);
198
- if (parsed?.type === "run.start") {
199
- start = index;
200
- event = parsed;
201
- break;
202
- }
203
- } catch {}
144
+ // The run the dashboard shows. A start line it can't read is no run.
145
+ const [start] = latestRun(parseProgress(text).events);
146
+ if (start?.event.type !== "run.start") return;
147
+ const lines = text.split("\n").slice(start.line - 1).filter((line) => line.trim());
148
+ const directory = await runsFolder(root, folder);
149
+ if (!directory) return;
150
+ // A rename replaces a link at the run's file instead of writing through it.
151
+ await writeAtomic(join(directory, runFileName(start.event.ts, start.event.title)), `${lines.join("\n")}\n`);
152
+ }
153
+
154
+ // The saved-runs folder in the real worktree, made one step at a time, or null
155
+ // when any step is a link or a file: a cloned repo can commit a link that leads
156
+ // out of it.
157
+ async function runsFolder(root: string, folder: string): Promise<string | null> {
158
+ let directory = await realpath(root);
159
+ for (const name of folder.split(sep)) {
160
+ directory = join(directory, name);
161
+ const info = await lstat(directory).catch(() => null);
162
+ if (!info) await mkdir(directory);
163
+ else if (!info.isDirectory()) return null;
204
164
  }
205
- if (start < 0 || !event?.ts || !event.title) return;
206
- const directory = join(root, folder);
207
- await mkdir(directory, { recursive: true });
208
- await writeFile(join(directory, runFileName(event.ts, event.title)), `${lines.slice(start).join("\n")}\n`);
165
+ return directory;
209
166
  }
210
167
 
211
168
  // The folders directly inside one folder of the worktree ("" is the top), for
212
- // the folder picker. .git is left out; so are links, which could leave the repo.
169
+ // the folder picker. .git folders are left out; so are links, which could leave the repo.
213
170
  export async function listFolders(root: string, folder: string): Promise<{ folder: string; folders: string[] }> {
214
171
  const checked = folder === "" ? { folder: "" } : checkFolder(folder);
215
172
  if ("error" in checked) throw new Error(checked.error);
@@ -217,6 +174,6 @@ export async function listFolders(root: string, folder: string): Promise<{ folde
217
174
  if (error.code === "ENOENT") return [];
218
175
  throw error;
219
176
  });
220
- const folders = entries.filter((entry) => entry.isDirectory() && !(checked.folder === "" && entry.name === ".git")).map((entry) => entry.name);
177
+ const folders = entries.filter((entry) => entry.isDirectory() && !inGitFolder(entry.name)).map((entry) => entry.name);
221
178
  return { folder: checked.folder, folders: folders.sort((a, b) => a.localeCompare(b)) };
222
179
  }
package/shared/events.ts CHANGED
@@ -7,8 +7,6 @@ import { QUESTION_MODES } from "./question-modes.ts";
7
7
  export const PROGRESS_FILE_NAME = "progress.jsonl";
8
8
  // Present once someone has opened the Progress panel for this worktree.
9
9
  export const PANEL_OPENED_NAME = "progress-panel-opened";
10
- // Where 0.1.x kept each worktree's files. store.ts moves them out.
11
- export const LEGACY_FOLDER = ".scratch";
12
10
  export const EVENT_VERSION = 1;
13
11
 
14
12
  export const TICKET_STATUSES = ["not_started", "working", "blocked", "done", "skipped"] as const;