@stevecastaneda/paseo-progress 0.1.7 → 0.2.0

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.
@@ -0,0 +1,205 @@
1
+ // Where a worktree's progress files live, and the run history a repo can opt
2
+ // in to saving. The working files never touch the repo: they live outside it,
3
+ // one folder per worktree, so git never sees them and nothing needs hiding.
4
+ // Only runs the user asks to save are written into the repo, one file per run.
5
+ import { execFile } from "node:child_process";
6
+ import { createHash } from "node:crypto";
7
+ import { realpathSync } from "node:fs";
8
+ import { appendFile, mkdir, readdir, readFile, rm, rmdir, stat, writeFile } from "node:fs/promises";
9
+ import { homedir } from "node:os";
10
+ import { basename, join, normalize, resolve } from "node:path";
11
+ import { promisify } from "node:util";
12
+ import { LEGACY_FOLDER, PANEL_OPENED_NAME, PROGRESS_FILE_NAME } from "../shared/events.js";
13
+ import { withLock } from "./lock.js";
14
+ import { outside } from "./paths.js";
15
+ const LOCK_NAME = `${PROGRESS_FILE_NAME}.lock`;
16
+ const SAVE_RUNS_KEY = "paseo-progress.saveRuns";
17
+ // Exactly what 0.1.x wrote into .scratch/.gitignore. Only this is ever removed.
18
+ const LEGACY_GITIGNORE = `# Added by the progress plugin for Paseo so its files stay out of git.
19
+ ${PROGRESS_FILE_NAME}
20
+ ${LOCK_NAME}/
21
+ ${PANEL_OPENED_NAME}
22
+ .gitignore
23
+ `;
24
+ export async function git(cwd, args) {
25
+ try {
26
+ const { stdout } = await promisify(execFile)("git", args, { cwd });
27
+ return { code: 0, stdout };
28
+ }
29
+ catch (error) {
30
+ const failed = error;
31
+ return { code: typeof failed.code === "number" ? failed.code : -1, stdout: failed.stdout ?? "" };
32
+ }
33
+ }
34
+ async function exists(path) {
35
+ return Boolean(await stat(path).catch(() => null));
36
+ }
37
+ // One folder per worktree on this computer, named so a person can tell which
38
+ // worktree it belongs to.
39
+ export function outsideDirectory(root, home = homedir()) {
40
+ let real = root;
41
+ try {
42
+ real = realpathSync(root);
43
+ }
44
+ catch { }
45
+ const hash = createHash("sha1").update(real).digest("hex").slice(0, 8);
46
+ return join(home, ".local", "state", "paseo-progress", `${basename(real)}-${hash}`);
47
+ }
48
+ // Whether git tracks a 0.1.x progress file, per worktree, until the file changes.
49
+ const trackedLegacy = new Map();
50
+ async function legacyTracked(root, mtimeMs) {
51
+ const cached = trackedLegacy.get(root);
52
+ if (cached && cached.mtimeMs === mtimeMs)
53
+ return cached.tracked;
54
+ const { code } = await git(root, ["ls-files", "--error-unmatch", "--", `${LEGACY_FOLDER}/${PROGRESS_FILE_NAME}`]);
55
+ trackedLegacy.set(root, { mtimeMs, tracked: code === 0 });
56
+ return code === 0;
57
+ }
58
+ // The worktree's working files. A run 0.1.x left in .scratch moves outside the
59
+ // first time anything asks, unless git tracks it: then it stays and is used in
60
+ // place, so nothing changes for a repo that commits it.
61
+ export async function findStore(root, home = homedir()) {
62
+ const directory = outsideDirectory(root, home);
63
+ const store = { directory, shown: directory.startsWith(home) ? `~${directory.slice(home.length)}` : directory };
64
+ const legacy = join(root, LEGACY_FOLDER);
65
+ const info = await stat(join(legacy, PROGRESS_FILE_NAME)).catch(() => null);
66
+ if (!info)
67
+ return store;
68
+ if (await legacyTracked(root, info.mtimeMs))
69
+ return { directory: legacy, shown: LEGACY_FOLDER };
70
+ await moveLegacy(legacy, directory);
71
+ return store;
72
+ }
73
+ // Appends the old file's lines to the new one rather than replacing it, so a
74
+ // command that wrote to the old place mid-move loses nothing: the next move
75
+ // picks those lines up. Then tidies what 0.1.x left behind.
76
+ async function moveLegacy(legacy, directory) {
77
+ await mkdir(directory, { recursive: true });
78
+ await withLock(join(legacy, LOCK_NAME), async () => {
79
+ const text = await readFile(join(legacy, PROGRESS_FILE_NAME), "utf8").catch(() => null);
80
+ if (text === null)
81
+ return;
82
+ await withLock(join(directory, LOCK_NAME), async () => {
83
+ const current = await readFile(join(directory, PROGRESS_FILE_NAME), "utf8").catch(() => "");
84
+ const lead = current && !current.endsWith("\n") ? "\n" : "";
85
+ await appendFile(join(directory, PROGRESS_FILE_NAME), `${lead}${text}`);
86
+ });
87
+ await rm(join(legacy, PROGRESS_FILE_NAME));
88
+ if (await exists(join(legacy, PANEL_OPENED_NAME))) {
89
+ await writeFile(join(directory, PANEL_OPENED_NAME), "");
90
+ await rm(join(legacy, PANEL_OPENED_NAME));
91
+ }
92
+ });
93
+ const ignore = await readFile(join(legacy, ".gitignore"), "utf8").catch(() => null);
94
+ if (ignore === LEGACY_GITIGNORE)
95
+ await rm(join(legacy, ".gitignore"));
96
+ // Only if nothing else is in there; the user's own files keep the folder.
97
+ await rmdir(legacy).catch(() => { });
98
+ }
99
+ // The repo's saved-history folder, kept in its local git config, which every
100
+ // worktree of the repo shares and nothing commits. Polls cost one stat; git
101
+ // runs again only when the config file changes.
102
+ const configFiles = new Map();
103
+ const savedFolders = new Map();
104
+ async function configFile(root) {
105
+ if (!configFiles.has(root)) {
106
+ const { code, stdout } = await git(root, ["rev-parse", "--git-common-dir"]);
107
+ configFiles.set(root, code === 0 ? join(resolve(root, stdout.trim()), "config") : null);
108
+ }
109
+ return configFiles.get(root);
110
+ }
111
+ // Whether the worktree is in a git repo, which saving run history needs.
112
+ export async function inGitRepo(root) {
113
+ return (await configFile(root)) !== null;
114
+ }
115
+ // The folder runs are saved to, relative to the worktree root, or null.
116
+ export async function historyFolder(root) {
117
+ const file = await configFile(root);
118
+ if (file === null)
119
+ return null;
120
+ const mtimeMs = (await stat(file).catch(() => null))?.mtimeMs ?? 0;
121
+ const cached = savedFolders.get(file);
122
+ if (cached && cached.mtimeMs === mtimeMs)
123
+ return cached.folder;
124
+ const { stdout } = await git(root, ["config", "--get", SAVE_RUNS_KEY]);
125
+ const folder = stdout.trim() || null;
126
+ savedFolders.set(file, { mtimeMs, folder });
127
+ return folder;
128
+ }
129
+ // A folder the user picked, as a clean path inside the worktree, or why not.
130
+ export function checkFolder(raw) {
131
+ const trimmed = raw.trim().replace(/\/+$/, "");
132
+ if (!trimmed)
133
+ return { error: "Choose a folder" };
134
+ const folder = normalize(trimmed);
135
+ if (outside(folder) || folder === ".")
136
+ return { error: "Choose a folder inside the repo" };
137
+ if (folder === ".git" || folder.startsWith(".git/"))
138
+ return { error: "Choose a folder outside .git" };
139
+ return { folder };
140
+ }
141
+ // Starts or stops saving runs for the whole repo. Files already saved stay.
142
+ export async function setHistoryFolder(root, raw) {
143
+ if (raw === null) {
144
+ const { code } = await git(root, ["config", "--local", "--unset-all", SAVE_RUNS_KEY]);
145
+ // 5: it wasn't set, which is what was asked for.
146
+ if (code !== 0 && code !== 5)
147
+ throw new Error("Could not change this repo's git settings.");
148
+ return null;
149
+ }
150
+ const checked = checkFolder(raw);
151
+ if ("error" in checked)
152
+ throw new Error(checked.error);
153
+ const { code } = await git(root, ["config", "--local", SAVE_RUNS_KEY, checked.folder]);
154
+ if (code !== 0)
155
+ throw new Error("Could not save this in the repo's git settings. Is it a git repo?");
156
+ return checked.folder;
157
+ }
158
+ // "20261001-064512-warn-when-scratch.jsonl": when the run started, then its
159
+ // title as it was at the start, so the name never changes and two runs never
160
+ // share one.
161
+ export function runFileName(startedAt, title) {
162
+ const stamp = startedAt.replace(/\.\d+Z$/, "").replace(/[-:]/g, "").replace("T", "-");
163
+ const slug = title.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60);
164
+ return `${stamp}${slug ? `-${slug}` : ""}.jsonl`;
165
+ }
166
+ // After each change: when the repo saves runs, rewrite the current run's file
167
+ // with every event from its start. Runs before the first start aren't saved.
168
+ export async function saveRunCopy(root, text) {
169
+ const folder = await historyFolder(root);
170
+ if (!folder)
171
+ return;
172
+ const lines = text.split("\n").filter((line) => line.trim());
173
+ let start = -1;
174
+ let event = null;
175
+ for (let index = lines.length - 1; index >= 0; index--) {
176
+ try {
177
+ const parsed = JSON.parse(lines[index]);
178
+ if (parsed?.type === "run.start") {
179
+ start = index;
180
+ event = parsed;
181
+ break;
182
+ }
183
+ }
184
+ catch { }
185
+ }
186
+ if (start < 0 || !event?.ts || !event.title)
187
+ return;
188
+ const directory = join(root, folder);
189
+ await mkdir(directory, { recursive: true });
190
+ await writeFile(join(directory, runFileName(event.ts, event.title)), `${lines.slice(start).join("\n")}\n`);
191
+ }
192
+ // The folders directly inside one folder of the worktree ("" is the top), for
193
+ // the folder picker. .git is left out; so are links, which could leave the repo.
194
+ export async function listFolders(root, folder) {
195
+ const checked = folder === "" ? { folder: "" } : checkFolder(folder);
196
+ if ("error" in checked)
197
+ throw new Error(checked.error);
198
+ const entries = await readdir(join(root, checked.folder), { withFileTypes: true }).catch((error) => {
199
+ if (error.code === "ENOENT")
200
+ return [];
201
+ throw error;
202
+ });
203
+ const folders = entries.filter((entry) => entry.isDirectory() && !(checked.folder === "" && entry.name === ".git")).map((entry) => entry.name);
204
+ return { folder: checked.folder, folders: folders.sort((a, b) => a.localeCompare(b)) };
205
+ }
@@ -1,10 +1,12 @@
1
1
  import { z } from "zod";
