Skip to content

Bump yannelli/prompt-manager to v0.4.1 - #179

Merged
paseo-bot[bot] merged 1 commit into
mainfrom
bump/yannelli/prompt-manager-v0.4.1
Oct 7, 2026
Merged

paseo-bot[bot] merged 1 commit into
mainfrom
bump/yannelli/prompt-manager-v0.4.1

Conversation

@github-actions

@github-actions github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

yannelli/prompt-manager: {"kind":"git","remote":"https://github.com/yannelli/paseo-prompt-manager.git","commit":"f6a724bf56b85adc42373ea68e2b6f46ce6d11b0","tag":"v0.3.0"} -> {"kind":"git","remote":"https://github.com/yannelli/paseo-prompt-manager.git","commit":"f3af8cf5ba5a60523c02507aa17f52bd307fa7d0","tag":"v0.4.1"}
Git tag v0.4.1 pins commit f3af8cf5ba5a60523c02507aa17f52bd307fa7d0.
Original submitter is not recorded; refer to the source owner.

Merging approves this version. The published index keeps pointing at the previous one until then.

Artifact diff (truncated; the full diff is in the workflow artifact)
diff --git a/OVERVIEW.md b/OVERVIEW.md
new file mode 100644
index 0000000..2233424
--- /dev/null
+++ b/OVERVIEW.md
@@ -0,0 +1,40 @@
+Prompt Manager keeps a library of Markdown prompts on the machine that runs the Paseo daemon, with search, folders, tags, and a **Prompts** item in the sidebar. The library is shared by all clients and agents of that daemon. Each save keeps a full snapshot of the prompt, and you can view a snapshot and restore it as a new version.
+
+A prompt is sent to an agent only when you choose it, from the composer, an attachment, or a slash command. The agent receives the prompt body. If a file was edited outside the plugin, saving a stale draft is rejected instead of overwriting it.
+
+## Setup
+
+Prompt Manager requires Paseo 0.11.0-beta.3 or later on the daemon and on each app that shows it. Local use needs no other setup.
+
+Git sync is optional. It needs:
+
+- Git on the daemon host, with your Git author name and email set.
+- A Git remote to sync across machines. It can be empty or already hold prompts from another machine. Without one, sync makes local commits only.
+- Remote credentials that work without a prompt, such as a credential helper for HTTPS or an SSH agent for SSH. Sync never asks for a password or passphrase, so a remote that needs one fails.
+
+## Git sync
+
+Git sync is off by default. Turn it on in **Prompts → Settings** and save. Then paste a remote URL and choose **Set up**. Leave the branch blank to use the remote's default branch. You can change the remote URL or branch later. Clearing the URL removes `origin`.
+
+Under **When to sync**, choose a mode:
+
+- **Manual**: syncs only when you choose **Sync now**. This is the default.
+- **Automatic**: syncs about 5 seconds after the last change made in the plugin, and when you open the library.
+- **Scheduled**: syncs every 5, 15, 30, or 60 minutes, and when you open the library.
+
+Each sync commits prompt files and version history, merges the remote branch, and pushes. Edits to different prompts on two machines merge. If both machines change the same prompt, the merge is aborted and sync stops with local commits intact. The error shows in the sidebar and settings, and you resolve the conflict with Git, then sync again.
+
+## What it reads and sends
+
+- It reads and writes the library directory, which is `~/.config/paseo/prompt-lib/` by default, and its settings file, `~/.config/paseo/prompt-manager.json`. If `XDG_CONFIG_HOME` is set, it replaces `~/.config`.
+- **Prompts → Settings** lets you pick another library directory. This opens that directory and does not move existing files.
+- Imports copy Markdown files or folders you select, either from your device in a browser or by path on the daemon host. They skip prompts that already exist and leave the originals in place.
+- The plugin makes no network requests of its own. With Git sync and a remote, Git sends prompt files and version history to that remote and brings files from it into the library, with the host's existing credentials.
+- Git hooks are disabled for every Git command, and no fetched code is run.
+
+## Known limits
+
+- Each import accepts up to 100 Markdown files, 8 MB in total, and 512 KB per file. Host folder scans stop at 5,000 entries. Hidden entries, symbolic links, and `versions` folders are skipped.
+- Automatic mode reacts to changes made in the plugin. Edits made in another editor are committed at the next sync.
+- The library must be the root of its Git repository. Staged files that are not prompts block sync.
+- Merge conflicts must be resolved with Git.
diff --git a/README.md b/README.md
index f25629f..d8d0441 100644
--- a/README.md
+++ b/README.md
@@ -74,12 +74,17 @@ Preferences live at `~/.config/paseo/prompt-manager.json`. If `XDG_CONFIG_HOME`
 
 ## Optional Git sync
 
-1. Configure your Git author name, email, and remote credentials on the daemon host.
+1. Configure your Git author name, email, and remote credentials on the daemon host. Background sync cannot answer password or passphrase prompts, so use a credential helper or an SSH agent.
 2. Turn **Git sync** on in library settings and save.
-3. Choose **Initialize**. Supply an origin URL to sync with a remote, or leave it blank for local Git checkpoints.
-4. Choose **Sync now** to commit prompt files and version history, fetch the remote branch, fast-forward, and push.
+3. Paste a remote URL, such as `git@github.com:you/prompts.git`, and choose **Set up**. The repository can be empty or already hold prompts from another machine. Leave the branch blank to use the remote's default branch. Leave the URL blank for local Git checkpoints only.
+4. Under **When to sync**, choose a mode:
+   - **Manual**: sync only when you choose **Sync now**.
+   - **Automatic**: sync a few seconds after each change.
+   - **Scheduled**: sync every 5, 15, 30, or 60 minutes.
 
-Sync runs when requested. Branch divergence stops the sync and leaves local commits intact. Resolve it with Git, then sync again. The library must be the repository root. Unrelated staged files block sync.
+Automatic and scheduled modes also pull when you open the library. Each sync commits prompt files and version history, merges the remote branch, and pushes. Edits to different prompts on two machines merge. If both machines change the same prompt, sync stops, leaves local commits intact, and shows the error in the sidebar and settings. Resolve the conflict with Git, then sync again. The library must be the repository root. Unrelated staged files block sync.
+
+You can change the remote URL or branch later in settings. Clearing the URL disconnects the remote.
 
 ## Screenshots
 
diff --git a/client/pill.tsx b/client/pill.tsx
index f53fec9..48e765c 100644
--- a/client/pill.tsx
+++ b/client/pill.tsx
@@ -1,12 +1,12 @@
 import type { PluginButtonContentProps, PluginButtonRegistration, PluginClientContext } from "@getpaseo/plugin/client";
 import { usePaseo, useRpc } from "@getpaseo/plugin/client";
 import { Icon, ScrollView, TextInput, useToast } from "@getpaseo/plugin/client/react-native";
-import { useMutation, useQuery } from "@tanstack/react-query";
+import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
 import { useState } from "react";
 import { Pressable, Text, View } from "react-native";
 import { previewLines } from "../shared/markdown";
 import { searchPrompts } from "../shared/prompts";
-import { inputStyle, radius } from "./ui";
+import { inputStyle, radius, useDebounced } from "./ui";
 
 const RESULT_LIMIT = 20;
 
