@stevecastaneda/paseo-progress 1.5.1 → 1.6.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/README.md CHANGED
@@ -18,9 +18,9 @@ The dashboard shows:
18
18
 
19
19
  <img src="images/2-ticket.png" alt="A ticket's story: its status, time against the estimate, the agent on it, and its timeline" width="400"> <img src="images/3-question.png" alt="A question waiting for an answer, with lettered options, the agent's recommendation and Copy buttons" width="400">
20
20
 
21
- <img src="images/5-settings.png" alt="Progress settings in Paseo: the Questions setting set to Work around, and the Language setting set to System" width="600">
21
+ <img src="images/5-settings.png" alt="Progress settings in Paseo: Questions set to Work around, Language set to System, and the Agent Skills section with View skill" width="600">
22
22
 
23
- The panel lives in Explorer, beside the agent chat, so you can watch both. Open it from Command Center with **Open Progress**. The cog at the top right of the panel opens the plugin's Settings. After an update, the panel shows what's new in that version once. When questions are waiting or something is stuck, a pill above the message box says so, for example `3 questions`. With something stuck too, it shortens to `3Q · 1 stuck` to fit. Pressing it opens a popover with each open question, its choices and their Copy buttons, and what is stuck, so you can answer without leaving the chat. **Open Progress** at the bottom opens the panel in Explorer.
23
+ The panel lives in Explorer, beside the agent chat, so you can watch both. Open it from Command Center with **Open Progress**. The cog at the top right of the panel opens the plugin's Settings. After an update, the panel shows what's new once, including any versions you skipped. When questions are waiting or something is stuck, a pill above the message box says so, for example `3 questions`. With something stuck too, it shortens to `3Q · 1 stuck` to fit. Pressing it opens a popover with each open question, its choices and their Copy buttons, and what is stuck, so you can answer without leaving the chat. **Open Progress** at the bottom opens the panel in Explorer.
24
24
 
25
25
  <img src="images/4-pill.png" alt="The pill above the message box, showing two questions waiting" width="600">
26
26
 
