@stevecastaneda/paseo-progress 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/OVERVIEW.md +1 -1
- package/README.md +18 -4
- package/client/attachments.tsx +7 -8
- package/client/deliverables.tsx +5 -6
- package/client/dialog-stack.tsx +24 -29
- package/client/errors.ts +6 -0
- package/client/history.tsx +3 -2
- package/client/launcher.tsx +3 -2
- package/client/motion.tsx +3 -9
- package/client/panel.tsx +2 -1
- package/client/pill-popover.tsx +7 -6
- package/client/preview.tsx +164 -96
- package/client/video.tsx +90 -0
- package/client/write-up.tsx +2 -1
- package/dist/server/cli.js +1 -1
- package/index.server.ts +4 -2
- package/package.json +1 -1
- package/server/cli.ts +1 -1
- package/server/open.ts +89 -18
- package/shared/attachments.ts +44 -5
- package/shared/rpc.ts +26 -1
- package/skills/paseo-progress/SKILL.md +2 -2
package/server/open.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { PluginHandlerContext } from "@getpaseo/plugin/server";
|
|
2
2
|
import { execFile } from "node:child_process";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { open, readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
5
|
+
import { homedir, hostname } from "node:os";
|
|
6
|
+
import { delimiter, join, relative, resolve } from "node:path";
|
|
6
7
|
import { promisify } from "node:util";
|
|
7
|
-
import { findAttachment, imageMimeType, previewKind } from "../shared/attachments.ts";
|
|
8
|
+
import { findAttachment, type FolderEntry, imageMimeType, previewKind, splitRef, videoMimeType } from "../shared/attachments.ts";
|
|
8
9
|
import { readProgress } from "./progress-file.ts";
|
|
9
10
|
import { findRoot, outside } from "./paths.ts";
|
|
10
11
|
import { findStore } from "./store.ts";
|
|
@@ -34,20 +35,32 @@ async function defaultBrowser(): Promise<string | null> {
|
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
export
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
38
|
+
export function canOpenFiles(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): boolean {
|
|
39
|
+
if (platform === "darwin" || platform === "win32") return true;
|
|
40
|
+
if (!env.DISPLAY && !env.WAYLAND_DISPLAY) return false;
|
|
41
|
+
return (env.PATH ?? "").split(delimiter).some((directory) => directory && existsSync(join(directory, "xdg-open")));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function hostOpener(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): Opener {
|
|
45
|
+
return async (path) => {
|
|
46
|
+
if (!canOpenFiles(platform, env)) throw new Error(`Can't open files on ${hostname()}`);
|
|
47
|
+
const browser = platform === "darwin" ? await defaultBrowser() : null;
|
|
48
|
+
const [command, args] = openCommand(path, platform, browser);
|
|
49
|
+
await promisify(execFile)(command, args);
|
|
50
|
+
};
|
|
51
|
+
}
|
|
42
52
|
|
|
43
53
|
// A deliverable or question attachment the agent recorded, found by reference,
|
|
44
54
|
// so the panel can never reach an arbitrary path. Only files inside the
|
|
45
55
|
// worktree resolve.
|
|
46
56
|
async function resolveAttachment(directory: string, ref: string, verb: string): Promise<{ path: string; target: string }> {
|
|
57
|
+
const { base, inner } = splitRef(ref);
|
|
47
58
|
const worktree = findRoot(directory);
|
|
48
59
|
const { dashboard } = await readProgress((await findStore(worktree)).directory);
|
|
49
|
-
const
|
|
50
|
-
if (!
|
|
60
|
+
const folder = findAttachment(dashboard, base)?.path;
|
|
61
|
+
if (!folder) throw new Error(`No local ${base.startsWith("Q") ? "attachment" : "deliverable"} ${base} in this worktree.`);
|
|
62
|
+
const path = inner ? join(folder, inner) : folder;
|
|
63
|
+
if (outside(relative(resolve(worktree, folder), resolve(worktree, path)))) throw new Error(`${path} is outside ${folder}, so it was not ${verb}.`);
|
|
51
64
|
const root = await realpath(worktree);
|
|
52
65
|
const target = await realpath(resolve(root, path)).catch(() => null);
|
|
53
66
|
if (!target) throw new Error(`${path} no longer exists.`);
|
|
@@ -68,17 +81,31 @@ export const MAX_PREVIEW_BYTES = 10 * 1024 * 1024;
|
|
|
68
81
|
// Text past this is cut off in the preview; the full file is a click away.
|
|
69
82
|
export const MAX_PREVIEW_TEXT_BYTES = 256 * 1024;
|
|
70
83
|
|
|
84
|
+
// A video travels in pieces, so it's capped by what the app can hold, not the socket.
|
|
85
|
+
export const MAX_PREVIEW_VIDEO_BYTES = 200 * 1024 * 1024;
|
|
86
|
+
|
|
87
|
+
// Small enough that one piece doesn't hold up the agent output sharing the socket.
|
|
88
|
+
export const VIDEO_PIECE_BYTES = 4 * 1024 * 1024;
|
|
89
|
+
|
|
90
|
+
export const MAX_FOLDER_ENTRIES = 500;
|
|
91
|
+
|
|
71
92
|
export type Preview =
|
|
72
93
|
| { kind: "image"; dataUri: string; bytes: number }
|
|
73
|
-
| { kind: "
|
|
94
|
+
| { kind: "video"; mimeType: string; bytes: number }
|
|
95
|
+
| { kind: "text"; text: string; bytes: number; truncated: boolean }
|
|
96
|
+
| { kind: "folder"; entries: FolderEntry[]; truncated: boolean };
|
|
74
97
|
|
|
75
|
-
// An image (as a data URI) or a text file's contents, so any client (desktop
|
|
76
|
-
// or phone) can show it without reaching the daemon host's disk.
|
|
77
98
|
export async function previewDeliverable(directory: string, id: string): Promise<Preview> {
|
|
78
99
|
const { path, target } = await resolveAttachment(directory, id, "previewed");
|
|
100
|
+
const stats = await stat(target);
|
|
101
|
+
if (stats.isDirectory()) return { kind: "folder", ...(await listFolder(target)) };
|
|
79
102
|
const kind = previewKind(target);
|
|
80
|
-
if (!kind) throw new Error(`${path} isn't an image or text file, so it can't be previewed.`);
|
|
81
|
-
const { size } =
|
|
103
|
+
if (!kind) throw new Error(`${path} isn't an image, video or text file, so it can't be previewed.`);
|
|
104
|
+
const { size } = stats;
|
|
105
|
+
if (kind === "video") {
|
|
106
|
+
if (size > MAX_PREVIEW_VIDEO_BYTES) throw new Error(tooLarge(path, size));
|
|
107
|
+
return { kind, mimeType: videoMimeType(target)!, bytes: size };
|
|
108
|
+
}
|
|
82
109
|
if (kind === "text") {
|
|
83
110
|
// Read only what's shown, so a huge log doesn't load whole.
|
|
84
111
|
const file = await open(target);
|
|
@@ -89,11 +116,44 @@ export async function previewDeliverable(directory: string, id: string): Promise
|
|
|
89
116
|
await file.close();
|
|
90
117
|
}
|
|
91
118
|
}
|
|
92
|
-
if (size > MAX_PREVIEW_BYTES) throw new Error(
|
|
119
|
+
if (size > MAX_PREVIEW_BYTES) throw new Error(tooLarge(path, size));
|
|
93
120
|
const data = await readFile(target);
|
|
94
121
|
return { kind, dataUri: `data:${imageMimeType(target)};base64,${data.toString("base64")}`, bytes: size };
|
|
95
122
|
}
|
|
96
123
|
|
|
124
|
+
async function listFolder(target: string): Promise<{ entries: FolderEntry[]; truncated: boolean }> {
|
|
125
|
+
const entries: FolderEntry[] = [];
|
|
126
|
+
for (const entry of await readdir(target, { withFileTypes: true })) {
|
|
127
|
+
if (entry.name.startsWith(".")) continue;
|
|
128
|
+
const linkedFolder = entry.isSymbolicLink() && (await stat(join(target, entry.name)).catch(() => null))?.isDirectory();
|
|
129
|
+
entries.push({ name: entry.name, folder: entry.isDirectory() || Boolean(linkedFolder) });
|
|
130
|
+
}
|
|
131
|
+
entries.sort((a, b) => Number(b.folder) - Number(a.folder) || a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
|
|
132
|
+
return { entries: entries.slice(0, MAX_FOLDER_ENTRIES), truncated: entries.length > MAX_FOLDER_ENTRIES };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function tooLarge(path: string, size: number): string {
|
|
136
|
+
return `${path} is ${Math.round(size / 1024 / 1024)} MB, too large to preview.`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// One piece of a video, from `offset`. Resolved by reference each time, like the
|
|
140
|
+
// preview, so a piece can only come from a video the agent recorded.
|
|
141
|
+
export async function readDeliverablePiece(directory: string, id: string, offset: number): Promise<{ data: string; bytes: number }> {
|
|
142
|
+
const { path, target } = await resolveAttachment(directory, id, "previewed");
|
|
143
|
+
if (previewKind(target) !== "video") throw new Error(`${path} isn't a video.`);
|
|
144
|
+
const file = await open(target);
|
|
145
|
+
try {
|
|
146
|
+
const { size } = await file.stat();
|
|
147
|
+
if (size > MAX_PREVIEW_VIDEO_BYTES) throw new Error(tooLarge(path, size));
|
|
148
|
+
const length = Math.max(0, Math.min(VIDEO_PIECE_BYTES, size - offset));
|
|
149
|
+
if (!length) return { data: "", bytes: size };
|
|
150
|
+
const { buffer, bytesRead } = await file.read({ buffer: Buffer.alloc(length), position: offset });
|
|
151
|
+
return { data: buffer.subarray(0, bytesRead).toString("base64"), bytes: size };
|
|
152
|
+
} finally {
|
|
153
|
+
await file.close();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
97
157
|
export async function handlePreviewDeliverable(
|
|
98
158
|
input: { workspaceId: string; workspaceDirectory: string; ref: string },
|
|
99
159
|
_context: PluginHandlerContext,
|
|
@@ -101,9 +161,20 @@ export async function handlePreviewDeliverable(
|
|
|
101
161
|
return previewDeliverable(input.workspaceDirectory, input.ref);
|
|
102
162
|
}
|
|
103
163
|
|
|
164
|
+
export async function handleReadDeliverablePiece(
|
|
165
|
+
input: { workspaceId: string; workspaceDirectory: string; ref: string; offset: number },
|
|
166
|
+
_context: PluginHandlerContext,
|
|
167
|
+
) {
|
|
168
|
+
return readDeliverablePiece(input.workspaceDirectory, input.ref, input.offset);
|
|
169
|
+
}
|
|
170
|
+
|
|
104
171
|
export async function handleOpenDeliverable(
|
|
105
172
|
input: { workspaceId: string; workspaceDirectory: string; ref: string },
|
|
106
173
|
_context: PluginHandlerContext,
|
|
107
174
|
) {
|
|
108
|
-
return openDeliverable(input.workspaceDirectory, input.ref,
|
|
175
|
+
return openDeliverable(input.workspaceDirectory, input.ref, hostOpener(process.platform, process.env));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function handleCanOpenOnHost(_input: { workspaceId: string; workspaceDirectory: string }, _context: PluginHandlerContext) {
|
|
179
|
+
return { canOpen: canOpenFiles(process.platform, process.env) };
|
|
109
180
|
}
|
package/shared/attachments.ts
CHANGED
|
@@ -55,6 +55,15 @@ const IMAGE_TYPES: Record<string, string> = {
|
|
|
55
55
|
".svg": "image/svg+xml",
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
+
// What Paseo's desktop app (Chromium) plays. A .mov plays when it holds H.264;
|
|
59
|
+
// one that doesn't shows the player's error, and Open still works.
|
|
60
|
+
const VIDEO_TYPES: Record<string, string> = {
|
|
61
|
+
".mp4": "video/mp4",
|
|
62
|
+
".m4v": "video/mp4",
|
|
63
|
+
".mov": "video/quicktime",
|
|
64
|
+
".webm": "video/webm",
|
|
65
|
+
};
|
|
66
|
+
|
|
58
67
|
// Plain text shown as-is in the preview dialog, and opened in the browser on
|
|
59
68
|
// the host. HTML is left to the browser either way.
|
|
60
69
|
const TEXT_EXTENSIONS = new Set([".md", ".markdown", ".mdx", ".txt", ".log", ".json", ".jsonl", ".yaml", ".yml", ".csv"]);
|
|
@@ -68,18 +77,48 @@ export function imageMimeType(path: string): string | null {
|
|
|
68
77
|
return IMAGE_TYPES[extension(path) ?? ""] ?? null;
|
|
69
78
|
}
|
|
70
79
|
|
|
71
|
-
|
|
80
|
+
// The video type of a path by its extension, or null when it isn't a video.
|
|
81
|
+
export function videoMimeType(path: string): string | null {
|
|
82
|
+
return VIDEO_TYPES[extension(path) ?? ""] ?? null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function previewKind(path: string): "image" | "video" | "text" | null {
|
|
72
86
|
if (imageMimeType(path)) return "image";
|
|
87
|
+
if (videoMimeType(path)) return "video";
|
|
73
88
|
return TEXT_EXTENSIONS.has(extension(path) ?? "") ? "text" : null;
|
|
74
89
|
}
|
|
75
90
|
|
|
76
|
-
|
|
77
|
-
|
|
91
|
+
export function isFolder(attachment: Attachment): boolean {
|
|
92
|
+
return attachment.kind === "folder" || /[\\/]$/.test(attachment.path ?? "");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface FolderEntry {
|
|
96
|
+
name: string;
|
|
97
|
+
folder: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function entryRef(folderRef: string, path: string): string {
|
|
101
|
+
return `${folderRef}/${path}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function splitRef(ref: string): { base: string; inner: string } {
|
|
105
|
+
const [base, ...rest] = ref.split("/");
|
|
106
|
+
return { base, inner: rest.join("/") };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function folderAttachments(folder: Attachment, entries: FolderEntry[]): Attachment[] {
|
|
110
|
+
const base = folder.path === "./" ? "" : (folder.path ?? "").replace(/[\\/]*$/, "/");
|
|
111
|
+
return entries.map((entry) => ({ ref: entryRef(folder.ref, entry.name), title: entry.name, path: `${base}${entry.name}${entry.folder ? "/" : ""}` }));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Whether the preview dialog can show it. Only a browser draws SVG and plays
|
|
115
|
+
// video: Paseo's desktop app can, the phone apps can't, so there they open on
|
|
78
116
|
// the host like any other file.
|
|
79
117
|
export function canPreview(attachment: Attachment, where: { browser: boolean }): boolean {
|
|
80
118
|
if (!attachment.path) return false;
|
|
119
|
+
if (isFolder(attachment)) return true;
|
|
81
120
|
const kind = previewKind(attachment.path);
|
|
82
|
-
if (kind === "image" && extension(attachment.path) === ".svg") return where.browser;
|
|
121
|
+
if (kind === "video" || (kind === "image" && extension(attachment.path) === ".svg")) return where.browser;
|
|
83
122
|
return kind !== null;
|
|
84
123
|
}
|
|
85
124
|
|
|
@@ -98,7 +137,7 @@ export function attachmentIcon(attachment: Attachment): string {
|
|
|
98
137
|
if (attachment.url) return "Globe";
|
|
99
138
|
if (!attachment.path) return "File";
|
|
100
139
|
const kind = previewKind(attachment.path);
|
|
101
|
-
return kind === "image" ? "Image" : kind === "text" ? "FileText" : /[\/]$/.test(attachment.path) ? "Folder" : "File";
|
|
140
|
+
return kind === "image" ? "Image" : kind === "video" ? "Video" : kind === "text" ? "FileText" : /[\/]$/.test(attachment.path) ? "Folder" : "File";
|
|
102
141
|
}
|
|
103
142
|
|
|
104
143
|
// The short name people recognize: a file or folder's own name, or a link's site.
|
package/shared/rpc.ts
CHANGED
|
@@ -124,7 +124,17 @@ export const openDeliverable = defineRpc({
|
|
|
124
124
|
output: z.object({ opened: z.string() }),
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
export const canOpenOnHost = defineRpc({
|
|
128
|
+
name: "progress.host.can-open",
|
|
129
|
+
input: z.object({
|
|
130
|
+
workspaceId: z.string().min(1),
|
|
131
|
+
workspaceDirectory: z.string().min(1),
|
|
132
|
+
}),
|
|
133
|
+
output: z.object({ canOpen: z.boolean() }),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// An image or text file's contents, for the preview dialog. A video comes back
|
|
137
|
+
// as its type and size only; its bytes follow in pieces (readDeliverablePiece).
|
|
128
138
|
export const previewDeliverable = defineRpc({
|
|
129
139
|
name: "progress.deliverable.preview",
|
|
130
140
|
input: z.object({
|
|
@@ -135,6 +145,21 @@ export const previewDeliverable = defineRpc({
|
|
|
135
145
|
}),
|
|
136
146
|
output: z.discriminatedUnion("kind", [
|
|
137
147
|
z.object({ kind: z.literal("image"), dataUri: z.string(), bytes: z.number() }),
|
|
148
|
+
z.object({ kind: z.literal("video"), mimeType: z.string(), bytes: z.number() }),
|
|
138
149
|
z.object({ kind: z.literal("text"), text: z.string(), bytes: z.number(), truncated: z.boolean() }),
|
|
150
|
+
z.object({ kind: z.literal("folder"), entries: z.array(z.object({ name: z.string(), folder: z.boolean() })), truncated: z.boolean() }),
|
|
139
151
|
]),
|
|
140
152
|
});
|
|
153
|
+
|
|
154
|
+
// One piece of a video from `offset`, base64, with the file's size so the client
|
|
155
|
+
// knows when it has it all.
|
|
156
|
+
export const readDeliverablePiece = defineRpc({
|
|
157
|
+
name: "progress.deliverable.piece",
|
|
158
|
+
input: z.object({
|
|
159
|
+
workspaceId: z.string().min(1),
|
|
160
|
+
workspaceDirectory: z.string().min(1),
|
|
161
|
+
ref: z.string().min(1),
|
|
162
|
+
offset: z.number().int().min(0),
|
|
163
|
+
}),
|
|
164
|
+
output: z.object({ data: z.string(), bytes: z.number() }),
|
|
165
|
+
});
|
|
@@ -27,7 +27,7 @@ Record each change as it happens, on the ticket's id from `show` (T01, T02, ...)
|
|
|
27
27
|
| --- | --- |
|
|
28
28
|
| You start a ticket | `ticket update T03 --status working --stage Build` |
|
|
29
29
|
| You move to the next stage | `ticket update T03 --stage Review`, then `Fixes`, then `Verify` |
|
|
30
|
-
| Something reviewable lands (a screenshot folder, a report, a preview URL) | `deliverable add "<what it shows>" <path or URL> --ticket T03` |
|
|
30
|
+
| Something reviewable lands (a screenshot folder, a screen recording, a report, a preview URL) | `deliverable add "<what it shows>" <path or URL> --ticket T03` |
|
|
31
31
|
| A milestone the user would want in the story | `activity add "<one line>" --ticket T03` |
|
|
32
32
|
| Between bigger events | `ticker set "<what you are doing right now>"` |
|
|
33
33
|
| The ticket is finished | `ticket update T03 --status done`, then start the next one |
|
|
@@ -66,7 +66,7 @@ paseo-progress question ask "<short title>" "<the question, in one or two senten
|
|
|
66
66
|
- Build no option before the user answers, not even the one you recommend. The point of asking is that the user decides before the work is done.
|
|
67
67
|
- Keep working on whatever the question doesn't affect: other tickets, or other parts of this one. If nothing else is left, stop, tell the user in chat which questions you're waiting on, and leave the run open.
|
|
68
68
|
- When the user replies ("Q7 A", or in their own words), run `question answer Q7 A --words "<their words>"`, then build what they chose.
|
|
69
|
-
- When a question is about how something looks, attach the screenshot, mockup or preview link with `--file`. The user opens it from the question: images preview in Paseo, links open in its browser. Add more later with `question update Q7 --file <path>=<what it shows>`.
|
|
69
|
+
- When a question is about how something looks, attach the screenshot, mockup or preview link with `--file`. The user opens it from the question: images and videos preview in Paseo, links open in its browser. Add more later with `question update Q7 --file <path>=<what it shows>`.
|
|
70
70
|
- When the user asks about a question, answer in chat and also put the explanation on the question with `question update Q7 --background "<text>"`, so the dashboard carries it too.
|
|
71
71
|
- `question remove Q7` withdraws a question that no longer applies.
|
|
72
72
|
|