@stevecastaneda/paseo-progress 0.1.7 → 0.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Progress
2
2
 
3
- A plugin for Paseo 0.9 and later that gives each worktree a live progress dashboard. Agents record progress with the `paseo-progress` command, which adds one line per change to `.scratch/progress.jsonl` in the worktree. The **Progress** panel draws the dashboard from that file and updates by itself.
3
+ A plugin for Paseo 0.9 and later that gives each worktree a live progress dashboard. Agents record progress with the `paseo-progress` command, which adds one line per change to the worktree's progress file. That file lives outside the repo, so git never sees it. The **Progress** panel draws the dashboard from that file and updates by itself.
4
4
 
5
5
  <img src="images/1-panel.png" alt="The Progress panel in Explorer" width="400">
6
6
 
@@ -18,7 +18,7 @@ The panel lives in Explorer, beside the agent chat, so you can watch both. Open
18
18
 
19
19
  On phones, where Paseo has no Explorer pane, Progress opens as a tab of its own, and the pill stays above the message box while a run is open so there's always a way in. SVG files preview on desktop; on phones they open on the daemon host.
20
20
 
21
- The first time a run starts in a worktree whose Progress panel has never been opened, the pill shows a spinner and reads **Progress** instead; pressing it opens the panel. Once the panel has been opened there, the plugin writes an empty `.scratch/progress-panel-opened` and the nudge never comes back, even for later runs. It doesn't open the panel by itself because Paseo switches to a workspace to open its panel, which would pull you away from whatever you're looking at.
21
+ The first time a run starts in a worktree whose Progress panel has never been opened, the pill shows a spinner and reads **Progress** instead; pressing it opens the panel. Once the panel has been opened there, the plugin writes an empty `progress-panel-opened` file next to the progress file and the nudge never comes back, even for later runs. It doesn't open the panel by itself because Paseo switches to a workspace to open its panel, which would pull you away from whatever you're looking at.
22
22
 
23
23
  ## Install
24
24
 
@@ -41,7 +41,19 @@ node "$([ -f "$dir/dist/server/cli.js" ] && echo "$dir/dist/server/cli.js" || ec
41
41
 
42
42
  If `paseo` isn't on your `PATH`, use `/Applications/Paseo.app/Contents/Resources/bin/paseo`. The command doesn't name a plugin folder: each run asks Paseo (`paseo plugin ls`) which copy of the plugin it is running and runs that copy, so it keeps working after updates and `npm run dev` switches. The npm package carries the command compiled to JavaScript in `dist/`, because Node won't run TypeScript from `node_modules`.
43
43
 
44
- The plugin keeps each worktree's progress in `.scratch/progress.jsonl`. The first time it creates `.scratch/`, it also writes `.scratch/.gitignore` naming only its own files, so git ignores them without any change to your repository's `.gitignore`. If `.scratch/.gitignore` already exists, the plugin leaves it alone.
44
+ ## Where progress is kept
45
+
46
+ Each worktree's progress lives outside the repo, in its own folder under `~/.local/state/paseo-progress/` on the daemon host. There's nothing to set up and nothing to hide: git never sees these files, and the plugin never edits a `.gitignore` or git's exclude file. The panel's empty state shows the exact path.
47
+
48
+ ### Saving run history to the repo
49
+
50
+ If you want to keep runs, at the bottom of the panel press **Save to repo…** and pick a folder (or name a new one). From then on, each run is saved as its own file there, like `history/20261001-064512-export-feature.jsonl`, and updated as the run goes. Commit them like any other file. One file per run means two branches never write the same file, so merges don't conflict. The lock and the panel marker never go there.
51
+
52
+ The choice covers every worktree of the repo and is kept in the repo's local git config (`paseo-progress.saveRuns`), which nothing commits. **Change…** picks another folder for future runs; **Stop saving** turns it off. Files already saved are never moved or deleted.
53
+
54
+ ### Upgrading from 0.1.x
55
+
56
+ Earlier versions kept progress in `.scratch/` in each worktree, hidden by a `.scratch/.gitignore` they wrote. The first time the command or the panel touches a worktree after the upgrade, the plugin moves its files out of `.scratch/` and removes that `.gitignore` if it's exactly the one the plugin wrote. If `.scratch/` is then empty, it goes too; anything else you keep there stays. If git tracks the old progress file (for example, an agent committed it), the plugin leaves it where it is and keeps using it, so nothing changes in that repo.
45
57
 
46
58
  ## Agent skill
47
59
 
@@ -86,13 +98,14 @@ paseo-progress finish "Snapshots ship for all four tables"
86
98
 
87
99
  ## The progress file
88
100
 
89
- `.scratch/progress.jsonl` holds one JSON event per line, each with a version (`v`), a UTC timestamp (`ts`) from the real clock, and a `type`. It is only ever appended to, so you can read or diff the history. A half-written last line is ignored. Other bad lines are skipped and listed in a small notice in the panel while the rest still renders. A lock keeps two commands from writing at once or handing out the same id.
101
+ The progress file, `progress.jsonl`, holds one JSON event per line, each with a version (`v`), a UTC timestamp (`ts`) from the real clock, and a `type`. It is only ever appended to, so you can read or diff the history. A half-written last line is ignored. Other bad lines are skipped and listed in a small notice in the panel while the rest still renders. A lock keeps two commands from writing at once or handing out the same id.
90
102
 
