@stevecastaneda/paseo-progress 0.1.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.
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/client/attachments.tsx +95 -0
- package/client/dashboard-query.ts +32 -0
- package/client/deliverables.tsx +67 -0
- package/client/dialog-stack.tsx +104 -0
- package/client/directory.ts +90 -0
- package/client/launcher.tsx +111 -0
- package/client/motion.tsx +173 -0
- package/client/panel-opened.ts +13 -0
- package/client/panel.tsx +480 -0
- package/client/pill-popover.tsx +122 -0
- package/client/pill-state.ts +34 -0
- package/client/pills.tsx +235 -0
- package/client/preview.tsx +228 -0
- package/client/questions.tsx +248 -0
- package/client/row.tsx +34 -0
- package/client/section-title.tsx +24 -0
- package/client/show-more.tsx +37 -0
- package/client/spinner.tsx +35 -0
- package/client/status-badge.tsx +30 -0
- package/client/surfaces.ts +45 -0
- package/client/ticket-dialog.tsx +203 -0
- package/client/when.tsx +15 -0
- package/dist/server/cli.js +528 -0
- package/dist/server/launcher.js +90 -0
- package/dist/server/lock.js +34 -0
- package/dist/server/paths.js +6 -0
- package/dist/server/progress-file.js +123 -0
- package/dist/shared/attachments.js +86 -0
- package/dist/shared/dashboard.js +369 -0
- package/dist/shared/events.js +117 -0
- package/dist/shared/format.js +37 -0
- package/dist/shared/show.js +72 -0
- package/images/1-panel.png +0 -0
- package/images/2-ticket.png +0 -0
- package/images/3-question.png +0 -0
- package/images/4-pill.png +0 -0
- package/index.client.tsx +33 -0
- package/index.server.ts +19 -0
- package/npm-shrinkwrap.json +4041 -0
- package/package.json +62 -0
- package/paseo-plugin.json +7 -0
- package/server/cli.ts +541 -0
- package/server/dashboard.ts +46 -0
- package/server/launcher.ts +97 -0
- package/server/lock.ts +32 -0
- package/server/open.ts +107 -0
- package/server/paths.ts +7 -0
- package/server/progress-file.ts +141 -0
- package/server/skill.ts +85 -0
- package/shared/attachments.ts +100 -0
- package/shared/dashboard.ts +491 -0
- package/shared/events.ts +131 -0
- package/shared/format.ts +39 -0
- package/shared/rpc.ts +109 -0
- package/shared/show.ts +68 -0
- package/shared/ticket-story.ts +93 -0
- package/skills/paseo-progress/SKILL.md +79 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,97 @@
|
|
|
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
|
+
|
|
5
|
+
export const PLUGIN_ID = "progress";
|
|
6
|
+
|
|
7
|
+
// The launcher never names a plugin folder. Each run asks Paseo which copy of
|
|
8
|
+
// the plugin it is running, so updates and `npm run dev` switches keep working.
|
|
9
|
+
export function launcherScript(): string {
|
|
10
|
+
return `#!/usr/bin/env node
|
|
11
|
+
// Written by the ${PLUGIN_ID} Paseo plugin. Runs the progress command from the
|
|
12
|
+
// copy of the plugin Paseo is running now.
|
|
13
|
+
const [major, minor] = process.versions.node.split(".").map(Number);
|
|
14
|
+
if (major < 22 || (major === 22 && minor < 18)) {
|
|
15
|
+
console.error("paseo-progress needs Node.js 22.18 or later; this is " + process.version + ".");
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
// Not require: a package.json with "type": "module" above this folder makes Node load it as ESM.
|
|
19
|
+
const { spawnSync } = process.getBuiltinModule("node:child_process");
|
|
20
|
+
const { existsSync } = process.getBuiltinModule("node:fs");
|
|
21
|
+
const { join } = process.getBuiltinModule("node:path");
|
|
22
|
+
|
|
23
|
+
function pluginDirectory() {
|
|
24
|
+
if (process.env.PASEO_PROGRESS_PLUGIN_DIR) return process.env.PASEO_PROGRESS_PLUGIN_DIR;
|
|
25
|
+
const clis = [process.env.PASEO_BIN, "paseo", "/Applications/Paseo.app/Contents/Resources/bin/paseo"].filter(Boolean);
|
|
26
|
+
for (const cli of clis) {
|
|
27
|
+
const result = spawnSync(cli, ["plugin", "ls", "${PLUGIN_ID}", "--json"], { encoding: "utf8" });
|
|
28
|
+
if (result.error || result.status !== 0) continue;
|
|
29
|
+
try {
|
|
30
|
+
const plugin = JSON.parse(result.stdout).find((entry) => entry.id === "${PLUGIN_ID}");
|
|
31
|
+
return plugin && plugin.path;
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// An npm install puts the plugin under node_modules, where Node won't run
|
|
40
|
+
// TypeScript, so the npm package carries the command compiled to dist/.
|
|
41
|
+
const directory = pluginDirectory();
|
|
42
|
+
const entry = directory && [join(directory, "dist", "server", "cli.js"), join(directory, "server", "cli.ts")].find((path) => existsSync(path));
|
|
43
|
+
if (!entry) {
|
|
44
|
+
console.error("The ${PLUGIN_ID} plugin is not installed in Paseo, or Paseo is not reachable. Check with: paseo plugin ls");
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
const run = spawnSync(process.execPath, [entry, ...process.argv.slice(2)], { stdio: "inherit" });
|
|
48
|
+
process.exit(run.status === null ? 1 : run.status);
|
|
49
|
+
`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const MARKER = `Written by the ${PLUGIN_ID} Paseo plugin`;
|
|
53
|
+
|
|
54
|
+
export function defaultLauncherPath(home = homedir()): string {
|
|
55
|
+
return join(home, ".local", "bin", "paseo-progress");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type LauncherState = "missing" | "current" | "outdated" | "foreign";
|
|
59
|
+
|
|
60
|
+
export async function launcherState(path: string): Promise<LauncherState> {
|
|
61
|
+
const current = await readFile(path, "utf8").catch(() => null);
|
|
62
|
+
if (current === null) return "missing";
|
|
63
|
+
if (current === launcherScript()) return "current";
|
|
64
|
+
return current.includes(MARKER) ? "outdated" : "foreign";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Writes the launcher when it is missing or out of date. Never replaces a file
|
|
68
|
+
// something else put there. Returns whether it wrote.
|
|
69
|
+
export async function installLauncher(path: string): Promise<boolean> {
|
|
70
|
+
const state = await launcherState(path);
|
|
71
|
+
if (state === "current") return false;
|
|
72
|
+
if (state === "foreign") throw new Error(`${path} already exists and was not written by this plugin. Move it, or pass another path.`);
|
|
73
|
+
await mkdir(dirname(path), { recursive: true });
|
|
74
|
+
await writeFile(path, launcherScript());
|
|
75
|
+
await chmod(path, 0o755);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// For the panel: whether agents on the daemon host can run `paseo-progress`.
|
|
80
|
+
export async function launcherStatus(path = defaultLauncherPath(), pathEnv = process.env.PATH ?? "") {
|
|
81
|
+
return {
|
|
82
|
+
path,
|
|
83
|
+
state: await launcherState(path),
|
|
84
|
+
onPath: pathEnv.split(delimiter).some((entry) => entry.replace(/\/+$/, "") === dirname(path)),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function handleLauncherStatus() {
|
|
89
|
+
return launcherStatus();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Runs only when the user presses Install in the panel.
|
|
93
|
+
export async function handleInstallLauncher() {
|
|
94
|
+
const path = defaultLauncherPath();
|
|
95
|
+
await installLauncher(path);
|
|
96
|
+
return launcherStatus(path);
|
|
97
|
+
}
|
package/server/lock.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { mkdir, rm, stat } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
const RETRY_MS = 25;
|
|
4
|
+
const WAIT_MS = 10_000;
|
|
5
|
+
// A lock older than this was left by a command that crashed.
|
|
6
|
+
const STALE_MS = 30_000;
|
|
7
|
+
|
|
8
|
+
// Serializes read-then-append so two commands never hand out the same id or
|
|
9
|
+
// interleave a line. mkdir is atomic, so only one process creates the folder.
|
|
10
|
+
export async function withLock<T>(lockPath: string, work: () => Promise<T>): Promise<T> {
|
|
11
|
+
const started = Date.now();
|
|
12
|
+
while (true) {
|
|
13
|
+
try {
|
|
14
|
+
await mkdir(lockPath);
|
|
15
|
+
break;
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") 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) throw new Error(`Timed out waiting for ${lockPath}. If no other progress command is running, delete it.`);
|
|
24
|
+
await new Promise((resolve) => setTimeout(resolve, RETRY_MS + Math.random() * RETRY_MS));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
return await work();
|
|
29
|
+
} finally {
|
|
30
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
}
|
package/server/open.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { PluginHandlerContext } from "@getpaseo/plugin/server";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { open, readFile, realpath, stat } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join, relative, resolve } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { findAttachment, imageMimeType, previewKind } from "../shared/attachments.ts";
|
|
8
|
+
import { readProgress } from "./progress-file.ts";
|
|
9
|
+
import { outside } from "./paths.ts";
|
|
10
|
+
|
|
11
|
+
export type Opener = (path: string) => Promise<void>;
|
|
12
|
+
|
|
13
|
+
// The command that opens `path` on this host: like a double-click, except
|
|
14
|
+
// plain-text files go to the default browser (`browser` is its bundle id): the
|
|
15
|
+
// Mac's default app for them is often an editor, or one that closes straight
|
|
16
|
+
// away. The browser shows them as text.
|
|
17
|
+
export function openCommand(path: string, platform: NodeJS.Platform, browser: string | null): [string, string[]] {
|
|
18
|
+
if (platform === "win32") return ["explorer", [path]];
|
|
19
|
+
if (platform !== "darwin") return ["xdg-open", [path]];
|
|
20
|
+
const toBrowser = previewKind(path) === "text";
|
|
21
|
+
return toBrowser ? ["open", ["-b", browser ?? "com.apple.Safari", path]] : ["open", [path]];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The default browser from Launch Services, e.g. "com.google.chrome".
|
|
25
|
+
async function defaultBrowser(): Promise<string | null> {
|
|
26
|
+
try {
|
|
27
|
+
const plist = join(homedir(), "Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist");
|
|
28
|
+
const { stdout } = await promisify(execFile)("plutil", ["-convert", "json", "-o", "-", plist]);
|
|
29
|
+
const handlers = (JSON.parse(stdout).LSHandlers ?? []) as Array<{ LSHandlerURLScheme?: string; LSHandlerRoleAll?: string }>;
|
|
30
|
+
return handlers.find((handler) => handler.LSHandlerURLScheme === "https")?.LSHandlerRoleAll ?? null;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const systemOpener: Opener = async (path) => {
|
|
37
|
+
const browser = process.platform === "darwin" ? await defaultBrowser() : null;
|
|
38
|
+
const [command, args] = openCommand(path, process.platform, browser);
|
|
39
|
+
await promisify(execFile)(command, args);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// A deliverable or question attachment the agent recorded, found by reference,
|
|
43
|
+
// so the panel can never reach an arbitrary path. Only files inside the
|
|
44
|
+
// worktree resolve.
|
|
45
|
+
async function resolveAttachment(directory: string, ref: string, verb: string): Promise<{ path: string; target: string }> {
|
|
46
|
+
const { dashboard } = await readProgress(directory);
|
|
47
|
+
const path = findAttachment(dashboard, ref)?.path;
|
|
48
|
+
if (!path) throw new Error(`No local ${ref.startsWith("Q") ? "attachment" : "deliverable"} ${ref} in this worktree.`);
|
|
49
|
+
const root = await realpath(directory);
|
|
50
|
+
const target = await realpath(resolve(root, path)).catch(() => null);
|
|
51
|
+
if (!target) throw new Error(`${path} no longer exists.`);
|
|
52
|
+
const inside = relative(root, target);
|
|
53
|
+
if (outside(inside)) throw new Error(`${path} is outside this worktree, so it was not ${verb}.`);
|
|
54
|
+
return { path, target };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function openDeliverable(directory: string, id: string, opener: Opener): Promise<{ opened: string }> {
|
|
58
|
+
const { target } = await resolveAttachment(directory, id, "opened");
|
|
59
|
+
await opener(target);
|
|
60
|
+
return { opened: target };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Large enough for full-page screenshots; the image travels over the socket.
|
|
64
|
+
export const MAX_PREVIEW_BYTES = 10 * 1024 * 1024;
|
|
65
|
+
|
|
66
|
+
// Text past this is cut off in the preview; the full file is a click away.
|
|
67
|
+
export const MAX_PREVIEW_TEXT_BYTES = 256 * 1024;
|
|
68
|
+
|
|
69
|
+
export type Preview =
|
|
70
|
+
| { kind: "image"; dataUri: string; bytes: number }
|
|
71
|
+
| { kind: "text"; text: string; bytes: number; truncated: boolean };
|
|
72
|
+
|
|
73
|
+
// An image (as a data URI) or a text file's contents, so any client (desktop
|
|
74
|
+
// or phone) can show it without reaching the daemon host's disk.
|
|
75
|
+
export async function previewDeliverable(directory: string, id: string): Promise<Preview> {
|
|
76
|
+
const { path, target } = await resolveAttachment(directory, id, "previewed");
|
|
77
|
+
const kind = previewKind(target);
|
|
78
|
+
if (!kind) throw new Error(`${path} isn't an image or text file, so it can't be previewed.`);
|
|
79
|
+
const { size } = await stat(target);
|
|
80
|
+
if (kind === "text") {
|
|
81
|
+
// Read only what's shown, so a huge log doesn't load whole.
|
|
82
|
+
const file = await open(target);
|
|
83
|
+
try {
|
|
84
|
+
const { buffer, bytesRead } = await file.read({ buffer: Buffer.alloc(Math.min(size, MAX_PREVIEW_TEXT_BYTES)), position: 0 });
|
|
85
|
+
return { kind, text: buffer.subarray(0, bytesRead).toString("utf8"), bytes: size, truncated: size > MAX_PREVIEW_TEXT_BYTES };
|
|
86
|
+
} finally {
|
|
87
|
+
await file.close();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (size > MAX_PREVIEW_BYTES) throw new Error(`${path} is ${Math.round(size / 1024 / 1024)} MB, too large to preview.`);
|
|
91
|
+
const data = await readFile(target);
|
|
92
|
+
return { kind, dataUri: `data:${imageMimeType(target)};base64,${data.toString("base64")}`, bytes: size };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function handlePreviewDeliverable(
|
|
96
|
+
input: { workspaceId: string; workspaceDirectory: string; ref: string },
|
|
97
|
+
_context: PluginHandlerContext,
|
|
98
|
+
) {
|
|
99
|
+
return previewDeliverable(input.workspaceDirectory, input.ref);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function handleOpenDeliverable(
|
|
103
|
+
input: { workspaceId: string; workspaceDirectory: string; ref: string },
|
|
104
|
+
_context: PluginHandlerContext,
|
|
105
|
+
) {
|
|
106
|
+
return openDeliverable(input.workspaceDirectory, input.ref, systemOpener);
|
|
107
|
+
}
|
package/server/paths.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { isAbsolute, sep } from "node:path";
|
|
2
|
+
|
|
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: string): boolean {
|
|
6
|
+
return inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside);
|
|
7
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
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.
|
|
5
|
+
import { access, appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
6
|
+
import { basename, dirname, join } from "node:path";
|
|
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";
|
|
9
|
+
import { withLock } from "./lock.ts";
|
|
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
|
+
`;
|
|
22
|
+
|
|
23
|
+
// An event as a command builds it; `append` stamps the version and time.
|
|
24
|
+
export type EventDraft = { [K in ProgressEvent["type"]]: Omit<Extract<ProgressEvent, { type: K }>, "v" | "ts"> }[ProgressEvent["type"]];
|
|
25
|
+
|
|
26
|
+
// A draft the event schema rejects. The message names each bad field.
|
|
27
|
+
export class InvalidEvent extends Error {}
|
|
28
|
+
|
|
29
|
+
export interface ProgressRead {
|
|
30
|
+
// Whether the worktree has a progress file at all.
|
|
31
|
+
configured: boolean;
|
|
32
|
+
dashboard: Dashboard;
|
|
33
|
+
panelOpened: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The parsed file per worktree, kept until its size or change time moves. The
|
|
37
|
+
// file only grows, and the panel and pills read it every few seconds.
|
|
38
|
+
const parsedFiles = new Map<string, { size: number; mtimeMs: number; ctimeMs: number; parsed: ReturnType<typeof parseProgress> }>();
|
|
39
|
+
// Replaced file snapshots and their derived data can be collected together.
|
|
40
|
+
const dashboards = new WeakMap<ReturnType<typeof parseProgress>, { dashboard: Dashboard; from: number; until: number }>();
|
|
41
|
+
|
|
42
|
+
// The dashboard at `now`, for the panel, the pills and `show`. Repeated reads
|
|
43
|
+
// reuse the last one until the file changes or a stale or overdue time passes.
|
|
44
|
+
// Stale and overdue checks use the daemon host's clock, the same clock the
|
|
45
|
+
// 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));
|
|
49
|
+
if (parsed === null) return { configured: false, dashboard: EMPTY_DASHBOARD, panelOpened };
|
|
50
|
+
let cached = dashboards.get(parsed);
|
|
51
|
+
// A backwards clock jump must also re-evaluate stale/overdue state.
|
|
52
|
+
if (!cached || now.getTime() < cached.from || now.getTime() >= cached.until) {
|
|
53
|
+
const { dashboard, validUntil } = reduceProgress(parsed, now);
|
|
54
|
+
cached = { dashboard, from: now.getTime(), until: validUntil };
|
|
55
|
+
dashboards.set(parsed, cached);
|
|
56
|
+
}
|
|
57
|
+
return { configured: true, dashboard: cached.dashboard, panelOpened };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Appends the one event `write` builds from the file as it is now. Commands
|
|
61
|
+
// that run at once take turns, so each sees the others' events and no two hand
|
|
62
|
+
// out the same id. `write` may throw to append nothing.
|
|
63
|
+
export async function appendProgress<T>(
|
|
64
|
+
root: string,
|
|
65
|
+
now: () => Date,
|
|
66
|
+
write: (state: { dashboard: Dashboard; nextIds: NextIds }) => { event: EventDraft; result: T },
|
|
67
|
+
): Promise<T> {
|
|
68
|
+
await prepareScratch(root);
|
|
69
|
+
return withLock(join(root, LOCK_FILE), async () => {
|
|
70
|
+
const text = (await readText(root)) ?? "";
|
|
71
|
+
const { dashboard, nextIds } = reduceProgress(parseProgress(text), now());
|
|
72
|
+
const { event, result } = write({ dashboard, nextIds });
|
|
73
|
+
const checked = eventSchema.safeParse({ v: EVENT_VERSION, ts: now().toISOString(), ...withoutUndefined(event) });
|
|
74
|
+
if (!checked.success) {
|
|
75
|
+
throw new InvalidEvent(checked.error.issues.map((issue) => `${issue.path.join(".") || "value"}: ${issue.message}`).join("; "));
|
|
76
|
+
}
|
|
77
|
+
// A line cut short (a killed command, a hand edit) would swallow this event, so start a new line.
|
|
78
|
+
const lead = text && !text.endsWith("\n") ? "\n" : "";
|
|
79
|
+
await appendFile(join(root, PROGRESS_FILE), `${lead}${JSON.stringify(checked.data)}\n`);
|
|
80
|
+
return result;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
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);
|
|
93
|
+
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
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function readText(root: string): Promise<string | null> {
|
|
100
|
+
try {
|
|
101
|
+
return await readFile(join(root, PROGRESS_FILE), "utf8");
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function readParsed(root: string): Promise<ReturnType<typeof parseProgress> | null> {
|
|
109
|
+
const info = await stat(join(root, PROGRESS_FILE)).catch((error: NodeJS.ErrnoException) => {
|
|
110
|
+
if (error.code === "ENOENT") return null;
|
|
111
|
+
throw error;
|
|
112
|
+
});
|
|
113
|
+
if (!info) {
|
|
114
|
+
parsedFiles.delete(root);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
const cached = parsedFiles.get(root);
|
|
118
|
+
if (cached && cached.size === info.size && cached.mtimeMs === info.mtimeMs && cached.ctimeMs === info.ctimeMs) return cached.parsed;
|
|
119
|
+
// 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);
|
|
121
|
+
if (text === null) {
|
|
122
|
+
parsedFiles.delete(root);
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
const parsed = parseProgress(text);
|
|
126
|
+
parsedFiles.set(root, { size: info.size, mtimeMs: info.mtimeMs, ctimeMs: info.ctimeMs, parsed });
|
|
127
|
+
return parsed;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function exists(path: string): Promise<boolean> {
|
|
131
|
+
try {
|
|
132
|
+
await access(path);
|
|
133
|
+
return true;
|
|
134
|
+
} catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function withoutUndefined<T extends object>(value: T): T {
|
|
140
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)) as T;
|
|
141
|
+
}
|
package/server/skill.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { lstat, mkdir, readlink, symlink, unlink } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { PLUGIN_ID } from "./launcher.ts";
|
|
7
|
+
|
|
8
|
+
export const SKILL_NAME = "paseo-progress";
|
|
9
|
+
|
|
10
|
+
// The same three folders Paseo installs its own skills into: the shared
|
|
11
|
+
// ~/.agents folder plus Claude Code's and Codex's own.
|
|
12
|
+
export function defaultSkillPaths(home = homedir()): string[] {
|
|
13
|
+
return [".agents", ".claude", ".codex"].map((folder) => join(home, folder, "skills", SKILL_NAME));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function skillSource(pluginDirectory: string): string {
|
|
17
|
+
return join(pluginDirectory, "skills", SKILL_NAME);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// The skill is a link into the copy of the plugin Paseo runs, so plugin
|
|
21
|
+
// updates reach agents without reinstalling. "outdated" is a link this plugin
|
|
22
|
+
// made to a copy Paseo no longer runs; "foreign" is anything else.
|
|
23
|
+
export type SkillState = "missing" | "current" | "outdated" | "foreign";
|
|
24
|
+
|
|
25
|
+
export async function skillState(path: string, pluginDirectory: string): Promise<SkillState> {
|
|
26
|
+
const stats = await lstat(path).catch(() => null);
|
|
27
|
+
if (!stats) return "missing";
|
|
28
|
+
if (!stats.isSymbolicLink()) return "foreign";
|
|
29
|
+
const target = resolve(dirname(path), await readlink(path));
|
|
30
|
+
if (target === skillSource(pluginDirectory)) return "current";
|
|
31
|
+
return basename(target) === SKILL_NAME && basename(dirname(target)) === "skills" ? "outdated" : "foreign";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Links the skill wherever it is missing or points at an old copy of the
|
|
35
|
+
// plugin. Leaves alone anything else at those paths. Returns whether it wrote.
|
|
36
|
+
export async function installSkill(paths: string[], pluginDirectory: string): Promise<boolean> {
|
|
37
|
+
const states = await Promise.all(paths.map((path) => skillState(path, pluginDirectory)));
|
|
38
|
+
if (states.every((state) => state === "foreign")) throw new Error(`${paths.join(", ")} already exist and were not linked by this plugin.`);
|
|
39
|
+
let wrote = false;
|
|
40
|
+
for (const [index, path] of paths.entries()) {
|
|
41
|
+
if (states[index] !== "missing" && states[index] !== "outdated") continue;
|
|
42
|
+
if (states[index] === "outdated") await unlink(path);
|
|
43
|
+
await mkdir(dirname(path), { recursive: true });
|
|
44
|
+
await symlink(skillSource(pluginDirectory), path, "dir");
|
|
45
|
+
wrote = true;
|
|
46
|
+
}
|
|
47
|
+
return wrote;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// "current" once every folder that can take the link has it.
|
|
51
|
+
export async function skillStatus(paths: string[], pluginDirectory: string) {
|
|
52
|
+
const targets = await Promise.all(paths.map(async (path) => ({ path, state: await skillState(path, pluginDirectory) })));
|
|
53
|
+
const states = targets.map((target) => target.state);
|
|
54
|
+
const state: SkillState = states.includes("outdated") ? "outdated"
|
|
55
|
+
: states.includes("missing") ? "missing"
|
|
56
|
+
: states.includes("current") ? "current" : "foreign";
|
|
57
|
+
return { state, targets };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The server bundle doesn't know its own folder, so ask Paseo, the same way
|
|
61
|
+
// the launcher does.
|
|
62
|
+
export async function findPluginDirectory(): Promise<string> {
|
|
63
|
+
const clis = [process.env.PASEO_BIN, "paseo", "/Applications/Paseo.app/Contents/Resources/bin/paseo"].filter((cli): cli is string => Boolean(cli));
|
|
64
|
+
for (const cli of clis) {
|
|
65
|
+
try {
|
|
66
|
+
const { stdout } = await promisify(execFile)(cli, ["plugin", "ls", PLUGIN_ID, "--json"]);
|
|
67
|
+
const plugin = (JSON.parse(stdout) as Array<{ id: string; path?: string }>).find((entry) => entry.id === PLUGIN_ID);
|
|
68
|
+
if (plugin?.path) return plugin.path;
|
|
69
|
+
} catch {
|
|
70
|
+
// Try the next place the paseo CLI might be.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
throw new Error("Could not ask Paseo where this plugin is installed.");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function handleSkillStatus() {
|
|
77
|
+
return skillStatus(defaultSkillPaths(), await findPluginDirectory());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Runs only when the user presses Install skill in the panel.
|
|
81
|
+
export async function handleInstallSkill() {
|
|
82
|
+
const pluginDirectory = await findPluginDirectory();
|
|
83
|
+
await installSkill(defaultSkillPaths(), pluginDirectory);
|
|
84
|
+
return skillStatus(defaultSkillPaths(), pluginDirectory);
|
|
85
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Anything the user can open from the dashboard: a deliverable ("D3") or a
|
|
2
|
+
// file on a question ("Q7.2", counting from 1). The panel lists them and the
|
|
3
|
+
// server finds them by the same reference, so both read it from here.
|
|
4
|
+
import type { Dashboard, Deliverable, Question } from "./dashboard.ts";
|
|
5
|
+
|
|
6
|
+
export interface Attachment {
|
|
7
|
+
ref: string;
|
|
8
|
+
title: string;
|
|
9
|
+
path?: string;
|
|
10
|
+
url?: string;
|
|
11
|
+
// What the agent said a deliverable is. Question files don't have one.
|
|
12
|
+
kind?: Deliverable["kind"];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function deliverableAttachment(deliverable: Deliverable): Attachment {
|
|
16
|
+
return { ref: deliverable.id, title: deliverable.title, path: deliverable.path, url: deliverable.url, kind: deliverable.kind };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function questionAttachments(question: Question): Attachment[] {
|
|
20
|
+
return question.files.map((file, index) => ({
|
|
21
|
+
ref: `${question.id}.${index + 1}`,
|
|
22
|
+
title: file.label ?? (file.path ?? file.url ?? "").replace(/[\\/]+$/, "").split(/[\\/]/).pop() ?? "",
|
|
23
|
+
path: file.path,
|
|
24
|
+
url: file.url,
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The attachment a reference names, or null when it names nothing.
|
|
29
|
+
export function findAttachment(dashboard: Dashboard, ref: string): Attachment | null {
|
|
30
|
+
const question = ref.match(/^(Q\d+)\./);
|
|
31
|
+
if (question) {
|
|
32
|
+
const found = [...dashboard.questions.open, ...dashboard.questions.answered].find((candidate) => candidate.id === question[1]);
|
|
33
|
+
return (found && questionAttachments(found).find((attachment) => attachment.ref === ref)) ?? null;
|
|
34
|
+
}
|
|
35
|
+
const deliverable = dashboard.deliverables.find((candidate) => candidate.id === ref);
|
|
36
|
+
return deliverable ? deliverableAttachment(deliverable) : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const IMAGE_TYPES: Record<string, string> = {
|
|
40
|
+
".png": "image/png",
|
|
41
|
+
".jpg": "image/jpeg",
|
|
42
|
+
".jpeg": "image/jpeg",
|
|
43
|
+
".gif": "image/gif",
|
|
44
|
+
".webp": "image/webp",
|
|
45
|
+
".svg": "image/svg+xml",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Plain text shown as-is in the preview dialog, and opened in the browser on
|
|
49
|
+
// the host. HTML is left to the browser either way.
|
|
50
|
+
const TEXT_EXTENSIONS = new Set([".md", ".markdown", ".mdx", ".txt", ".log", ".json", ".jsonl", ".yaml", ".yml", ".csv"]);
|
|
51
|
+
|
|
52
|
+
function extension(path: string): string | null {
|
|
53
|
+
return path.toLowerCase().match(/\.[a-z0-9]+$/)?.[0] ?? null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The image type of a path by its extension, or null when it isn't an image.
|
|
57
|
+
export function imageMimeType(path: string): string | null {
|
|
58
|
+
return IMAGE_TYPES[extension(path) ?? ""] ?? null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function previewKind(path: string): "image" | "text" | null {
|
|
62
|
+
if (imageMimeType(path)) return "image";
|
|
63
|
+
return TEXT_EXTENSIONS.has(extension(path) ?? "") ? "text" : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Whether the preview dialog can show it. Only a browser draws SVG: Paseo's
|
|
67
|
+
// desktop app can, the phone apps' image view can't, so there an SVG opens on
|
|
68
|
+
// the host like any other file.
|
|
69
|
+
export function canPreview(attachment: Attachment, where: { browser: boolean }): boolean {
|
|
70
|
+
if (!attachment.path) return false;
|
|
71
|
+
const kind = previewKind(attachment.path);
|
|
72
|
+
if (kind === "image" && extension(attachment.path) === ".svg") return where.browser;
|
|
73
|
+
return kind !== null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const KIND_ICON: Record<NonNullable<Deliverable["kind"]>, string> = {
|
|
77
|
+
file: "File",
|
|
78
|
+
folder: "Folder",
|
|
79
|
+
report: "FileChartColumn",
|
|
80
|
+
screenshot: "Image",
|
|
81
|
+
link: "Globe",
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// One icon per attachment, the same on every screen. What the agent called a
|
|
85
|
+
// deliverable wins; otherwise the file itself decides.
|
|
86
|
+
export function attachmentIcon(attachment: Attachment): string {
|
|
87
|
+
if (attachment.kind && attachment.kind !== "file") return KIND_ICON[attachment.kind];
|
|
88
|
+
if (attachment.url) return "Globe";
|
|
89
|
+
if (!attachment.path) return "File";
|
|
90
|
+
const kind = previewKind(attachment.path);
|
|
91
|
+
return kind === "image" ? "Image" : kind === "text" ? "FileText" : /[\/]$/.test(attachment.path) ? "Folder" : "File";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The short name people recognize: a file or folder's own name, or a link's site.
|
|
95
|
+
export function shortName(attachment: Attachment): string {
|
|
96
|
+
if (attachment.url) {
|
|
97
|
+
try { return new URL(attachment.url).host.replace(/^www\./, ""); } catch { return attachment.url; }
|
|
98
|
+
}
|
|
99
|
+
return (attachment.path ?? "").replace(/[\\/]+$/, "").split(/[\\/]/).pop() ?? "";
|
|
100
|
+
}
|