Skip to content

Bump yannelli/prompt-manager to v0.4.0 - #164

Closed
github-actions[bot] wants to merge 1 commit into
mainfrom
bump/yannelli/prompt-manager-v0.4.0
Closed

github-actions[bot] wants to merge 1 commit into
mainfrom
bump/yannelli/prompt-manager-v0.4.0

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":"2338a9a605f2aaa2412f5ec73f4ae735747f3984","tag":"v0.4.0"}
Git tag v0.4.0 pins commit 2338a9a605f2aaa2412f5ec73f4ae735747f3984.
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/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..86315a5 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.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "@yannelli/paseo-prompt-manager",
-      "version": "0.3.0",
+      "version": "0.4.0",
       "license": "MIT",
       "devDependencies": {
         "@getpaseo/plugin": "0.11.0-beta.3",
diff --git a/package.json b/package.json
index 2fa70fc..58e6ac7 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.0",
   "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: [] };
       const destinationFolder = validFolder(input.folder ?? "");
       let candidates = 0;
@@ -436,7 +500,7 @@ export class PromptLibrary {
   }
 
   save(input: { id?: string; name: string; content: string; revision: string | null; folder?: string; description?: string; tags?: string[] }) {
-    return this.withRoot(async (root) => {
+    return this.write(async (root) => {
       validateContent(input.content);
       const oldId = input.id ? validId(input.id) : undefined;
       const folder = input.folder === undefined ? (oldId ? folderOf(oldId) : "") : input.folder;
@@ -499,7 +563,7 @@ export class PromptLibrary {
   }
 
   archive(input: { id: string; revision: string }) {
-    return this.withRoot(async (root) => {
+    return this.write(async (root) => {
       const current = await this.current(root, input.id);
       this.checkRevision(current, input.revision);
       if (!current || current.archived) throw new Error("Active prompt not found.");
@@ -517,7 +581,7 @@ export class PromptLibrary {
   }
 
   restore(input: { id: string; filename: string; revision: string | null }) {
-    return this.withRoot(async (root) => {
+    return this.write(async (root) => {
       const current = await this.current(root, input.id);
       this.checkRevision(current, input.revision);
       const content = await this.historicalContent(root, input.id, input.filename);
diff --git a/server/sync.test.ts b/server/sync.test.ts
new file mode 100644
index 0000000..8c12189
--- /dev/null
+++ b/server/sync.test.ts
@@ -0,0 +1,156 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import type { LibrarySettings } from "../shared/prompts.ts";
+import { SyncScheduler } from "./sync.ts";
+
+type Gate = { promise: Promise<void>; open: () => void };
+const gate = (): Gate => {
+  let open = () => {};
+  const promise = new Promise<void>((resolve) => { open = resolve; });
+  return { promise, open };
+};
+const settle = async () => { for (let step = 0; step < 10; step++) await Promise.resolve(); };
+
+function harness(settings: Partial<LibrarySettings>, options: { initialized?: boolean; fail?: string } = {}) {
+  let clock = Date.parse("2026-10-06T12:00:00.000Z");
+  const runs: Gate[] = [];
+  let synced = 0;
+  const saved: LibrarySettings = { directory: "/library", gitEnabled: true, syncMode: "manual", syncInterval: 15, ...settings };
+  const scheduler = new SyncScheduler({
+    settings: async () => saved,
+    initialized: async () => options.initialized ?? true,
+    sync: async () => {
+      const run = gate();
+      runs.push(run);
+      await run.promise;
+      if (options.fail) throw new Error(options.fail);
+      return { message: "Synced with origin." };
+    },
+    synced: () => { synced++; },
+    debounceMs: 1_000,
+    openThrottleMs: 60_000,
+    now: () => clock,
+  });
+  return { scheduler, runs, saved, synced: () => synced, advance: (ms: number) => { clock += ms; } };
+}
+
+test("after-changes mode waits for writes to settle and never overlaps syncs", async (t) => {
+  t.mock.timers.enable({ apis: ["setTimeout", "setInterval"] });
+  const { scheduler, runs, synced } = harness({ syncMode: "changes" });
+  await scheduler.reload();
+  scheduler.changed();
+  t.mock.timers.tick(500);
+  scheduler.changed();
+  t.mock.timers.tick(999);
+  await settle();
+  assert.equal(runs.length, 0);
+  t.mock.timers.tick(1);
+  await settle();
+  assert.equal(runs.length, 1);
+  assert.equal((await scheduler.snapshot()).running, true);
+  scheduler.changed();
+  t.mock.timers.tick(1_000);
+  await settle();
+ 

This version has no OVERVIEW.md. The registry requires one to update a listing; the bump cannot merge until the repository adds it.

Inline validation failed. See the Bump workflow log.

@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, changes requested

@yannelli, this bump can't merge yet: Prompt Manager v0.4.0 has no OVERVIEW.md, and the registry needs one for every new version.

Change needed: add OVERVIEW.md next to paseo-plugin.json in https://github.com/yannelli/paseo-prompt-manager, tag a new release, and the bump workflow will open a fresh pull request for it. Your file replaces the registry copy, plugins/yannelli/prompt-manager.md. You can start from that copy without its last "imported from paseo.cafe" line, but two of its statements no longer hold for v0.4.0:

  • It says sync runs only when you choose Sync now. In v0.4.0 the Automatic and Scheduled modes sync on their own (5 seconds after a change, or every chosen interval) and also when the library is opened.
  • It says sync stops when the remote branch has diverged. v0.4.0 merges diverged branches and stops only on a merge conflict.

Also worth covering: the remote URL and branch can be changed later, clearing the URL removes origin, and background sync needs a credential helper or SSH agent because it never prompts for a password.

The shape is in REVIEW.md, in this order: what the plugin is and does; how it works, if needed; setup (Paseo 0.11.0-beta.3 or newer, a Git remote for sync); then what it reads or sends and where, its settings, and known limits. Leave out installation steps, badges, changelog, and license sections.

The code change in v0.4.0 is fine. Nothing else needs to change.

Review data

  • Artifact: git https://github.com/yannelli/paseo-prompt-manager.git, 2338a9a605f2aaa2412f5ec73f4ae735747f3984 (tag v0.4.0), from f6a724bf56b85adc42373ea68e2b6f46ce6d11b0 (tag v0.3.0). No pluginPath.
  • Pin: both commits exist on the remote, and v0.3.0 and v0.4.0 still point at them. paseo-plugin.json is at the root and unchanged (paseo-prompt-manager, requires Paseo >=0.11.0-beta.3).
  • Validation: npm test passes (140/140). node scripts/validate.ts --online --changed fails with one error: yannelli/prompt-manager/OVERVIEW.md is required.
  • Extracted and inspected: both commits exported and diffed locally, 17 files, +827/−89. New server/sync.ts (sync scheduler with manual, changes, and interval modes), client/sync.tsx (sync status badge and mode picker); server/git.ts now merges instead of fast-forward only and can change or remove origin; prompt-list cache and composer search debounce. paseo-plugin.json, scripts/, and .github/ 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. The lockfile diff is the version field only.
  • 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 is new (server/git.ts:74); fetch, ls-remote, and push (server/git.ts:160-165) can now run on a schedule the user picks.
  • Credentials and environment: GIT_SSH_COMMAND, defaulted to ssh -o BatchMode=yes so background sync never prompts (server/git.ts:27), is new; XDG_CONFIG_HOME (server/library.ts:16) is existing. Git uses the host's existing Git credentials, which is what Git sync needs.
  • Filesystem: no new paths. Writes its settings file under the Paseo config directory (now also syncMode and syncInterval) and the prompt library directory, into which merges now bring files from origin.
  • Execution: execFile("git", ["-c", "core.hooksPath=/dev/null", ...]) as before (server/git.ts:26-27), so repository hooks never run. New Git subcommands: remote set-url, remote remove origin (server/git.ts:94-95), branch -m, symbolic-ref, and merge --no-edit FETCH_HEAD with merge --abort on failure (server/git.ts:137-150). Automatic sync runs only when Git sync is enabled and the user has chosen a non-manual mode (server/sync.ts:39-74). 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.0.
  • Overview: missing at the pinned commit. The registry copy describes v0.3.0's manual-only, fast-forward-only sync, as listed above.
  • Decision: changes requested, because OVERVIEW.md is missing.
  • Reviewed commit: 9ef19d91b920ff9c41dff384077788100b1546bd

@paseo-bot

paseo-bot Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Closing this obsolete bump: PR #179 already published Prompt Manager v0.4.1. Nothing is needed from you for v0.4.0. The earlier requirement to release solely for an overview is superseded by the current policy, which permits a registry overview.

Review data
  • PR head: 9ef19d91b920ff9c41dff384077788100b1546bd; main record pins Git f3af8cf5ba5a60523c02507aa17f52bd307fa7d0, tag v0.4.1. Obsolete artifact: 2338a9a605f2aaa2412f5ec73f4ae735747f3984, tag v0.4.0.
  • REVIEW.md blob: 7268134d240de9e474c52480f4937af4e369a6e2. Read diff, full discussion and prior review; no author replies or linked submission issue. No registry edits.
  • Superseded queue cleanup, not security/artifact approval. Integrity, installation and validator checks were not repeated for the obsolete proposal. No plugin code or dependencies executed.

@paseo-bot paseo-bot Bot closed this Oct 8, 2026
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