@@ -82,7 +82,7 @@ Earlier versions kept progress in `.scratch/` in each worktree, hidden by a `.sc
82
82
 
83
83
  ## Agent skill
84
84
 
85
- The plugin ships an agent skill, `paseo-progress`, that tells agents when to run each command during a multi-ticket job: set up the run, move tickets through their stages, ask questions, and finish. It stays short and leaves the details to `paseo-progress --help`. Press **Install skill** in the Progress panel. It links `paseo-progress` into `~/.agents/skills`, `~/.claude/skills`, and `~/.codex/skills` (the folders Paseo installs its own skills into), pointing at the copy of the plugin Paseo runs, so plugin updates reach agents. On Windows the links are junctions, which don't need admin rights or Developer Mode. If Paseo later runs the plugin from somewhere else, the button changes to **Update skill**. It never replaces a skill it didn't link; that folder is skipped.
85
+ The plugin ships an agent skill, `paseo-progress`, that tells agents when to run each command during a multi-ticket job: set up the run, move tickets through their stages, ask questions, and finish. It stays short and leaves the details to `paseo-progress --help`. Press **Install skill** in the Progress panel. It links `paseo-progress` into `~/.agents/skills`, `~/.claude/skills`, and `~/.codex/skills` (the folders Paseo installs its own skills into), pointing at the copy of the plugin Paseo runs, so plugin updates reach agents. On Windows the links are junctions, which don't need admin rights or Developer Mode. Paseo puts each plugin update in a new folder, so when Progress starts it repoints the links it made to that folder. If it can't, the button changes to **Update skill**. It never replaces a skill it didn't link; that folder is skipped. To read what agents get, open **View skill** under Agent Skills in Progress settings.
86
86
 
87
87
  From a terminal instead:
88
88
 
@@ -145,9 +145,15 @@ The progress file, `progress.jsonl`, holds one JSON event per line, each with a
145
145
 
146
146
  ## Changelog
147
147
 
148
+ ### 1.6.0
149
+
150
+ - [New] **Read the skill in Settings.** Progress settings has an Agent Skills section. **View skill** shows the instructions agents get, and **Install skill** appears there when the skill isn't installed.
151
+ - [Fixed] **The skill stays linked after updates.** Paseo puts each plugin update in a new folder, so after every update Progress asked you to press **Update skill**, even when the skill hadn't changed. Progress now fixes the link itself.
152
+ - [Improved] **What's new catches you up.** If you skip an update, What's new now shows the changes from every version you missed, newest first.
153
+ - [Fixed] **Code inside lists.** A code block inside a list item in a write-up no longer shows shifted to the right.
154
+
148
155
  ### 1.5.1
149
156
 
150
- - [New] **Simplified Chinese.** Progress now comes in English and Simplified Chinese. Pick one under Display in Progress settings, or let it follow each device's language. Thanks to @xzthemeofsss for the translation.
151
157
  - [Improved] **Settings screenshot.** It shows the new Language setting.
152
158
 
153
159
  ### 1.5.0
@@ -70,23 +70,28 @@ export function LauncherBanner({ colors, host }: { colors: Colors; host: PluginW
70
70
  return <SetupBanner colors={colors} icon="Terminal" notice={notice} pending={mutation.isPending} onPress={() => mutation.mutate()} />;
71
71
  }
72
72
 
73
- export function SkillBanner({ colors, host }: { colors: Colors; host: PluginWorkspacePanelProps["host"] }) {
73
+ // The panel banner and Settings share the status and the install action.
74
+ export function useSkill(hostId: string) {
74
75
  const { messages: m } = useProgressLanguage();
75
76
  const fetchStatus = useRpc(getSkillStatus);
76
77
  const install = useRpc(installSkill);
77
78
  const queryClient = useQueryClient();
78
79
  const toast = useToast();
79
- const status = useQuery({ queryKey: skillQueryKey(host.id), queryFn: () => fetchStatus({}), staleTime: 60_000 });
80
+ const status = useQuery({ queryKey: skillQueryKey(hostId), queryFn: () => fetchStatus({}), staleTime: 60_000 });
80
81
  const mutation = useMutation({
81
82
  mutationFn: () => install({}),
82
83
  onSuccess: (next) => {
83
- queryClient.setQueryData(skillQueryKey(host.id), next);
84
+ queryClient.setQueryData(skillQueryKey(hostId), next);
84
85
  toast.show(m.skillInstalled, { variant: "success" });
85
86
  },
86
87
  onError: (error) => toast.error(errorText(error, m.installSkillFailure)),
87
88
  });
88
- const notice = status.data ? skillNotice(status.data, m) : null;
89
- return <SetupBanner colors={colors} icon="Sparkles" notice={notice} pending={mutation.isPending} onPress={() => mutation.mutate()} />;
89
+ return { notice: status.data ? skillNotice(status.data, m) : null, state: status.data?.state, pending: mutation.isPending, install: () => mutation.mutate() };
90
+ }
91
+
92
+ export function SkillBanner({ colors, host }: { colors: Colors; host: PluginWorkspacePanelProps["host"] }) {
93
+ const skill = useSkill(host.id);
94
+ return <SetupBanner colors={colors} icon="Sparkles" notice={skill.notice} pending={skill.pending} onPress={skill.install} />;
90
95
  }
91
96
 
92
97
  function SetupBanner({ colors, icon, notice, pending, onPress }: {
@@ -86,6 +86,7 @@ export const messages: ProgressMessages = {
86
86
  showFewer: "Show fewer",
87
87
  seeWhatsNew: "See what’s new",
88
88
  dismissWhatsNew: "Dismiss what's new",
89
+ whatsNewAll: "What’s new in Progress",
89
90
  gotIt: "Got it",
90
91
  loadingPreview: "Loading preview",
91
92
  truncatedText: "Showing the first 256 KB.",
@@ -124,6 +125,11 @@ export const messages: ProgressMessages = {
124
125
  skillForeign: "Something else already exists at every paseo-progress skill path, so the plugin won't install the skill.",
125
126
  skillInstalled: "Installed the paseo-progress skill. New agent sessions will use it.",
126
127
  installSkillFailure: "Could not install the skill.",
128
+ agentSkill: "Agent Skills",
129
+ skillName: "paseo-progress skill",
130
+ viewSkill: "View skill",
131
+ skillNotInstalled: "Not installed",
132
+ skillLinkOutdated: "Link out of date",
127
133
  installing: "Installing…",
128
134
  loadingSettings: "Loading settings…",
129
135
  tryAgain: "Try again",
@@ -178,6 +184,7 @@ export const messages: ProgressMessages = {
178
184
  readError: (error: string) => `Couldn't read it here: ${error}`,
179
185
  showOlder: (count: number) => `Show ${count} older`,
180
186
  newVersion: (version: string) => `New in ${version}:`,
187
+ updatedTo: (version: string) => `Updated to ${version}:`,
181
188
  whatsNewTitle: (version: string) => `What’s new in Progress ${version}`,
182
189
  loadingVideo: (percent: number) => `Loading video, ${percent}%`,
183
190
  previewError: (error: string) => `Could not preview it: ${error}`,
