@stevecastaneda/paseo-progress 1.14.1 → 1.14.3
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 +28 -17
- package/client/attachments.tsx +122 -28
- package/client/blocks.tsx +33 -8
- package/client/chat-row-view.tsx +19 -6
- package/client/chat-row.ts +13 -2
- package/client/dashboard-query.ts +9 -4
- package/client/deliverables.tsx +82 -17
- package/client/dialog-stack.tsx +104 -42
- package/client/directory.ts +1 -3
- package/client/errors.ts +6 -1
- package/client/history.tsx +233 -79
- package/client/i18n.ts +10 -5
- package/client/launcher.tsx +57 -15
- package/client/locales/en.ts +28 -22
- package/client/locales/zh-CN.ts +18 -17
- package/client/messages.ts +1 -0
- package/client/motion.tsx +38 -14
- package/client/panel-opened.ts +3 -1
- package/client/panel.tsx +442 -160
- package/client/pdf.tsx +18 -4
- package/client/pieces.ts +6 -1
- package/client/pill-popover.tsx +108 -23
- package/client/pills.tsx +35 -12
- package/client/preview.tsx +265 -79
- package/client/questions.tsx +176 -55
- package/client/row.tsx +14 -3
- package/client/section-title.tsx +11 -2
- package/client/settings.tsx +76 -24
- package/client/show-more.tsx +23 -6
- package/client/spinner.tsx +11 -7
- package/client/status-badge.tsx +9 -7
- package/client/tabs.tsx +98 -20
- package/client/ticket-dialog.tsx +241 -73
- package/client/time-bar.tsx +5 -2
- package/client/video.tsx +15 -6
- package/client/whats-new.tsx +94 -36
- package/client/when.tsx +2 -1
- package/client/write-up.tsx +79 -12
- package/dist/server/cli.js +199 -101
- package/index.client.tsx +28 -13
- package/index.server.ts +19 -1
- package/package.json +15 -14
- package/server/cli.ts +72 -32
- package/server/dashboard.ts +9 -13
- package/server/launcher.ts +33 -17
- package/server/lock.ts +14 -7
- package/server/open.ts +32 -98
- package/server/progress-file.ts +6 -6
- package/server/question-mode.ts +12 -6
- package/server/skill.ts +22 -16
- package/server/store.ts +26 -41
- package/server/system.ts +113 -0
- package/server/tool.ts +89 -44
- package/server/whats-new.ts +25 -13
- package/shared/attachments.ts +40 -8
- package/shared/blocks.ts +9 -6
- package/shared/dashboard.ts +46 -29
- package/shared/events.ts +12 -10
- package/shared/markdown.ts +16 -8
- package/shared/question-modes.ts +8 -5
- package/shared/rpc.ts +9 -3
- package/shared/show.ts +20 -8
- package/shared/ticket-story.ts +1 -3
- package/skills/paseo-progress/SKILL.md +1 -0
- package/tsconfig.json +3 -10
- package/client/page-opener.ts +0 -9
- package/client/page-screen.tsx +0 -97
package/server/open.ts
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
import type { PluginHandlerContext } from "@getpaseo/plugin/server";
|
|
2
|
-
import { execFile } from "node:child_process";
|
|
3
2
|
import { existsSync } from "node:fs";
|
|
4
|
-
import {
|
|
3
|
+
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
5
4
|
import { homedir, hostname } from "node:os";
|
|
6
5
|
import nodePath, { delimiter, dirname, join, relative, resolve } from "node:path";
|
|
7
|
-
import {
|
|
8
|
-
|
|
6
|
+
import {
|
|
7
|
+
findAttachment,
|
|
8
|
+
type FolderEntry,
|
|
9
|
+
imageMimeType,
|
|
10
|
+
isPieced,
|
|
11
|
+
opensOnHost,
|
|
12
|
+
type PiecedKind,
|
|
13
|
+
previewKind,
|
|
14
|
+
splitRef,
|
|
15
|
+
textFormat,
|
|
16
|
+
videoMimeType,
|
|
17
|
+
} from "../shared/attachments.ts";
|
|
9
18
|
import { prepareMarkdown, previewText } from "../shared/markdown.ts";
|
|
10
19
|
import { readProgress } from "./progress-file.ts";
|
|
11
|
-
import { findRoot,
|
|
12
|
-
import { findStore
|
|
20
|
+
import { findRoot, outside } from "./paths.ts";
|
|
21
|
+
import { findStore } from "./store.ts";
|
|
22
|
+
import { openToRead, runProgram } from "./system.ts";
|
|
13
23
|
|
|
14
24
|
export type Opener = (path: string) => Promise<void>;
|
|
15
25
|
|
|
@@ -28,7 +38,7 @@ export function openCommand(path: string, platform: NodeJS.Platform, browser: st
|
|
|
28
38
|
async function defaultBrowser(): Promise<string | null> {
|
|
29
39
|
try {
|
|
30
40
|
const plist = join(homedir(), "Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist");
|
|
31
|
-
const { stdout } = await
|
|
41
|
+
const { stdout } = await runProgram("plutil", ["-convert", "json", "-o", "-", plist]);
|
|
32
42
|
const handlers = (JSON.parse(stdout).LSHandlers ?? []) as Array<{ LSHandlerURLScheme?: string; LSHandlerRoleAll?: string }>;
|
|
33
43
|
return handlers.find((handler) => handler.LSHandlerURLScheme === "https")?.LSHandlerRoleAll ?? null;
|
|
34
44
|
} catch {
|
|
@@ -47,14 +57,14 @@ export function hostOpener(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): O
|
|
|
47
57
|
if (!canOpenFiles(platform, env)) throw new Error(`Can't open files on ${hostname()}`);
|
|
48
58
|
const browser = platform === "darwin" ? await defaultBrowser() : null;
|
|
49
59
|
const [command, args] = openCommand(path, platform, browser);
|
|
50
|
-
await
|
|
60
|
+
await runProgram(command, args);
|
|
51
61
|
};
|
|
52
62
|
}
|
|
53
63
|
|
|
54
64
|
// A deliverable or question attachment the agent recorded, found by reference,
|
|
55
65
|
// so the panel can never reach an arbitrary path. Only files inside the
|
|
56
66
|
// worktree resolve.
|
|
57
|
-
async function resolveAttachment(directory: string, ref: string, verb: string): Promise<{ path: string; realTarget: string;
|
|
67
|
+
async function resolveAttachment(directory: string, ref: string, verb: string): Promise<{ path: string; realTarget: string; worktree: string }> {
|
|
58
68
|
const { base, inner } = splitRef(ref);
|
|
59
69
|
const worktree = findRoot(directory);
|
|
60
70
|
const { dashboard } = await readProgress((await findStore(worktree)).directory);
|
|
@@ -67,11 +77,15 @@ async function resolveAttachment(directory: string, ref: string, verb: string):
|
|
|
67
77
|
if (!realTarget) throw new Error(`${path} no longer exists.`);
|
|
68
78
|
const inside = relative(realRoot, realTarget);
|
|
69
79
|
if (outside(inside)) throw new Error(`${path} is outside this worktree, so it was not ${verb}.`);
|
|
70
|
-
return { path, realTarget,
|
|
80
|
+
return { path, realTarget, worktree };
|
|
71
81
|
}
|
|
72
82
|
|
|
83
|
+
// The real file is checked, not the name the agent recorded, so a link named
|
|
84
|
+
// like a picture can't hand a script to the default app.
|
|
73
85
|
export async function openDeliverable(directory: string, id: string, opener: Opener): Promise<{ opened: string }> {
|
|
74
|
-
const { realTarget } = await resolveAttachment(directory, id, "opened");
|
|
86
|
+
const { path, realTarget } = await resolveAttachment(directory, id, "opened");
|
|
87
|
+
if (!(await stat(realTarget)).isFile() || !opensOnHost(realTarget))
|
|
88
|
+
throw new Error(`Progress opens only images, video, audio, PDFs and text files, not ${path}`);
|
|
75
89
|
await opener(realTarget);
|
|
76
90
|
return { opened: realTarget };
|
|
77
91
|
}
|
|
@@ -98,15 +112,15 @@ export type Preview =
|
|
|
98
112
|
| { kind: "pdf"; bytes: number }
|
|
99
113
|
| { kind: "text"; text: string; bytes: number; truncated: boolean }
|
|
100
114
|
| { kind: "markdown"; markdown: string; bytes: number; truncated: boolean }
|
|
101
|
-
| { kind: "html"; html: string; bytes: number }
|
|
102
115
|
| { kind: "folder"; entries: FolderEntry[]; truncated: boolean };
|
|
103
116
|
|
|
104
117
|
export async function previewDeliverable(directory: string, id: string): Promise<Preview> {
|
|
105
|
-
const { path, realTarget,
|
|
118
|
+
const { path, realTarget, worktree } = await resolveAttachment(directory, id, "previewed");
|
|
106
119
|
const stats = await stat(realTarget);
|
|
107
120
|
if (stats.isDirectory()) return { kind: "folder", ...(await listFolder(realTarget)) };
|
|
121
|
+
// A page opens in Paseo's own preview, in a tab.
|
|
108
122
|
const kind = previewKind(realTarget);
|
|
109
|
-
if (!kind) throw new Error(`${path} isn't an image, video, PDF
|
|
123
|
+
if (!kind || kind === "html") throw new Error(`${path} isn't an image, video, PDF or text file, so it can't be previewed.`);
|
|
110
124
|
const { size } = stats;
|
|
111
125
|
if (isPieced(kind)) {
|
|
112
126
|
if (size > PIECED_LIMIT_BYTES[kind]) throw new Error(tooLarge(path, size));
|
|
@@ -114,7 +128,7 @@ export async function previewDeliverable(directory: string, id: string): Promise
|
|
|
114
128
|
}
|
|
115
129
|
if (kind === "text") {
|
|
116
130
|
// Read only what's shown, so a huge log doesn't load whole.
|
|
117
|
-
const file = await
|
|
131
|
+
const file = await openToRead(realTarget);
|
|
118
132
|
try {
|
|
119
133
|
const { buffer, bytesRead } = await file.read({ buffer: Buffer.alloc(Math.min(size, MAX_PREVIEW_TEXT_BYTES)), position: 0 });
|
|
120
134
|
const bytes = buffer.subarray(0, bytesRead);
|
|
@@ -127,7 +141,6 @@ export async function previewDeliverable(directory: string, id: string): Promise
|
|
|
127
141
|
}
|
|
128
142
|
}
|
|
129
143
|
if (size > MAX_PREVIEW_BYTES) throw new Error(tooLarge(path, size));
|
|
130
|
-
if (kind === "html") return { kind, html: await inlineLocalFiles(await readFile(realTarget, "utf8"), realTarget, realRoot), bytes: size };
|
|
131
144
|
const data = await readFile(realTarget);
|
|
132
145
|
return { kind, dataUri: `data:${imageMimeType(realTarget)};base64,${data.toString("base64")}`, bytes: size };
|
|
133
146
|
}
|
|
@@ -143,79 +156,6 @@ async function listFolder(target: string): Promise<{ entries: FolderEntry[]; tru
|
|
|
143
156
|
return { entries: entries.slice(0, MAX_FOLDER_ENTRIES), truncated: entries.length > MAX_FOLDER_ENTRIES };
|
|
144
157
|
}
|
|
145
158
|
|
|
146
|
-
// The preview gets only the page, so the files beside it that it loads (its
|
|
147
|
-
// screenshots, a script, a stylesheet) are written into it. Only files inside the
|
|
148
|
-
// worktree, up to MAX_PREVIEW_BYTES in all; anything else is left as written and
|
|
149
|
-
// fails to load. Web addresses are left alone. A cloned repo's page runs here,
|
|
150
|
-
// so it must not read and send .env or .git files: it gets a file git says it
|
|
151
|
-
// keeps, or, when git ignores the page, like an agent's report in .scratch, a
|
|
152
|
-
// file in the page's own folder or below. Never one in a .git folder. When git
|
|
153
|
-
// can't answer, as for a file in a submodule or when git fails, the file stays out.
|
|
154
|
-
async function inlineLocalFiles(html: string, page: string, root: string): Promise<string> {
|
|
155
|
-
const directory = dirname(page);
|
|
156
|
-
const inRepo = hasGitEntry(root);
|
|
157
|
-
// -v -n prints a line for every path git answers for, so a git that fails
|
|
158
|
-
// with no answer never counts as "not ignored". The "./" stops git reading a
|
|
159
|
-
// name like ":(glob)x" as a pattern.
|
|
160
|
-
const ignoreCheck = async (path: string): Promise<"ignored" | "kept" | "unknown"> => {
|
|
161
|
-
if (!inRepo) return "kept";
|
|
162
|
-
const { code, stdout } = await git(root, ["check-ignore", "-v", "-n", "--", `./${relative(root, path).split(nodePath.sep).join("/")}`]);
|
|
163
|
-
if (!stdout.trim()) return "unknown";
|
|
164
|
-
return code === 0 ? "ignored" : code === 1 ? "kept" : "unknown";
|
|
165
|
-
};
|
|
166
|
-
const pageIgnored = (await ignoreCheck(page)) === "ignored";
|
|
167
|
-
const allowed = async (file: string) => {
|
|
168
|
-
if (inGitFolder(relative(root, file))) return false;
|
|
169
|
-
if ((await ignoreCheck(file)) === "kept") return true;
|
|
170
|
-
return pageIgnored && !outside(relative(directory, file));
|
|
171
|
-
};
|
|
172
|
-
let room = MAX_PREVIEW_BYTES - Buffer.byteLength(html);
|
|
173
|
-
const inline = async (url: string, encode: (data: Buffer, file: string) => string): Promise<string | null> => {
|
|
174
|
-
if (!url || /^([a-z][a-z0-9+.-]*:|[/\\#?])/i.test(url)) return null;
|
|
175
|
-
try {
|
|
176
|
-
const file = await realpath(resolve(directory, decodeURI(url.replace(/[?#].*$/, ""))));
|
|
177
|
-
const stats = await stat(file);
|
|
178
|
-
if (outside(relative(root, file)) || !stats.isFile() || stats.size > room) return null;
|
|
179
|
-
if (!(await allowed(file))) return null;
|
|
180
|
-
const encoded = encode(await readFile(file), file);
|
|
181
|
-
if (encoded.length > room) return null;
|
|
182
|
-
room -= encoded.length;
|
|
183
|
-
return encoded;
|
|
184
|
-
} catch {
|
|
185
|
-
return null;
|
|
186
|
-
}
|
|
187
|
-
};
|
|
188
|
-
const dataUri = (data: Buffer, file: string) => `data:${imageMimeType(file) ?? videoMimeType(file) ?? "application/octet-stream"};base64,${data.toString("base64")}`;
|
|
189
|
-
// Its own end tag would close the element early.
|
|
190
|
-
const body = (tag: string) => (data: Buffer) => data.toString("utf8").replace(new RegExp(`</${tag}`, "gi"), `<\\/${tag}`);
|
|
191
|
-
html = await replaceEach(html, /<(?:img|source|video)\b[^>]*>/gi, (tag) =>
|
|
192
|
-
replaceEach(tag[0], /(?<=\s)(src|poster)(\s*=\s*)(["'])([^"']*)\3/gi, async ([, name, equals, quote, url]) => {
|
|
193
|
-
const uri = await inline(url, dataUri);
|
|
194
|
-
return uri && `${name}${equals}${quote}${uri}${quote}`;
|
|
195
|
-
}));
|
|
196
|
-
html = await replaceEach(html, /<link\b[^>]*>/gi, async ([tag]) => {
|
|
197
|
-
const href = tag.match(/\bhref\s*=\s*(["'])([^"']*)\1/i)?.[2];
|
|
198
|
-
if (!href || !/\brel\s*=\s*(["'])\s*stylesheet\s*\1/i.test(tag)) return null;
|
|
199
|
-
const css = await inline(href, body("style"));
|
|
200
|
-
return css === null ? null : `<style>${css}</style>`;
|
|
201
|
-
});
|
|
202
|
-
return replaceEach(html, /<script\b([^>]*?)\s+src\s*=\s*(["'])([^"']*)\2([^>]*)>\s*<\/script\s*>/gi, async ([, before, , src, after]) => {
|
|
203
|
-
const script = await inline(src, body("script"));
|
|
204
|
-
return script === null ? null : `<script${before}${after}>${script}</script>`;
|
|
205
|
-
});
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// `replace` on each match in turn; a null keeps the match as it was.
|
|
209
|
-
async function replaceEach(text: string, pattern: RegExp, replace: (match: RegExpMatchArray) => Promise<string | null>): Promise<string> {
|
|
210
|
-
let result = "";
|
|
211
|
-
let last = 0;
|
|
212
|
-
for (const match of text.matchAll(pattern)) {
|
|
213
|
-
result += text.slice(last, match.index) + ((await replace(match)) ?? match[0]);
|
|
214
|
-
last = match.index! + match[0].length;
|
|
215
|
-
}
|
|
216
|
-
return result + text.slice(last);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
159
|
function tooLarge(path: string, size: number): string {
|
|
220
160
|
return `${path} is ${Math.round(size / 1024 / 1024)} MB, too large to preview.`;
|
|
221
161
|
}
|
|
@@ -224,7 +164,7 @@ export async function readDeliverablePiece(directory: string, id: string, offset
|
|
|
224
164
|
const { path, realTarget } = await resolveAttachment(directory, id, "previewed");
|
|
225
165
|
const kind = previewKind(realTarget);
|
|
226
166
|
if (!isPieced(kind)) throw new Error(`${path} isn't a video or PDF.`);
|
|
227
|
-
const file = await
|
|
167
|
+
const file = await openToRead(realTarget);
|
|
228
168
|
try {
|
|
229
169
|
const { size } = await file.stat();
|
|
230
170
|
if (size > PIECED_LIMIT_BYTES[kind]) throw new Error(tooLarge(path, size));
|
|
@@ -237,10 +177,7 @@ export async function readDeliverablePiece(directory: string, id: string, offset
|
|
|
237
177
|
}
|
|
238
178
|
}
|
|
239
179
|
|
|
240
|
-
export async function handlePreviewDeliverable(
|
|
241
|
-
input: { workspaceId: string; workspaceDirectory: string; ref: string },
|
|
242
|
-
_context: PluginHandlerContext,
|
|
243
|
-
) {
|
|
180
|
+
export async function handlePreviewDeliverable(input: { workspaceId: string; workspaceDirectory: string; ref: string }, _context: PluginHandlerContext) {
|
|
244
181
|
return previewDeliverable(input.workspaceDirectory, input.ref);
|
|
245
182
|
}
|
|
246
183
|
|
|
@@ -251,10 +188,7 @@ export async function handleReadDeliverablePiece(
|
|
|
251
188
|
return readDeliverablePiece(input.workspaceDirectory, input.ref, input.offset);
|
|
252
189
|
}
|
|
253
190
|
|
|
254
|
-
export async function handleOpenDeliverable(
|
|
255
|
-
input: { workspaceId: string; workspaceDirectory: string; ref: string },
|
|
256
|
-
_context: PluginHandlerContext,
|
|
257
|
-
) {
|
|
191
|
+
export async function handleOpenDeliverable(input: { workspaceId: string; workspaceDirectory: string; ref: string }, _context: PluginHandlerContext) {
|
|
258
192
|
return openDeliverable(input.workspaceDirectory, input.ref, hostOpener(process.platform, process.env));
|
|
259
193
|
}
|
|
260
194
|
|
package/server/progress-file.ts
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
// the command appends to and the panel-opened marker. Each function takes the
|
|
3
3
|
// folder that holds them, which `findStore` in store.ts picks. The command and
|
|
4
4
|
// the panel's RPC handlers go through here, so they agree on how they're read.
|
|
5
|
-
import { access,
|
|
5
|
+
import { access, readFile, stat } from "node:fs/promises";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { EMPTY_DASHBOARD, latestRun, parseProgress, reduceProgress, type Dashboard, type NextIds } from "../shared/dashboard.ts";
|
|
8
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
|
+
import { appendFileIn, makeFolder, writeFileIn } from "./system.ts";
|
|
10
11
|
|
|
11
12
|
const LOCK_NAME = `${PROGRESS_FILE_NAME}.lock`;
|
|
12
13
|
|
|
@@ -63,8 +64,8 @@ export async function appendProgress<T>(
|
|
|
63
64
|
// Runs before the lock is released, with the whole file as it now is.
|
|
64
65
|
after?: (text: string) => Promise<void>,
|
|
65
66
|
): Promise<T> {
|
|
66
|
-
await
|
|
67
|
-
return withLock(
|
|
67
|
+
await makeFolder(directory);
|
|
68
|
+
return withLock(directory, LOCK_NAME, async () => {
|
|
68
69
|
const text = (await readText(directory)) ?? "";
|
|
69
70
|
const parsed = parseProgress(text);
|
|
70
71
|
const { dashboard, nextIds } = reduceProgress(parsed, now());
|
|
@@ -76,15 +77,14 @@ export async function appendProgress<T>(
|
|
|
76
77
|
// A line cut short (a killed command, a hand edit) would swallow this event, so start a new line.
|
|
77
78
|
const lead = text && !text.endsWith("\n") ? "\n" : "";
|
|
78
79
|
const line = `${lead}${JSON.stringify(checked.data)}\n`;
|
|
79
|
-
await
|
|
80
|
+
await appendFileIn(directory, PROGRESS_FILE_NAME, line);
|
|
80
81
|
await after?.(text + line);
|
|
81
82
|
return result;
|
|
82
83
|
});
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
export async function markPanelOpened(directory: string): Promise<void> {
|
|
86
|
-
await
|
|
87
|
-
await writeFile(join(directory, PANEL_OPENED_NAME), "");
|
|
87
|
+
await writeFileIn(directory, PANEL_OPENED_NAME, "");
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
async function readText(directory: string): Promise<string | null> {
|
package/server/question-mode.ts
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
// The question mode lives in Paseo's settings, which only the plugin's server
|
|
2
2
|
// process can read. It mirrors the mode into a file the paseo-progress command
|
|
3
3
|
// can read on its own.
|
|
4
|
-
import { readFile
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import type { PluginSettings } from "@getpaseo/plugin/server";
|
|
9
9
|
import { DEFAULT_QUESTION_MODE, QUESTION_MODES, type QuestionMode } from "../shared/question-modes.ts";
|
|
10
10
|
import type { questionSettings } from "../shared/settings.ts";
|
|
11
|
-
import { stateRoot
|
|
11
|
+
import { stateRoot } from "./store.ts";
|
|
12
|
+
import { removeIn, writeFileIn } from "./system.ts";
|
|
12
13
|
|
|
13
14
|
const mirrorSchema = z.object({ questionMode: z.enum(QUESTION_MODES) });
|
|
14
15
|
|
|
16
|
+
const SETTINGS_NAME = "settings.json";
|
|
17
|
+
|
|
15
18
|
export function questionModePath(home = homedir()): string {
|
|
16
|
-
return join(stateRoot(home),
|
|
19
|
+
return join(stateRoot(home), SETTINGS_NAME);
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
// The default unless the mirror says otherwise, so a missing or damaged file
|
|
@@ -27,7 +30,7 @@ export async function readQuestionMode(home = homedir()): Promise<QuestionMode>
|
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
export async function writeQuestionMode(mode: QuestionMode, home = homedir()): Promise<void> {
|
|
30
|
-
await
|
|
33
|
+
await writeFileIn(stateRoot(home), SETTINGS_NAME, `${JSON.stringify({ questionMode: mode })}\n`, { atomic: true });
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
type Settings = Pick<PluginSettings<(typeof questionSettings)["schema"]>, "read" | "subscribe">;
|
|
@@ -48,14 +51,17 @@ export function mirrorQuestionMode(settings: Settings, home = homedir()): { stop
|
|
|
48
51
|
return write(state);
|
|
49
52
|
});
|
|
50
53
|
// A change heard while the first read is in flight is newer than that read.
|
|
51
|
-
const settled = settings
|
|
54
|
+
const settled = settings
|
|
55
|
+
.read()
|
|
56
|
+
.catch(() => null)
|
|
57
|
+
.then((state) => (changed ? writes : write(state)));
|
|
52
58
|
return {
|
|
53
59
|
settled,
|
|
54
60
|
async stop() {
|
|
55
61
|
await unsubscribe();
|
|
56
62
|
await settled;
|
|
57
63
|
await writes;
|
|
58
|
-
await
|
|
64
|
+
await removeIn(stateRoot(home), SETTINGS_NAME);
|
|
59
65
|
},
|
|
60
66
|
};
|
|
61
67
|
}
|
package/server/skill.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { lstat, mkdir, readFile, readlink, symlink, unlink } from "node:fs/promises";
|
|
1
|
+
import { lstat, readFile, readlink } from "node:fs/promises";
|
|
3
2
|
import { homedir } from "node:os";
|
|
4
3
|
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
-
import { promisify } from "node:util";
|
|
6
4
|
import { paseoCommands, PLUGIN_ID } from "./launcher.ts";
|
|
5
|
+
import { linkFolderIn, removeLinkIn, runCommandLine, runProgram } from "./system.ts";
|
|
7
6
|
|
|
8
7
|
export const SKILL_NAME = "paseo-progress";
|
|
9
8
|
|
|
@@ -37,8 +36,8 @@ export async function skillState(path: string, pluginDirectory: string): Promise
|
|
|
37
36
|
export async function relinkSkill(paths: string[], pluginDirectory: string): Promise<boolean> {
|
|
38
37
|
let wrote = false;
|
|
39
38
|
for (const path of paths) {
|
|
40
|
-
if (await skillState(path, pluginDirectory) !== "outdated") continue;
|
|
41
|
-
await
|
|
39
|
+
if ((await skillState(path, pluginDirectory)) !== "outdated") continue;
|
|
40
|
+
await removeLinkIn(dirname(path), basename(path));
|
|
42
41
|
await link(path, pluginDirectory);
|
|
43
42
|
wrote = true;
|
|
44
43
|
}
|
|
@@ -60,19 +59,20 @@ export async function installSkill(paths: string[], pluginDirectory: string): Pr
|
|
|
60
59
|
}
|
|
61
60
|
|
|
62
61
|
async function link(path: string, pluginDirectory: string) {
|
|
63
|
-
await
|
|
64
|
-
// A junction on Windows, where a directory symlink needs admin rights or
|
|
65
|
-
// Developer Mode. Other systems ignore the type and make a symlink.
|
|
66
|
-
await symlink(skillSource(pluginDirectory), path, "junction");
|
|
62
|
+
await linkFolderIn(dirname(path), basename(path), skillSource(pluginDirectory));
|
|
67
63
|
}
|
|
68
64
|
|
|
69
65
|
// "current" once every folder that can take the link has it.
|
|
70
66
|
export async function skillStatus(paths: string[], pluginDirectory: string) {
|
|
71
67
|
const targets = await Promise.all(paths.map(async (path) => ({ path, state: await skillState(path, pluginDirectory) })));
|
|
72
68
|
const states = targets.map((target) => target.state);
|
|
73
|
-
const state: SkillState = states.includes("outdated")
|
|
74
|
-
|
|
75
|
-
: states.includes("
|
|
69
|
+
const state: SkillState = states.includes("outdated")
|
|
70
|
+
? "outdated"
|
|
71
|
+
: states.includes("missing")
|
|
72
|
+
? "missing"
|
|
73
|
+
: states.includes("current")
|
|
74
|
+
? "current"
|
|
75
|
+
: "foreign";
|
|
76
76
|
return { state, targets };
|
|
77
77
|
}
|
|
78
78
|
|
|
@@ -96,8 +96,8 @@ export async function findPluginDirectory(): Promise<string> {
|
|
|
96
96
|
function askPaseo(cli: string) {
|
|
97
97
|
const options = { timeout: 10_000 };
|
|
98
98
|
return process.platform === "win32"
|
|
99
|
-
?
|
|
100
|
-
:
|
|
99
|
+
? runCommandLine(`"${cli}" plugin ls ${PLUGIN_ID} --json`, options)
|
|
100
|
+
: runProgram(cli, ["plugin", "ls", PLUGIN_ID, "--json"], options);
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
// Runs once per load, when the server starts: an update reloads the bundle.
|
|
@@ -106,7 +106,9 @@ function askPaseo(cli: string) {
|
|
|
106
106
|
let repair: Promise<unknown> | null = null;
|
|
107
107
|
|
|
108
108
|
export function repairSkill(): Promise<unknown> {
|
|
109
|
-
repair ??= findPluginDirectory()
|
|
109
|
+
repair ??= findPluginDirectory()
|
|
110
|
+
.then((directory) => relinkSkill(defaultSkillPaths(), directory))
|
|
111
|
+
.catch(() => {});
|
|
110
112
|
return repair;
|
|
111
113
|
}
|
|
112
114
|
|
|
@@ -118,7 +120,11 @@ export async function handleSkillStatus() {
|
|
|
118
120
|
// SKILL.md's frontmatter holds one-line fields; only the description is shown.
|
|
119
121
|
export function skillDocument(text: string): { markdown: string; description: string } {
|
|
120
122
|
const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
|
|
121
|
-
const description =
|
|
123
|
+
const description =
|
|
124
|
+
/^description:[ \t]*(.*)$/m
|
|
125
|
+
.exec(frontmatter?.[1] ?? "")?.[1]
|
|
126
|
+
.trim()
|
|
127
|
+
.replace(/^(["'])(.*)\1$/, "$2") ?? "";
|
|
122
128
|
return { markdown: text.slice(frontmatter?.[0].length ?? 0).trimStart(), description };
|
|
123
129
|
}
|
|
124
130
|
|
package/server/store.ts
CHANGED
|
@@ -2,27 +2,24 @@
|
|
|
2
2
|
// in to saving. The working files never touch the repo: they live outside it,
|
|
3
3
|
// one folder per worktree, so git never sees them and nothing needs hiding.
|
|
4
4
|
// Only runs the user asks to save are written into the repo, one file per run.
|
|
5
|
-
import {
|
|
6
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
7
6
|
import { realpathSync } from "node:fs";
|
|
8
|
-
import {
|
|
7
|
+
import { readdir, realpath, stat } from "node:fs/promises";
|
|
9
8
|
import { homedir } from "node:os";
|
|
10
|
-
import { basename,
|
|
11
|
-
import { promisify } from "node:util";
|
|
9
|
+
import { basename, join, normalize, resolve } from "node:path";
|
|
12
10
|
import { latestRun, parseProgress } from "../shared/dashboard.ts";
|
|
13
11
|
import { hasGitEntry, inGitFolder, outside } from "./paths.ts";
|
|
12
|
+
import { makeFoldersIn, runProgram, writeFileIn } from "./system.ts";
|
|
14
13
|
|
|
15
14
|
const SAVE_RUNS_KEY = "paseo-progress.saveRuns";
|
|
16
15
|
|
|
17
16
|
// `directory` holds the working files. `shown` names it for people.
|
|
18
17
|
export type Store = { directory: string; shown: string };
|
|
19
18
|
|
|
20
|
-
// Git runs in the worktree through -C,
|
|
21
|
-
// lives in: Windows looks for a program in the folder it starts in before
|
|
22
|
-
// PATH, and a cloned repo can commit a git.exe.
|
|
19
|
+
// Git runs in the worktree through -C, never from it: a cloned repo can commit a git.exe.
|
|
23
20
|
export async function git(root: string, args: string[]): Promise<{ code: number; stdout: string }> {
|
|
24
21
|
try {
|
|
25
|
-
const { stdout } = await
|
|
22
|
+
const { stdout } = await runProgram("git", ["-C", resolve(root), ...args]);
|
|
26
23
|
return { code: 0, stdout };
|
|
27
24
|
} catch (error) {
|
|
28
25
|
const failed = error as { code?: number | string; stdout?: string };
|
|
@@ -35,18 +32,6 @@ export function stateRoot(home = homedir()): string {
|
|
|
35
32
|
return join(home, ".local", "state", "paseo-progress");
|
|
36
33
|
}
|
|
37
34
|
|
|
38
|
-
// Readers see the old file or the new one, never half of one.
|
|
39
|
-
export async function writeAtomic(path: string, text: string): Promise<void> {
|
|
40
|
-
await mkdir(dirname(path), { recursive: true });
|
|
41
|
-
const temp = `${path}.${randomUUID()}.tmp`;
|
|
42
|
-
try {
|
|
43
|
-
await writeFile(temp, text);
|
|
44
|
-
await rename(temp, path);
|
|
45
|
-
} finally {
|
|
46
|
-
await rm(temp, { force: true });
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
35
|
// One folder per worktree on this computer, named so a person can tell which
|
|
51
36
|
// worktree it belongs to.
|
|
52
37
|
export function outsideDirectory(root: string, home = homedir()): string {
|
|
@@ -131,8 +116,16 @@ export async function setHistoryFolder(root: string, raw: string | null): Promis
|
|
|
131
116
|
// title as it was at the start, so the name never changes and two runs never
|
|
132
117
|
// share one.
|
|
133
118
|
export function runFileName(startedAt: string, title: string): string {
|
|
134
|
-
const stamp = startedAt
|
|
135
|
-
|
|
119
|
+
const stamp = startedAt
|
|
120
|
+
.replace(/\.\d+Z$/, "")
|
|
121
|
+
.replace(/[-:]/g, "")
|
|
122
|
+
.replace("T", "-");
|
|
123
|
+
const slug = title
|
|
124
|
+
.toLowerCase()
|
|
125
|
+
.normalize("NFKD")
|
|
126
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
127
|
+
.replace(/^-|-$/g, "")
|
|
128
|
+
.slice(0, 60);
|
|
136
129
|
return `${stamp}${slug ? `-${slug}` : ""}.jsonl`;
|
|
137
130
|
}
|
|
138
131
|
|
|
@@ -144,25 +137,17 @@ export async function saveRunCopy(root: string, text: string): Promise<void> {
|
|
|
144
137
|
// The run the dashboard shows. A start line it can't read is no run.
|
|
145
138
|
const [start] = latestRun(parseProgress(text).events);
|
|
146
139
|
if (start?.event.type !== "run.start") return;
|
|
147
|
-
const lines = text
|
|
148
|
-
|
|
140
|
+
const lines = text
|
|
141
|
+
.split("\n")
|
|
142
|
+
.slice(start.line - 1)
|
|
143
|
+
.filter((line) => line.trim());
|
|
144
|
+
// The saved-runs folder in the real worktree, made one step at a time. None
|
|
145
|
+
// when any step is a link or a file: a cloned repo can commit a link that
|
|
146
|
+
// leads out of it.
|
|
147
|
+
const directory = await makeFoldersIn(await realpath(root), folder);
|
|
149
148
|
if (!directory) return;
|
|
150
149
|
// A rename replaces a link at the run's file instead of writing through it.
|
|
151
|
-
await
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// The saved-runs folder in the real worktree, made one step at a time, or null
|
|
155
|
-
// when any step is a link or a file: a cloned repo can commit a link that leads
|
|
156
|
-
// out of it.
|
|
157
|
-
async function runsFolder(root: string, folder: string): Promise<string | null> {
|
|
158
|
-
let directory = await realpath(root);
|
|
159
|
-
for (const name of folder.split(sep)) {
|
|
160
|
-
directory = join(directory, name);
|
|
161
|
-
const info = await lstat(directory).catch(() => null);
|
|
162
|
-
if (!info) await mkdir(directory);
|
|
163
|
-
else if (!info.isDirectory()) return null;
|
|
164
|
-
}
|
|
165
|
-
return directory;
|
|
150
|
+
await writeFileIn(directory, runFileName(start.event.ts, start.event.title), `${lines.join("\n")}\n`, { atomic: true });
|
|
166
151
|
}
|
|
167
152
|
|
|
168
153
|
// The folders directly inside one folder of the worktree ("" is the top), for
|
|
@@ -175,5 +160,5 @@ export async function listFolders(root: string, folder: string): Promise<{ folde
|
|
|
175
160
|
throw error;
|
|
176
161
|
});
|
|
177
162
|
const folders = entries.filter((entry) => entry.isDirectory() && !inGitFolder(entry.name)).map((entry) => entry.name);
|
|
178
|
-
return { folder: checked.folder, folders: folders.
|
|
163
|
+
return { folder: checked.folder, folders: folders.toSorted((a, b) => a.localeCompare(b)) };
|
|
179
164
|
}
|
package/server/system.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// The only module in Progress that starts programs or changes files. The lint
|
|
2
|
+
// config allows node:child_process and the fs functions that change files here
|
|
3
|
+
// and in tests, nowhere else, so each new call gets these defaults.
|
|
4
|
+
//
|
|
5
|
+
// A program starts in LAUNCH_FOLDER unless the caller names a folder it has a
|
|
6
|
+
// reason for. Windows looks for a program in the folder it starts in before
|
|
7
|
+
// PATH, and a relative PATH entry resolves against that folder, so a bare name
|
|
8
|
+
// started in a worktree could run a program the repo committed.
|
|
9
|
+
//
|
|
10
|
+
// A file change names a folder the caller trusts (the plugin's state folder, a
|
|
11
|
+
// folder in the user's home, a worktree's real path) and a relative name
|
|
12
|
+
// inside it. The name can't climb out with `..`, and no folder between the two
|
|
13
|
+
// may be a link, which a cloned repo could commit to lead the change elsewhere.
|
|
14
|
+
import { exec, execFile } from "node:child_process";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { appendFile, chmod, type FileHandle, lstat, mkdir, open, rename, rm, symlink, unlink, writeFile } from "node:fs/promises";
|
|
17
|
+
import { dirname, isAbsolute, join, sep } from "node:path";
|
|
18
|
+
import { promisify } from "node:util";
|
|
19
|
+
|
|
20
|
+
export const LAUNCH_FOLDER = dirname(process.execPath);
|
|
21
|
+
|
|
22
|
+
export type RunOptions = { cwd?: string; timeout?: number };
|
|
23
|
+
|
|
24
|
+
export function runProgram(program: string, args: readonly string[], { cwd = LAUNCH_FOLDER, ...options }: RunOptions = {}) {
|
|
25
|
+
return promisify(execFile)(program, args, { ...options, cwd, encoding: "utf8" });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Through cmd.exe, for Windows .cmd programs, which Node can start only that way.
|
|
29
|
+
export function runCommandLine(line: string, { cwd = LAUNCH_FOLDER, ...options }: RunOptions = {}) {
|
|
30
|
+
return promisify(exec)(line, { ...options, cwd, encoding: "utf8" });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function openToRead(path: string): Promise<FileHandle> {
|
|
34
|
+
return open(path, "r");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function stepsOf(folder: string, name: string): string[] {
|
|
38
|
+
if (isAbsolute(name) || name.split(/[\\/]/).includes("..")) throw new Error(`${name} is not a name inside ${folder}.`);
|
|
39
|
+
return name.split(sep);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function placeIn(folder: string, name: string): Promise<string> {
|
|
43
|
+
const steps = stepsOf(folder, name);
|
|
44
|
+
let step = folder;
|
|
45
|
+
for (const part of steps.slice(0, -1)) {
|
|
46
|
+
step = join(step, part);
|
|
47
|
+
if ((await lstat(step).catch(() => null))?.isSymbolicLink()) throw new Error(`${step} is a link, so nothing was written through it.`);
|
|
48
|
+
}
|
|
49
|
+
return join(folder, name);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// `atomic` writes beside the file, then renames over it: readers see the old
|
|
53
|
+
// file or the new one, never half of one, and a link at the file is replaced
|
|
54
|
+
// instead of written through.
|
|
55
|
+
export async function writeFileIn(folder: string, name: string, text: string, { atomic = false, executable = false } = {}): Promise<void> {
|
|
56
|
+
const path = await placeIn(folder, name);
|
|
57
|
+
await mkdir(dirname(path), { recursive: true });
|
|
58
|
+
if (!atomic) {
|
|
59
|
+
await writeFile(path, text);
|
|
60
|
+
} else {
|
|
61
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
62
|
+
try {
|
|
63
|
+
await writeFile(temporary, text);
|
|
64
|
+
await rename(temporary, path);
|
|
65
|
+
} finally {
|
|
66
|
+
await rm(temporary, { force: true });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (executable) await chmod(path, 0o755);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function appendFileIn(folder: string, name: string, text: string): Promise<void> {
|
|
73
|
+
await appendFile(await placeIn(folder, name), text);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The trusted folder itself, with any missing parents.
|
|
77
|
+
export async function makeFolder(folder: string): Promise<void> {
|
|
78
|
+
await mkdir(folder, { recursive: true });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Fails with EEXIST when the folder is already there, so only one caller makes it.
|
|
82
|
+
export async function createFolderIn(folder: string, name: string): Promise<void> {
|
|
83
|
+
await mkdir(await placeIn(folder, name));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Makes each missing folder of `name` one at a time. Null when any of them is
|
|
87
|
+
// a link or a file.
|
|
88
|
+
export async function makeFoldersIn(folder: string, name: string): Promise<string | null> {
|
|
89
|
+
let directory = folder;
|
|
90
|
+
for (const part of stepsOf(folder, name)) {
|
|
91
|
+
directory = join(directory, part);
|
|
92
|
+
const info = await lstat(directory).catch(() => null);
|
|
93
|
+
if (!info) await mkdir(directory);
|
|
94
|
+
else if (!info.isDirectory()) return null;
|
|
95
|
+
}
|
|
96
|
+
return directory;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function removeIn(folder: string, name: string, { recursive = false } = {}): Promise<void> {
|
|
100
|
+
await rm(await placeIn(folder, name), { recursive, force: true });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// A junction on Windows, where a folder symlink needs admin rights or
|
|
104
|
+
// Developer Mode. Other systems ignore the type and make a symlink.
|
|
105
|
+
export async function linkFolderIn(folder: string, name: string, target: string): Promise<void> {
|
|
106
|
+
const path = await placeIn(folder, name);
|
|
107
|
+
await mkdir(dirname(path), { recursive: true });
|
|
108
|
+
await symlink(target, path, "junction");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function removeLinkIn(folder: string, name: string): Promise<void> {
|
|
112
|
+
await unlink(await placeIn(folder, name));
|
|
113
|
+
}
|