@stevecastaneda/paseo-progress 1.3.0 → 1.4.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 +34 -5
- package/client/launcher.tsx +2 -2
- package/client/panel.tsx +30 -10
- package/client/questions.tsx +20 -4
- package/client/settings.tsx +40 -0
- package/client/surfaces.ts +20 -8
- package/client/ticket-dialog.tsx +2 -1
- package/client/whats-new.tsx +120 -0
- package/client/write-up.tsx +3 -2
- package/dist/server/cli.js +140 -37
- package/images/1-panel.png +0 -0
- package/images/3-question.png +0 -0
- package/images/5-settings.png +0 -0
- package/index.client.tsx +12 -1
- package/index.server.ts +11 -2
- package/package.json +2 -1
- package/paseo-plugin.json +2 -1
- package/server/cli.ts +28 -11
- package/server/launcher.ts +81 -21
- package/server/question-mode.ts +61 -0
- package/server/skill.ts +15 -6
- package/server/store.ts +21 -4
- package/server/whats-new.ts +127 -0
- package/shared/dashboard.ts +9 -2
- package/shared/events.ts +9 -4
- package/shared/question-modes.ts +45 -0
- package/shared/rpc.ts +18 -0
- package/shared/settings.ts +10 -0
- package/shared/show.ts +3 -1
- package/shared/whats-new.ts +16 -0
- package/shared/write-up.ts +9 -8
- package/skills/paseo-progress/SKILL.md +26 -68
package/server/launcher.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import {
|
|
3
|
+
import { basename, dirname, join, posix, win32 } from "node:path";
|
|
4
4
|
|
|
5
5
|
export const PLUGIN_ID = "progress";
|
|
6
6
|
|
|
7
|
+
// Where Paseo's command may be, in the order tried: an override, the one
|
|
8
|
+
// Paseo names for the agents and plugins it starts, PATH, then the app's
|
|
9
|
+
// default install. The launcher script below carries the same list.
|
|
10
|
+
export function paseoCommands(env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): string[] {
|
|
11
|
+
const installed = platform === "win32"
|
|
12
|
+
? env.LOCALAPPDATA && win32.join(env.LOCALAPPDATA, "Programs", "Paseo", "resources", "bin", "paseo.cmd")
|
|
13
|
+
: "/Applications/Paseo.app/Contents/Resources/bin/paseo";
|
|
14
|
+
return [env.PASEO_BIN, env.PASEO_CLI, "paseo", installed].filter((cli): cli is string => Boolean(cli));
|
|
15
|
+
}
|
|
16
|
+
|
|
7
17
|
// The launcher never names a plugin folder. Each run asks Paseo which copy of
|
|
8
18
|
// the plugin it is running, so updates and `npm run dev` switches keep working.
|
|
9
19
|
export function launcherScript(): string {
|
|
@@ -20,11 +30,28 @@ const { spawnSync } = process.getBuiltinModule("node:child_process");
|
|
|
20
30
|
const { existsSync } = process.getBuiltinModule("node:fs");
|
|
21
31
|
const { join } = process.getBuiltinModule("node:path");
|
|
22
32
|
|
|
33
|
+
const windows = process.platform === "win32";
|
|
34
|
+
|
|
35
|
+
function paseoCommands() {
|
|
36
|
+
const env = process.env;
|
|
37
|
+
const installed = windows
|
|
38
|
+
? env.LOCALAPPDATA && join(env.LOCALAPPDATA, "Programs", "Paseo", "resources", "bin", "paseo.cmd")
|
|
39
|
+
: "/Applications/Paseo.app/Contents/Resources/bin/paseo";
|
|
40
|
+
return [env.PASEO_BIN, env.PASEO_CLI, "paseo", installed].filter(Boolean);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// On Windows Paseo's command is paseo.cmd, which Node can start, or find on
|
|
44
|
+
// PATH, only through cmd.exe.
|
|
45
|
+
function askPaseo(cli) {
|
|
46
|
+
return windows
|
|
47
|
+
? spawnSync('"' + cli + '" plugin ls ${PLUGIN_ID} --json', { shell: true, encoding: "utf8" })
|
|
48
|
+
: spawnSync(cli, ["plugin", "ls", "${PLUGIN_ID}", "--json"], { encoding: "utf8" });
|
|
49
|
+
}
|
|
50
|
+
|
|
23
51
|
function pluginDirectory() {
|
|
24
52
|
if (process.env.PASEO_PROGRESS_PLUGIN_DIR) return process.env.PASEO_PROGRESS_PLUGIN_DIR;
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
const result = spawnSync(cli, ["plugin", "ls", "${PLUGIN_ID}", "--json"], { encoding: "utf8" });
|
|
53
|
+
for (const cli of paseoCommands()) {
|
|
54
|
+
const result = askPaseo(cli);
|
|
28
55
|
if (result.error || result.status !== 0) continue;
|
|
29
56
|
try {
|
|
30
57
|
const plugin = JSON.parse(result.stdout).find((entry) => entry.id === "${PLUGIN_ID}");
|
|
@@ -57,31 +84,64 @@ export function defaultLauncherPath(home = homedir()): string {
|
|
|
57
84
|
|
|
58
85
|
export type LauncherState = "missing" | "current" | "outdated" | "foreign";
|
|
59
86
|
|
|
60
|
-
|
|
61
|
-
|
|
87
|
+
type LauncherFile = { path: string; content: string };
|
|
88
|
+
|
|
89
|
+
// PowerShell and cmd run only files with an extension like .cmd, so on
|
|
90
|
+
// Windows a .cmd file beside the launcher runs it with node.
|
|
91
|
+
function launcherFiles(path: string, platform: NodeJS.Platform): LauncherFile[] {
|
|
92
|
+
const files = [{ path, content: launcherScript() }];
|
|
93
|
+
if (platform === "win32") {
|
|
94
|
+
files.push({ path: `${path}.cmd`, content: `@echo off\r\nrem ${MARKER}. Lets PowerShell and cmd run ${basename(path)}.\r\nnode "%~dp0${basename(path)}" %*\r\nexit /b %errorlevel%\r\n` });
|
|
95
|
+
}
|
|
96
|
+
return files;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function fileState(file: LauncherFile): Promise<LauncherState> {
|
|
100
|
+
const current = await readFile(file.path, "utf8").catch(() => null);
|
|
62
101
|
if (current === null) return "missing";
|
|
63
|
-
if (current ===
|
|
102
|
+
if (current === file.content) return "current";
|
|
64
103
|
return current.includes(MARKER) ? "outdated" : "foreign";
|
|
65
104
|
}
|
|
66
105
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
106
|
+
async function fileStates(path: string, platform: NodeJS.Platform) {
|
|
107
|
+
const files = launcherFiles(path, platform);
|
|
108
|
+
const states = await Promise.all(files.map(fileState));
|
|
109
|
+
return files.map((file, index) => ({ ...file, state: states[index] }));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Writes the launcher files that are missing or out of date. Never replaces a
|
|
113
|
+
// file something else put there. Returns whether it wrote.
|
|
114
|
+
export async function installLauncher(path: string, platform: NodeJS.Platform = process.platform): Promise<boolean> {
|
|
115
|
+
const files = await fileStates(path, platform);
|
|
116
|
+
const foreign = files.find((file) => file.state === "foreign");
|
|
117
|
+
if (foreign) throw new Error(`${foreign.path} already exists and was not written by this plugin. Move it, or pass another path.`);
|
|
118
|
+
let wrote = false;
|
|
119
|
+
for (const file of files) {
|
|
120
|
+
if (file.state === "current") continue;
|
|
121
|
+
await mkdir(dirname(file.path), { recursive: true });
|
|
122
|
+
await writeFile(file.path, file.content);
|
|
123
|
+
await chmod(file.path, 0o755);
|
|
124
|
+
wrote = true;
|
|
125
|
+
}
|
|
126
|
+
return wrote;
|
|
77
127
|
}
|
|
78
128
|
|
|
79
129
|
// For the panel: whether agents on the daemon host can run `paseo-progress`.
|
|
80
|
-
|
|
130
|
+
// When something else is in the way, path names that file.
|
|
131
|
+
export async function launcherStatus(path = defaultLauncherPath(), pathEnv = process.env.PATH ?? "", platform: NodeJS.Platform = process.platform) {
|
|
132
|
+
const files = await fileStates(path, platform);
|
|
133
|
+
const states = files.map((file) => file.state);
|
|
134
|
+
const foreign = files.find((file) => file.state === "foreign");
|
|
135
|
+
const state: LauncherState = foreign ? "foreign"
|
|
136
|
+
: states.every((each) => each === "current") ? "current"
|
|
137
|
+
: states.every((each) => each === "missing") ? "missing" : "outdated";
|
|
138
|
+
// Windows paths ignore case and may end in either slash.
|
|
139
|
+
const paths = platform === "win32" ? win32 : posix;
|
|
140
|
+
const folder = (dir: string) => platform === "win32" ? dir.replace(/[\\/]+$/, "").toLowerCase() : dir.replace(/\/+$/, "");
|
|
81
141
|
return {
|
|
82
|
-
path,
|
|
83
|
-
state
|
|
84
|
-
onPath: pathEnv.split(delimiter).some((entry) => entry
|
|
142
|
+
path: foreign?.path ?? path,
|
|
143
|
+
state,
|
|
144
|
+
onPath: pathEnv.split(paths.delimiter).some((entry) => folder(entry) === folder(paths.dirname(path))),
|
|
85
145
|
};
|
|
86
146
|
}
|
|
87
147
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The question mode lives in Paseo's settings, which only the plugin's server
|
|
2
|
+
// process can read. It mirrors the mode into a file the paseo-progress command
|
|
3
|
+
// can read on its own.
|
|
4
|
+
import { readFile, rm } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import type { PluginSettings } from "@getpaseo/plugin/server";
|
|
9
|
+
import { DEFAULT_QUESTION_MODE, QUESTION_MODES, type QuestionMode } from "../shared/question-modes.ts";
|
|
10
|
+
import type { questionSettings } from "../shared/settings.ts";
|
|
11
|
+
import { stateRoot, writeAtomic } from "./store.ts";
|
|
12
|
+
|
|
13
|
+
const mirrorSchema = z.object({ questionMode: z.enum(QUESTION_MODES) });
|
|
14
|
+
|
|
15
|
+
export function questionModePath(home = homedir()): string {
|
|
16
|
+
return join(stateRoot(home), "settings.json");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// The default unless the mirror says otherwise, so a missing or damaged file
|
|
20
|
+
// never lets an agent build ahead of the user.
|
|
21
|
+
export async function readQuestionMode(home = homedir()): Promise<QuestionMode> {
|
|
22
|
+
try {
|
|
23
|
+
return mirrorSchema.parse(JSON.parse(await readFile(questionModePath(home), "utf8"))).questionMode;
|
|
24
|
+
} catch {
|
|
25
|
+
return DEFAULT_QUESTION_MODE;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function writeQuestionMode(mode: QuestionMode, home = homedir()): Promise<void> {
|
|
30
|
+
await writeAtomic(questionModePath(home), `${JSON.stringify({ questionMode: mode })}\n`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type Settings = Pick<PluginSettings<(typeof questionSettings)["schema"]>, "read" | "subscribe">;
|
|
34
|
+
type State = Awaited<ReturnType<Settings["read"]>>;
|
|
35
|
+
|
|
36
|
+
// Writes the mode now and on every change. Stopping deletes the mirror, so
|
|
37
|
+
// agents fall back to the default while the plugin is off.
|
|
38
|
+
export function mirrorQuestionMode(settings: Settings, home = homedir()): { stop(): Promise<void>; settled: Promise<void> } {
|
|
39
|
+
let writes = Promise.resolve();
|
|
40
|
+
let changed = false;
|
|
41
|
+
const write = (state: State | null) => {
|
|
42
|
+
const mode = state?.status === "ready" ? state.values.mode : DEFAULT_QUESTION_MODE;
|
|
43
|
+
writes = writes.then(() => writeQuestionMode(mode, home)).catch(() => {});
|
|
44
|
+
return writes;
|
|
45
|
+
};
|
|
46
|
+
const unsubscribe = settings.subscribe((state) => {
|
|
47
|
+
changed = true;
|
|
48
|
+
return write(state);
|
|
49
|
+
});
|
|
50
|
+
// A change heard while the first read is in flight is newer than that read.
|
|
51
|
+
const settled = settings.read().catch(() => null).then((state) => (changed ? writes : write(state)));
|
|
52
|
+
return {
|
|
53
|
+
settled,
|
|
54
|
+
async stop() {
|
|
55
|
+
await unsubscribe();
|
|
56
|
+
await settled;
|
|
57
|
+
await writes;
|
|
58
|
+
await rm(questionModePath(home), { force: true });
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
package/server/skill.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
1
|
+
import { exec, execFile } from "node:child_process";
|
|
2
2
|
import { lstat, mkdir, readlink, symlink, unlink } from "node:fs/promises";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { basename, dirname, join, resolve } from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
|
-
import { PLUGIN_ID } from "./launcher.ts";
|
|
6
|
+
import { paseoCommands, PLUGIN_ID } from "./launcher.ts";
|
|
7
7
|
|
|
8
8
|
export const SKILL_NAME = "paseo-progress";
|
|
9
9
|
|
|
@@ -41,7 +41,9 @@ export async function installSkill(paths: string[], pluginDirectory: string): Pr
|
|
|
41
41
|
if (states[index] !== "missing" && states[index] !== "outdated") continue;
|
|
42
42
|
if (states[index] === "outdated") await unlink(path);
|
|
43
43
|
await mkdir(dirname(path), { recursive: true });
|
|
44
|
-
|
|
44
|
+
// A junction on Windows, where a directory symlink needs admin rights or
|
|
45
|
+
// Developer Mode. Other systems ignore the type and make a symlink.
|
|
46
|
+
await symlink(skillSource(pluginDirectory), path, "junction");
|
|
45
47
|
wrote = true;
|
|
46
48
|
}
|
|
47
49
|
return wrote;
|
|
@@ -60,10 +62,9 @@ export async function skillStatus(paths: string[], pluginDirectory: string) {
|
|
|
60
62
|
// The server bundle doesn't know its own folder, so ask Paseo, the same way
|
|
61
63
|
// the launcher does.
|
|
62
64
|
export async function findPluginDirectory(): Promise<string> {
|
|
63
|
-
|
|
64
|
-
for (const cli of clis) {
|
|
65
|
+
for (const cli of paseoCommands()) {
|
|
65
66
|
try {
|
|
66
|
-
const { stdout } = await
|
|
67
|
+
const { stdout } = await askPaseo(cli);
|
|
67
68
|
const plugin = (JSON.parse(stdout) as Array<{ id: string; path?: string }>).find((entry) => entry.id === PLUGIN_ID);
|
|
68
69
|
if (plugin?.path) return plugin.path;
|
|
69
70
|
} catch {
|
|
@@ -73,6 +74,14 @@ export async function findPluginDirectory(): Promise<string> {
|
|
|
73
74
|
throw new Error("Could not ask Paseo where this plugin is installed.");
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
// On Windows Paseo's command is paseo.cmd, which Node can start, or find on
|
|
78
|
+
// PATH, only through cmd.exe.
|
|
79
|
+
function askPaseo(cli: string) {
|
|
80
|
+
return process.platform === "win32"
|
|
81
|
+
? promisify(exec)(`"${cli}" plugin ls ${PLUGIN_ID} --json`)
|
|
82
|
+
: promisify(execFile)(cli, ["plugin", "ls", PLUGIN_ID, "--json"]);
|
|
83
|
+
}
|
|
84
|
+
|
|
76
85
|
export async function handleSkillStatus() {
|
|
77
86
|
return skillStatus(defaultSkillPaths(), await findPluginDirectory());
|
|
78
87
|
}
|
package/server/store.ts
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
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
5
|
import { execFile } from "node:child_process";
|
|
6
|
-
import { createHash } from "node:crypto";
|
|
6
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
7
7
|
import { realpathSync } from "node:fs";
|
|
8
|
-
import { appendFile, mkdir, readdir, readFile, rm, rmdir, stat, writeFile } from "node:fs/promises";
|
|
8
|
+
import { appendFile, mkdir, readdir, readFile, rename, rm, rmdir, stat, writeFile } from "node:fs/promises";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
|
-
import { basename, join, normalize, resolve } from "node:path";
|
|
10
|
+
import { basename, dirname, join, normalize, resolve } from "node:path";
|
|
11
11
|
import { promisify } from "node:util";
|
|
12
12
|
import { LEGACY_FOLDER, PANEL_OPENED_NAME, PROGRESS_FILE_NAME } from "../shared/events.ts";
|
|
13
13
|
import { withLock } from "./lock.ts";
|
|
@@ -40,6 +40,23 @@ async function exists(path: string): Promise<boolean> {
|
|
|
40
40
|
return Boolean(await stat(path).catch(() => null));
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// The plugin's per-user folder on this computer.
|
|
44
|
+
export function stateRoot(home = homedir()): string {
|
|
45
|
+
return join(home, ".local", "state", "paseo-progress");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Readers see the old file or the new one, never half of one.
|
|
49
|
+
export async function writeAtomic(path: string, text: string): Promise<void> {
|
|
50
|
+
await mkdir(dirname(path), { recursive: true });
|
|
51
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
52
|
+
try {
|
|
53
|
+
await writeFile(temp, text);
|
|
54
|
+
await rename(temp, path);
|
|
55
|
+
} finally {
|
|
56
|
+
await rm(temp, { force: true });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
43
60
|
// One folder per worktree on this computer, named so a person can tell which
|
|
44
61
|
// worktree it belongs to.
|
|
45
62
|
export function outsideDirectory(root: string, home = homedir()): string {
|
|
@@ -48,7 +65,7 @@ export function outsideDirectory(root: string, home = homedir()): string {
|
|
|
48
65
|
real = realpathSync(root);
|
|
49
66
|
} catch {}
|
|
50
67
|
const hash = createHash("sha1").update(real).digest("hex").slice(0, 8);
|
|
51
|
-
return join(home,
|
|
68
|
+
return join(stateRoot(home), `${basename(real)}-${hash}`);
|
|
52
69
|
}
|
|
53
70
|
|
|
54
71
|
// Whether git tracks a 0.1.x progress file, per worktree, until the file changes.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// After an update, the panel shows what changed in this version, once. The
|
|
2
|
+
// notes come from the changelog in the plugin's own README, so they're
|
|
3
|
+
// written in one place. Whether they've been seen is kept per user on this
|
|
4
|
+
// computer.
|
|
5
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { NOTE_KINDS, type NoteKind, type WhatsNew, type WhatsNewNote } from "../shared/whats-new.ts";
|
|
10
|
+
import { spans } from "../shared/write-up.ts";
|
|
11
|
+
import { findPluginDirectory } from "./skill.ts";
|
|
12
|
+
import { stateRoot, writeAtomic } from "./store.ts";
|
|
13
|
+
|
|
14
|
+
export type Release = { version: string; readme: string | null };
|
|
15
|
+
|
|
16
|
+
const seenSchema = z.object({ seen: z.string() });
|
|
17
|
+
const packageSchema = z.object({ version: z.string() });
|
|
18
|
+
|
|
19
|
+
export function whatsNewPath(home = homedir()): string {
|
|
20
|
+
return join(stateRoot(home), "whats-new.json");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The top-level bullets under `### <version>` in the README's `## Changelog`,
|
|
24
|
+
// or null when that version has none.
|
|
25
|
+
export function changelogNotes(readme: string, version: string): WhatsNewNote[] | null {
|
|
26
|
+
let section: "other" | "changelog" | "version" = "other";
|
|
27
|
+
const notes: string[] = [];
|
|
28
|
+
for (const line of readme.replace(/\r\n?/g, "\n").split("\n")) {
|
|
29
|
+
if (section === "version" && /^#{2,3}\s/.test(line)) break;
|
|
30
|
+
if (/^##\s/.test(line)) section = line.trim() === "## Changelog" ? "changelog" : "other";
|
|
31
|
+
else if (section === "changelog" && line.trim() === `### ${version}`) section = "version";
|
|
32
|
+
else if (section === "version" && /^[-*]\s/.test(line)) notes.push(line.slice(2).trim());
|
|
33
|
+
else if (section === "version" && notes.length && /^\s+\S/.test(line) && !/^\s+([-*+]|\d+[.)])\s/.test(line)) {
|
|
34
|
+
notes[notes.length - 1] += ` ${line.trim()}`;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return notes.length ? notes.map(note) : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A bullet can open with a kind tag, like `[Fixed]`, and then a bold
|
|
41
|
+
// sentence, like `**Title.** Detail.`, which is its title.
|
|
42
|
+
function note(bullet: string): WhatsNewNote {
|
|
43
|
+
const tag = /^\[([a-z]+)\]\s*(.*)$/i.exec(bullet);
|
|
44
|
+
const kind = tag ? noteKind(tag[1]) : undefined;
|
|
45
|
+
const rest = kind ? tag![2] : bullet;
|
|
46
|
+
const text = rest.split(/(`[^`]+`)/).map((part, index) => index % 2 ? part : curly(part)).join("");
|
|
47
|
+
const lead = /^\*\*([^*]+?[.!?])\*\*\s*(.*)$/.exec(text);
|
|
48
|
+
return { ...(kind && { kind }), ...(lead ? { title: plain(lead[1]), body: lead[2] } : { body: text }) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function noteKind(tag: string): NoteKind | undefined {
|
|
52
|
+
const key = tag.toLowerCase();
|
|
53
|
+
return Object.hasOwn(NOTE_KINDS, key) ? key as NoteKind : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The README is typed with straight quotes; the panel shows typeset ones. Code keeps its own.
|
|
57
|
+
function curly(text: string): string {
|
|
58
|
+
return text.replace(/(^|[\s([{])"/g, "$1\u201c").replace(/"/g, "\u201d").replace(/(^|[\s([{])'/g, "$1\u2018").replace(/'/g, "\u2019");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function plain(markdown: string): string {
|
|
62
|
+
return spans(markdown).map((span) => span.text).join("");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A note's title, or else the first sentence of its body, without Markdown.
|
|
66
|
+
export function headline(note: WhatsNewNote): string {
|
|
67
|
+
if (note.title) return note.title;
|
|
68
|
+
const body = plain(note.body);
|
|
69
|
+
return /^.*?[.!?](?=\s|$)/.exec(body)?.[0] ?? body;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function readSeen(home: string): Promise<string | null> {
|
|
73
|
+
try {
|
|
74
|
+
return seenSchema.parse(JSON.parse(await readFile(whatsNewPath(home), "utf8"))).seen;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function markWhatsNewSeen(version: string, home = homedir()): Promise<void> {
|
|
81
|
+
await writeAtomic(whatsNewPath(home), `${JSON.stringify({ seen: version })}\n`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A worktree's folder (see outsideDirectory) means the plugin was in use
|
|
85
|
+
// before this version, so this is an update, not a new install.
|
|
86
|
+
async function usedBefore(home: string): Promise<boolean> {
|
|
87
|
+
const entries = await readdir(stateRoot(home), { withFileTypes: true }).catch(() => []);
|
|
88
|
+
return entries.some((entry) => entry.isDirectory() && /-[0-9a-f]{8}$/.test(entry.name));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// What to show, or null. A new install has nothing to catch up on, so it
|
|
92
|
+
// records this version as seen.
|
|
93
|
+
export async function whatsNew({ version, readme }: Release, home = homedir()): Promise<WhatsNew | null> {
|
|
94
|
+
const notes = readme === null ? null : changelogNotes(readme, version);
|
|
95
|
+
if (!notes) return null;
|
|
96
|
+
const seen = await readSeen(home);
|
|
97
|
+
if (seen === version) return null;
|
|
98
|
+
if (seen === null && !(await usedBefore(home))) {
|
|
99
|
+
await markWhatsNewSeen(version, home);
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
return { version, headline: headline(notes[0]), notes };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The server bundle can't read files beside it, so ask Paseo where the plugin
|
|
106
|
+
// is. Read once per load: an update reloads the bundle, which starts over.
|
|
107
|
+
let release: Promise<Release> | null = null;
|
|
108
|
+
|
|
109
|
+
function currentRelease(): Promise<Release> {
|
|
110
|
+
release ??= findPluginDirectory().then(async (directory) => ({
|
|
111
|
+
version: packageSchema.parse(JSON.parse(await readFile(join(directory, "package.json"), "utf8"))).version,
|
|
112
|
+
readme: await readFile(join(directory, "README.md"), "utf8").catch(() => null),
|
|
113
|
+
}));
|
|
114
|
+
release.catch(() => {
|
|
115
|
+
release = null;
|
|
116
|
+
});
|
|
117
|
+
return release;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function handleGetWhatsNew(): Promise<WhatsNew | null> {
|
|
121
|
+
return whatsNew(await currentRelease());
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function handleDismissWhatsNew(input: { version: string }) {
|
|
125
|
+
await markWhatsNewSeen(input.version);
|
|
126
|
+
return {};
|
|
127
|
+
}
|
package/shared/dashboard.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { EVENT_VERSION, eventSchema, type DELIVERABLE_KINDS, type FileLink, type ProgressEvent, type QuestionOption, type TicketStatus } from "./events.ts";
|
|
2
|
+
import { QUESTION_MODE_RULES, type QuestionState } from "./question-modes.ts";
|
|
2
3
|
|
|
3
4
|
// One recorded change to a ticket, oldest first. `stage: ""` clears the stage.
|
|
4
5
|
export interface TicketChange {
|
|
@@ -43,14 +44,18 @@ export interface Question {
|
|
|
43
44
|
title: string;
|
|
44
45
|
question: string;
|
|
45
46
|
options: QuestionOption[];
|
|
46
|
-
// The option the agent leans toward.
|
|
47
|
+
// The option the agent leans toward.
|
|
47
48
|
recommended: string;
|
|
49
|
+
// What the agent does while the question is open.
|
|
50
|
+
state: QuestionState;
|
|
51
|
+
// Why the question holds even in Keep building mode.
|
|
52
|
+
big?: string;
|
|
48
53
|
background?: string;
|
|
49
54
|
files: FileLink[];
|
|
50
55
|
raisedBy?: string;
|
|
51
56
|
ticketId?: string;
|
|
52
57
|
askedAt: string;
|
|
53
|
-
// `changedCourse
|
|
58
|
+
// `changedCourse`: the agent had built another option and changed course.
|
|
54
59
|
answer?: { choice: string; words?: string; changedCourse?: boolean; at: string };
|
|
55
60
|
}
|
|
56
61
|
|
|
@@ -279,6 +284,8 @@ export function reduceProgress(parsed: { events: ParsedLine[]; issues: FileIssue
|
|
|
279
284
|
question: event.question,
|
|
280
285
|
options: event.options ?? [],
|
|
281
286
|
recommended: event.recommend ?? event.default ?? "",
|
|
287
|
+
state: event.mode ? QUESTION_MODE_RULES[event.mode].state(Boolean(event.big)) : event.default !== undefined && !event.waits ? "building" : "waiting",
|
|
288
|
+
big: event.big,
|
|
282
289
|
background: event.background,
|
|
283
290
|
files: event.files ?? [],
|
|
284
291
|
raisedBy: event.raisedBy,
|
package/shared/events.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { QUESTION_MODES } from "./question-modes.ts";
|
|
2
3
|
|
|
3
4
|
// One line of `progress.jsonl`. The command-line tool writes these; the
|
|
4
5
|
// dashboard reducer reads them. `v` lets later versions change a shape without
|
|
@@ -77,9 +78,11 @@ export const eventSchema = z.discriminatedUnion("type", [
|
|
|
77
78
|
// A blocker that is not a ticket status, like a failing external service.
|
|
78
79
|
z.object({ ...base, type: z.literal("stuck.set"), id: text, reason: text, ticket: text.optional() }),
|
|
79
80
|
z.object({ ...base, type: z.literal("stuck.clear"), id: text }),
|
|
80
|
-
// The agent asks
|
|
81
|
-
//
|
|
82
|
-
//
|
|
81
|
+
// The agent asks and recommends an option. `mode` is the question mode when
|
|
82
|
+
// asked; without it (0.3.0 to 1.3.x) the agent built nothing before the
|
|
83
|
+
// answer, like "work-around". `big` says why the question holds even in
|
|
84
|
+
// "keep-building" mode. Questions from before 0.3.0 carry `default`, the
|
|
85
|
+
// option the agent carried on with unless it `waits`.
|
|
83
86
|
z.object({
|
|
84
87
|
...base,
|
|
85
88
|
type: z.literal("question.ask"),
|
|
@@ -89,6 +92,8 @@ export const eventSchema = z.discriminatedUnion("type", [
|
|
|
89
92
|
options: z.array(questionOptionSchema).max(26).optional(),
|
|
90
93
|
// The agent's pick: an option letter, or a word such as "Looks right" when there are no options.
|
|
91
94
|
recommend: text.optional(),
|
|
95
|
+
mode: z.enum(QUESTION_MODES).optional(),
|
|
96
|
+
big: text.optional(),
|
|
92
97
|
default: text.optional(),
|
|
93
98
|
waits: z.boolean().optional(),
|
|
94
99
|
background: text.optional(),
|
|
@@ -104,7 +109,7 @@ export const eventSchema = z.discriminatedUnion("type", [
|
|
|
104
109
|
choice: text,
|
|
105
110
|
// The user's own words, quoted under the answer.
|
|
106
111
|
words: text.optional(),
|
|
107
|
-
//
|
|
112
|
+
// True when the agent had to undo work built on another option.
|
|
108
113
|
changedCourse: z.boolean().optional(),
|
|
109
114
|
}),
|
|
110
115
|
// Adds detail to an open question; its reference and default stay.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// What an agent does when it asks the user a question, picked in Paseo's
|
|
2
|
+
// Settings. The command, the settings page, and the dashboard all read this
|
|
3
|
+
// table. No Paseo imports: the command's bundle includes this file.
|
|
4
|
+
export const QUESTION_MODES = ["stop", "work-around", "keep-building"] as const;
|
|
5
|
+
export type QuestionMode = (typeof QUESTION_MODES)[number];
|
|
6
|
+
|
|
7
|
+
// Also what a missing or damaged mirror means.
|
|
8
|
+
export const DEFAULT_QUESTION_MODE: QuestionMode = "work-around";
|
|
9
|
+
|
|
10
|
+
// An open question on the dashboard: the agent stopped all work, holds what the
|
|
11
|
+
// question affects, or builds its recommendation.
|
|
12
|
+
export type QuestionState = "stopped" | "waiting" | "building";
|
|
13
|
+
|
|
14
|
+
interface QuestionModeRules {
|
|
15
|
+
label: string;
|
|
16
|
+
hint: string;
|
|
17
|
+
// What the agent does until the answer. `question ask` prints it after "Mode: <label>."
|
|
18
|
+
askRule(question: { recommend: string; big: boolean }): string;
|
|
19
|
+
state(big: boolean): QuestionState;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const HOLD = "Build no option before the answer, not even your recommendation. Work on everything the question doesn't affect. If nothing else is left, end your turn, tell the user in chat which questions you're waiting on, and leave the run open.";
|
|
23
|
+
|
|
24
|
+
export const QUESTION_MODE_RULES: Record<QuestionMode, QuestionModeRules> = {
|
|
25
|
+
stop: {
|
|
26
|
+
label: "Stop",
|
|
27
|
+
hint: "Agents stop all work when they ask, until you answer.",
|
|
28
|
+
askRule: () => "Ask any other questions you already have, then stop all work and resume only after an answer. End your turn now, tell the user in chat which questions you're waiting on, and leave the run open.",
|
|
29
|
+
state: () => "stopped",
|
|
30
|
+
},
|
|
31
|
+
"work-around": {
|
|
32
|
+
label: "Work around",
|
|
33
|
+
hint: "Agents hold what your answer affects and keep working on the rest.",
|
|
34
|
+
askRule: () => HOLD,
|
|
35
|
+
state: () => "waiting",
|
|
36
|
+
},
|
|
37
|
+
"keep-building": {
|
|
38
|
+
label: "Keep building",
|
|
39
|
+
hint: "Agents build their recommended option while you decide, and change course if you pick another. Big questions still wait.",
|
|
40
|
+
askRule: ({ recommend, big }) => big
|
|
41
|
+
? `This question is big, so hold it like Work around. ${HOLD}`
|
|
42
|
+
: `Build ${recommend} now and keep working. If the user picks another option, undo the work built on ${recommend} and build their choice first. If the rest of the work is done while it's still open, leave it open and tell the user in chat; don't withdraw it.`,
|
|
43
|
+
state: (big) => (big ? "waiting" : "building"),
|
|
44
|
+
},
|
|
45
|
+
};
|
package/shared/rpc.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineRpc } from "@getpaseo/plugin";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import type { Dashboard } from "./dashboard.ts";
|
|
4
|
+
import type { WhatsNew } from "./whats-new.ts";
|
|
4
5
|
|
|
5
6
|
// `panelOpened`: the Progress panel has been opened for this worktree before.
|
|
6
7
|
// `root`: the worktree root the file was read from, which stored paths are
|
|
@@ -163,3 +164,20 @@ export const readDeliverablePiece = defineRpc({
|
|
|
163
164
|
}),
|
|
164
165
|
output: z.object({ data: z.string(), bytes: z.number() }),
|
|
165
166
|
});
|
|
167
|
+
|
|
168
|
+
// This version's changelog notes, once after an update; null otherwise.
|
|
169
|
+
// `headline` is the first sentence of the first note, without Markdown.
|
|
170
|
+
// A changelog bullet. `title` is its bold lead without Markdown, when it has
|
|
171
|
+
// one; `body` is the rest, Markdown kept.
|
|
172
|
+
export const getWhatsNew = defineRpc({
|
|
173
|
+
name: "progress.whats-new.get",
|
|
174
|
+
input: z.object({}),
|
|
175
|
+
output: z.custom<WhatsNew | null>(),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// The user closed it; it stays away for this version.
|
|
179
|
+
export const dismissWhatsNew = defineRpc({
|
|
180
|
+
name: "progress.whats-new.dismiss",
|
|
181
|
+
input: z.object({ version: z.string().min(1) }),
|
|
182
|
+
output: z.object({}),
|
|
183
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { defineSettings } from "@getpaseo/plugin";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { DEFAULT_QUESTION_MODE, QUESTION_MODES } from "./question-modes.ts";
|
|
4
|
+
|
|
5
|
+
export const questionSettings = defineSettings({
|
|
6
|
+
id: "questions",
|
|
7
|
+
scope: "host",
|
|
8
|
+
version: 1,
|
|
9
|
+
schema: z.object({ mode: z.enum(QUESTION_MODES).default(DEFAULT_QUESTION_MODE) }),
|
|
10
|
+
});
|
package/shared/show.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { Dashboard } from "./dashboard.ts";
|
|
2
2
|
import { headlineText, shortTitle } from "./dashboard.ts";
|
|
3
3
|
import { formatAgo, formatClock, formatMinutes, formatWorkDone, minutesSince } from "./format.ts";
|
|
4
|
+
import type { QuestionState } from "./question-modes.ts";
|
|
4
5
|
|
|
5
6
|
const STATUS: Record<string, string> = { not_started: "Not started", working: "Working", blocked: "Blocked", done: "Done", skipped: "Skipped" };
|
|
7
|
+
const QUESTION_STATE: Record<QuestionState, string> = { stopped: "agent stopped until the answer", waiting: "waiting for the answer", building: "building with it" };
|
|
6
8
|
|
|
7
9
|
// The dashboard as plain text, so an agent can check what the user sees.
|
|
8
10
|
export function dashboardText(dashboard: Dashboard, nowMs: number): string {
|
|
@@ -44,7 +46,7 @@ export function dashboardText(dashboard: Dashboard, nowMs: number): string {
|
|
|
44
46
|
if (dashboard.questions.open.length) {
|
|
45
47
|
lines.push("", "Questions waiting");
|
|
46
48
|
for (const question of dashboard.questions.open) {
|
|
47
|
-
lines.push(` ${question.id} ${question.ticketId ? `[${question.ticketId}] ` : ""}(${question.title}): ${question.question} You recommended ${question.recommended}`);
|
|
49
|
+
lines.push(` ${question.id} ${question.ticketId ? `[${question.ticketId}] ` : ""}(${question.title}): ${question.question} You recommended ${question.recommended}, ${QUESTION_STATE[question.state]}${question.big ? `. Big: ${question.big}` : ""}`);
|
|
48
50
|
for (const option of question.options) lines.push(` ${option.letter}) ${option.label}${option.consequence ? `: ${option.consequence}` : ""}`);
|
|
49
51
|
}
|
|
50
52
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// A changelog bullet can open with a tag, like `- [Fixed] **Title.** Detail.`,
|
|
2
|
+
// that says what kind of change it is. The parser and the dialog both read
|
|
3
|
+
// this table. `tone` is the hue the dialog colors the tag with.
|
|
4
|
+
export const NOTE_KINDS = {
|
|
5
|
+
new: { label: "New", tone: "green" },
|
|
6
|
+
improved: { label: "Improved", tone: "violet" },
|
|
7
|
+
fixed: { label: "Fixed", tone: "amber" },
|
|
8
|
+
} as const;
|
|
9
|
+
|
|
10
|
+
export type NoteTone = (typeof NOTE_KINDS)[NoteKind]["tone"];
|
|
11
|
+
|
|
12
|
+
export type NoteKind = keyof typeof NOTE_KINDS;
|
|
13
|
+
|
|
14
|
+
export type WhatsNewNote = { kind?: NoteKind; title?: string; body: string };
|
|
15
|
+
|
|
16
|
+
export type WhatsNew = { version: string; headline: string; notes: WhatsNewNote[] };
|