@@ -86,6 +86,7 @@ export const messages: ProgressMessages = {
86
86
  showFewer: "收起",
87
87
  seeWhatsNew: "查看更新",
88
88
  dismissWhatsNew: "关闭更新提示",
89
+ whatsNewAll: "Progress 更新内容",
89
90
  gotIt: "知道了",
90
91
  loadingPreview: "正在加载预览",
91
92
  truncatedText: "仅显示前 256 KB。",
@@ -124,6 +125,11 @@ export const messages: ProgressMessages = {
124
125
  skillForeign: "所有 paseo-progress 技能路径都已被其他内容占用,插件无法安装技能。",
125
126
  skillInstalled: "已安装 paseo-progress 技能。新的智能体会话将使用此技能。",
126
127
  installSkillFailure: "无法安装技能。",
128
+ agentSkill: "智能体技能",
129
+ skillName: "paseo-progress 技能",
130
+ viewSkill: "查看技能",
131
+ skillNotInstalled: "未安装",
132
+ skillLinkOutdated: "链接已过期",
127
133
  installing: "安装中…",
128
134
  loadingSettings: "正在加载设置…",
129
135
  tryAgain: "重试",
@@ -178,6 +184,7 @@ export const messages: ProgressMessages = {
178
184
  readError: (error: string) => `无法在此读取:${error}`,
179
185
  showOlder: (count: number) => `显示更早的 ${count} 项`,
180
186
  newVersion: (version: string) => `${version} 的更新:`,
187
+ updatedTo: (version: string) => `已更新到 ${version}:`,
181
188
  whatsNewTitle: (version: string) => `Progress ${version} 更新内容`,
182
189
  loadingVideo: (percent: number) => `正在加载视频,${percent}%`,
183
190
  previewError: (error: string) => `无法预览:${error}`,
@@ -68,6 +68,7 @@ export interface ProgressMessages {
68
68
  showFewer: string;
69
69
  seeWhatsNew: string;
70
70
  dismissWhatsNew: string;
71
+ whatsNewAll: string;
71
72
  gotIt: string;
72
73
  loadingPreview: string;
73
74
  truncatedText: string;
@@ -106,6 +107,11 @@ export interface ProgressMessages {
106
107
  skillForeign: string;
107
108
  skillInstalled: string;
108
109
  installSkillFailure: string;
110
+ agentSkill: string;
111
+ skillName: string;
112
+ viewSkill: string;
113
+ skillNotInstalled: string;
114
+ skillLinkOutdated: string;
109
115
  installing: string;
110
116
  loadingSettings: string;
111
117
  tryAgain: string;
@@ -160,6 +166,7 @@ export interface ProgressMessages {
160
166
  readError: (error: string) => string;
161
167
  showOlder: (count: number) => string;
162
168
  newVersion: (version: string) => string;
169
+ updatedTo: (version: string) => string;
163
170
  whatsNewTitle: (version: string) => string;
164
171
  loadingVideo: (percent: number) => string;
165
172
  previewError: (error: string) => string;
@@ -1,29 +1,78 @@
1
- import { type PluginSurfaceProps, useSettings } from "@getpaseo/plugin/client";
2
- import { SettingsAction, SettingsCard, SettingsSection, SettingsSelect } from "@getpaseo/plugin/client/ui";
3
- import React from "react";
4
- import { Text } from "react-native";
1
+ import { type PluginSurfaceProps, useRpc, useSettings } from "@getpaseo/plugin/client";
2
+ import { Icon, Modal } from "@getpaseo/plugin/client/react-native";
3
+ import { SettingsAction, SettingsCard, SettingsSelect } from "@getpaseo/plugin/client/ui";
4
+ import { useQuery } from "@tanstack/react-query";
5
+ import React, { type ReactNode, useState } from "react";
6
+ import { Text, View } from "react-native";
5
7
  import { DEFAULT_QUESTION_MODE, QUESTION_MODES } from "../shared/question-modes";
8
+ import { readSkill } from "../shared/rpc";
6
9
  import { questionSettings, LANGUAGE_PREFERENCES } from "../shared/settings";
10
+ import { parseWriteUp, withoutTitle } from "../shared/write-up";
11
+ import { errorText } from "./errors";
7
12
  import { useProgressLanguage } from "./language";
13
+ import { useSkill } from "./launcher";
14
+ import { BlockView } from "./write-up";
8
15
 
9
- export function QuestionSettings({ theme }: PluginSurfaceProps) {
10
- return <><QuestionModeSettings theme={theme} /><DisplaySettings theme={theme} /></>;
16
+ export function QuestionSettings({ theme, host }: PluginSurfaceProps) {
17
+ return <><QuestionModeSettings theme={theme} /><DisplaySettings theme={theme} /><SkillSettings colors={theme.colors} hostId={host.id} /></>;
11
18
  }
12
19
 
13
20
  type Colors = PluginSurfaceProps["theme"]["colors"];
14
21
 
22
+ // Paseo's SettingsSection has no icon slot, so this copies its layout and adds one.
23
+ function Section({ colors, icon, title, children }: { colors: Colors; icon: string; title: string; children: ReactNode }) {
24
+ return (
25
+ <View style={{ marginBottom: 24 }}>
26
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 12, marginLeft: 4 }}>
27
+ <Icon name={icon} size={14} color={colors.foregroundMuted} />
28
+ <Text style={{ color: colors.foregroundMuted, fontSize: 12, fontWeight: "400" }}>{title}</Text>
29
+ </View>
30
+ <View style={{ gap: 12 }}>{children}</View>
31
+ </View>
32
+ );
33
+ }
34
+
15
35
  function DisplaySettings({ theme }: Pick<PluginSurfaceProps, "theme">) {
16
36
  const { messages: m, settings } = useProgressLanguage();
17
37
  const muted = { color: theme.colors.foregroundMuted, fontSize: 13, lineHeight: 19 };
18
38
  const options = LANGUAGE_PREFERENCES.map((value) => ({ value, label: value === "system" ? m.systemLanguage : value === "en" ? "English" : "简体中文" }));
19
39
  return (
20
- <SettingsSection title={m.display}>
40
+ <Section colors={theme.colors} icon="Languages" title={m.display}>
21
41
  {settings.status === "loading" ? <Text style={muted}>{m.loadingSettings}</Text>
22
42
  : settings.status !== "ready" ? <SettingsRecovery colors={theme.colors} settings={settings} />
23
43
  : <SettingsCard><SettingsSelect label={m.language} hint={m.languageHint} value={settings.values.language} options={options}
24
44
  disabled={settings.saving} error={settings.saveError}
25
45
  onValueChange={(language) => void settings.save({ ...settings.values, language }, settings.revision)} /></SettingsCard>}
26
- </SettingsSection>
46
+ </Section>
47
+ );
48
+ }
49
+
50
+ function SkillSettings({ colors, hostId }: { colors: Colors; hostId: string }) {
51
+ const { messages: m } = useProgressLanguage();
52
+ const skill = useSkill(hostId);
53
+ const fetchSkill = useRpc(readSkill);
54
+ const document = useQuery({ queryKey: ["progress", "skill-document", hostId], queryFn: () => fetchSkill({}), staleTime: 60_000 });
55
+ const [open, setOpen] = useState(false);
56
+ const blocks = document.data ? withoutTitle(parseWriteUp(document.data.markdown)) : [];
57
+ const muted = { color: colors.foregroundMuted, fontSize: 13, lineHeight: 19 };
58
+ return (
59
+ <Section colors={colors} icon="Sparkles" title={m.agentSkill}>
60
+ <SettingsCard>
61
+ <SettingsAction label={m.skillName} hint={document.data?.description || undefined} actionLabel={m.viewSkill} onPress={() => setOpen(true)} />
62
+ {skill.notice?.action ? (
63
+ <SettingsAction label={skill.state === "outdated" ? m.skillLinkOutdated : m.skillNotInstalled} hint={skill.notice.text}
64
+ actionLabel={skill.pending ? m.installing : skill.notice.action} disabled={skill.pending} onPress={skill.install} />
65
+ ) : null}
66
+ </SettingsCard>
67
+ {/* Paseo draws the modal outside this tree, past the language context, so it only gets finished text. */}
68
+ <Modal title={m.skillName} icon={<Icon name="Sparkles" size={16} color={colors.foregroundMuted} />} open={open} onOpenChange={setOpen}>
69
+ <Modal.Content>
70
+ {document.error ? <Text style={muted}>{m.readError(errorText(document.error, m.unknownError))}</Text>
71
+ : !document.data ? <Text style={muted}>{m.loading}</Text>
72
+ : <View style={{ gap: 8 }}>{blocks.map((block, index) => <BlockView key={index} colors={colors} block={block} first={index === 0} />)}</View>}
73
+ </Modal.Content>
74
+ </Modal>
75
+ </Section>
27
76
  );
28
77
  }
29
78
 
@@ -32,13 +81,13 @@ function QuestionModeSettings({ theme }: Pick<PluginSurfaceProps, "theme">) {
32
81
  const settings = useSettings(questionSettings);
33
82
  const muted = { color: theme.colors.foregroundMuted, fontSize: 13, lineHeight: 19 };
34
83
  return (
35
- <SettingsSection title={m.questions}>
84
+ <Section colors={theme.colors} icon="MessageCircleQuestion" title={m.questions}>
36
85
  {settings.status === "loading" ? <Text style={muted}>{m.loadingSettings}</Text>
37
86
  : settings.status !== "ready" ? <SettingsRecovery colors={theme.colors} settings={settings} resetLabel={m.resetTo(m.questionModes[DEFAULT_QUESTION_MODE].label)} />
38
87
  : <SettingsCard><SettingsSelect label={m.questionMode} hint={m.questionModes[settings.values.mode].hint}
39
88
  error={settings.saveError} value={settings.values.mode} options={QUESTION_MODES.map((value) => ({ value, label: m.questionModes[value].label }))}
40
89
  disabled={settings.saving} onValueChange={(mode) => void settings.save({ ...settings.values, mode }, settings.revision)} /></SettingsCard>}
41
- </SettingsSection>
90
+ </Section>
42
91
  );
43
92
  }
44
93
 
@@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
4
4
  import React, { useState } from "react";
5
5
  import { Text, View } from "react-native";
6
6
  import { dismissWhatsNew, getWhatsNew } from "../shared/rpc";
7
- import { NOTE_KINDS, type NoteKind, type NoteTone, type WhatsNew, type WhatsNewNote } from "../shared/whats-new";
7
+ import { NOTE_KINDS, type NoteKind, type NoteTone, type WhatsNew, type WhatsNewNote, type WhatsNewRelease } from "../shared/whats-new";
8
8
  import { spans } from "../shared/write-up";
9
9
  import { PressScale, Presence } from "./motion";
10
10
  import { isLight, raised, tint } from "./surfaces";
@@ -13,8 +13,8 @@ import { ProgressLanguageProvider, useProgressLanguage } from "./language";
13
13
 
14
14
  type Colors = PluginWorkspacePanelProps["theme"]["colors"];
15
15
 
16
- // Once after an update: this version's headline, with its full notes a press
17
- // away. Closing either hides both for this version.
16
+ // Once after an update: the headline of what changed, with the full notes a
17
+ // press away. Closing either hides both until the next version.
18
18
  export function WhatsNewBanner({ colors, host }: { colors: Colors; host: PluginWorkspacePanelProps["host"] }) {
19
19
  const { messages: m } = useProgressLanguage();
20
20
  const fetchWhatsNew = useRpc(getWhatsNew);
@@ -42,7 +42,7 @@ export function WhatsNewBanner({ colors, host }: { colors: Colors; host: PluginW
42
42
  <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 10, margin: 12, marginBottom: 0, padding: 8, borderRadius: 12, ...raised(colors), backgroundColor: colors.surface1 }}>
43
43
  <View style={{ alignSelf: "flex-start", height: 17, justifyContent: "center" }}><Icon name="Gift" size={14} color={colors.foregroundMuted} /></View>
44
44
  <Text selectable style={{ flex: 1, minWidth: 200, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
45
- <Text style={{ color: colors.foreground, fontWeight: "500" }}>{m.newVersion(whatsNew.version)}</Text> {whatsNew.headline}
45
+ <Text style={{ color: colors.foreground, fontWeight: "500" }}>{whatsNew.releases.length > 1 ? m.updatedTo(whatsNew.version) : m.newVersion(whatsNew.version)}</Text> {whatsNew.headline}
46
46
  </Text>
47
47
  {/* 8px keeps the close button's 6px hit slop off the button beside it. */}
48
48
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
@@ -57,11 +57,11 @@ export function WhatsNewBanner({ colors, host }: { colors: Colors; host: PluginW
57
57
  </View>
58
58
  </View>
59
59
  ) : null}</Presence>
60
- <Modal title={shown ? m.whatsNewTitle(shown.version) : ""} icon={<Icon name="Gift" size={16} color={colors.foregroundMuted} />}
60
+ <Modal title={!shown ? "" : shown.releases.length > 1 ? m.whatsNewAll : m.whatsNewTitle(shown.version)} icon={<Icon name="Gift" size={16} color={colors.foregroundMuted} />}
61
61
  open={open} onOpenChange={(next) => { if (!next) closeDialog(); }}>
62
62
  <Modal.Content>
63
63
  <ProgressLanguageProvider>
64
- {shown ? <Notes colors={colors} notes={shown.notes} /> : null}
64
+ {shown ? <Releases colors={colors} releases={shown.releases} /> : null}
65
65
  <View style={{ flexDirection: "row", justifyContent: "flex-end", marginTop: 8 }}>
66
66
  <PressScale accessibilityRole="button" onPress={closeDialog}
67
67
  style={({ pressed, hovered }) => ({ paddingHorizontal: 12, paddingVertical: 6, borderRadius: 6, backgroundColor: colors.accent, opacity: pressed ? 0.7 : hovered ? 0.85 : 1 })}>
@@ -75,6 +75,22 @@ export function WhatsNewBanner({ colors, host }: { colors: Colors; host: PluginW
75
75
  );
76
76
  }
77
77
 
78
+ // One version reads as before. After a skipped update, each version's notes sit
79
+ // under its number, so it's clear which release brought what.
80
+ function Releases({ colors, releases }: { colors: Colors; releases: WhatsNewRelease[] }) {
81
+ if (releases.length === 1) return <Notes colors={colors} notes={releases[0].notes} />;
82
+ return (
83
+ <View style={{ gap: 40 }}>
84
+ {releases.map((release) => (
85
+ <View key={release.version} style={{ gap: 12 }}>
86
+ <Text accessibilityRole="header" selectable style={{ color: colors.foreground, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>{release.version}</Text>
87
+ <Notes colors={colors} notes={release.notes} />
88
+ </View>
89
+ ))}
90
+ </View>
91
+ );
92
+ }
93
+
78
94
  // Titled notes read as a short list of headings with their details; a changelog
79
95
  // written without titles stays a bulleted list.
80
96
  function Notes({ colors, notes }: { colors: Colors; notes: WhatsNewNote[] }) {
Binary file
package/index.server.ts CHANGED
@@ -3,9 +3,9 @@ import { handleGetAttention, handleGetDashboard, handleListFolders, handleMarkPa
3
3
  import { handleInstallLauncher, handleLauncherStatus } from "./server/launcher";
4
4
  import { handleCanOpenOnHost, handleOpenDeliverable, handlePreviewDeliverable, handleReadDeliverablePiece } from "./server/open";
5
5
  import { mirrorQuestionMode } from "./server/question-mode";
6
- import { handleInstallSkill, handleSkillStatus } from "./server/skill";
6
+ import { handleInstallSkill, handleReadSkill, handleSkillStatus, repairSkill } from "./server/skill";
7
7
  import { handleDismissWhatsNew, handleGetWhatsNew } from "./server/whats-new";
8
- import { canOpenOnHost, dismissWhatsNew, getAttention, getDashboard, getLauncherStatus, getSkillStatus, getWhatsNew, installLauncher, installSkill, listFolders, markPanelOpened, openDeliverable, previewDeliverable, readDeliverablePiece, setHistoryFolder } from "./shared/rpc";
8
+ import { canOpenOnHost, dismissWhatsNew, getAttention, getDashboard, getLauncherStatus, getSkillStatus, getWhatsNew, installLauncher, installSkill, listFolders, readSkill, markPanelOpened, openDeliverable, previewDeliverable, readDeliverablePiece, setHistoryFolder } from "./shared/rpc";
9
9
  import { displaySettings, questionSettings } from "./shared/settings";
10
10
 
11
11
  export default function contribute(server: PluginServerContext) {
@@ -20,6 +20,7 @@ export default function contribute(server: PluginServerContext) {
20
20
  server.handle(readDeliverablePiece, handleReadDeliverablePiece);
21
21
  server.handle(getSkillStatus, handleSkillStatus);
22
22
  server.handle(installSkill, handleInstallSkill);
23
+ server.handle(readSkill, handleReadSkill);
23
24
  server.handle(listFolders, handleListFolders);
24
25
  server.handle(setHistoryFolder, handleSetHistoryFolder);
25
26
  server.handle(getWhatsNew, handleGetWhatsNew);
@@ -27,6 +28,7 @@ export default function contribute(server: PluginServerContext) {
27
28
  // Settles a new install before any panel opens. Opening one makes a
28
29
  // worktree folder, which would look like an earlier version's.
29
30
  void handleGetWhatsNew().catch(() => {});
31
+ void repairSkill();
30
32
  server.registerSettings(displaySettings);
31
33
  const mirror = mirrorQuestionMode(server.registerSettings(questionSettings));
32
34
  return mirror.stop;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stevecastaneda/paseo-progress",
3
- "version": "1.5.1",
3
+ "version": "1.6.0",
4
4
  "description": "A live progress dashboard per worktree for Paseo, drawn from a file agents write with the paseo-progress command.",
5
5
  "license": "MIT",
6
6
  "author": "Steve Castaneda",
package/server/skill.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { exec, execFile } from "node:child_process";
2
- import { lstat, mkdir, readlink, symlink, unlink } from "node:fs/promises";
2
+ import { lstat, mkdir, readFile, 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";
@@ -31,24 +31,41 @@ export async function skillState(path: string, pluginDirectory: string): Promise
31
31
  return basename(target) === SKILL_NAME && basename(dirname(target)) === "skills" ? "outdated" : "foreign";
32
32
  }
33
33
 
34
+ // Repoints each link this plugin made to an old copy at the copy Paseo runs
35
+ // now. Paseo puts every update in a new folder, so this follows each update.
36
+ // Returns whether it wrote.
37
+ export async function relinkSkill(paths: string[], pluginDirectory: string): Promise<boolean> {
38
+ let wrote = false;
39
+ for (const path of paths) {
40
+ if (await skillState(path, pluginDirectory) !== "outdated") continue;
41
+ await unlink(path);
42
+ await link(path, pluginDirectory);
43
+ wrote = true;
44
+ }
45
+ return wrote;
46
+ }
47
+
34
48
  // Links the skill wherever it is missing or points at an old copy of the
35
49
  // plugin. Leaves alone anything else at those paths. Returns whether it wrote.
36
50
  export async function installSkill(paths: string[], pluginDirectory: string): Promise<boolean> {
37
51
  const states = await Promise.all(paths.map((path) => skillState(path, pluginDirectory)));
38
52
  if (states.every((state) => state === "foreign")) throw new Error(`${paths.join(", ")} already exist and were not linked by this plugin.`);
39
- let wrote = false;
53
+ let wrote = await relinkSkill(paths, pluginDirectory);
40
54
  for (const [index, path] of paths.entries()) {
41
- if (states[index] !== "missing" && states[index] !== "outdated") continue;
42
- if (states[index] === "outdated") await unlink(path);
43
- await mkdir(dirname(path), { recursive: true });
44
- // 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");
55
+ if (states[index] !== "missing") continue;
56
+ await link(path, pluginDirectory);
47
57
  wrote = true;
48
58
  }
49
59
  return wrote;
50
60
  }
51
61
 
62
+ async function link(path: string, pluginDirectory: string) {
63
+ await mkdir(dirname(path), { recursive: true });
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");
67
+ }
68
+
52
69
  // "current" once every folder that can take the link has it.
53
70
  export async function skillStatus(paths: string[], pluginDirectory: string) {
54
71
  const targets = await Promise.all(paths.map(async (path) => ({ path, state: await skillState(path, pluginDirectory) })));
@@ -75,19 +92,45 @@ export async function findPluginDirectory(): Promise<string> {
75
92
  }
76
93
 
77
94
  // On Windows Paseo's command is paseo.cmd, which Node can start, or find on
78
- // PATH, only through cmd.exe.
95
+ // PATH, only through cmd.exe. A hung CLI must fail so the next one is tried.
79
96
  function askPaseo(cli: string) {
97
+ const options = { timeout: 10_000 };
80
98
  return process.platform === "win32"
81
- ? promisify(exec)(`"${cli}" plugin ls ${PLUGIN_ID} --json`)
82
- : promisify(execFile)(cli, ["plugin", "ls", PLUGIN_ID, "--json"]);
99
+ ? promisify(exec)(`"${cli}" plugin ls ${PLUGIN_ID} --json`, options)
100
+ : promisify(execFile)(cli, ["plugin", "ls", PLUGIN_ID, "--json"], options);
101
+ }
102
+
103
+ // Runs once per load, when the server starts: an update reloads the bundle.
104
+ // Only repoints links the user already asked for; new ones wait for Install
105
+ // skill. A failure leaves the links as they were, and status says so.
106
+ let repair: Promise<unknown> | null = null;
107
+
108
+ export function repairSkill(): Promise<unknown> {
109
+ repair ??= findPluginDirectory().then((directory) => relinkSkill(defaultSkillPaths(), directory)).catch(() => {});
110
+ return repair;
83
111
  }
84
112
 
85
113
  export async function handleSkillStatus() {
114
+ await repairSkill();
86
115
  return skillStatus(defaultSkillPaths(), await findPluginDirectory());
87
116
  }
88
117
 
89
- // Runs only when the user presses Install skill in the panel.
118
+ // SKILL.md's frontmatter holds one-line fields; only the description is shown.
119
+ export function skillDocument(text: string): { markdown: string; description: string } {
120
+ const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
121
+ const description = /^description:[ \t]*(.*)$/m.exec(frontmatter?.[1] ?? "")?.[1].trim().replace(/^(["'])(.*)\1$/, "$2") ?? "";
122
+ return { markdown: text.slice(frontmatter?.[0].length ?? 0).trimStart(), description };
123
+ }
124
+
125
+ export async function handleReadSkill() {
126
+ return skillDocument(await readFile(join(skillSource(await findPluginDirectory()), "SKILL.md"), "utf8"));
127
+ }
128
+
129
+ // Runs only when the user presses Install skill. Links that only need to
130
+ // follow an update are repointed on their own (repairSkill), which must finish
131
+ // first: both would unlink the same outdated link.
90
132
  export async function handleInstallSkill() {
133
+ await repairSkill();
91
134
  const pluginDirectory = await findPluginDirectory();
92
135
  await installSkill(defaultSkillPaths(), pluginDirectory);
93
136
  return skillStatus(defaultSkillPaths(), pluginDirectory);
@@ -1,40 +1,56 @@
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.
1
+ // After an update, the panel shows what changed since the version last seen,
2
+ // once. The notes come from the changelog in the plugin's own README, so
3
+ // they're written in one place. Which version was seen is kept per user on
4
+ // this computer.
5
5
  import { readdir, readFile } from "node:fs/promises";
6
6
  import { homedir } from "node:os";
7
7
  import { join } from "node:path";
8
8
  import { z } from "zod";
9
- import { NOTE_KINDS, type NoteKind, type WhatsNew, type WhatsNewNote } from "../shared/whats-new.ts";
9
+ import { NOTE_KINDS, type NoteKind, type WhatsNew, type WhatsNewNote, type WhatsNewRelease } from "../shared/whats-new.ts";
10
10
  import { spans } from "../shared/write-up.ts";
11
11
  import { findPluginDirectory } from "./skill.ts";
12
12
  import { stateRoot, writeAtomic } from "./store.ts";
13
13
 
14
14
  export type Release = { version: string; readme: string | null };
15
15
 
16
- const seenSchema = z.object({ seen: z.string() });
16
+ const seenSchema = z.object({ seen: z.string().regex(/^\d+\.\d+\.\d+$/) });
17
17
  const packageSchema = z.object({ version: z.string() });
18
18
 
19
19
  export function whatsNewPath(home = homedir()): string {
20
20
  return join(stateRoot(home), "whats-new.json");
21
21
  }
22
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[] = [];
23
+ // Each `### x.y.z` under the README's `## Changelog` with its top-level
24
+ // bullets, newest first. Versions without bullets are left out.
25
+ export function changelog(readme: string): WhatsNewRelease[] {
26
+ const releases: { version: string; bullets: string[] }[] = [];
27
+ let inChangelog = false;
28
+ let current: string[] | null = null;
28
29
  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()}`;
30
+ if (/^##\s/.test(line)) {
31
+ inChangelog = line.trim() === "## Changelog";
32
+ current = null;
33
+ continue;
34
+ }
35
+ if (!inChangelog) continue;
36
+ if (/^###\s/.test(line)) {
37
+ const version = /^###\s+(\d+\.\d+\.\d+)\s*$/.exec(line)?.[1];
38
+ current = null;
39
+ if (version) releases.push({ version, bullets: (current = []) });
40
+ } else if (current && /^[-*]\s/.test(line)) current.push(line.slice(2).trim());
41
+ else if (current?.length && /^\s+\S/.test(line) && !/^\s+([-*+]|\d+[.)])\s/.test(line)) {
42
+ current[current.length - 1] += ` ${line.trim()}`;
35
43
  }
36
44
  }
37
- return notes.length ? notes.map(note) : null;
45
+ return releases
46
+ .filter((release) => release.bullets.length)
47
+ .map(({ version, bullets }) => ({ version, notes: bullets.map(note) }))
48
+ .sort((a, b) => compareVersions(b.version, a.version));
49
+ }
50
+
51
+ function compareVersions(a: string, b: string): number {
52
+ const [x, y] = [a, b].map((version) => version.split(".").map(Number));
53
+ return x[0] - y[0] || x[1] - y[1] || x[2] - y[2];
38
54
  }
39
55
 
40
56
  // A bullet can open with a kind tag, like `[Fixed]`, and then a bold
@@ -88,18 +104,23 @@ async function usedBefore(home: string): Promise<boolean> {
88
104
  return entries.some((entry) => entry.isDirectory() && /-[0-9a-f]{8}$/.test(entry.name));
89
105
  }
90
106
 
91
- // What to show, or null. A new install has nothing to catch up on, so it
92
- // records this version as seen.
107
+ // What to show, or null: every version since the one last seen, newest
108
+ // first. A new install has nothing to catch up on, so it records this version
109
+ // as seen. An update from before versions were recorded can't say where it
110
+ // started, so it shows this version alone rather than the whole history.
93
111
  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
112
  const seen = await readSeen(home);
97
113
  if (seen === version) return null;
98
114
  if (seen === null && !(await usedBefore(home))) {
99
115
  await markWhatsNewSeen(version, home);
100
116
  return null;
101
117
  }
102
- return { version, headline: headline(notes[0]), notes };
118
+ const releases = (readme === null ? [] : changelog(readme)).filter((release) => seen === null
119
+ ? release.version === version
120
+ : compareVersions(release.version, seen) > 0 && compareVersions(release.version, version) <= 0);
121
+ if (!releases.length) return null;
122
+ const notes = releases.flatMap((release) => release.notes);
123
+ return { version, headline: headline(notes.find((note) => note.kind === "new") ?? notes[0]), releases };
103
124
  }
104
125
 
105
126
  // The server bundle can't read files beside it, so ask Paseo where the plugin
package/shared/rpc.ts CHANGED
@@ -89,6 +89,14 @@ export const installSkill = defineRpc({
89
89
  output: skillStatus,
90
90
  });
91
91
 
92
+ // The skill agents get: its SKILL.md without the frontmatter, and the
93
+ // frontmatter's description.
94
+ export const readSkill = defineRpc({
95
+ name: "progress.skill.read",
96
+ input: z.object({}),
97
+ output: z.object({ markdown: z.string(), description: z.string() }),
98
+ });
99
+
92
100
  // The folders inside one folder of the worktree ("" is the top), for the
93
101
  // folder picker.
94
102
  export const listFolders = defineRpc({
@@ -13,4 +13,7 @@ export type NoteKind = keyof typeof NOTE_KINDS;
13
13
 
14
14
  export type WhatsNewNote = { kind?: NoteKind; title?: string; body: string };
15
15
 
16
- export type WhatsNew = { version: string; headline: string; notes: WhatsNewNote[] };
16
+ export type WhatsNewRelease = { version: string; notes: WhatsNewNote[] };
17
+
18
+ // `version` is the one running now; `releases` run newest first and are never empty.
19
+ export type WhatsNew = { version: string; headline: string; releases: WhatsNewRelease[] };
@@ -33,7 +33,9 @@ export function spans(text: string): Span[] {
33
33
  export function parseWriteUp(markdown: string): Block[] {
34
34
  const blocks: Block[] = [];
35
35
  let paragraph: string[] = [];
36
- let fence: string[] | null = null;
36
+ // A fence opened inside a list item is indented like the item; its lines
37
+ // lose up to that much indentation.
38
+ let fence: { indent: number; lines: string[] } | null = null;
37
39
  const flush = () => {
38
40
  if (paragraph.length) blocks.push({ kind: "paragraph", spans: spans(paragraph.join(" ")) });
39
41
  paragraph = [];
@@ -41,14 +43,14 @@ export function parseWriteUp(markdown: string): Block[] {
41
43
  for (const raw of markdown.replace(/\r\n?/g, "\n").split("\n")) {
42
44
  if (fence) {
43
45
  if (/^\s*(```|~~~)/.test(raw)) {
44
- blocks.push({ kind: "code", text: fence.join("\n") });
46
+ blocks.push({ kind: "code", text: fence.lines.join("\n") });
45
47
  fence = null;
46
- } else fence.push(raw);
48
+ } else fence.lines.push(raw.replace(new RegExp(`^[ \\t]{0,${fence.indent}}`), ""));
47
49
  continue;
48
50
  }
49
51
  if (/^\s*(```|~~~)/.test(raw)) {
50
52
  flush();
51
- fence = [];
53
+ fence = { indent: /^[ \t]*/.exec(raw)![0].length, lines: [] };
52
54
  continue;
53
55
  }
54
56
  const line = raw.trimEnd();
@@ -69,7 +71,7 @@ export function parseWriteUp(markdown: string): Block[] {
69
71
  } else paragraph.push(line.trim().replace(/^>\s?/, ""));
70
72
  }
71
73
  flush();
72
- if (fence) blocks.push({ kind: "code", text: fence.join("\n") });
74
+ if (fence) blocks.push({ kind: "code", text: fence.lines.join("\n") });
73
75
  return blocks;
74
76
  }
75
77