2
- // One line of `.scratch/progress.jsonl`. The command-line tool writes these;
3
- // the dashboard reducer reads them. `v` lets later versions change a shape
4
- // without misreading old files.
5
- export const PROGRESS_FILE = ".scratch/progress.jsonl";
2
+ // One line of `progress.jsonl`. The command-line tool writes these; the
3
+ // dashboard reducer reads them. `v` lets later versions change a shape without
4
+ // misreading old files. Which folder holds it is set up per repo.
5
+ export const PROGRESS_FILE_NAME = "progress.jsonl";
6
6
  // Present once someone has opened the Progress panel for this worktree.
7
- export const PANEL_OPENED_FILE = ".scratch/progress-panel-opened";
7
+ export const PANEL_OPENED_NAME = "progress-panel-opened";
8
+ // Where 0.1.x kept each worktree's files. store.ts moves them out.
9
+ export const LEGACY_FOLDER = ".scratch";
8
10
  export const EVENT_VERSION = 1;
9
11
  export const TICKET_STATUSES = ["not_started", "working", "blocked", "done", "skipped"];
10
12
  export const ticketStatusSchema = z.enum(TICKET_STATUSES);
package/index.server.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import type { PluginServerContext } from "@getpaseo/plugin/server";
2
- import { handleGetAttention, handleGetDashboard, handleMarkPanelOpened } from "./server/dashboard";
2
+ import { handleGetAttention, handleGetDashboard, handleListFolders, handleMarkPanelOpened, handleSetHistoryFolder } from "./server/dashboard";
3
3
  import { handleInstallLauncher, handleLauncherStatus } from "./server/launcher";