@@ -18,7 +18,8 @@ function PromptPicker({ theme, layout, close, ...target }: PluginButtonContentPr
   const toast = useToast();
   const searchRpc = useRpc(searchPrompts);
   const [query, setQuery] = useState("");
-  const results = useQuery({ queryKey: ["prompt-pill", query.trim()], queryFn: () => searchRpc({ query: query.trim() }) });
+  const search = useDebounced(query.trim(), 200);
+  const results = useQuery({ queryKey: ["prompt-pill", search], queryFn: () => searchRpc({ query: search }), placeholderData: keepPreviousData });
   const send = useMutation({
     mutationFn: async (item: { title: string; text: string }) => {
       if (target.context !== "agent") throw new Error("Open this from an agent composer.");
diff --git a/client/prompts.tsx b/client/prompts.tsx
index 59e0a13..79c7a9d 100644
--- a/client/prompts.tsx
+++ b/client/prompts.tsx
@@ -1,8 +1,8 @@
 import type { PluginAgentPanelProps, PluginSurfaceProps } from "@getpaseo/plugin/client";
 import { usePaseo, useRpc } from "@getpaseo/plugin/client";
 import { Icon, ScrollView, TextInput, useToast } from "@getpaseo/plugin/client/react-native";
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { useRef, useState } from "react";
+import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useEffect, useRef, useState } from "react";
 import { Pressable, Text, View } from "react-native";
 import { MarkdownPreview } from "./markdown";
 import { FolderPicker, FolderBreadcrumb } from "./editor";
@@ -10,13 +10,16 @@ import { VersionHistory } from "./history";
 import { ImportPrompts } from "./import";
 import { LibrarySettingsScreen } from "./settings";
 import { LibrarySidebar } from "./sidebar";
-import { Banner, Button, Card, Chip, ConfirmModal, EmptyState, Field, IconButton, Meta, Segmented, StatusDot, Tip, inputStyle, radius, relativeTime, row, wordCount } from "./ui";
+import { SyncBadge, describeMode } from "./sync";
+import { Banner, Button, Card, Chip, ConfirmModal, EmptyState, Field, IconButton, Meta, Segmented, StatusDot, Tip, inputStyle, radius, relativeTime, row, useDebounced, wordCount } from "./ui";
 import {
-  archivePrompt, createFolder, listFolders, getGitStatus, getSettings, initGit, listPrompts, listVersions,
-  readPrompt, readVersion, restoreVersion, savePrompt, saveSettings, syncGit,
-  type Prompt,
+  archivePrompt, createFolder, listFolders, getGitStatus, getSettings, getSyncState, initGit, listPrompts, listVersions,
+  openSync, readPrompt, readVersion, restoreVersion, savePrompt, saveSettings, syncGit,
+  type Prompt, type SyncMode,
 } from "../shared/prompts";
 
+const FRESH_MS = 30_000;
+
 type Props = PluginSurfaceProps & { agentId?: string };
 
 export function PromptPanel(props: PluginAgentPanelProps) {
@@ -42,6 +45,8 @@ export function PromptLibrary({ theme, layout, agentId }: Props) {
   const gitStatusRpc = useRpc(getGitStatus);
   const initRpc = useRpc(initGit);
   const syncRpc = useRpc(syncGit);
+  const syncStateRpc = useRpc(getSyncState);
+  const openSyncRpc = useRpc(openSync);
   const [query, setQuery] = useState("");
   const [archived, setArchived] = useState(false);
   const [mode, setMode] = useState<"list" | "editor" | "settings" | "import">("list");
@@ -63,18 +68,25 @@ export function PromptLibrary({ theme, layout, agentId }: Props) {
   const [directory, setDirectory] = useState("");
   const [gitEnabled, setGitEnabled] = useState(false);
   const [remote, setRemote] = useState("");
+  const [branch, setBranch] = useState("");
   const [pending, setPending] = useState<(() => void) | null>(null);
   const [archiveConfirm, setArchiveConfirm] = useState(false);
   const operationLock = useRef(false);
 
   const settings = useQuery({ queryKey: ["prompt-settings"], queryFn: () => settingsRpc({}) });
   const root = settings.data?.directory;
-  const folders = useQuery({ queryKey: ["prompt-folders", root], queryFn: () => foldersRpc({}), enabled: Boolean(root) });
-  const tagSources = useQuery({ queryKey: ["prompt-library", root, "tags", archived], queryFn: () => listRpc({ archived }), enabled: Boolean(root) });
+  const searchQuery = useDebounced(query, 200);
+  const folders = useQuery({ queryKey: ["prompt-folders", root], queryFn: () => foldersRpc({}), enabled: Boolean(root), staleTime: FRESH_MS });
+  // Shares the unfiltered list query, so tags cost no extra request until a filter is applied.
+  const tagSources = useQuery({
+    queryKey: ["prompt-library", root, "", archived, undefined, undefined],
+    queryFn: () => listRpc({ query: "", archived }), enabled: Boolean(root), staleTime: FRESH_MS,
+  });
   const availableTags = [...new Set(tagSources.data?.flatMap((prompt) => prompt.tags) ?? [])].sort();
   const prompts = useQuery({
-    queryKey: ["prompt-library", root, query, archived, filterFolder, filterTag],
-    queryFn: () => listRpc({ query, archived, folder: filterFolder, tag: filterTag }), enabled: Boolean(root),
+    queryKey: ["prompt-library", root, searchQuery, archived, filterFolder, filterTag],
+    queryFn: () => listRpc({ query: searchQuery, archived, folder: filterFolder, tag: filterTag }), enabled: Boolean(root),
+    staleTime: FRESH_MS, placeholderData: keepPreviousData,
   });
   const versions = useQuery({
     queryKey: ["prompt-versions", root, current?.id],
@@ -89,6 +101,29 @@ export function PromptLibrary({ theme, layout, agentId }: Props) {
     queryKey: ["prompt-git", root], queryFn: () => gitStatusRpc({}),
     enabled: mode === "settings" && Boolean(settings.data?.gitEnabled),
   });
+  const gitEnabledSaved = Boolean(settings.data?.gitEnabled);
+  const syncState = useQuery({
+    queryKey: ["prompt-sync", root], queryFn: () => syncStateRpc({}), enabled: gitEnabledSaved,
+    refetchInterval: (state) => state.state.data?.running ? 2_000 : state.state.data?.mode === "manual" ? false : 30_000,
+  });
+  const lastSyncedAt = syncState.data?.lastSyncedAt;
+  const seenSync = useRef<string | null | undefined>(undefined);
+  useEffect(() => {
+    // A sync can pull prompts from the remote, so reload lists whenever a new sync lands.
+    if (seenSync.current !== undefined && lastSyncedAt !== seenSync.current) void refresh();
+    seenSync.current = lastSyncedAt;
+  }, [lastSyncedAt]);
+  const listed = prompts.isSuccess;
+  useEffect(() => {
+    // Wait for the first list so the background pull never delays it.
+    if (!gitEnabledSaved || !root || !listed) return;
+    openSyncRpc({}).then((state) => cache.setQueryData(["prompt-sync", root], state)).catch(() => undefined);
+  }, [gitEnabledSaved, root, listed]);
+  useEffect(() => {
+    if (mode !== "settings" || !git.data) return;
+    setRemote(git.data.remote);
+    setBranch(git.data.branch);
+  }, [mode, git.data?.remote, git.data?.branch]);
   const mutation = useMutation({
     mutationFn: async (action: () => Promise<void>) => { await action(); },
     onSettled: () => { operationLock.current = false; },
@@ -133,6 +168,7 @@ export function PromptLibrary({ theme, layout, agentId }: Props) {
       cache.invalidateQueries({ queryKey: ["prompt-versions"] }),
       cache.invalidateQueries({ queryKey: ["prompt-folders"] }),
       cache.invalidateQueries({ queryKey: ["prompt-git"] }),
+      cache.invalidateQueries({ queryKey: ["prompt-sync"] }),
     ]);
   };
   const success = (message: string) => toast.show(message, { variant: "success" });
@@ -141,9 +177,16 @@ export function PromptLibrary({ theme, layout, agentId }: Props) {
   const openSettings = () => guard(() => {
     setDirectory(settings.data?.directory ?? "");
     setGitEnabled(settings.data?.gitEnabled ?? false);
-    setRemote("");
+    setRemote(git.data?.remote ?? "");
+    setBranch(git.data?.branch ?? "");
     setMode("settings");
   });
+  const syncNow = () => run(async () => {
+    const result = await syncRpc({});
+    cache.setQueryData(["prompt-git", root], result);
+    await refresh();
+    success(result.message);
+  });
   const openPrompt = (id: string) => guard(() => run(async () => accept(await readRpc({ id }))));
   const createFolderAt = (parent: string, assign: (path: string) => void) => run(async () => {
     const created = await createFolderRpc({ path: [parent, newFolder.trim()].filter(Boolean).join("/") });
@@ -354,6 +397,9 @@ export function PromptLibrary({ theme, layout, agentId }: Props) {
           setGitEnabled={setGitEnabled}
           remote={remote}
           setRemote={setRemote}
+          branch={branch}
+          setBranch={setBranch}
+          syncState={syncState.data}
           configDirty={configDirty}
           onBack={() => guard(() => setMode(current || draftDirty ? "editor" : "list"))}
           onSave={() => run(async () => {
@@ -366,16 +412,18 @@ export function PromptLibrary({ theme, layout, agentId }: Props) {
             success("Settings saved.");
           })}
           onInit={() => run(async () => {
-            const result = await initRpc({ remote });
+            // An untouched branch field lets the server pick the remote's default branch.
+            const result = await initRpc({ remote, branch: branch.trim() === (git.data?.branch ?? "") ? "" : branch });
             cache.setQueryData(["prompt-git", root], result);
-            setRemote("");
+            await cache.invalidateQueries({ queryKey: ["prompt-sync"] });
             success(result.message);
           })}
-          onSync={() => run(async () => {
-            const result = await syncRpc({});
-            cache.setQueryData(["prompt-git", root], result);
-            await refresh();
-            success(result.message);
+          onSync={syncNow}
+          onSyncMode={(syncMode: SyncMode, syncInterval: number) => run(async () => {
+            const result = await configureRpc({ ...settings.data!, syncMode, syncInterval });
+            cache.setQueryData(["prompt-settings"], result);
+            await cache.invalidateQueries({ queryKey: ["prompt-sync"] });
+            success(describeMode(result.syncMode, result.syncInterval));
           })}
         />
       ) : (
@@ -409,6 +457,7 @@ export function PromptLibrary({ theme, layout, agentId }: Props) {
               setNewFolder={setNewFolder}
               onCreateFolder={() => newFolder.trim() && createFolderAt(filterFolder ?? "", setFilterFolder)}
               root={root}
+              footer={gitEnabledSaved && syncState.data?.initialized ? <SyncBadge colors={colors} state={syncState.data} disabled={locked} onSync={syncNow} /> : undefined}
             />
           )}
           {showMain && <View style={{ flex: 1, minWidth: 0, minHeight: 0 }}>{mode === "list" ? welcome : editor}</View>}
diff --git a/client/settings.tsx b/client/settings.tsx
index a466e4f..58a75cb 100644
--- a/client/settings.tsx
+++ b/client/settings.tsx
@@ -1,7 +1,8 @@
 import { Icon, ScrollView, TextInput } from "@getpaseo/plugin/client/react-native";
 import { Switch, Text, View } from "react-native";
-import type { LibrarySettings } from "../shared/prompts";
-import { Button, Card, Field, IconButton, Meta, SectionTitle, inputStyle, radius, type Colors } from "./ui";
+import type { LibrarySettings, SyncMode, SyncState } from "../shared/prompts";
+import { SyncModePicker } from "./sync";
+import { Banner, Button, Card, Field, IconButton, Meta, SectionTitle, inputStyle, radius, relativeTime, type Colors } from "./ui";
 
 type GitStatus = { initialized: boolean; branch: string; remote: string; changes: number; message: string };
 
@@ -16,15 +17,21 @@ type Props = {
   setGitEnabled: (value: boolean) => void;
   remote: string;
   setRemote: (value: string) => void;
+  branch: string;
+  setBranch: (value: string) => void;
+  syncState: SyncState | undefined;
   configDirty: boolean;
   onSave: () => void;
   onInit: () => void;
   onSync: () => void;
+  onSyncMode: (mode: SyncMode, interval: number) => void;
   onBack: () => void;
 };
 
-export function LibrarySettingsScreen({ colors, busy, settings, git, directory, setDirectory, gitEnabled, setGitEnabled, remote, setRemote, configDirty, onSave, onInit, onSync, onBack }: Props) {
+export function LibrarySettingsScreen({ colors, busy, settings, git, directory, setDirectory, gitEnabled, setGitEnabled, remote, setRemote, branch, setBranch, syncState, configDirty, onSave, onInit, onSync, onSyncMode, onBack }: Props) {
   const status = git.data;
+  const connectionChanged = Boolean(status && (remote.trim() !== status.remote || (branch.trim() !== "" && branch.trim() !== status.branch)));
+  const blocked = busy || configDirty;
   return (
     <ScrollView keyboardShouldPersistTaps="handled" style={{ flex: 1, minWidth: 0 }} contentContainerStyle={{ gap: 14, paddingBottom: 28, width: "100%", maxWidth: 680 }}>
       <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
@@ -59,18 +66,21 @@ export function LibrarySettingsScreen({ colors, busy, settings, git, directory,
                 {status.initialized && <Stat colors={colors} icon="FileDiff" label="Pending" value={`${status.changes} changed file${status.changes === 1 ? "" : "s"}`} tone={status.changes > 0 ? colors.statusWarning : undefined} />}
                 {status.initialized && <Stat colors={colors} icon="Globe" label="Remote" value={status.remote || "No remote"} />}
               </View>
-              <Meta colors={colors} icon="Info">{status.message}</Meta>
-              {(!status.initialized || !status.remote) && (
-                <Field label={status.initialized ? "Add a remote" : "Initialize repository"} hint={status.initialized ? "Set an origin URL to push and pull." : "Leave the URL blank for local checkpoints only."} colors={colors}>
-                  <View style={{ flexDirection: "row", gap: 8, alignItems: "center" }}>
-                    <TextInput accessibilityLabel="Git remote URL" value={remote} onChangeText={setRemote} editable={!busy && !configDirty} autoCapitalize="none" autoCorrect={false} placeholder="git@github.com:you/prompts.git" placeholderTextColor={colors.foregroundMuted} style={{ ...inputStyle(colors), backgroundColor: colors.surface0, flex: 1, width: undefined, fontFamily: "monospace", fontSize: 13 }} />
-                    <Button title={status.initialized ? "Set remote" : "Initialize"} icon={status.initialized ? "Link" : "GitBranchPlus"} onPress={onInit} colors={colors} disabled={busy || configDirty || (status.initialized && !remote.trim())} />
-                  </View>
-                </Field>
-              )}
+              {status.message !== "" && <Meta colors={colors} icon="Info">{status.message}</Meta>}
+              {syncState?.lastError && !syncState.running && <Banner tone="danger" colors={colors} message={`Last sync failed: ${syncState.lastError}`} />}
+              <Field label="Remote repository" hint="Paste an HTTPS or SSH URL, for example from GitHub. The repository can be empty or already hold prompts from another machine. Leave it blank for local checkpoints only." colors={colors}>
+                <TextInput accessibilityLabel="Git remote URL" value={remote} onChangeText={setRemote} editable={!blocked} autoCapitalize="none" autoCorrect={false} placeholder="git@github.com:you/prompts.git" placeholderTextColor={colors.foregroundMuted} style={{ ...inputStyle(colors), backgroundColor: colors.surface0, fontFamily: "monospace", fontSize: 13 }} />
+              </Field>
+              <Field label="Branch" hint="Leave blank to use the remote's default branch." colors={colors}>
+                <View style={{ flexDirection: "row", gap: 8, alignItems: "center" }}>
+                  <TextInput accessibilityLabel="Git branch" value={branch} onChangeText={setBranch} editable={!blocked} autoCapitalize="none" autoCorrect={false} placeholder="Remote default" placeholderTextColor={colors.foregroundMuted} style={{ ...inputStyle(colors), backgroundColor: colors.surface0, flex: 1, width: undefined, fontFamily: "monospace", fontSize: 13 }} />
+                  <Button title={!status.initialized ? "Set up" : !status.remote ? "Connect" : remote.trim() ? "Update" : "Disconnect"} icon={status.initialized ? "Link" : "GitBranchPlus"} variant={status.initialized ? "secondary" : "primary"} onPress={onInit} colors={colors} disabled={blocked || (status.initialized && !connectionChanged)} />
+                </View>
+              </Field>
               {status.initialized && (
-                <View style={{ flexDirection: "row", justifyContent: "flex-end" }}>
-                  <Button title="Sync now" icon="RefreshCw" variant="primary" onPress={onSync} colors={colors} disabled={busy || configDirty} />
+                <View style={{ flexDirection: "row", justifyContent: "flex-end", alignItems: "center", gap: 10 }}>
+                  {syncState?.lastSyncedAt && <Text style={{ color: colors.foregroundMuted, fontSize: 12 }}>Last synced {relativeTime(syncState.lastSyncedAt)}</Text>}
+                  <Button title={syncState?.running ? "Syncing…" : "Sync now"} icon="RefreshCw" variant="primary" onPress={onSync} colors={colors} disabled={blocked || connectionChanged || Boolean(syncState?.running)} />
                 </View>
               )}
               {configDirty && <Meta colors={colors} icon="TriangleAlert">Save settings before running Git actions.</Meta>}
@@ -78,6 +88,13 @@ export function LibrarySettingsScreen({ colors, busy, settings, git, directory,
           )}
         </Card>
       )}
+      {settings?.gitEnabled && status?.initialized && (
+        <Card colors={colors}>
+          <SectionTitle title="When to sync" subtitle="Each sync commits prompt changes, merges changes from the remote, and pushes." colors={colors} icon="Clock" />
+          <SyncModePicker colors={colors} mode={settings.syncMode} interval={settings.syncInterval} disabled={blocked} onChange={onSyncMode} />
+          {settings.syncMode === "interval" && syncState?.nextSyncAt && <Meta colors={colors} icon="Clock">Next sync {new Date(syncState.nextSyncAt).toLocaleTimeString()}</Meta>}
+        </Card>
+      )}
     </ScrollView>
   );
 }
diff --git a/client/sidebar.tsx b/client/sidebar.tsx
index 1ec7252..6f125f4 100644
--- a/client/sidebar.tsx
+++ b/client/sidebar.tsx
@@ -1,4 +1,5 @@
 import { Icon, ScrollView, TextInput } from "@getpaseo/plugin/client/react-native";
+import type { ReactNode } from "react";
 import { Pressable, Text, View } from "react-native";
 import type { Prompt } from "../shared/prompts";
 import { FolderPicker } from "./editor";
@@ -31,10 +32,11 @@ type Props = {
   setNewFolder: (value: string) => void;
   onCreateFolder: () => void;
   root: string | undefined;
+  footer?: ReactNode;
 };
 
 export function LibrarySidebar(props: Props) {
-  const { colors, compact, disabled, query, setQuery, archived, setArchived, filterFolder, setFilterFolder, filterTag, setFilterTag, organize, setOrganize, folders, tags, prompts, loading, currentId, onOpen, onRefresh, newFolder, setNewFolder, onCreateFolder, root } = props;
+  const { colors, compact, disabled, query, setQuery, archived, setArchived, filterFolder, setFilterFolder, filterTag, setFilterTag, organize, setOrganize, folders, tags, prompts, loading, currentId, onOpen, onRefresh, newFolder, setNewFolder, onCreateFolder, root, footer } = props;
   const filtered = filterFolder !== undefined || filterTag !== undefined || archived || query !== "";
   const count = prompts?.length ?? 0;
   return (
@@ -79,6 +81,7 @@ export function LibrarySidebar(props: Props) {
           </View>
         )}
       </ScrollView>
+      {footer}
       {root && <Meta colors={colors} icon="HardDrive">{root}</Meta>}
     </View>
   );
diff --git a/client/sync.tsx b/client/sync.tsx
new file mode 100644
index 0000000..79c00b3
--- /dev/null
+++ b/client/sync.tsx
@@ -0,0 +1,74 @@
+import { Icon } from "@getpaseo/plugin/client/react-native";
+import { Text, View } from "react-native";
+import type { SyncMode, SyncState } from "../shared/prompts";
+import { Chip, IconButton, Segmented, relativeTime, type Colors } from "./ui";
+
+const INTERVALS = [5, 15, 30, 60] as const;
+
+const MODES = [
+  { value: "manual", label: "Manual", icon: "Hand" },
+  { value: "changes", label: "Automatic", icon: "Zap" },
+  { value: "interval", label: "Scheduled", icon: "Clock" },
+] as const;
+
+export function describeMode(mode: SyncMode, interval: number) {
+  if (mode === "changes") return "Syncs a few seconds after each change, and pulls when you open the library.";
+  if (mode === "interval") return `Syncs every ${formatInterval(interval)}, and pulls when you open the library.`;
+  return "Syncs only when you choose Sync now.";
+}
+
+function formatInterval(minutes: number) {
+  if (minutes % 60 === 0) return minutes === 60 ? "hour" : `${minutes / 60} hours`;
+  return minutes === 1 ? "minute" : `${minutes} minutes`;
+}
+
+function shortMode(state: SyncState) {
+  if (state.mode === "changes") return "auto";
+  if (state.mode === "interval") return `every ${state.interval < 60 || state.interval % 60 ? `${state.interval}m` : `${state.interval / 60}h`}`;
+  return "manual";
+}
+
+/** One-line sync status with a Sync now button, for the library sidebar. */
+export function SyncBadge({ colors, state, disabled, onSync }: { colors: Colors; state: SyncState; disabled: boolean; onSync: () => void }) {
+  const failed = !state.running && state.lastError !== "";
+  const label = state.running ? "Syncing…"
+    : failed ? "Sync failed"
+    : state.lastSyncedAt ? `Synced ${relativeTime(state.lastSyncedAt)}` : "Not synced yet";
+  const tone = failed ? colors.statusDanger : state.running ? colors.accent : colors.foregroundMuted;
+  return (
+    <View style={{ flexDirection: "row", alignItems: "center", gap: 6, minWidth: 0 }}>
+      <Icon name={state.running ? "LoaderCircle" : failed ? "CircleAlert" : "GitBranch"} size={13} color={tone} />
+      <Text numberOfLines={1} accessibilityLabel={failed ? `Sync failed: ${state.lastError}` : undefined} style={{ color: tone, fontSize: 11, flex: 1, minWidth: 0 }}>
+        {label} · {shortMode(state)}{failed ? ` · ${state.lastError}` : ""}
+      </Text>
+      <IconButton icon="RefreshCw" label="Sync now" onPress={onSync} colors={colors} disabled={disabled || state.running} size={26} />
+    </View>
+  );
+}
+
+type PickerProps = {
+  colors: Colors;
+  mode: SyncMode;
+  interval: number;
+  disabled: boolean;
+  onChange: (mode: SyncMode, interval: number) => void;
+};
+
+/** Chooses when Git sync runs: manual, after changes, or on an interval. */
+export function SyncModePicker({ colors, mode, interval, disabled, onChange }: PickerProps) {
+  return (
+    <View style={{ gap: 10 }}>
+      <View style={{ flexDirection: "row" }}>
+        <Segmented options={MODES} value={mode} onChange={(value) => onChange(value, interval)} colors={colors} disabled={disabled} />
+      </View>
+      <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>{describeMode(mode, interval)}</Text>
+      {mode === "interval" && (
+        <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6 }}>
+          {INTERVALS.map((minutes) => (
+            <Chip key={minutes} icon="Clock" label={`Every ${formatInterval(minutes)}`} active={interval === minutes} onPress={() => onChange("interval", minutes)} colors={colors} disabled={disabled} />
+          ))}
+        </View>
+      )}
+    </View>
+  );
+}
diff --git a/client/ui.tsx b/client/ui.tsx
index ca39db4..f2a633c 100644
--- a/client/ui.tsx
+++ b/client/ui.tsx
@@ -1,6 +1,6 @@
 import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
 import { Icon, Modal } from "@getpaseo/plugin/client/react-native";
-import type { ReactNode } from "react";
+import { useEffect, useState, type ReactNode } from "react";
 import { Pressable, Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
 
 export type Colors = PluginSurfaceProps["theme"]["colors"];
@@ -16,6 +16,16 @@ export function inputStyle(colors: Colors): TextStyle {
   };
 }
 
+/** Returns `value` once it has stopped changing for `delay` milliseconds. */
+export function useDebounced<Value>(value: Value, delay: number) {
+  const [settled, setSettled] = useState(value);
+  useEffect(() => {
+    const timer = setTimeout(() => setSettled(value), delay);
+    return () => clearTimeout(timer);
+  }, [value, delay]);
+  return settled;
+}
+
 export function relativeTime(iso: string, now = Date.now()) {
   const stamp = new Date(iso).getTime();
   if (!Number.isFinite(stamp)) return "";
diff --git a/index.server.ts b/index.server.ts
index 8226b94..49ec475 100644
--- a/index.server.ts
+++ b/index.server.ts
@@ -1,12 +1,28 @@
 import type { PluginServerContext } from "@getpaseo/plugin/server";
 import { PromptLibrary } from "./server/library";
-import { gitStatus, initializeGit, synchronizeGit } from "./server/git";
+import { gitStatus, hasRepository, initializeGit, synchronizeGit } from "./server/git";
+import { SyncScheduler } from "./server/sync";
 import * as rpc from "./shared/prompts";
 
 export default function contribute(server: PluginServerContext) {
   const library = new PromptLibrary();
+  const scheduler = new SyncScheduler({
+    settings: () => library.settings(),
+    initialized: (settings) => hasRepository(settings.directory),
+    sync: (settings) => {
+      if (!settings.gitEnabled) throw new Error("Enable Git sync in library settings first.");
+      return synchronizeGit(settings.directory, (operation) => library.exclusive(operation));
+    },
+    synced: () => library.invalidate(),
+  });
+  library.onChange = () => scheduler.changed();
+  scheduler.reload().catch((error: unknown) => console.error("Prompt sync scheduler failed to start", error));
   server.handle(rpc.getSettings, () => library.settings());
-  server.handle(rpc.saveSettings, (input) => library.configure(input));
+  server.handle(rpc.saveSettings, async (input) => {
+    const settings = await library.configure(input);
+    await scheduler.reload();
+    return settings;
+  });
   server.handle(rpc.listPrompts, (input) => library.list(input));
   server.handle(rpc.listFolders, () => library.folders());
   server.handle(rpc.createFolder, ({ path }) => library.createFolder(path));
@@ -18,14 +34,13 @@ export default function contribute(server: PluginServerContext) {
   server.handle(rpc.readVersion, (input) => library.version(input));
   server.handle(rpc.restoreVersion, (input) => library.restore(input));
   server.handle(rpc.getGitStatus, () => library.withRoot((root) => gitStatus(root)));
-  server.handle(rpc.initGit, ({ remote }) => library.withRoot((root, settings) => {
+  server.handle(rpc.initGit, ({ remote, branch }) => scheduler.exclusive(() => library.withRoot((root, settings) => {
     if (!settings.gitEnabled) throw new Error("Enable Git sync in library settings first.");
-    return initializeGit(root, remote);
-  }));
-  server.handle(rpc.syncGit, () => library.withRoot((root, settings) => {
-    if (!settings.gitEnabled) throw new Error("Enable Git sync in library settings first.");
-    return synchronizeGit(root);
-  }));
+    return initializeGit(root, remote, branch);
+  })));
+  server.handle(rpc.syncGit, () => scheduler.syncNow());
+  server.handle(rpc.getSyncState, () => scheduler.snapshot());
+  server.handle(rpc.openSync, () => scheduler.opened());
   server.handle(rpc.searchPrompts, ({ query }) => library.search(query));
-  return () => {};
+  return () => scheduler.dispose();
 }
diff --git a/package-lock.json b/package-lock.json
index f7a33b6..d8201c4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "@yannelli/paseo-prompt-manager",
-  "version": "0.3.0",
+  "version": "0.4.1",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "@yannelli/paseo-prompt-manager",
-      "version": "0.3.0",
+      "version": "0.4.1",
       "license": "MIT",
       "devDependencies": {
         "@getpaseo/plugin": "0.11.0-beta.3",
diff --git a/package.json b/package.json
index 2fa70fc..ada1e97 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@yannelli/paseo-prompt-manager",
   "type": "module",
-  "version": "0.3.0",
+  "version": "0.4.1",
   "description": "Markdown prompt library for Paseo with local version history, imports, and optional Git sync",
   "license": "MIT",
   "author": "Ryan Yannelli",
diff --git a/server/git.test.ts b/server/git.test.ts
index 6968161..8a2985e 100644
--- a/server/git.test.ts
+++ b/server/git.test.ts
@@ -96,3 +96,95 @@ test("sync tracks nested prompts, metadata, and moved version history", async (t
   await synchronizeGit(root);
   assert.deepEqual(command(root, "ls-files").split("\n"), ["operations/review.md", `versions/operations/review/${history}`]);
 });
+
+async function bare(base: string, branch = "main") {
+  const remote = join(base, `${branch}-remote.git`);
+  await mkdir(remote);
+  command(remote, "init", "--bare", `--initial-branch=${branch}`);
+  return remote;
+}
+
+async function machine(base: string, name: string, remote: string, branch = "") {
+  const root = join(base, name);
+  await mkdir(root);
+  await initializeGit(root, "", "");
+  command(root, "config", "user.name", "Prompt test");
+  command(root, "config", "user.email", "test@example.invalid");
+  return { root, connect: () => initializeGit(root, remote, branch) };
+}
+
+test("connecting a library with its own prompts merges an existing remote library", async (t) => {
+  const { base, root } = await fixture(t);
+  const remote = await bare(base);
+  await initializeGit(root, remote);
+  await writeFile(join(root, "review.md"), "first host");
+  await synchronizeGit(root);
+  const second = await machine(base, "second", remote);
+  await writeFile(join(second.root, "plan.md"), "second host");
+  await synchronizeGit(second.root);
+  await second.connect();
+  await synchronizeGit(second.root);
+  assert.equal(await readFile(join(second.root, "review.md"), "utf8"), "first host");
+  await synchronizeGit(root);
+  assert.equal(await readFile(join(root, "plan.md"), "utf8"), "second host");
+});
+
+test("an empty library pulls a remote library and edits to different prompts merge", async (t) => {
+  const { base, root } = await fixture(t);
+  const remote = await bare(base);
+  await initializeGit(root, remote);
+  await writeFile(join(root, "review.md"), "review");
+  await synchronizeGit(root);
+  const second = await machine(base, "second", remote);
+  await second.connect();
+  await synchronizeGit(second.root);
+  assert.equal(await readFile(join(second.root, "review.md"), "utf8"), "review");
+  await writeFile(join(second.root, "plan.md"), "plan");
+  await synchronizeGit(second.root);
+  await writeFile(join(root, "review.md"), "edited review");
+  await synchronizeGit(root);
+  assert.equal(await readFile(join(root, "plan.md"), "utf8"), "plan");
+  assert.equal(command(remote, "show", "main:review.md"), "edited review");
+});
+
+test("connect follows the remote default branch, edits origin and validates the branch", async (t) => {
+  const { base, root } = await fixture(t);
+  const trunk = await bare(base, "trunk");
+  const seed = await machine(base, "seed", trunk, "trunk");
+  await seed.connect();
+  await writeFile(join(seed.root, "seed.md"), "seed");
+  await synchronizeGit(seed.root);
+  const status = await initializeGit(root, trunk);
+  assert.equal(status.branch, "trunk");
+  assert.equal(status.remote, trunk);
+  const other = await bare(base, "other");
+  assert.equal((await initializeGit(root, other, "prompts")).branch, "prompts");
+  assert.equal(command(root, "remote", "get-url", "origin"), other);
+  await assert.rejects(initializeGit(root, other, "--force"), /valid branch/);
+  await assert.rejects(initializeGit(root, other, "bad..name"), /valid branch/);
+  await writeFile(join(root, "review.md"), "one");
+  await synchronizeGit(root);
+  assert.equal(command(other, "show", "prompts:review.md"), "one");
+  const moved = join(base, "moved.git");
+  command(base, "clone", "--bare", other, moved);
+  assert.equal((await initializeGit(root, moved)).branch, "prompts", "a tracking branch keeps its name when the URL changes");
+  assert.equal((await initializeGit(root, "")).remote, "");
+  await assert.rejects(initializeGit(root, join(base, "missing.git")), /Could not reach origin/);
+});
+
+test("a local-only library with commits adopts the remote default branch when it connects", async (t) => {
+  const { base, root } = await fixture(t);
+  const trunk = await bare(base, "trunk");
+  const seed = await machine(base, "seed", trunk, "trunk");
+  await seed.connect();
+  await writeFile(join(seed.root, "seed.md"), "seed");
+  await synchronizeGit(seed.root);
+  await writeFile(join(root, "local.md"), "local");
+  await synchronizeGit(root);
+  assert.equal((await gitStatus(root)).branch, "main");
+  assert.equal((await initializeGit(root, trunk)).branch, "trunk");
+  await synchronizeGit(root);
+  assert.equal(await readFile(join(root, "seed.md"), "utf8"), "seed");
+  assert.equal(command(trunk, "show", "trunk:local.md"), "local");
+  assert.equal(command(trunk, "branch", "--list", "main"), "");
+});
diff --git a/server/git.ts b/server/git.ts
index d0d027c..06c32f4 100644
--- a/server/git.ts
+++ b/server/git.ts
@@ -24,7 +24,7 @@ async function git(root: string, args: string[]) {
     const result = await execute("git", ["-c", "core.hooksPath=/dev/null", "-C", root, ...args], {
       timeout: 30_000,
       maxBuffer: 2 * 1024 * 1024,
-      env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_EDITOR: "true" },
+      env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_EDITOR: "true", GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND ?? "ssh -o BatchMode=yes" },
     });
     return result.stdout.trim();
   } catch (error) {
@@ -33,13 +33,23 @@ async function git(root: string, args: string[]) {
   }
 }
 
-async function isRepository(root: string) {
+async function attempt(root: string, args: string[]) {
+  try { return await git(root, args); } catch { return null; }
+}
+
+/** Cheap check for a repository at the library root, without running Git. */
+export async function hasRepository(root: string) {
   try {
     await lstat(join(root, ".git"));
+    return true;
   } catch (error) {
     if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
     throw error;
   }
+}
+
+async function isRepository(root: string) {
+  if (!await hasRepository(root)) return false;
   const top = await git(root, ["rev-parse", "--show-toplevel"]);
   if (await realpath(top) !== await realpath(root)) throw new Error("Choose a Git repository whose root is the prompt directory.");
   return true;
@@ -54,16 +64,45 @@ export async function gitStatus(root: string, message = "") {
   return { initialized: true, branch, remote, changes: status ? status.split("\n").length : 0, message };
 }
 
-export async function initializeGit(root: string, remote: string) {
+async function hasUpstream(root: string, branch: string) {
+  return Boolean(branch && await attempt(root, ["config", "--get", `branch.${branch}.remote`]));
+}
+
+async function remoteDefaultBranch(root: string) {
+  let refs: string;
+  try {
+    refs = await git(root, ["ls-remote", "--symref", "origin", "HEAD"]);
+  } catch (error) {
+    throw new Error(`Could not reach origin. Check the URL and this host's Git credentials. ${(error as Error).message}`);
+  }
+  return /^ref: refs\/heads\/(\S+)\s+HEAD$/m.exec(refs)?.[1] ?? "";
+}
+
+/** Creates the repository if needed, then points origin and the local branch at the chosen remote. */
+export async function initializeGit(root: string, remote: string, branch = "") {
   const url = remote.trim();
+  const wanted = branch.trim();
   if (url && (!/^(?:https:\/\/|ssh:\/\/|git@[a-zA-Z0-9.-]+:|\/)/.test(url) || /[\r\n\0]/.test(url))) {
     throw new Error("Use an HTTPS URL, SSH URL, git@host:path, or absolute local remote path.");
   }
+  if (wanted && (wanted.startsWith("-") || await attempt(root, ["check-ref-format", "--branch", wanted]) === null)) {
+    throw new Error("Enter a valid branch name, such as main.");
+  }
   if (!await isRepository(root)) await git(root, ["init", "--initial-branch=main"]);
   const status = await gitStatus(root);
-  if (url && status.remote && status.remote !== url) throw new Error("Origin already points elsewhere. Change it with Git before syncing.");
   if (url && !status.remote) await git(root, ["remote", "add", "origin", url]);
-  return gitStatus(root, "Git is ready. Sync commits prompt files and version history.");
+  else if (url && status.remote !== url) await git(root, ["remote", "set-url", "origin", url]);
+  else if (!url && status.remote) await git(root, ["remote", "remove", "origin"]);
+  // A branch that already tracks origin keeps its name when only the URL changes.
+  const detect = url && !wanted && !await hasUpstream(root, status.branch);
+  const target = wanted || (detect ? await remoteDefaultBranch(root) : "") || status.branch;
+  if (target && target !== status.branch) {
+    if (await attempt(root, ["rev-parse", "--verify", "--quiet", "HEAD"]) === null) await git(root, ["symbolic-ref", "HEAD", `refs/heads/${target}`]);
+    else await git(root, ["branch", "-m", status.branch, target]);
+  }
+  return gitStatus(root, url
+    ? `Connected to origin on ${target}. Sync merges prompts from both sides, then pushes.`
+    : "Git is ready for local checkpoints. Add a remote to sync across machines.");
 }
 
 async function managedFiles(root: string) {
@@ -80,7 +119,7 @@ async function managedFiles(root: string) {
   return [...new Set([...files, ...tracked])];
 }
 
-export async function synchronizeGit(root: string) {
+async function checkpoint(root: string) {
   const status = await gitStatus(root);
   if (!status.initialized) throw new Error("Initialize Git in the prompt directory first.");
   const staged = (await git(root, ["diff", "--cached", "--name-only", "-z"])).split("\0").filter(Boolean);
@@ -92,18 +131,36 @@ export async function synchronizeGit(root: string) {
   if (await git(root, ["diff", "--cached", "--name-only"])) {
     await git(root, ["commit", "-m", "Update prompt library"]);
   }
+  return status;
+}
+
+async function integrate(root: string, branch: string) {
+  const args = ["merge", "--no-edit", "-m", "Merge prompt library from origin", "FETCH_HEAD"];
+  if (await attempt(root, ["rev-parse", "--verify", "--quiet", "HEAD"]) !== null
+    && await attempt(root, ["merge-base", "HEAD", "FETCH_HEAD"]) === null
+    && !await hasUpstream(root, branch)) {
+    // First connection of a library that already has its own history.
+    args.splice(1, 0, "--allow-unrelated-histories");
+  }
+  try {
+    await git(root, args);
+  } catch (error) {
+    await attempt(root, ["merge", "--abort"]);
+    throw new Error(`Git sync stopped before merging origin. Resolve branch divergence with Git, then sync again. ${(error as Error).message}`);
+  }
+}
+
+type Exclusive = <T>(operation: () => Promise<T>) => Promise<T>;
+
+// Commits prompt files, merges origin's branch, and pushes. `exclusive` holds the library lock
+// for steps that touch the working tree; network steps run without it so prompts stay usable.
+export async function synchronizeGit(root: string, exclusive: Exclusive = (operation) => operation()) {
+  const status = await exclusive(() => checkpoint(root));
   if (!status.remote) return gitStatus(root, "Saved a local Git checkpoint. Add an origin remote to sync across machines.");
   const remoteRefs = await git(root, ["ls-remote", "--heads", "origin"]);
-  const branchExists = remoteRefs.split("\n").some((line) => line.endsWith(`refs/heads/${status.branch}`));
-  if (branchExists) {
+  if (remoteRefs.split("\n").some((line) => line.endsWith(`refs/heads/${status.branch}`))) {
     await git(root, ["fetch", "origin", status.branch]);
-    try {
-      await git(root, ["merge", "--ff-only", "FETCH_HEAD"]);
-    } catch (error) {
-      throw new Error(`Git sync stopped. Resolve branch divergence or local changes with Git, then sync again. ${(error as Error).message}`);
-    }
-  } else if (remoteRefs) {
-    throw new Error(`Origin has no ${status.branch} branch. Check out the remote library branch with Git before syncing.`);
+    await exclusive(() => integrate(root, status.branch));
   }
   await git(root, ["push", "--set-upstream", "origin", status.branch]);
   return gitStatus(root, "Synced with origin.");
diff --git a/server/library.test.ts b/server/library.test.ts
index 0d7a7bc..acff636 100644
--- a/server/library.test.ts
+++ b/server/library.test.ts
@@ -1,5 +1,5 @@
 import assert from "node:assert/strict";
-import { mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
+import { mkdtemp, mkdir, readFile, readdir, rm, symlink, utimes, writeFile } from "node:fs/promises";
 import { tmpdir } from "node:os";
 import { join } from "node:path";
 import { test } from "node:test";
@@ -112,7 +112,7 @@ test("searches content, includes archived on request, and reloads chosen setting
   assert.equal((await library.list()).length, 1);
   assert.equal((await library.list({ archived: true })).length, 2);
   const reopened = new PromptLibrary(join(base, "settings.json"));
-  assert.deepEqual(await reopened.settings(), { directory: root, gitEnabled: false });
+  assert.deepEqual(await reopened.settings(), { directory: root, gitEnabled: false, syncMode: "manual", syncInterval: 15 });
   const other = join(base, "other");
   await library.configure({ directory: other, gitEnabled: true });
   assert.equal((await reopened.settings()).directory, other);
@@ -476,3 +476,34 @@ test("attachment search previews the first four body lines after the title", asy
   assert.equal(steps!.subtitle, "Read the diff. Check tests Keep it small npm test");
   assert.equal(headingOnly!.subtitle, "Use for headings");
 });
+
+test("list reuses unchanged prompts and notices external edits of the same size", async (t) => {
+  const { library, root } = await fixture(t);
+  await create(library, "# One\nFirst body.");
+  const first = (await library.list())[0]!;
+  await writeFile(join(root, "code-review.md"), "# Two\nFirst body.");
+  await utimes(join(root, "code-review.md"), new Date(first.updatedAt), new Date(Date.parse(first.updatedAt) + 5_000));
+  const edited = (await library.list())[0]!;
+  assert.equal(edited.title, "Two");
+  assert.notEqual(edited.revision, first.revision);
+  await rm(join(root, "code-review.md"));
+  assert.deepEqual(await library.list(), []);
+  assert.equal((await library.list({ archived: true }))[0]!.archived, true);
+});
+
+test("keeps sync preferences when settings omit them and rejects invalid values", async (t) => {
+  const { library, base, root } = await fixture(t);
+  const changes: string[] = [];
+  library.onChange = () => changes.push("change");
+  assert.deepEqual(await library.configure({ directory: root, gitEnabled: true, syncMode: "interval", syncInterval: 30 }),
+    { directory: root, gitEnabled: true, syncMode: "interval", syncInterval: 30 });
+  assert.deepEqual(await library.configure({ directory: root, gitEnabled: true }), { directory: root, gitEnabled: true, syncMode: "interval", syncInterval: 30 });
+  await assert.rejects(library.configure({ directory: root, gitEnabled: true, syncInterval: 0 }), /Invalid prompt library settings/);
+  await writeFile(join(base, "settings.json"), JSON.stringify({ directory: root, gitEnabled: false, syncMode: "hourly" }));
+  await assert.rejects(library.settings(), /Invalid prompt library settings/);
+  await writeFile(join(base, "settings.json"), JSON.stringify({ directory: root, gitEnabled: false }));
+  assert.equal((await library.settings()).syncMode, "manual");
+  await create(library);
+  await library.list();
+  assert.deepEqual(changes, ["change"]);
+});
diff --git a/server/library.ts b/server/library.ts
index 8c54202..c515680 100644
--- a/server/library.ts
+++ b/server/library.ts
@@ -5,9 +5,10 @@ import { homedir } from "node:os";
 import { basename, dirname, isAbsolute, join, parse, resolve } from "node:path";
 import { pathToFileURL } from "node:url";
 import { previewLines } from "../shared/markdown.ts";
-import type { ImportResult, LibrarySettings, Prompt, Version } from "../shared/prompts.ts";
+import type { ImportResult, LibrarySettings, LibrarySettingsInput, Prompt, Version } from "../shared/prompts.ts";
 
 const MAX_BYTES = 512_000;
+const SYNC_MODES = ["manual", "changes", "interval"];
 const IMPORT_MAX_BYTES = 8_000_000;
 const IMPORT_MAX_FILES = 100;
 const IMPORT_MAX_ENTRIES = 5_000;
@@ -65,12 +66,24 @@ function encode(content: string, value: Metadata) {
   return raw;
 }
 
+function syncMode(value: unknown): LibrarySettings["syncMode"] {
+  if (typeof value !== "string" || !SYNC_MODES.includes(value)) throw new Error("Invalid prompt library settings.");
+  return value as LibrarySettings["syncMode"];
+}
+
+function syncInterval(value: unknown) {
+  if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 1440) throw new Error("Invalid prompt library settings.");
+  return value;
+}
+
 function directoryPath(value: string) {
   const expanded = value === "~" ? homedir() : value.startsWith("~/") ? join(homedir(), value.slice(2)) : value;
   if (!isAbsolute(expanded)) throw new Error("Choose an absolute directory path.");
   return resolve(expanded);
 }
 
+const fileKey = (info: { ino: number; size: number; mtimeMs: number }) => `${info.ino}:${info.size}:${info.mtimeMs}`;
+
 async function regularFile(path: string) {
   try {
     const info = await lstat(path);
@@ -105,15 +118,22 @@ async function readFile(path: string) {
   try {
     const info = await handle.stat();
     if (!info.isFile() || info.size > MAX_BYTES) throw new Error("Prompt exceeds the 512 KB file limit.");
-    const buffer = Buffer.alloc(MAX_BYTES + 1);
+    // Size the buffer from stat. The spare byte detects a file that grew after stat.
+    let buffer = Buffer.allocUnsafe(info.size + 1);
     let length = 0;
-    while (length < buffer.length) {
+    while (true) {
+      if (length === buffer.length) {
+        if (length > MAX_BYTES) break;
+        const grown = Buffer.allocUnsafe(MAX_BYTES + 1);
+        buffer.copy(grown, 0, 0, length);
+        buffer = grown;
+      }
       const { bytesRead } = await handle.read(buffer, length, buffer.length - length, null);
       if (bytesRead === 0) break;
       length += bytesRead;
     }
     if (length > MAX_BYTES) throw new Error("Prompt exceeds the 512 KB file limit.");
-    return { content: buffer.subarray(0, length).toString("utf8"), updatedAt: info.mtime.toISOString() };
+    return { content: buffer.subarray(0, length).toString("utf8"), updatedAt: info.mtime.toISOString(), key: fileKey(info) };
   } finally {
     await handle.close();
   }
@@ -146,6 +166,8 @@ async function atomicWrite(path: string, content: string, exclusive = false) {
 export class PromptLibrary {
   private readonly configPath: string;
   private queue: Promise<unknown> = Promise.resolve();
+  private index = new Map<string, { key: string; prompt: Prompt }>();
+  onChange?: () => void;
 
   constructor(configPath = join(configHome(), "paseo", "prompt-manager.json")) {
     this.configPath = configPath;
@@ -162,21 +184,43 @@ export class PromptLibrary {
       const value: unknown = JSON.parse((await readFile(this.configPath)).content);
       if (!value || typeof value !== "object" || !("directory" in value) || typeof value.directory !== "string"
         || !("gitEnabled" in value) || typeof value.gitEnabled !== "boolean") throw new Error("Invalid prompt library settings.");
-      return { directory: directoryPath(value.directory), gitEnabled: value.gitEnabled };
+      const sync = value as { syncMode?: unknown; syncInterval?: unknown };
+      return {
+        directory: directoryPath(value.directory), gitEnabled: value.gitEnabled,
+        syncMode: syncMode(sync.syncMode ?? "manual"), syncInterval: syncInterval(sync.syncInterval ?? 15),
+      };
     } catch (error) {
       if (!missing(error)) throw error;
-      return { directory: join(configHome(), "paseo", "prompt-lib"), gitEnabled: false };
+      return { directory: join(configHome(), "paseo", "prompt-lib"), gitEnabled: false, syncMode: "manual", syncInterval: 15 };
     }
   }
 
   settings() { return this.serialize(() => this.loadSettings()); }
 
-  configure(settings: LibrarySettings) {
+  configure(settings: LibrarySettingsInput) {
     return this.serialize(async () => {
-      const value = { directory: directoryPath(settings.directory), gitEnabled: settings.gitEnabled };
+      const previous = await this.loadSettings();
+      const value: LibrarySettings = {
+        directory: directoryPath(settings.directory), gitEnabled: settings.gitEnabled,
+        syncMode: syncMode(settings.syncMode ?? previous.syncMode), syncInterval: syncInterval(settings.syncInterval ?? previous.syncInterval),
+      };
       await directory(value.directory);
       await mkdir(dirname(this.configPath), { recursive: true });
       await atomicWrite(this.configPath, `${JSON.stringify(value, null, 2)}\n`);
+      this.invalidate();
+      return value;
+    });
+  }
+
+  /** Drops cached prompt summaries, for example after Git rewrites files. */
+  invalidate() { this.index.clear(); }
+
+  /** Runs an operation with the library lock held, so no prompt writes interleave. */
+  exclusive<T>(operation: () => Promise<T>) { return this.serialize(operation); }
+
+  private write<T>(operation: (root: string, settings: LibrarySettings) => Promise<T>) {
+    return this.withRoot(operation).then((value) => {
+      this.onChange?.();
       return value;
     });
   }
@@ -236,8 +280,7 @@ export class PromptLibrary {
     const file = join(root, `${id}.md`);
     if (await directory(dirname(file), false) && await regularFile(file)) {
       const { content: raw, updatedAt } = await readFile(file);
-      const value = decode(raw);
-      return { id, folder: folderOf(id), title: this.title(id, value.content), ...value, revision: digest(raw), updatedAt, archived: false };
+      return this.active(id, raw, updatedAt);
     }
     const latest = (await this.history(root, id))[0];
     if (!latest) return null;
@@ -246,6 +289,24 @@ export class PromptLibrary {
     return { id, folder: folderOf(id), title: this.title(id, value.content), ...value, revision: digest(`archived\0${latest.filename}\0${raw}`), updatedAt: latest.createdAt, archived: true };
   }
 
+  private active(id: string, raw: string, updatedAt: string): Prompt {
+    const value = decode(raw);
+    return { id, folder: folderOf(id), title: this.title(id, value.content), ...value, revision: digest(raw), updatedAt, archived: false };
+  }
+
+  /** Reads a prompt found by scan(), which already skipped linked folders, reusing unchanged files. */
+  private async indexed(root: string, id: string): Promise<Prompt | null> {
+    const file = join(root, `${id}.md`);
+    const info = await regularFile(file);
+    if (!info) return this.current(root, id);
+    const cached = this.index.get(file);
+    if (cached?.key === fileKey(info)) return cached.prompt;
+    const { content: raw, updatedAt, key } = await readFile(file);
+    const prompt = this.active(id, raw, updatedAt);
+    this.index.set(file, { key, prompt });
+    return prompt;
+  }
+
   private title(id: string, content: string) {
     return /^#{1,6}\s+(.+?)\s*#*\s*$/m.exec(content)?.[1]?.trim() || id;
   }
@@ -305,7 +366,7 @@ export class PromptLibrary {
   }
 
   createFolder(path: string) {
-    return this.withRoot(async (root) => {
+    return this.write(async (root) => {
       const normalized = normalizeFolder(path);
       await directory(join(root, normalized));
       return { path: normalized };
@@ -318,9 +379,11 @@ export class PromptLibrary {
     if (archived) for (const id of (await this.scan(root, true)).ids) ids.add(id);
     const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
     const result: Prompt[] = [];
+    const seen = new Set<string>();
     for (const id of ids) {
       let prompt: Prompt | null;
-      try { prompt = await this.current(root, id); } catch (error) {
+      seen.add(join(root, `${id}.md`));
+      try { prompt = await this.indexed(root, id); } catch (error) {
         console.error(`Skipping prompt "${id}": ${error instanceof Error ? error.message : String(error)}`);
         continue;
       }
@@ -329,6 +392,7 @@ export class PromptLibrary {
       const text = `${prompt.title}\n${id}.md\n${prompt.content}\n${prompt.description}\n${prompt.tags.join(" ")}\n${prompt.folder}`.toLowerCase();
       if (terms.every((term) => text.includes(term))) result.push(prompt);
     }
+    for (const file of this.index.keys()) if (!seen.has(file)) this.index.delete(file);
     return result.sort((a, b) => a.title.localeCompare(b.title));
   }
 
@@ -353,7 +417,7 @@ export class PromptLibrary {
   }
 
   import(input: { paths: string[]; files: { name: string; content: string }[]; folder?: string }): Promise<ImportResult> {
-    return this.withRoot(async (root) => {
+    return this.write(async (root) => {
       const result: ImportResult = { imported: [], skipped: [] };
 

This version ships OVERVIEW.md, so the registry's copy is removed in this bump.

Inline validation passed: npm test and the checks from node scripts/validate.ts --online --changed. The default GITHUB_TOKEN does not trigger the PR validation workflow.

@github-actions github-actions Bot added the bump Pinned artifact update label Oct 7, 2026

@paseo-bot paseo-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs from you: nothing, merged

Prompt Manager v0.4.1 adds the OVERVIEW.md that blocked #164, and it fits the shape. The only other change from v0.4.0 is the version number in package.json and package-lock.json, so the v0.4.0 code review carries over unchanged. The overview now describes the Automatic and Scheduled sync modes and the merge of diverged branches the way the code does them, and this pull request correctly removes the registry copy.

One sentence in the overview is broader than the code, and it does not block this bump. It says "If both machines change the same prompt, the merge is aborted", but Git aborts only when the two edits overlap; edits to different lines of the same prompt merge cleanly (server/git.ts:137-150). @yannelli, "If both machines change the same lines of a prompt" would match the code in a later release.

Review data

  • Artifact: git https://github.com/yannelli/paseo-prompt-manager.git, f3af8cf5ba5a60523c02507aa17f52bd307fa7d0 (tag v0.4.1), from f6a724bf56b85adc42373ea68e2b6f46ce6d11b0 (tag v0.3.0). No pluginPath.
  • Pin: both commits exist on the remote and are reachable from main; v0.3.0 and v0.4.1 still point at them. paseo-plugin.json is at the root and unchanged (paseo-prompt-manager, requires Paseo >=0.11.0-beta.3). The record changes only the pin and reviewedAt, and deletes the registry overview plugins/yannelli/prompt-manager.md.
  • Validation: npm test passes (143/143). node scripts/validate.ts --online --changed passes: 210 records well-formed, 1 record matches its pinned artifact.
  • Extracted and inspected: v0.3.0, v0.4.0, and v0.4.1 exported and diffed locally. v0.3.0 to v0.4.1 is 18 files, +867/-89. v0.4.0 to v0.4.1 is the new OVERVIEW.md plus the version field in package.json and package-lock.json, nothing else. The v0.3.0 to v0.4.0 code change, reviewed in #164, adds server/sync.ts (sync scheduler with manual, changes, and interval modes) and client/sync.tsx (status badge and mode picker); server/git.ts now merges instead of fast-forward only and can change or remove origin. paseo-plugin.json, scripts/, .github/, and docs/ are unchanged.
  • Install-time commands: none. paseo-plugin.json has no install or build. package.json has no preinstall, install, postinstall, or prepare; its scripts are typecheck, test, check, and release:dry-run.
  • Dependencies: no runtime dependencies; dev dependencies unchanged. package-lock.json has 317 entries, each resolved on registry.npmjs.org with an integrity, and npm ci --ignore-scripts installs it. The only entry with an install script is the optional macOS dev dependency fsevents.
  • Hosts: no hard-coded hosts and no fetch or HTTP client. Network traffic is Git to the origin the user configures: ls-remote --symref origin HEAD (server/git.ts:74), and ls-remote, fetch, and push (server/git.ts:160-165), which run on a schedule only when the user picks a non-manual mode.
  • Credentials and environment: GIT_SSH_COMMAND, defaulted to ssh -o BatchMode=yes, plus GIT_TERMINAL_PROMPT=0, so sync never prompts (server/git.ts:27); XDG_CONFIG_HOME (server/library.ts:16). Git uses the host's existing Git credentials, which is what Git sync needs.
  • Filesystem: the settings file prompt-manager.json under the Paseo config directory and the prompt library directory, into which merges bring files from origin. Imports read only the paths the user selects.
  • Execution: execFile("git", ["-c", "core.hooksPath=/dev/null", ...]) (server/git.ts:24-28), so repository hooks never run. Subcommands added in v0.4.0: remote set-url, remote remove origin, branch -m, symbolic-ref, and merge --no-edit FETCH_HEAD with merge --abort on failure. Automatic sync runs only when Git sync is enabled and the mode is not manual (server/sync.ts:39-72). No eval, new Function, or dynamic import().
  • Runtime installs: none.
  • Source match: git artifact; all .ts and .tsx files are readable source, with no bundles or minified files.
  • Listing media: six PNGs pinned to f6a724bf, each HTTP 200 image/png and byte-identical at v0.4.1.
  • Overview: OVERVIEW.md is present and follows the shape: what it is, setup, Git sync, what it reads and sends, known limits; no install steps, badges, changelog, or license. Checked against the code: the three modes with the 5 second debounce and the 5, 15, 30, and 60 minute choices, sync on library open, merge of diverged branches with abort on conflict, changing the URL or branch and removing origin when the URL is cleared, no password prompts, hooks disabled, the settings and library paths, and the import limits (100 files, 8 MB, 512 KB per file, 5,000 entries). The only imprecise sentence is the one noted above. The registry copy is deleted in this pull request.
  • Decision: merge.
  • Reviewed commit: 2f7cb44a5679f2f1917e048e334eddd869fa2812

@paseo-bot
paseo-bot Bot merged commit b3560be into main Oct 7, 2026
@paseo-bot
paseo-bot Bot deleted the bump/yannelli/prompt-manager-v0.4.1 branch October 7, 2026 22:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bump Pinned artifact update

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants