@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/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 { open, readFile, realpath, stat } from "node:fs/promises";
4
- import { homedir } from "node:os";
5
- import { join, relative, resolve } from "node:path";
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 const systemOpener: Opener = async (path) => {
38
- const browser = process.platform === "darwin" ? await defaultBrowser() : null;
39
- const [command, args] = openCommand(path, process.platform, browser);
40
- await promisify(execFile)(command, args);
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 path = findAttachment(dashboard, ref)?.path;
50
- if (!path) throw new Error(`No local ${ref.startsWith("Q") ? "attachment" : "deliverable"} ${ref} in this worktree.`);
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: "text"; text: string; bytes: number; truncated: boolean };
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 } = await stat(target);
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(`${path} is ${Math.round(size / 1024 / 1024)} MB, too large to preview.`);
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, systemOpener);
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
  }
@@ -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
- export function previewKind(path: string): "image" | "text" | null {
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
- // Whether the preview dialog can show it. Only a browser draws SVG: Paseo's
77
- // desktop app can, the phone apps' image view can't, so there an SVG opens on
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
- // An image or text file's contents, for the preview dialog.
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