4
4
  import { handleOpenDeliverable, handlePreviewDeliverable } from "./server/open";
5
5
  import { handleInstallSkill, handleSkillStatus } from "./server/skill";
6
- import { getAttention, getDashboard, getLauncherStatus, getSkillStatus, installLauncher, installSkill, markPanelOpened, openDeliverable, previewDeliverable } from "./shared/rpc";
6
+ import { getAttention, getDashboard, getLauncherStatus, getSkillStatus, installLauncher, installSkill, listFolders, markPanelOpened, openDeliverable, previewDeliverable, setHistoryFolder } from "./shared/rpc";
7
7
 
8
8
  export default function contribute(server: PluginServerContext) {
9
9
  server.handle(getDashboard, handleGetDashboard);
@@ -15,5 +15,7 @@ export default function contribute(server: PluginServerContext) {
15
15
  server.handle(previewDeliverable, handlePreviewDeliverable);
16
16
  server.handle(getSkillStatus, handleSkillStatus);
17
17
  server.handle(installSkill, handleInstallSkill);
18
+ server.handle(listFolders, handleListFolders);
19
+ server.handle(setHistoryFolder, handleSetHistoryFolder);
18
20
  return () => {};
19
21
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@stevecastaneda/paseo-progress",
3
- "version": "0.1.7",
3
+ "version": "0.2.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@stevecastaneda/paseo-progress",
9
- "version": "0.1.7",
9
+ "version": "0.2.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@getpaseo/client": "^0.9.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stevecastaneda/paseo-progress",
3
- "version": "0.1.7",
3
+ "version": "0.2.0",
4
4
  "description": "A live progress dashboard per worktree for Paseo, drawn from a file agents write with the paseo-progress command.",
5
5
  "license": "MIT",
6
6
  "author": "Steve Castaneda",
package/server/cli.ts CHANGED
@@ -1,14 +1,15 @@
1
1
  // The command agents run to record progress. Each command appends one event
2
2
  // to the worktree's progress file and prints what it recorded.
3
- import { existsSync, realpathSync, statSync } from "node:fs";
4
- import { dirname, join, relative, resolve } from "node:path";
3
+ import { realpathSync, statSync } from "node:fs";
4
+ import { relative, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { parseArgs } from "node:util";
7
7
  import { isSettled, type Dashboard, type NextIds } from "../shared/dashboard.ts";
8
- import { DELIVERABLE_KINDS, PROGRESS_FILE, TICKET_STATUSES } from "../shared/events.ts";
8
+ import { DELIVERABLE_KINDS, TICKET_STATUSES } from "../shared/events.ts";
9
9
  import { appendProgress, InvalidEvent, readProgress, type EventDraft } from "./progress-file.ts";
10
- import { outside } from "./paths.ts";
10
+ import { findRoot, outside } from "./paths.ts";
11
11
  import { installLauncher } from "./launcher.ts";
12
+ import { findStore, saveRunCopy } from "./store.ts";
12
13
  import { dashboardText } from "../shared/show.ts";
13
14
  import { imageMimeType } from "../shared/attachments.ts";
14
15
  import { homedir } from "node:os";
@@ -438,7 +439,7 @@ function help(): string {
438
439
  return [
439
440
  `Usage: ${USAGE_NAME} <command>`,
440
441
  "",
441
- `Records progress in ${PROGRESS_FILE} at the worktree root, shown by the Progress panel in Paseo.`,
442
+ `Records progress for this worktree, shown by the Progress panel in Paseo. The file lives outside the repo, so git never sees it.`,
442
443
  `Add --help after any command for its options.`,
443
444
  "",
444
445
  ...Object.values(commands).flatMap((command) => [` ${command.usage}`, ` ${command.summary}`]),
@@ -449,18 +450,6 @@ function help(): string {
449
450
  ].join("\n");
450
451
  }
451
452
 
452
- // The worktree root is the nearest directory with a `.git` entry (a folder in
453
- // a checkout, a file in a linked worktree). Outside git, use the directory.
454
- export function findRoot(cwd: string): string {
455
- let directory = resolve(cwd);
456
- while (true) {
457
- if (existsSync(join(directory, ".git"))) return directory;
458
- const parent = dirname(directory);
459
- if (parent === directory) return resolve(cwd);
460
- directory = parent;
461
- }
462
- }
463
-
464
453
  export async function runCli(argv: string[], options: CliOptions): Promise<number> {
465
454
  const out = options.out ?? ((line: string) => console.log(line));
466
455
  const now = options.now ?? (() => new Date());
@@ -476,7 +465,8 @@ export async function runCli(argv: string[], options: CliOptions): Promise<numbe
476
465
  }
477
466
  if (first === "show") {
478
467
  const root = findRoot(options.cwd);
479
- out(`Worktree: ${root}\n\n${dashboardText((await readProgress(root, now())).dashboard, now().getTime())}`);
468
+ const store = await findStore(root);
469
+ out(`Worktree: ${root}\n\n${dashboardText((await readProgress(store.directory, now())).dashboard, now().getTime())}`);
480
470
  return 0;
481
471
  }
482
472
  const target = resolve(options.cwd, (argv[1] ?? DEFAULT_LAUNCHER).replace(/^~(?=$|\/)/, homedir()));
@@ -502,14 +492,15 @@ export async function runCli(argv: string[], options: CliOptions): Promise<numbe
502
492
  throw new UsageError((error as Error).message);
503
493
  }
504
494
  const root = findRoot(options.cwd);
505
- const message = await appendProgress(root, now, (state) => {
495
+ const store = await findStore(root);
496
+ const message = await appendProgress(store.directory, now, (state) => {
506
497
  const finished = state.dashboard.run?.finished;
507
498
  if (finished && command !== commands.start) {
508
499
  throw new UsageError(`The run "${state.dashboard.run!.title}" is finished. Start new work with: ${USAGE_NAME} start "<title>"`);
509
500
  }
510
501
  const { event, message } = command.run({ positionals: parsed.positionals, values: parsed.values, state, cwd: options.cwd, root });
511
502
  return { event, result: message };
512
- });
503
+ }, (text) => saveRunCopy(root, text));
513
504
  out(message);
514
505
  return 0;
515
506
  } catch (error) {
@@ -1,16 +1,20 @@
1
1
  import type { PluginHandlerContext } from "@getpaseo/plugin/server";
2
2
  import { createHash } from "node:crypto";
3
+ import { join } from "node:path";
3
4
  import type { Dashboard } from "../shared/dashboard.ts";
5
+ import { PROGRESS_FILE_NAME } from "../shared/events.ts";
4
6
  import type { AttentionResult, DashboardResult } from "../shared/rpc.ts";
5
7
  import { markPanelOpened, readProgress } from "./progress-file.ts";
8
+ import { findRoot } from "./paths.ts";
9
+ import { findStore, historyFolder, inGitRepo, listFolders, setHistoryFolder } from "./store.ts";
6
10
 
7
- const versions = new WeakMap<Dashboard, { panelOpened: boolean; version: string }>();
11
+ const versions = new WeakMap<Dashboard, { key: string; version: string }>();
8
12
 
9
13
  export async function handleMarkPanelOpened(
10
14
  input: { workspaceId: string; workspaceDirectory: string },
11
15
  _context: PluginHandlerContext,
12
16
  ) {
13
- await markPanelOpened(input.workspaceDirectory);
17
+ await markPanelOpened((await findStore(findRoot(input.workspaceDirectory))).directory);
14
18
  return {};
15
19
  }
16
20
 
@@ -20,14 +24,20 @@ export async function handleGetDashboard(
20
24
  input: { workspaceId: string; workspaceDirectory: string; since?: string },
21
25
  _context: PluginHandlerContext,
22
26
  ): Promise<DashboardResult | { unchanged: true; version: string }> {
23
- const result = await readProgress(input.workspaceDirectory);
27
+ const root = findRoot(input.workspaceDirectory);
28
+ const store = await findStore(root);
29
+ const result = await readProgress(store.directory);
30
+ const file = join(store.shown, PROGRESS_FILE_NAME);
31
+ const savedTo = await historyFolder(root);
32
+ const inRepo = await inGitRepo(root);
33
+ const key = JSON.stringify([result.panelOpened, file, savedTo, inRepo]);
24
34
  let cached = versions.get(result.dashboard);
25
- if (!cached || cached.panelOpened !== result.panelOpened) {
26
- cached = { panelOpened: result.panelOpened, version: createHash("sha1").update(JSON.stringify(result)).digest("base64url") };
35
+ if (!cached || cached.key !== key) {
36
+ cached = { key, version: createHash("sha1").update(JSON.stringify(result) + key).digest("base64url") };
27
37
  versions.set(result.dashboard, cached);
28
38
  }
29
39
  const { version } = cached;
30
- return input.since === version ? { unchanged: true, version } : { ...result, version };
40
+ return input.since === version ? { unchanged: true, version } : { ...result, file, savedTo, inRepo, root, version };
31
41
  }
32
42
 
33
43
  // Just what a message-box pill needs, so polling every workspace stays small.
@@ -35,7 +45,7 @@ export async function handleGetAttention(
35
45
  input: { workspaceId: string; workspaceDirectory: string },
36
46
  _context: PluginHandlerContext,
37
47
  ): Promise<AttentionResult> {
38
- const { configured, dashboard, panelOpened } = await readProgress(input.workspaceDirectory);
48
+ const { configured, dashboard, panelOpened } = await readProgress((await findStore(findRoot(input.workspaceDirectory))).directory);
39
49
  return {
40
50
  configured,
41
51
  questions: dashboard.questions.open.length,
@@ -44,3 +54,18 @@ export async function handleGetAttention(
44
54
  panelOpened,
45
55
  };
46
56
  }
57
+
58
+ export async function handleListFolders(
59
+ input: { workspaceId: string; workspaceDirectory: string; folder: string },
60
+ _context: PluginHandlerContext,
61
+ ) {
62
+ return listFolders(findRoot(input.workspaceDirectory), input.folder);
63
+ }
64
+
65
+ // Runs only when the user picks a folder, or stops saving, in the panel.
66
+ export async function handleSetHistoryFolder(
67
+ input: { workspaceId: string; workspaceDirectory: string; folder: string | null },
68
+ _context: PluginHandlerContext,
69
+ ) {
70
+ return { savedTo: await setHistoryFolder(findRoot(input.workspaceDirectory), input.folder) };
71
+ }
package/server/open.ts CHANGED
@@ -6,7 +6,8 @@ import { join, relative, resolve } from "node:path";
6
6
  import { promisify } from "node:util";
7
7
  import { findAttachment, imageMimeType, previewKind } from "../shared/attachments.ts";
8
8
  import { readProgress } from "./progress-file.ts";
9
- import { outside } from "./paths.ts";
9
+ import { findRoot, outside } from "./paths.ts";
10
+ import { findStore } from "./store.ts";
10
11
 
11
12
  export type Opener = (path: string) => Promise<void>;
12
13
 
@@ -43,10 +44,11 @@ export const systemOpener: Opener = async (path) => {
43
44
  // so the panel can never reach an arbitrary path. Only files inside the
44
45
  // worktree resolve.
45
46
  async function resolveAttachment(directory: string, ref: string, verb: string): Promise<{ path: string; target: string }> {
46
- const { dashboard } = await readProgress(directory);
47
+ const worktree = findRoot(directory);
48
+ const { dashboard } = await readProgress((await findStore(worktree)).directory);
47
49
  const path = findAttachment(dashboard, ref)?.path;
48
50
  if (!path) throw new Error(`No local ${ref.startsWith("Q") ? "attachment" : "deliverable"} ${ref} in this worktree.`);
49
- const root = await realpath(directory);
51
+ const root = await realpath(worktree);
50
52
  const target = await realpath(resolve(root, path)).catch(() => null);
51
53
  if (!target) throw new Error(`${path} no longer exists.`);
52
54
  const inside = relative(root, target);
package/server/paths.ts CHANGED
@@ -1,7 +1,22 @@
1
- import { isAbsolute, sep } from "node:path";
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
2
3
 
3
4
  // Whether a path from relative(root, target) leaves the root. "..notes" is a
4
5
  // name inside it, not a step out.
5
6
  export function outside(inside: string): boolean {
6
7
  return inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside);
7
8
  }
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.
14
+ export function findRoot(cwd: string): string {
15
+ let directory = resolve(cwd);
16
+ while (true) {
17
+ if (existsSync(join(directory, ".git"))) return directory;
18
+ const parent = dirname(directory);
19
+ if (parent === directory) return resolve(cwd);
20
+ directory = parent;
21
+ }
22
+ }
@@ -1,24 +1,14 @@
1
1
  // Everything that reads or writes a worktree's progress files: the event log
2
- // the command appends to, the panel-opened marker, and the .gitignore that keeps
3
- // both out of git. The command and the panel's RPC handlers go through here, so
4
- // they always agree on where the files are and how they're read.
2
+ // the command appends to and the panel-opened marker. Each function takes the
3
+ // folder that holds them, which `findStore` in store.ts picks. The command and
4
+ // the panel's RPC handlers go through here, so they agree on how they're read.
5
5
  import { access, appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
6
- import { basename, dirname, join } from "node:path";
6
+ import { join } from "node:path";
7
7
  import { EMPTY_DASHBOARD, parseProgress, reduceProgress, type Dashboard, type NextIds } from "../shared/dashboard.ts";
8
- import { EVENT_VERSION, eventSchema, PANEL_OPENED_FILE, PROGRESS_FILE, type ProgressEvent } from "../shared/events.ts";
8
+ import { EVENT_VERSION, eventSchema, PANEL_OPENED_NAME, PROGRESS_FILE_NAME, type ProgressEvent } from "../shared/events.ts";
9
9
  import { withLock } from "./lock.ts";
10
10
 
11
- const SCRATCH_DIR = dirname(PROGRESS_FILE);
12
- const LOCK_FILE = `${PROGRESS_FILE}.lock`;
13
-
14
- // Names only this plugin's files, so they stay out of git without the user
15
- // editing their own .gitignore. Other files in .scratch/ are left to the repo.
16
- export const SCRATCH_GITIGNORE = `# Added by the progress plugin for Paseo so its files stay out of git.
17
- ${basename(PROGRESS_FILE)}
18
- ${basename(LOCK_FILE)}/
19
- ${basename(PANEL_OPENED_FILE)}
20
- .gitignore
21
- `;
11
+ const LOCK_NAME = `${PROGRESS_FILE_NAME}.lock`;
22
12
 
23
13
  // An event as a command builds it; `append` stamps the version and time.
24
14
  export type EventDraft = { [K in ProgressEvent["type"]]: Omit<Extract<ProgressEvent, { type: K }>, "v" | "ts"> }[ProgressEvent["type"]];
@@ -33,7 +23,7 @@ export interface ProgressRead {
33
23
  panelOpened: boolean;
34
24
  }
35
25
 
36
- // The parsed file per worktree, kept until its size or change time moves. The
26
+ // The parsed file per folder, kept until its size or change time moves. The
37
27
  // file only grows, and the panel and pills read it every few seconds.
38
28
  const parsedFiles = new Map<string, { size: number; mtimeMs: number; ctimeMs: number; parsed: ReturnType<typeof parseProgress> }>();
39
29
  // Replaced file snapshots and their derived data can be collected together.
@@ -43,9 +33,9 @@ const dashboards = new WeakMap<ReturnType<typeof parseProgress>, { dashboard: Da
43
33
  // reuse the last one until the file changes or a stale or overdue time passes.
44
34
  // Stale and overdue checks use the daemon host's clock, the same clock the
45
35
  // command stamps events with.
46
- export async function readProgress(root: string, now = new Date()): Promise<ProgressRead> {
47
- const parsed = await readParsed(root);
48
- const panelOpened = await exists(join(root, PANEL_OPENED_FILE));
36
+ export async function readProgress(directory: string, now = new Date()): Promise<ProgressRead> {
37
+ const parsed = await readParsed(directory);
38
+ const panelOpened = await exists(join(directory, PANEL_OPENED_NAME));
49
39
  if (parsed === null) return { configured: false, dashboard: EMPTY_DASHBOARD, panelOpened };
50
40
  let cached = dashboards.get(parsed);
51
41
  // A backwards clock jump must also re-evaluate stale/overdue state.
@@ -61,13 +51,15 @@ export async function readProgress(root: string, now = new Date()): Promise<Prog
61
51
  // that run at once take turns, so each sees the others' events and no two hand
62
52
  // out the same id. `write` may throw to append nothing.
63
53
  export async function appendProgress<T>(
64
- root: string,
54
+ directory: string,
65
55
  now: () => Date,
66
56
  write: (state: { dashboard: Dashboard; nextIds: NextIds }) => { event: EventDraft; result: T },
57
+ // Runs before the lock is released, with the whole file as it now is.
58
+ after?: (text: string) => Promise<void>,
67
59
  ): Promise<T> {
68
- await prepareScratch(root);
69
- return withLock(join(root, LOCK_FILE), async () => {
70
- const text = (await readText(root)) ?? "";
60
+ await mkdir(directory, { recursive: true });
61
+ return withLock(join(directory, LOCK_NAME), async () => {
62
+ const text = (await readText(directory)) ?? "";
71
63
  const { dashboard, nextIds } = reduceProgress(parseProgress(text), now());
72
64
  const { event, result } = write({ dashboard, nextIds });
73
65
  const checked = eventSchema.safeParse({ v: EVENT_VERSION, ts: now().toISOString(), ...withoutUndefined(event) });
@@ -76,54 +68,46 @@ export async function appendProgress<T>(
76
68
  }
77
69
  // A line cut short (a killed command, a hand edit) would swallow this event, so start a new line.
78
70
  const lead = text && !text.endsWith("\n") ? "\n" : "";
79
- await appendFile(join(root, PROGRESS_FILE), `${lead}${JSON.stringify(checked.data)}\n`);
71
+ const line = `${lead}${JSON.stringify(checked.data)}\n`;
72
+ await appendFile(join(directory, PROGRESS_FILE_NAME), line);
73
+ await after?.(text + line);
80
74
  return result;
81
75
  });
82
76
  }
83
77
 
84
- export async function markPanelOpened(root: string): Promise<void> {
85
- await prepareScratch(root);
86
- await writeFile(join(root, PANEL_OPENED_FILE), "");
87
- }
88
-
89
- // Creates .scratch/ in the worktree and, the first time, its .gitignore. An
90
- // existing .gitignore is the user's, so it is never touched.
91
- async function prepareScratch(root: string): Promise<void> {
92
- const directory = join(root, SCRATCH_DIR);
78
+ export async function markPanelOpened(directory: string): Promise<void> {
93
79
  await mkdir(directory, { recursive: true });
94
- await writeFile(join(directory, ".gitignore"), SCRATCH_GITIGNORE, { flag: "wx" }).catch((error: NodeJS.ErrnoException) => {
95
- if (error.code !== "EEXIST") throw error;
96
- });
80
+ await writeFile(join(directory, PANEL_OPENED_NAME), "");
97
81
  }
98
82
 
99
- async function readText(root: string): Promise<string | null> {
83
+ async function readText(directory: string): Promise<string | null> {
100
84
  try {
101
- return await readFile(join(root, PROGRESS_FILE), "utf8");
85
+ return await readFile(join(directory, PROGRESS_FILE_NAME), "utf8");
102
86
  } catch (error) {
103
87
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
104
88
  throw error;
105
89
  }
106
90
  }
107
91
 
108
- async function readParsed(root: string): Promise<ReturnType<typeof parseProgress> | null> {
109
- const info = await stat(join(root, PROGRESS_FILE)).catch((error: NodeJS.ErrnoException) => {
92
+ async function readParsed(directory: string): Promise<ReturnType<typeof parseProgress> | null> {
93
+ const info = await stat(join(directory, PROGRESS_FILE_NAME)).catch((error: NodeJS.ErrnoException) => {
110
94
  if (error.code === "ENOENT") return null;
111
95
  throw error;
112
96
  });
113
97
  if (!info) {
114
- parsedFiles.delete(root);
98
+ parsedFiles.delete(directory);
115
99
  return null;
116
100
  }
117
- const cached = parsedFiles.get(root);
101
+ const cached = parsedFiles.get(directory);
118
102
  if (cached && cached.size === info.size && cached.mtimeMs === info.mtimeMs && cached.ctimeMs === info.ctimeMs) return cached.parsed;
119
103
  // Stat before reading: if the file grows in between, the next stat won't match and it's read again.
120
- const text = await readText(root);
104
+ const text = await readText(directory);
121
105
  if (text === null) {
122
- parsedFiles.delete(root);
106
+ parsedFiles.delete(directory);
123
107
  return null;
124
108
  }
125
109
  const parsed = parseProgress(text);
126
- parsedFiles.set(root, { size: info.size, mtimeMs: info.mtimeMs, ctimeMs: info.ctimeMs, parsed });
110
+ parsedFiles.set(directory, { size: info.size, mtimeMs: info.mtimeMs, ctimeMs: info.ctimeMs, parsed });
127
111
  return parsed;
128
112
  }
129
113