91
103
  ## Limitations
92
104
 
93
105
  - Nothing updates unless the agent runs `paseo-progress`. Agents need the skill, and an agent that skips the command leaves the dashboard behind.
94
106
  - The command runs on the machine that hosts the Paseo daemon, and it needs Node.js 22.18 or later there.
95
- - Each worktree keeps its own dashboard in `.scratch/progress.jsonl`. There's no view across worktrees.
107
+ - Each worktree keeps its own dashboard. There's no view across worktrees.
108
+ - Progress folders outside the repo stay behind when a worktree is deleted. They're small, and you can delete them from `~/.local/state/paseo-progress/`.
96
109
  - Paseo can't open a panel without switching you to its workspace. So a new run shows a "Progress" pill, and you open the panel yourself.
97
110
  - Pills check busy worktrees every 5 seconds and quiet ones every 30. In a quiet worktree, a new question can take up to half a minute to show.
98
111
  - Previews cover images up to 10 MB and the first 256 KB of a text file. Other files open in their default app.
@@ -16,10 +16,11 @@ export function isPreviewable(attachment: Attachment): boolean {
16
16
  return canPreview(attachment, { browser: Platform.OS === "web" });
17
17
  }
18
18
 
19
- // Full path on the daemon host for a stored path.
20
- export function absolutePath(path: string, workspaceDirectory: string): string {
19
+ // Full path on the daemon host for a stored path. `root` is the worktree
20
+ // root the dashboard was read from, not the workspace directory.
21
+ export function absolutePath(path: string, root: string): string {
21
22
  if (path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path)) return path;
22
- const base = workspaceDirectory.replace(/[\\/]+$/, "");
23
+ const base = root.replace(/[\\/]+$/, "");
23
24
  return path === "./" ? `${base}/` : `${base}/${path}`;
24
25
  }
25
26
 
