@stevecastaneda/paseo-progress 0.3.3 → 0.3.4
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 +7 -1
- package/client/directory.ts +16 -2
- package/client/pills.tsx +1 -2
- package/dist/server/cli.js +21015 -503
- package/package.json +5 -6
- package/dist/server/launcher.js +0 -90
- package/dist/server/lock.js +0 -34
- package/dist/server/paths.js +0 -22
- package/dist/server/progress-file.js +0 -115
- package/dist/server/store.js +0 -205
- package/dist/shared/attachments.js +0 -92
- package/dist/shared/dashboard.js +0 -386
- package/dist/shared/events.js +0 -127
- package/dist/shared/format.js +0 -50
- package/dist/shared/show.js +0 -74
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stevecastaneda/paseo-progress",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
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",
|
|
@@ -43,21 +43,20 @@
|
|
|
43
43
|
"test": "node --test \"*/*.test.ts\"",
|
|
44
44
|
"dev": "node ../scripts/dev.mjs",
|
|
45
45
|
"check:install": "node ../scripts/check-install.mjs",
|
|
46
|
-
"build:cli": "rm -rf dist &&
|
|
46
|
+
"build:cli": "rm -rf dist && esbuild server/cli.ts --bundle --platform=node --format=esm --target=node22 --outfile=dist/server/cli.js --log-level=warning",
|
|
47
47
|
"prepack": "npm run build:cli",
|
|
48
48
|
"postpack": "rm -rf dist"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
+
"@getpaseo/client": "^0.9.2",
|
|
51
52
|
"@getpaseo/plugin": "^0.9.2",
|
|
52
53
|
"@tanstack/react-query": "^5.90.11",
|
|
53
54
|
"@types/node": "^20.9.0",
|
|
54
55
|
"@types/react": "~19.2.0",
|
|
56
|
+
"esbuild": "^0.25.12",
|
|
55
57
|
"react": "19.1.0",
|
|
56
58
|
"react-native": "0.81.5",
|
|
57
|
-
"typescript": "^5.9.3"
|
|
58
|
-
},
|
|
59
|
-
"dependencies": {
|
|
60
|
-
"@getpaseo/client": "^0.9.2",
|
|
59
|
+
"typescript": "^5.9.3",
|
|
61
60
|
"zod": "^4.4.3"
|
|
62
61
|
}
|
|
63
62
|
}
|
package/dist/server/launcher.js
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { delimiter, dirname, join } from "node:path";
|
|
4
|
-
export const PLUGIN_ID = "progress";
|
|
5
|
-
// The launcher never names a plugin folder. Each run asks Paseo which copy of
|
|
6
|
-
// the plugin it is running, so updates and `npm run dev` switches keep working.
|
|
7
|
-
export function launcherScript() {
|
|
8
|
-
return `#!/usr/bin/env node
|
|
9
|
-
// Written by the ${PLUGIN_ID} Paseo plugin. Runs the progress command from the
|
|
10
|
-
// copy of the plugin Paseo is running now.
|
|
11
|
-
const [major, minor] = process.versions.node.split(".").map(Number);
|
|
12
|
-
if (major < 22 || (major === 22 && minor < 18)) {
|
|
13
|
-
console.error("paseo-progress needs Node.js 22.18 or later; this is " + process.version + ".");
|
|
14
|
-
process.exit(1);
|
|
15
|
-
}
|
|
16
|
-
// Not require: a package.json with "type": "module" above this folder makes Node load it as ESM.
|
|
17
|
-
const { spawnSync } = process.getBuiltinModule("node:child_process");
|
|
18
|
-
const { existsSync } = process.getBuiltinModule("node:fs");
|
|
19
|
-
const { join } = process.getBuiltinModule("node:path");
|
|
20
|
-
|
|
21
|
-
function pluginDirectory() {
|
|
22
|
-
if (process.env.PASEO_PROGRESS_PLUGIN_DIR) return process.env.PASEO_PROGRESS_PLUGIN_DIR;
|
|
23
|
-
const clis = [process.env.PASEO_BIN, "paseo", "/Applications/Paseo.app/Contents/Resources/bin/paseo"].filter(Boolean);
|
|
24
|
-
for (const cli of clis) {
|
|
25
|
-
const result = spawnSync(cli, ["plugin", "ls", "${PLUGIN_ID}", "--json"], { encoding: "utf8" });
|
|
26
|
-
if (result.error || result.status !== 0) continue;
|
|
27
|
-
try {
|
|
28
|
-
const plugin = JSON.parse(result.stdout).find((entry) => entry.id === "${PLUGIN_ID}");
|
|
29
|
-
return plugin && plugin.path;
|
|
30
|
-
} catch {
|
|
31
|
-
return undefined;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return undefined;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// An npm install puts the plugin under node_modules, where Node won't run
|
|
38
|
-
// TypeScript, so the npm package carries the command compiled to dist/.
|
|
39
|
-
const directory = pluginDirectory();
|
|
40
|
-
const entry = directory && [join(directory, "dist", "server", "cli.js"), join(directory, "server", "cli.ts")].find((path) => existsSync(path));
|
|
41
|
-
if (!entry) {
|
|
42
|
-
console.error("The ${PLUGIN_ID} plugin is not installed in Paseo, or Paseo is not reachable. Check with: paseo plugin ls");
|
|
43
|
-
process.exit(1);
|
|
44
|
-
}
|
|
45
|
-
const run = spawnSync(process.execPath, [entry, ...process.argv.slice(2)], { stdio: "inherit" });
|
|
46
|
-
process.exit(run.status === null ? 1 : run.status);
|
|
47
|
-
`;
|
|
48
|
-
}
|
|
49
|
-
const MARKER = `Written by the ${PLUGIN_ID} Paseo plugin`;
|
|
50
|
-
export function defaultLauncherPath(home = homedir()) {
|
|
51
|
-
return join(home, ".local", "bin", "paseo-progress");
|
|
52
|
-
}
|
|
53
|
-
export async function launcherState(path) {
|
|
54
|
-
const current = await readFile(path, "utf8").catch(() => null);
|
|
55
|
-
if (current === null)
|
|
56
|
-
return "missing";
|
|
57
|
-
if (current === launcherScript())
|
|
58
|
-
return "current";
|
|
59
|
-
return current.includes(MARKER) ? "outdated" : "foreign";
|
|
60
|
-
}
|
|
61
|
-
// Writes the launcher when it is missing or out of date. Never replaces a file
|
|
62
|
-
// something else put there. Returns whether it wrote.
|
|
63
|
-
export async function installLauncher(path) {
|
|
64
|
-
const state = await launcherState(path);
|
|
65
|
-
if (state === "current")
|
|
66
|
-
return false;
|
|
67
|
-
if (state === "foreign")
|
|
68
|
-
throw new Error(`${path} already exists and was not written by this plugin. Move it, or pass another path.`);
|
|
69
|
-
await mkdir(dirname(path), { recursive: true });
|
|
70
|
-
await writeFile(path, launcherScript());
|
|
71
|
-
await chmod(path, 0o755);
|
|
72
|
-
return true;
|
|
73
|
-
}
|
|
74
|
-
// For the panel: whether agents on the daemon host can run `paseo-progress`.
|
|
75
|
-
export async function launcherStatus(path = defaultLauncherPath(), pathEnv = process.env.PATH ?? "") {
|
|
76
|
-
return {
|
|
77
|
-
path,
|
|
78
|
-
state: await launcherState(path),
|
|
79
|
-
onPath: pathEnv.split(delimiter).some((entry) => entry.replace(/\/+$/, "") === dirname(path)),
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
export async function handleLauncherStatus() {
|
|
83
|
-
return launcherStatus();
|
|
84
|
-
}
|
|
85
|
-
// Runs only when the user presses Install in the panel.
|
|
86
|
-
export async function handleInstallLauncher() {
|
|
87
|
-
const path = defaultLauncherPath();
|
|
88
|
-
await installLauncher(path);
|
|
89
|
-
return launcherStatus(path);
|
|
90
|
-
}
|
package/dist/server/lock.js
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { mkdir, rm, stat } from "node:fs/promises";
|
|
2
|
-
const RETRY_MS = 25;
|
|
3
|
-
const WAIT_MS = 10_000;
|
|
4
|
-
// A lock older than this was left by a command that crashed.
|
|
5
|
-
const STALE_MS = 30_000;
|
|
6
|
-
// Serializes read-then-append so two commands never hand out the same id or
|
|
7
|
-
// interleave a line. mkdir is atomic, so only one process creates the folder.
|
|
8
|
-
export async function withLock(lockPath, work) {
|
|
9
|
-
const started = Date.now();
|
|
10
|
-
while (true) {
|
|
11
|
-
try {
|
|
12
|
-
await mkdir(lockPath);
|
|
13
|
-
break;
|
|
14
|
-
}
|
|
15
|
-
catch (error) {
|
|
16
|
-
if (error.code !== "EEXIST")
|
|
17
|
-
throw error;
|
|
18
|
-
const since = await stat(lockPath).then((info) => Date.now() - info.mtimeMs, () => 0);
|
|
19
|
-
if (since > STALE_MS) {
|
|
20
|
-
await rm(lockPath, { recursive: true, force: true });
|
|
21
|
-
continue;
|
|
22
|
-
}
|
|
23
|
-
if (Date.now() - started > WAIT_MS)
|
|
24
|
-
throw new Error(`Timed out waiting for ${lockPath}. If no other progress command is running, delete it.`);
|
|
25
|
-
await new Promise((resolve) => setTimeout(resolve, RETRY_MS + Math.random() * RETRY_MS));
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
try {
|
|
29
|
-
return await work();
|
|
30
|
-
}
|
|
31
|
-
finally {
|
|
32
|
-
await rm(lockPath, { recursive: true, force: true });
|
|
33
|
-
}
|
|
34
|
-
}
|
package/dist/server/paths.js
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
2
|
-
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
3
|
-
// Whether a path from relative(root, target) leaves the root. "..notes" is a
|
|
4
|
-
// name inside it, not a step out.
|
|
5
|
-
export function outside(inside) {
|
|
6
|
-
return inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside);
|
|
7
|
-
}
|
|
8
|
-
// The worktree root is the nearest directory with a `.git` entry (a folder in
|
|
9
|
-
// a checkout, a file in a linked worktree). Outside git, use the directory.
|
|
10
|
-
// The command and the panel both start from here, so a Paseo project set at a
|
|
11
|
-
// subfolder of the repo still reads the file the agent writes.
|
|
12
|
-
export function findRoot(cwd) {
|
|
13
|
-
let directory = resolve(cwd);
|
|
14
|
-
while (true) {
|
|
15
|
-
if (existsSync(join(directory, ".git")))
|
|
16
|
-
return directory;
|
|
17
|
-
const parent = dirname(directory);
|
|
18
|
-
if (parent === directory)
|
|
19
|
-
return resolve(cwd);
|
|
20
|
-
directory = parent;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
// Everything that reads or writes a worktree's progress files: the event log
|
|
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
|
-
import { access, appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
6
|
-
import { join } from "node:path";
|
|
7
|
-
import { EMPTY_DASHBOARD, latestRun, parseProgress, reduceProgress } from "../shared/dashboard.js";
|
|
8
|
-
import { EVENT_VERSION, eventSchema, PANEL_OPENED_NAME, PROGRESS_FILE_NAME } from "../shared/events.js";
|
|
9
|
-
import { withLock } from "./lock.js";
|
|
10
|
-
const LOCK_NAME = `${PROGRESS_FILE_NAME}.lock`;
|
|
11
|
-
// A draft the event schema rejects. The message names each bad field.
|
|
12
|
-
export class InvalidEvent extends Error {
|
|
13
|
-
}
|
|
14
|
-
// The parsed file per folder, kept until its size or change time moves. The
|
|
15
|
-
// file only grows, and the panel and pills read it every few seconds.
|
|
16
|
-
const parsedFiles = new Map();
|
|
17
|
-
// Replaced file snapshots and their derived data can be collected together.
|
|
18
|
-
const dashboards = new WeakMap();
|
|
19
|
-
// The dashboard at `now`, for the panel, the pills and `show`. Repeated reads
|
|
20
|
-
// reuse the last one until the file changes or a stale or overdue time passes.
|
|
21
|
-
// Stale and overdue checks use the daemon host's clock, the same clock the
|
|
22
|
-
// command stamps events with.
|
|
23
|
-
export async function readProgress(directory, now = new Date()) {
|
|
24
|
-
const parsed = await readParsed(directory);
|
|
25
|
-
const panelOpened = await exists(join(directory, PANEL_OPENED_NAME));
|
|
26
|
-
if (parsed === null)
|
|
27
|
-
return { configured: false, dashboard: EMPTY_DASHBOARD, panelOpened };
|
|
28
|
-
let cached = dashboards.get(parsed);
|
|
29
|
-
// A backwards clock jump must also re-evaluate stale/overdue state.
|
|
30
|
-
if (!cached || now.getTime() < cached.from || now.getTime() >= cached.until) {
|
|
31
|
-
const { dashboard, validUntil } = reduceProgress(parsed, now);
|
|
32
|
-
cached = { dashboard, from: now.getTime(), until: validUntil };
|
|
33
|
-
dashboards.set(parsed, cached);
|
|
34
|
-
}
|
|
35
|
-
return { configured: true, dashboard: cached.dashboard, panelOpened };
|
|
36
|
-
}
|
|
37
|
-
// The events of the latest run, oldest first.
|
|
38
|
-
export async function readLatestRun(directory) {
|
|
39
|
-
return latestRun((await readParsed(directory))?.events ?? []).map(({ event }) => event);
|
|
40
|
-
}
|
|
41
|
-
// Appends the one event `write` builds from the file as it is now. Commands
|
|
42
|
-
// that run at once take turns, so each sees the others' events and no two hand
|
|
43
|
-
// out the same id. `write` may throw to append nothing.
|
|
44
|
-
export async function appendProgress(directory, now,
|
|
45
|
-
// `events` is the latest run as it is now, oldest first.
|
|
46
|
-
write,
|
|
47
|
-
// Runs before the lock is released, with the whole file as it now is.
|
|
48
|
-
after) {
|
|
49
|
-
await mkdir(directory, { recursive: true });
|
|
50
|
-
return withLock(join(directory, LOCK_NAME), async () => {
|
|
51
|
-
const text = (await readText(directory)) ?? "";
|
|
52
|
-
const parsed = parseProgress(text);
|
|
53
|
-
const { dashboard, nextIds } = reduceProgress(parsed, now());
|
|
54
|
-
const { event, result } = write({ dashboard, nextIds, events: latestRun(parsed.events).map((line) => line.event) });
|
|
55
|
-
const checked = eventSchema.safeParse({ v: EVENT_VERSION, ts: now().toISOString(), ...withoutUndefined(event) });
|
|
56
|
-
if (!checked.success) {
|
|
57
|
-
throw new InvalidEvent(checked.error.issues.map((issue) => `${issue.path.join(".") || "value"}: ${issue.message}`).join("; "));
|
|
58
|
-
}
|
|
59
|
-
// A line cut short (a killed command, a hand edit) would swallow this event, so start a new line.
|
|
60
|
-
const lead = text && !text.endsWith("\n") ? "\n" : "";
|
|
61
|
-
const line = `${lead}${JSON.stringify(checked.data)}\n`;
|
|
62
|
-
await appendFile(join(directory, PROGRESS_FILE_NAME), line);
|
|
63
|
-
await after?.(text + line);
|
|
64
|
-
return result;
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
export async function markPanelOpened(directory) {
|
|
68
|
-
await mkdir(directory, { recursive: true });
|
|
69
|
-
await writeFile(join(directory, PANEL_OPENED_NAME), "");
|
|
70
|
-
}
|
|
71
|
-
async function readText(directory) {
|
|
72
|
-
try {
|
|
73
|
-
return await readFile(join(directory, PROGRESS_FILE_NAME), "utf8");
|
|
74
|
-
}
|
|
75
|
-
catch (error) {
|
|
76
|
-
if (error.code === "ENOENT")
|
|
77
|
-
return null;
|
|
78
|
-
throw error;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
async function readParsed(directory) {
|
|
82
|
-
const info = await stat(join(directory, PROGRESS_FILE_NAME)).catch((error) => {
|
|
83
|
-
if (error.code === "ENOENT")
|
|
84
|
-
return null;
|
|
85
|
-
throw error;
|
|
86
|
-
});
|
|
87
|
-
if (!info) {
|
|
88
|
-
parsedFiles.delete(directory);
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
91
|
-
const cached = parsedFiles.get(directory);
|
|
92
|
-
if (cached && cached.size === info.size && cached.mtimeMs === info.mtimeMs && cached.ctimeMs === info.ctimeMs)
|
|
93
|
-
return cached.parsed;
|
|
94
|
-
// Stat before reading: if the file grows in between, the next stat won't match and it's read again.
|
|
95
|
-
const text = await readText(directory);
|
|
96
|
-
if (text === null) {
|
|
97
|
-
parsedFiles.delete(directory);
|
|
98
|
-
return null;
|
|
99
|
-
}
|
|
100
|
-
const parsed = parseProgress(text);
|
|
101
|
-
parsedFiles.set(directory, { size: info.size, mtimeMs: info.mtimeMs, ctimeMs: info.ctimeMs, parsed });
|
|
102
|
-
return parsed;
|
|
103
|
-
}
|
|
104
|
-
async function exists(path) {
|
|
105
|
-
try {
|
|
106
|
-
await access(path);
|
|
107
|
-
return true;
|
|
108
|
-
}
|
|
109
|
-
catch {
|
|
110
|
-
return false;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
function withoutUndefined(value) {
|
|
114
|
-
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
115
|
-
}
|
package/dist/server/store.js
DELETED
|
@@ -1,205 +0,0 @@
|
|
|
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,92 +0,0 @@
|
|
|
1
|
-
export function deliverableAttachment(deliverable) {
|
|
2
|
-
return { ref: deliverable.id, title: deliverable.title, path: deliverable.path, url: deliverable.url, kind: deliverable.kind };
|
|
3
|
-
}
|
|
4
|
-
function fileAttachment(ref, file) {
|
|
5
|
-
return { ref, title: file.label ?? (file.path ?? file.url ?? "").replace(/[\\/]+$/, "").split(/[\\/]/).pop() ?? "", path: file.path, url: file.url };
|
|
6
|
-
}
|
|
7
|
-
export function questionAttachments(question) {
|
|
8
|
-
return question.files.map((file, index) => fileAttachment(`${question.id}.${index + 1}`, file));
|
|
9
|
-
}
|
|
10
|
-
export function ticketSourceAttachment(ticket) {
|
|
11
|
-
return ticket.source ? fileAttachment(`${ticket.id}.source`, ticket.source) : null;
|
|
12
|
-
}
|
|
13
|
-
// The attachment a reference names, or null when it names nothing.
|
|
14
|
-
export function findAttachment(dashboard, ref) {
|
|
15
|
-
const question = ref.match(/^(Q\d+)\./);
|
|
16
|
-
if (question) {
|
|
17
|
-
const found = [...dashboard.questions.open, ...dashboard.questions.answered].find((candidate) => candidate.id === question[1]);
|
|
18
|
-
return (found && questionAttachments(found).find((attachment) => attachment.ref === ref)) ?? null;
|
|
19
|
-
}
|
|
20
|
-
const ticket = ref.match(/^(.+)\.source$/);
|
|
21
|
-
if (ticket) {
|
|
22
|
-
const found = dashboard.tickets.find((candidate) => candidate.id === ticket[1]);
|
|
23
|
-
return found ? ticketSourceAttachment(found) : null;
|
|
24
|
-
}
|
|
25
|
-
const deliverable = dashboard.deliverables.find((candidate) => candidate.id === ref);
|
|
26
|
-
return deliverable ? deliverableAttachment(deliverable) : null;
|
|
27
|
-
}
|
|
28
|
-
const IMAGE_TYPES = {
|
|
29
|
-
".png": "image/png",
|
|
30
|
-
".jpg": "image/jpeg",
|
|
31
|
-
".jpeg": "image/jpeg",
|
|
32
|
-
".gif": "image/gif",
|
|
33
|
-
".webp": "image/webp",
|
|
34
|
-
".svg": "image/svg+xml",
|
|
35
|
-
};
|
|
36
|
-
// Plain text shown as-is in the preview dialog, and opened in the browser on
|
|
37
|
-
// the host. HTML is left to the browser either way.
|
|
38
|
-
const TEXT_EXTENSIONS = new Set([".md", ".markdown", ".mdx", ".txt", ".log", ".json", ".jsonl", ".yaml", ".yml", ".csv"]);
|
|
39
|
-
function extension(path) {
|
|
40
|
-
return path.toLowerCase().match(/\.[a-z0-9]+$/)?.[0] ?? null;
|
|
41
|
-
}
|
|
42
|
-
// The image type of a path by its extension, or null when it isn't an image.
|
|
43
|
-
export function imageMimeType(path) {
|
|
44
|
-
return IMAGE_TYPES[extension(path) ?? ""] ?? null;
|
|
45
|
-
}
|
|
46
|
-
export function previewKind(path) {
|
|
47
|
-
if (imageMimeType(path))
|
|
48
|
-
return "image";
|
|
49
|
-
return TEXT_EXTENSIONS.has(extension(path) ?? "") ? "text" : null;
|
|
50
|
-
}
|
|
51
|
-
// Whether the preview dialog can show it. Only a browser draws SVG: Paseo's
|
|
52
|
-
// desktop app can, the phone apps' image view can't, so there an SVG opens on
|
|
53
|
-
// the host like any other file.
|
|
54
|
-
export function canPreview(attachment, where) {
|
|
55
|
-
if (!attachment.path)
|
|
56
|
-
return false;
|
|
57
|
-
const kind = previewKind(attachment.path);
|
|
58
|
-
if (kind === "image" && extension(attachment.path) === ".svg")
|
|
59
|
-
return where.browser;
|
|
60
|
-
return kind !== null;
|
|
61
|
-
}
|
|
62
|
-
const KIND_ICON = {
|
|
63
|
-
file: "File",
|
|
64
|
-
folder: "Folder",
|
|
65
|
-
report: "FileChartColumn",
|
|
66
|
-
screenshot: "Image",
|
|
67
|
-
link: "Globe",
|
|
68
|
-
};
|
|
69
|
-
// One icon per attachment, the same on every screen. What the agent called a
|
|
70
|
-
// deliverable wins; otherwise the file itself decides.
|
|
71
|
-
export function attachmentIcon(attachment) {
|
|
72
|
-
if (attachment.kind && attachment.kind !== "file")
|
|
73
|
-
return KIND_ICON[attachment.kind];
|
|
74
|
-
if (attachment.url)
|
|
75
|
-
return "Globe";
|
|
76
|
-
if (!attachment.path)
|
|
77
|
-
return "File";
|
|
78
|
-
const kind = previewKind(attachment.path);
|
|
79
|
-
return kind === "image" ? "Image" : kind === "text" ? "FileText" : /[\/]$/.test(attachment.path) ? "Folder" : "File";
|
|
80
|
-
}
|
|
81
|
-
// The short name people recognize: a file or folder's own name, or a link's site.
|
|
82
|
-
export function shortName(attachment) {
|
|
83
|
-
if (attachment.url) {
|
|
84
|
-
try {
|
|
85
|
-
return new URL(attachment.url).host.replace(/^www\./, "");
|
|
86
|
-
}
|
|
87
|
-
catch {
|
|
88
|
-
return attachment.url;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
return (attachment.path ?? "").replace(/[\\/]+$/, "").split(/[\\/]/).pop() ?? "";
|
|
92
|
-
}
|