@@ -0,0 +1,157 @@
1
+ import { type PluginWorkspacePanelProps, useRpc } from "@getpaseo/plugin/client";
2
+ import { Icon, Modal, useToast } from "@getpaseo/plugin/client/react-native";
3
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4
+ import React, { useState } from "react";
5
+ import { ActivityIndicator, ScrollView, Text, TextInput, View } from "react-native";
6
+ import { PressScale } from "./motion";
7
+ import { PressableRow } from "./row";
8
+ import { listFolders, setHistoryFolder } from "../shared/rpc";
9
+
10
+ type Colors = PluginWorkspacePanelProps["theme"]["colors"];
11
+
12
+ // Run history stays on this computer unless the repo opts in. This quiet row
13
+ // is the only place that changes it; nothing in the repo changes until then.
14
+ export function HistoryRow({ colors, workspaceId, directory, savedTo }: {
15
+ colors: Colors;
16
+ workspaceId: string;
17
+ directory: string;
18
+ savedTo: string | null;
19
+ }) {
20
+ const [picking, setPicking] = useState(false);
21
+ const set = useRpc(setHistoryFolder);
22
+ const queryClient = useQueryClient();
23
+ const toast = useToast();
24
+ const mutation = useMutation({
25
+ mutationFn: (folder: string | null) => set({ workspaceId, workspaceDirectory: directory, folder }),
26
+ onSuccess: ({ savedTo: next }) => {
27
+ queryClient.invalidateQueries({ queryKey: ["progress", "dashboard"] });
28
+ toast.show(next ? `Each run will be saved to ${next}.` : "Runs will stay on this computer. Files already saved stay in the repo.", { variant: "success" });
29
+ },
30
+ onError: (error) => toast.error(error instanceof Error ? error.message : "Could not change where runs are saved."),
31
+ });
32
+ // While saving, the button that started it says so.
33
+ const busy = mutation.isPending ? (mutation.variables === null ? "stop" : "pick") : null;
34
+ const actions: { label: string; busyLabel: string; kind: "pick" | "stop"; onPress(): void }[] = savedTo
35
+ ? [{ label: "Change…", busyLabel: "Saving…", kind: "pick", onPress: () => setPicking(true) }, { label: "Stop saving", busyLabel: "Stopping…", kind: "stop", onPress: () => mutation.mutate(null) }]
36
+ : [{ label: "Save to repo…", busyLabel: "Saving…", kind: "pick", onPress: () => setPicking(true) }];
37
+
38
+ return (
39
+ <View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", columnGap: 8, rowGap: 4, marginHorizontal: 12, marginTop: 16 }}>
40
+ <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 6, flex: 1, minWidth: 180 }}>
41
+ <View style={{ paddingTop: 2 }}><Icon name="History" size={13} color={colors.foregroundMuted} /></View>
42
+ <Text style={{ flex: 1, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
43
+ {savedTo ? <>Each run is saved to <Text style={{ color: colors.foreground }}>{savedTo}</Text> in this repo.</> : "Run history stays on this computer."}
44
+ </Text>
45
+ </View>
46
+ <View style={{ flexDirection: "row", gap: 4 }}>
47
+ {actions.map((action) => (
48
+ <PressScale key={action.label} accessibilityRole="button" disabled={mutation.isPending} onPress={action.onPress}
49
+ style={({ pressed, hovered }) => ({ paddingHorizontal: 8, paddingVertical: 4, borderRadius: 4, opacity: mutation.isPending ? 0.7 : 1,
50
+ backgroundColor: pressed ? colors.border : hovered ? colors.surface2 : "transparent" })}>
51
+ <Text style={{ color: colors.foreground, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>{busy === action.kind ? action.busyLabel : action.label}</Text>
52
+ </PressScale>
53
+ ))}
54
+ </View>
55
+ <FolderPicker key={picking ? "open" : "closed"} colors={colors} workspaceId={workspaceId} directory={directory} open={picking} start={savedTo ?? ""}
56
+ onClose={() => setPicking(false)} onPick={(folder) => { setPicking(false); mutation.mutate(folder); }} />
57
+ </View>
58
+ );
59
+ }
60
+
61
+ // Browses the repo's folders on the daemon host. A new folder only needs a
62
+ // name: it's made the first time a run is saved there.
63
+ function FolderPicker({ colors, workspaceId, directory, open, start, onClose, onPick }: {
64
+ colors: Colors;
65
+ workspaceId: string;
66
+ directory: string;
67
+ open: boolean;
68
+ // The folder chosen so far; browsing starts in the folder that holds it.
69
+ start: string;
70
+ onClose(): void;
71
+ onPick(folder: string): void;
72
+ }) {
73
+ const parentOf = (path: string) => path.split("/").slice(0, -1).join("/");
74
+ const [at, setAt] = useState(() => parentOf(start));
75
+ const [naming, setNaming] = useState<string | null>(null);
76
+ const list = useRpc(listFolders);
77
+ const listed = useQuery({
78
+ queryKey: ["progress", "folders", workspaceId, directory, at],
79
+ queryFn: () => list({ workspaceId, workspaceDirectory: directory, folder: at }),
80
+ enabled: open,
81
+ });
82
+ const join = (name: string) => (at ? `${at}/${name}` : name);
83
+ const crumbs = at ? at.split("/") : [];
84
+ const close = () => { setNaming(null); setAt(parentOf(start)); onClose(); };
85
+ const newName = naming?.trim().replace(/\/+$/, "") ?? "";
86
+
87
+ return (
88
+ <Modal title="Save run history to a folder" icon={<Icon name="FolderOpen" size={16} color={colors.foregroundMuted} />} open={open} onOpenChange={(next) => { if (!next) close(); }}>
89
+ {/* Paseo sizes a non-scrolling dialog to 85% of the window, so let it size to
90
+ its content; the list's own height cap keeps the buttons in view. */}
91
+ <Modal.Content>
92
+ <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
93
+ Each run becomes its own file in this folder, in every worktree of this repo. Commit them like any other file.
94
+ </Text>
95
+ <View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 2, paddingHorizontal: 4 }}>
96
+ {["Top of the repo", ...crumbs].map((crumb, index) => (
97
+ <React.Fragment key={index}>
98
+ {index ? <Icon name="ChevronRight" size={12} color={colors.foregroundMuted} /> : null}
99
+ <PressScale accessibilityRole="button" disabled={index === crumbs.length} onPress={() => { setNaming(null); setAt(crumbs.slice(0, index).join("/")); }}
100
+ style={({ pressed, hovered }) => ({ paddingHorizontal: 4, paddingVertical: 2, borderRadius: 4,
101
+ backgroundColor: index === crumbs.length ? "transparent" : pressed ? colors.border : hovered ? colors.surface2 : "transparent" })}>
102
+ <Text style={{ color: index === crumbs.length ? colors.foreground : colors.foregroundMuted, fontSize: 12, lineHeight: 16, fontWeight: index === crumbs.length ? "600" : "400" }}>{crumb}</Text>
103
+ </PressScale>
104
+ </React.Fragment>
105
+ ))}
106
+ </View>
107
+
108
+ {/* Only the list scrolls, so Use and Cancel stay in view in a big repo. */}
109
+ <ScrollView style={{ maxHeight: 320, flexGrow: 0 }} contentContainerStyle={{ gap: 2 }}>
110
+ {listed.isPending ? <ActivityIndicator color={colors.foregroundMuted} accessibilityLabel="Loading folders" style={{ alignSelf: "flex-start", padding: 8 }} /> : null}
111
+ {listed.data?.folders.map((name) => (
112
+ <PressableRow key={name} colors={colors} accessibilityRole="button" accessibilityLabel={`Open ${name}`} onPress={() => { setNaming(null); setAt(join(name)); }}
113
+ style={{ flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 8, paddingVertical: 6, borderRadius: 6 }}>
114
+ <Icon name="Folder" size={14} color={colors.foregroundMuted} />
115
+ <Text numberOfLines={1} ellipsizeMode="middle" style={{ flex: 1, color: colors.foreground, fontSize: 12, lineHeight: 16 }}>{name}</Text>
116
+ <Icon name="ChevronRight" size={14} color={colors.foregroundMuted} />
117
+ </PressableRow>
118
+ ))}
119
+ {listed.data && !listed.data.folders.length ? (
120
+ <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 16, paddingHorizontal: 8, paddingVertical: 6 }}>No folders in here.</Text>
121
+ ) : null}
122
+ {listed.error ? <Text accessibilityRole="alert" style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 17, paddingHorizontal: 8 }}>{listed.error.message}</Text> : null}
123
+ </ScrollView>
124
+
125
+ {naming === null ? (
126
+ <PressScale accessibilityRole="button" onPress={() => setNaming("")}
127
+ style={({ pressed, hovered }) => ({ alignSelf: "flex-start", flexDirection: "row", alignItems: "center", gap: 6, paddingHorizontal: 8, paddingVertical: 5, borderRadius: 6, backgroundColor: pressed ? colors.border : hovered ? colors.surface2 : "transparent" })}>
128
+ <Icon name="FolderPlus" size={14} color={colors.foreground} />
129
+ <Text style={{ color: colors.foreground, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>New folder</Text>
130
+ </PressScale>
131
+ ) : (
132
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
133
+ <TextInput autoFocus value={naming} onChangeText={setNaming} onSubmitEditing={() => { if (newName) onPick(join(newName)); }}
134
+ autoCapitalize="none" autoCorrect={false} spellCheck={false} accessibilityLabel="New folder name" placeholder="progress-history" placeholderTextColor={colors.foregroundMuted}
135
+ style={{ flex: 1, color: colors.foreground, fontSize: 12, lineHeight: 16, paddingHorizontal: 8, paddingVertical: 6, borderRadius: 6, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface1 }} />
136
+ <PressScale accessibilityRole="button" disabled={!newName} onPress={() => onPick(join(newName))}
137
+ style={({ pressed, hovered }) => ({ paddingHorizontal: 10, paddingVertical: 6, borderRadius: 6, backgroundColor: colors.accent, opacity: !newName ? 0.5 : pressed ? 0.7 : hovered ? 0.85 : 1 })}>
138
+ <Text style={{ color: colors.accentForeground, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>Use new folder</Text>
139
+ </PressScale>
140
+ </View>
141
+ )}
142
+
143
+ <View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", justifyContent: "flex-end", gap: 8 }}>
144
+ {at ? null : <Text style={{ flex: 1, minWidth: 140, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>Open a folder to use it, or make a new one.</Text>}
145
+ <PressScale accessibilityRole="button" onPress={close}
146
+ style={({ pressed, hovered }) => ({ paddingHorizontal: 12, paddingVertical: 6, borderRadius: 6, backgroundColor: pressed ? colors.border : hovered ? colors.surface2 : "transparent" })}>
147
+ <Text style={{ color: colors.foreground, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>Cancel</Text>
148
+ </PressScale>
149
+ <PressScale accessibilityRole="button" disabled={!at} onPress={() => onPick(at)} outerStyle={{ flexShrink: 1 }}
150
+ style={({ pressed, hovered }) => ({ paddingHorizontal: 12, paddingVertical: 6, borderRadius: 6, backgroundColor: colors.accent, opacity: !at ? 0.5 : pressed ? 0.7 : hovered ? 0.85 : 1 })}>
151
+ <Text numberOfLines={1} ellipsizeMode="middle" style={{ color: colors.accentForeground, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>{at ? `Use ${crumbs[crumbs.length - 1]}` : "Use folder"}</Text>
152
+ </PressScale>
153
+ </View>
154
+ </Modal.Content>
155
+ </Modal>
156
+ );
157
+ }
package/client/panel.tsx CHANGED
@@ -2,6 +2,7 @@ import { type PluginWorkspacePanelProps, useRpc, useWorkspace } from "@getpaseo/
2
2
  import { Icon } from "@getpaseo/plugin/client/react-native";
3
3
  import { DeliverablesList } from "./deliverables";
4
4
  import { LauncherBanner, SkillBanner } from "./launcher";
5
+ import { HistoryRow } from "./history";
5
6
  import { AnsweredQuestionsList, QuestionsSection } from "./questions";
6
7
  import { Spinner, StalledPulse, WaitingDot } from "./spinner";
7
8
  import { IconSwap, Presence, StaggerRoot, nativeDriver } from "./motion";
@@ -11,7 +12,7 @@ import { When } from "./when";
11
12
  import React, { useEffect, useRef, useState } from "react";
12
13
  import { ActivityIndicator, Animated, Platform, Pressable, ScrollView, Text, View } from "react-native";
13
14
  import { finishedToFold, isFinished, shortTitle, type Activity, type Dashboard, type FileIssue, type ProgressSegment, type StuckItem, type Ticket } from "../shared/dashboard";
14
- import { PROGRESS_FILE, type TicketStatus } from "../shared/events";
15
+ import type { TicketStatus } from "../shared/events";
15
16
  import { formatEstimate, formatMinutes, formatWorkDone, minutesSince } from "../shared/format";
16
17
  import { markPanelOpened } from "../shared/rpc";
17
18
  import { useDashboard } from "./dashboard-query";
@@ -51,10 +52,11 @@ function WorkspaceProgress({ theme, workspaceId, host, layout, navigation }: Plu
51
52
  <LauncherBanner colors={colors} host={host} />
52
53
  <SkillBanner colors={colors} host={host} />
53
54
  {/* Fades in like the dashboard's sections instead of popping in after the spinner. */}
54
- <Presence show={Boolean(result && !result.configured)}>{result && !result.configured ? <EmptyState colors={colors} /> : null}</Presence>
55
+ <Presence show={Boolean(result && !result.configured)}>{result && !result.configured ? <EmptyState colors={colors} file={result.file} /> : null}</Presence>
55
56
  {/* Keyed by run: a new run starts with fresh tabs, pages and dialogs. */}
56
57
  {result?.configured ? <DashboardView key={result.dashboard.run?.startedAt ?? "none"} colors={colors} dashboard={result.dashboard} agentRunning={agentRunning} compact={layout.compact}
57
- workspaceId={workspaceId} workspaceDirectory={directory ?? ""} navigation={navigation} /> : null}
58
+ workspaceId={workspaceId} workspaceDirectory={result.root} file={result.file} navigation={navigation} /> : null}
59
+ {result?.inRepo && directory ? <HistoryRow colors={colors} workspaceId={workspaceId} directory={directory} savedTo={result.savedTo} /> : null}
58
60
  </ScrollView>
59
61
  );
60
62
  }
@@ -74,7 +76,7 @@ function useMarkPanelOpened(workspaceId: string, directory: string | null, neede
74
76
  }, [needed, directory, workspaceId, mark]);
75
77
  }
76
78
 
77
- function EmptyState({ colors }: { colors: Colors }) {
79
+ function EmptyState({ colors, file }: { colors: Colors; file: string }) {
78
80
  return (
79
81
  <View style={{ padding: 12, paddingTop: 20, gap: 10 }}>
80
82
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
@@ -91,9 +93,9 @@ function EmptyState({ colors }: { colors: Colors }) {
91
93
  {`paseo-progress start "Build the export feature"\npaseo-progress ticket add "Ticket 01: Export button" --estimate 60`}
92
94
  </Text>
93
95
  </View>
94
- {/* So a new .scratch folder in the repo isn't a surprise. */}
96
+ {/* So people know nothing lands in the repo. */}
95
97
  <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
96
- Progress is saved in <Text style={{ color: colors.foreground }}>.scratch/progress.jsonl</Text> in this worktree. The plugin adds a <Text style={{ color: colors.foreground }}>.gitignore</Text> there, so git leaves its files alone.
98
+ Progress is saved outside the repo, in <Text selectable style={{ color: colors.foreground }}>{file}</Text>, so git never sees it.
97
99
  </Text>
98
100
  </View>
99
101
  );
@@ -122,13 +124,14 @@ function useNow(intervalMs: number): number {
122
124
 
123
125
  const WIDE_MIN = 760;
124
126
 
125
- function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId, workspaceDirectory, navigation }: {
127
+ function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId, workspaceDirectory, file, navigation }: {
126
128
  colors: Colors;
127
129
  dashboard: Dashboard;
128
130
  agentRunning: boolean;
129
131
  compact: boolean;
130
132
  workspaceId: string;
131
133
  workspaceDirectory: string;
134
+ file: string;
132
135
  navigation: PluginWorkspacePanelProps["navigation"];
133
136
  }) {
134
137
  const now = useNow(15_000);
@@ -198,7 +201,7 @@ function DashboardView({ colors, dashboard, agentRunning, compact, workspaceId,
198
201
  </Text>
199
202
  </View>
200
203
  ) : null}</Presence>
201
- <Presence show={Boolean(dashboard.issues.length)} order={1}>{dashboard.issues.length ? <IssuesNotice colors={colors} issues={dashboard.issues} /> : null}</Presence>
204
+ <Presence show={Boolean(dashboard.issues.length)} order={1}>{dashboard.issues.length ? <IssuesNotice colors={colors} issues={dashboard.issues} file={file} /> : null}</Presence>
202
205
  <Presence show={Boolean(dashboard.progress.totalMin > 0)} order={2}>{dashboard.progress.totalMin > 0 ? <ProgressBar colors={colors} progress={dashboard.progress} live={live} /> : null}</Presence>
203
206
  <View onLayout={(event) => setWide(event.nativeEvent.layout.width >= WIDE_MIN)}
204
207
  style={{ flexDirection: wide ? "row" : "column", alignItems: "flex-start" }}>
@@ -314,13 +317,13 @@ function ActivityList({ colors, activity, now }: { colors: Colors; activity: Act
314
317
  }
315
318
 
316
319
  // Small and non-blocking: the rest of the dashboard still renders.
317
- function IssuesNotice({ colors, issues }: { colors: Colors; issues: FileIssue[] }) {
320
+ function IssuesNotice({ colors, issues, file }: { colors: Colors; issues: FileIssue[]; file: string }) {
318
321
  const shown = issues.slice(0, 5);
319
322
  return (
320
323
  <View style={{ flexDirection: "row", gap: 8, margin: 12, marginBottom: 0, padding: 8, borderRadius: 6, backgroundColor: colors.surface1 }}>
321
324
  <View style={{ paddingTop: 1.5 }}><Icon name="FileWarning" size={14} color={colors.statusWarning} /></View>
322
325
  <Text selectable style={{ flex: 1, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
323
- Skipped {issues.length === 1 ? "1 line" : `${issues.length} lines`} in {PROGRESS_FILE}:{" "}
326
+ Skipped {issues.length === 1 ? "1 line" : `${issues.length} lines`} in {file}:{" "}
324
327
  {shown.map((issue) => `line ${issue.line} (${issue.reason})`).join(", ")}
325
328
  {issues.length > shown.length ? `, and ${issues.length - shown.length} more` : ""}.
326
329
  </Text>
@@ -28,12 +28,14 @@ export function AttentionPopover({ theme, host, layout, workspaceId, close, open
28
28
  const directory = useWorkspace(workspaceId, (workspace) => workspace.directory);
29
29
  const query = useDashboard(host.id, workspaceId, directory);
30
30
  const dashboard = query.data?.configured ? query.data.dashboard : null;
31
+ // Stored paths are relative to the worktree root, which can sit above the workspace directory.
32
+ const root = query.data?.root ?? directory ?? "";
31
33
  const copy = useCopy();
32
34
  // Previews open in a dialog over the popover, which stays open to return to.
33
35
  // Links and other files leave Paseo, so the popover closes for those.
34
36
  const [previewing, setPreviewing] = useState<{ attachment: Attachment; group: Attachment[] } | null>(null);
35
37
  const group = useRef<Attachment[]>([]);
36
- const opener = useAttachmentOpener({ workspaceId, workspaceDirectory: directory ?? "", onPreview: (attachment) => setPreviewing({ attachment, group: previewable(group.current) }) });
38
+ const opener = useAttachmentOpener({ workspaceId, workspaceDirectory: root, onPreview: (attachment) => setPreviewing({ attachment, group: previewable(group.current) }) });
37
39
  return (
38
40
  <View style={{ width: 360, maxWidth: "100%", gap: 8 }}>
39
41
  {dashboard ? (
@@ -52,7 +54,7 @@ export function AttentionPopover({ theme, host, layout, workspaceId, close, open
52
54
  {query.error ? `Could not read progress: ${query.error.message}` : "Loading…"}
53
55
  </Text>
54
56
  )}
55
- <PreviewDialog colors={colors} attachment={previewing?.attachment ?? null} workspaceId={workspaceId} workspaceDirectory={directory ?? ""}
57
+ <PreviewDialog colors={colors} attachment={previewing?.attachment ?? null} workspaceId={workspaceId} workspaceDirectory={root}
56
58
  onClose={() => setPreviewing(null)} onOpenOnHost={(attachment) => void opener.openOnHost(attachment)}
57
59
  gallery={previewing ? { items: previewing.group, onSelect: (attachment) => setPreviewing({ ...previewing, attachment }) } : undefined} />
58
60
  <View style={{ paddingTop: touch ? 12 : 8, borderTopWidth: 1, borderTopColor: colors.border }}>
@@ -1,14 +1,15 @@
1
1
  // The command agents run to record progress. Each command appends one event
2
2
  // to the worktree's progress file and prints what it recorded.
3
- import { existsSync, realpathSync, statSync } from "node:fs";
4
- import { dirname, join, relative, resolve } from "node:path";
3
+ import { realpathSync, statSync } from "node:fs";
4
+ import { relative, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { parseArgs } from "node:util";
7
7
  import { isSettled } from "../shared/dashboard.js";
8
- import { DELIVERABLE_KINDS, PROGRESS_FILE, TICKET_STATUSES } from "../shared/events.js";
8
+ import { DELIVERABLE_KINDS, TICKET_STATUSES } from "../shared/events.js";
9
9
  import { appendProgress, InvalidEvent, readProgress } from "./progress-file.js";
10
- import { outside } from "./paths.js";
10
+ import { findRoot, outside } from "./paths.js";
11
11
  import { installLauncher } from "./launcher.js";
12
+ import { findStore, saveRunCopy } from "./store.js";
12
13
  import { dashboardText } from "../shared/show.js";
13
14
  import { imageMimeType } from "../shared/attachments.js";
14
15
  import { homedir } from "node:os";
@@ -426,7 +427,7 @@ function help() {
426
427
  return [
427
428
  `Usage: ${USAGE_NAME} <command>`,
428
429
  "",
429
- `Records progress in ${PROGRESS_FILE} at the worktree root, shown by the Progress panel in Paseo.`,
430
+ `Records progress for this worktree, shown by the Progress panel in Paseo. The file lives outside the repo, so git never sees it.`,
430
431
  `Add --help after any command for its options.`,
431
432
  "",
432
433
  ...Object.values(commands).flatMap((command) => [` ${command.usage}`, ` ${command.summary}`]),
@@ -436,19 +437,6 @@ function help() {
436
437
  ` ${INSTALL_SUMMARY}`,
437
438
  ].join("\n");
438
439
  }
439
- // The worktree root is the nearest directory with a `.git` entry (a folder in
440
- // a checkout, a file in a linked worktree). Outside git, use the directory.
441
- export function findRoot(cwd) {
442
- let directory = resolve(cwd);
443
- while (true) {
444
- if (existsSync(join(directory, ".git")))
445
- return directory;
446
- const parent = dirname(directory);
447
- if (parent === directory)
448
- return resolve(cwd);
449
- directory = parent;
450
- }
451
- }
452
440
  export async function runCli(argv, options) {
453
441
  const out = options.out ?? ((line) => console.log(line));
454
442
  const now = options.now ?? (() => new Date());
@@ -464,7 +452,8 @@ export async function runCli(argv, options) {
464
452
  }
465
453
  if (first === "show") {
466
454
  const root = findRoot(options.cwd);
467
- out(`Worktree: ${root}\n\n${dashboardText((await readProgress(root, now())).dashboard, now().getTime())}`);
455
+ const store = await findStore(root);
456
+ out(`Worktree: ${root}\n\n${dashboardText((await readProgress(store.directory, now())).dashboard, now().getTime())}`);
468
457
  return 0;
469
458
  }
470
459
  const target = resolve(options.cwd, (argv[1] ?? DEFAULT_LAUNCHER).replace(/^~(?=$|\/)/, homedir()));
@@ -491,14 +480,15 @@ export async function runCli(argv, options) {
491
480
  throw new UsageError(error.message);
492
481
  }
493
482
  const root = findRoot(options.cwd);
494
- const message = await appendProgress(root, now, (state) => {
483
+ const store = await findStore(root);
484
+ const message = await appendProgress(store.directory, now, (state) => {
495
485
  const finished = state.dashboard.run?.finished;
496
486
  if (finished && command !== commands.start) {
497
487
  throw new UsageError(`The run "${state.dashboard.run.title}" is finished. Start new work with: ${USAGE_NAME} start "<title>"`);
498
488
  }
499
489
  const { event, message } = command.run({ positionals: parsed.positionals, values: parsed.values, state, cwd: options.cwd, root });
500
490
  return { event, result: message };
501
- });
491
+ }, (text) => saveRunCopy(root, text));
502
492
  out(message);
503
493
  return 0;
504
494
  }
@@ -1,6 +1,22 @@
1
- import { isAbsolute, sep } from "node:path";
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
2
3
  // Whether a path from relative(root, target) leaves the root. "..notes" is a
3
4
  // name inside it, not a step out.
4
5
  export function outside(inside) {
5
6
  return inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside);
6
7
  }
8
+ // The worktree root is the nearest directory with a `.git` entry (a folder in
9
+ // a checkout, a file in a linked worktree). Outside git, use the directory.
10
+ // The command and the panel both start from here, so a Paseo project set at a
11
+ // subfolder of the repo still reads the file the agent writes.
12
+ export function findRoot(cwd) {
13
+ let directory = resolve(cwd);
14
+ while (true) {
15
+ if (existsSync(join(directory, ".git")))
16
+ return directory;
17
+ const parent = dirname(directory);
18
+ if (parent === directory)
19
+ return resolve(cwd);
20
+ directory = parent;
21
+ }
22
+ }
@@ -1,26 +1,17 @@
1
1
  // Everything that reads or writes a worktree's progress files: the event log
2
- // the command appends to, the panel-opened marker, and the .gitignore that keeps
3
- // both out of git. The command and the panel's RPC handlers go through here, so
4
- // they always agree on where the files are and how they're read.
2
+ // the command appends to and the panel-opened marker. Each function takes the
3
+ // folder that holds them, which `findStore` in store.ts picks. The command and
4
+ // the panel's RPC handlers go through here, so they agree on how they're read.
5
5
  import { access, appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
6
- import { basename, dirname, join } from "node:path";
6
+ import { join } from "node:path";
7
7
  import { EMPTY_DASHBOARD, parseProgress, reduceProgress } from "../shared/dashboard.js";
8
- import { EVENT_VERSION, eventSchema, PANEL_OPENED_FILE, PROGRESS_FILE } from "../shared/events.js";
8
+ import { EVENT_VERSION, eventSchema, PANEL_OPENED_NAME, PROGRESS_FILE_NAME } from "../shared/events.js";
9
9
  import { withLock } from "./lock.js";
10
- const SCRATCH_DIR = dirname(PROGRESS_FILE);
11
- const LOCK_FILE = `${PROGRESS_FILE}.lock`;
12
- // Names only this plugin's files, so they stay out of git without the user
13
- // editing their own .gitignore. Other files in .scratch/ are left to the repo.
14
- export const SCRATCH_GITIGNORE = `# Added by the progress plugin for Paseo so its files stay out of git.
15
- ${basename(PROGRESS_FILE)}
16
- ${basename(LOCK_FILE)}/
17
- ${basename(PANEL_OPENED_FILE)}
18
- .gitignore
19
- `;
10
+ const LOCK_NAME = `${PROGRESS_FILE_NAME}.lock`;
20
11
  // A draft the event schema rejects. The message names each bad field.
21
12
  export class InvalidEvent extends Error {
22
13
  }
23
- // The parsed file per worktree, kept until its size or change time moves. The
14
+ // The parsed file per folder, kept until its size or change time moves. The
24
15
  // file only grows, and the panel and pills read it every few seconds.
25
16
  const parsedFiles = new Map();
26
17
  // Replaced file snapshots and their derived data can be collected together.
@@ -29,9 +20,9 @@ const dashboards = new WeakMap();
29
20
  // reuse the last one until the file changes or a stale or overdue time passes.
30
21
  // Stale and overdue checks use the daemon host's clock, the same clock the
31
22
  // command stamps events with.
32
- export async function readProgress(root, now = new Date()) {
33
- const parsed = await readParsed(root);
34
- const panelOpened = await exists(join(root, PANEL_OPENED_FILE));
23
+ export async function readProgress(directory, now = new Date()) {
24
+ const parsed = await readParsed(directory);
25
+ const panelOpened = await exists(join(directory, PANEL_OPENED_NAME));
35
26
  if (parsed === null)
36
27
  return { configured: false, dashboard: EMPTY_DASHBOARD, panelOpened };
37
28
  let cached = dashboards.get(parsed);
@@ -46,10 +37,12 @@ export async function readProgress(root, now = new Date()) {
46
37
  // Appends the one event `write` builds from the file as it is now. Commands
47
38
  // that run at once take turns, so each sees the others' events and no two hand
48
39
  // out the same id. `write` may throw to append nothing.
49
- export async function appendProgress(root, now, write) {
50
- await prepareScratch(root);
51
- return withLock(join(root, LOCK_FILE), async () => {
52
- const text = (await readText(root)) ?? "";
40
+ export async function appendProgress(directory, now, write,
41
+ // Runs before the lock is released, with the whole file as it now is.
42
+ after) {
43
+ await mkdir(directory, { recursive: true });
44
+ return withLock(join(directory, LOCK_NAME), async () => {
45
+ const text = (await readText(directory)) ?? "";
53
46
  const { dashboard, nextIds } = reduceProgress(parseProgress(text), now());
54
47
  const { event, result } = write({ dashboard, nextIds });
55
48
  const checked = eventSchema.safeParse({ v: EVENT_VERSION, ts: now().toISOString(), ...withoutUndefined(event) });
@@ -58,27 +51,19 @@ export async function appendProgress(root, now, write) {
58
51
  }
59
52
  // A line cut short (a killed command, a hand edit) would swallow this event, so start a new line.
60
53
  const lead = text && !text.endsWith("\n") ? "\n" : "";
61
- await appendFile(join(root, PROGRESS_FILE), `${lead}${JSON.stringify(checked.data)}\n`);
54
+ const line = `${lead}${JSON.stringify(checked.data)}\n`;
55
+ await appendFile(join(directory, PROGRESS_FILE_NAME), line);
56
+ await after?.(text + line);
62
57
  return result;
63
58
  });
64
59
  }
65
- export async function markPanelOpened(root) {
66
- await prepareScratch(root);
67
- await writeFile(join(root, PANEL_OPENED_FILE), "");
68
- }
69
- // Creates .scratch/ in the worktree and, the first time, its .gitignore. An
70
- // existing .gitignore is the user's, so it is never touched.
71
- async function prepareScratch(root) {
72
- const directory = join(root, SCRATCH_DIR);
60
+ export async function markPanelOpened(directory) {
73
61
  await mkdir(directory, { recursive: true });
74
- await writeFile(join(directory, ".gitignore"), SCRATCH_GITIGNORE, { flag: "wx" }).catch((error) => {
75
- if (error.code !== "EEXIST")
76
- throw error;
77
- });
62
+ await writeFile(join(directory, PANEL_OPENED_NAME), "");
78
63
  }
79
- async function readText(root) {
64
+ async function readText(directory) {
80
65
  try {
81
- return await readFile(join(root, PROGRESS_FILE), "utf8");
66
+ return await readFile(join(directory, PROGRESS_FILE_NAME), "utf8");
82
67
  }
83
68
  catch (error) {
84
69
  if (error.code === "ENOENT")
@@ -86,27 +71,27 @@ async function readText(root) {
86
71
  throw error;
87
72
  }
88
73
  }
89
- async function readParsed(root) {
90
- const info = await stat(join(root, PROGRESS_FILE)).catch((error) => {
74
+ async function readParsed(directory) {
75
+ const info = await stat(join(directory, PROGRESS_FILE_NAME)).catch((error) => {
91
76
  if (error.code === "ENOENT")
92
77
  return null;
93
78
  throw error;
94
79
  });
95
80
  if (!info) {
96
- parsedFiles.delete(root);
81
+ parsedFiles.delete(directory);
97
82
  return null;
98
83
  }
99
- const cached = parsedFiles.get(root);
84
+ const cached = parsedFiles.get(directory);
100
85
  if (cached && cached.size === info.size && cached.mtimeMs === info.mtimeMs && cached.ctimeMs === info.ctimeMs)
101
86
  return cached.parsed;
102
87
  // Stat before reading: if the file grows in between, the next stat won't match and it's read again.
103
- const text = await readText(root);
88
+ const text = await readText(directory);
104
89
  if (text === null) {
105
- parsedFiles.delete(root);
90
+ parsedFiles.delete(directory);
106
91
  return null;
107
92
  }
108
93
  const parsed = parseProgress(text);
109
- parsedFiles.set(root, { size: info.size, mtimeMs: info.mtimeMs, ctimeMs: info.ctimeMs, parsed });
94
+ parsedFiles.set(directory, { size: info.size, mtimeMs: info.mtimeMs, ctimeMs: info.ctimeMs, parsed });
110
95
  return parsed;
111
96
  }
112
97
  async function exists(path) {