@narumitw/pi-sync 0.28.0 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,7 @@ import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
4
4
  import { Container, type SettingItem, SettingsList, Text } from "@earendil-works/pi-tui";
5
5
  import {
6
6
  agentDir,
7
+ deprecatedPiSyncEnvironmentWarnings,
7
8
  isExplicitlyEnabled,
8
9
  loadPartialConfig,
9
10
  localConfigPath,
@@ -22,28 +23,77 @@ const BUILT_IN_PREFIX = "builtin:";
22
23
  const EXTRA_PREFIX = "extra:";
23
24
  const SESSIONS_ID = "sessions";
24
25
 
25
- export async function showFileSelection(ctx: ExtensionCommandContext) {
26
- const partial = await loadPartialConfig();
27
- const selectedBuiltIns = new Set(normalizeSyncFiles(partial.syncFiles));
28
- const selectedExtras = new Set(normalizeExtraFiles(partial.extraFiles));
26
+ interface SelectionDraft {
27
+ builtIns: Set<string>;
28
+ extras: Set<string>;
29
+ sessions: boolean;
30
+ }
31
+
32
+ export async function showFileSelection(ctx: ExtensionCommandContext, targetName?: string) {
33
+ const partial = await loadPartialConfig(targetName);
34
+ const original: SelectionDraft = {
35
+ builtIns: new Set(normalizeSyncFiles(partial.syncFiles)),
36
+ extras: new Set(normalizeExtraFiles(partial.extraFiles)),
37
+ sessions: isExplicitlyEnabled(partial.syncSessions),
38
+ };
39
+ const draft = cloneDraft(original);
29
40
  const sessionEnvironmentOverride = Object.hasOwn(process.env, "PI_SYNC_SESSIONS");
30
- const sessionsIncluded = isExplicitlyEnabled(partial.syncSessions);
31
- const extraCandidates = await listExtraFileCandidates(selectedExtras);
41
+ const extraCandidates = await listExtraFileCandidates(draft.extras);
32
42
 
33
43
  if (ctx.mode !== "tui") {
34
44
  ctx.ui.notify(
35
45
  [
36
- "pi-sync selected files:",
37
- `built-ins: ${[...selectedBuiltIns].join(", ") || "none"}`,
38
- `sessions: ${sessionsIncluded ? "included" : "excluded"}${sessionEnvironmentOverride ? " (PI_SYNC_SESSIONS)" : ""}`,
39
- `extra files: ${[...selectedExtras].map(safeTerminalText).join(", ") || "none"}`,
46
+ `pi-sync selected files for target ${safeTerminalText(partial.target ?? "default")}:`,
47
+ `built-ins: ${[...draft.builtIns].join(", ") || "none"}`,
48
+ `sessions: ${draft.sessions ? INCLUDED : EXCLUDED}${sessionEnvironmentOverride ? " (PI_SYNC_SESSIONS, deprecated)" : ""}`,
49
+ `extra files: ${[...draft.extras].map(safeTerminalText).join(", ") || "none"}`,
40
50
  `Edit syncFiles, syncSessions, and extraFiles in ${safeTerminalText(localConfigPath())}.`,
51
+ ...deprecatedPiSyncEnvironmentWarnings(),
41
52
  ].join("\n"),
42
53
  "info",
43
54
  );
44
55
  return;
45
56
  }
46
57
 
58
+ while (true) {
59
+ await showDraftEditor(
60
+ ctx,
61
+ partial.target ?? "default",
62
+ draft,
63
+ extraCandidates,
64
+ sessionEnvironmentOverride,
65
+ );
66
+ if (sameDraft(original, draft)) return;
67
+ const choice = await ctx.ui.select(formatDraftPreview(original, draft), [
68
+ "Save changes",
69
+ "Discard changes",
70
+ "Continue editing",
71
+ ]);
72
+ if (choice === "Continue editing") continue;
73
+ if (choice !== "Save changes") {
74
+ ctx.ui.notify("Synced-content changes discarded.", "info");
75
+ return;
76
+ }
77
+ try {
78
+ await persistSelectionDraft(draft, targetName, sessionEnvironmentOverride);
79
+ ctx.ui.notify(
80
+ `Saved synced content for target “${safeTerminalText(partial.target ?? "default")}”. It applies to the next manual or automatic sync.`,
81
+ "info",
82
+ );
83
+ } catch (error) {
84
+ ctx.ui.notify(`Could not save pi-sync file selection: ${errorMessage(error)}`, "error");
85
+ }
86
+ return;
87
+ }
88
+ }
89
+
90
+ async function showDraftEditor(
91
+ ctx: ExtensionCommandContext,
92
+ targetName: string,
93
+ draft: SelectionDraft,
94
+ extraCandidates: string[],
95
+ sessionEnvironmentOverride: boolean,
96
+ ) {
47
97
  const items: SettingItem[] = [
48
98
  ...DEFAULT_SYNC_FILES.map((fileName) => ({
49
99
  id: `${BUILT_IN_PREFIX}${fileName}`,
@@ -51,18 +101,18 @@ export async function showFileSelection(ctx: ExtensionCommandContext) {
51
101
  description: fileName.includes(".")
52
102
  ? `Sync the top-level ${fileName} file when present.`
53
103
  : `Recursively sync every safe file under ${fileName}/.`,
54
- currentValue: selectedBuiltIns.has(fileName) ? INCLUDED : EXCLUDED,
104
+ currentValue: draft.builtIns.has(fileName) ? INCLUDED : EXCLUDED,
55
105
  values: [INCLUDED, EXCLUDED],
56
106
  })),
57
107
  {
58
108
  id: SESSIONS_ID,
59
109
  label: "sessions",
60
110
  description: sessionEnvironmentOverride
61
- ? "Read-only here because PI_SYNC_SESSIONS overrides the local setting. Session JSONL may contain prompts, tool output, paths, images, and secrets."
111
+ ? "Read-only because deprecated PI_SYNC_SESSIONS currently overrides this target. Move it into target settings before the future major removal."
62
112
  : "Session JSONL may contain prompts, tool output, paths, images, and secrets. Sync only to storage you trust.",
63
113
  currentValue: sessionEnvironmentOverride
64
- ? `${sessionsIncluded ? INCLUDED : EXCLUDED} (environment)`
65
- : sessionsIncluded
114
+ ? `${draft.sessions ? INCLUDED : EXCLUDED} (environment, deprecated)`
115
+ : draft.sessions
66
116
  ? INCLUDED
67
117
  : EXCLUDED,
68
118
  ...(sessionEnvironmentOverride ? {} : { values: [INCLUDED, EXCLUDED] }),
@@ -72,59 +122,33 @@ export async function showFileSelection(ctx: ExtensionCommandContext) {
72
122
  label: safeTerminalText(fileName),
73
123
  description:
74
124
  "Additional safe top-level file. It may be absent locally and pulled from another machine.",
75
- currentValue: selectedExtras.has(fileName) ? INCLUDED : EXCLUDED,
125
+ currentValue: draft.extras.has(fileName) ? INCLUDED : EXCLUDED,
76
126
  values: [INCLUDED, EXCLUDED],
77
127
  })),
78
128
  ];
79
129
 
80
- let saveQueue = Promise.resolve();
81
- let nextOwner = 0;
82
- const owners = new Map<string, number>();
83
- let closed = false;
84
-
85
130
  await ctx.ui.custom((tui, theme, _keybindings, done) => {
86
131
  const container = new Container();
87
132
  const title = new Text("", 1, 0);
88
133
  const hint = new Text("", 1, 0);
89
134
  const updateChrome = () => {
90
- title.setText(theme.fg("accent", theme.bold("pi-sync Files")));
91
- hint.setText(
92
- theme.fg("dim", "Changes save immediately and apply to the next manual or automatic sync."),
135
+ title.setText(
136
+ theme.fg("accent", theme.bold(`Synced Content · ${safeTerminalText(targetName)}`)),
93
137
  );
138
+ hint.setText(theme.fg("dim", "Draft only · Esc reviews Save, Discard, or Continue editing."));
94
139
  };
95
140
  updateChrome();
96
141
  container.addChild(title);
97
-
98
- let settingsList: SettingsList;
99
- const queueChange = (id: string, newValue: string) => {
100
- const previousValue = newValue === INCLUDED ? EXCLUDED : INCLUDED;
101
- const owner = ++nextOwner;
102
- owners.set(id, owner);
103
- const save = async () => {
104
- try {
105
- await persistSelectionChange(id, newValue === INCLUDED);
106
- } catch (error) {
107
- if (owners.get(id) === owner && !closed) {
108
- settingsList.updateValue(id, previousValue);
109
- tui.requestRender();
110
- }
111
- ctx.ui.notify(`Could not save pi-sync file selection: ${errorMessage(error)}`, "error");
112
- }
113
- };
114
- saveQueue = saveQueue.then(save, save);
115
- };
116
-
117
- settingsList = new SettingsList(
142
+ const settingsList = new SettingsList(
118
143
  items,
119
144
  Math.min(items.length + 2, 15),
120
145
  getSettingsListTheme(),
121
- queueChange,
146
+ (id, newValue) => updateDraft(draft, id, newValue === INCLUDED),
122
147
  () => done(undefined),
123
148
  { enableSearch: true },
124
149
  );
125
150
  container.addChild(settingsList);
126
151
  container.addChild(hint);
127
-
128
152
  return {
129
153
  render(width: number) {
130
154
  return container.render(width);
@@ -139,8 +163,70 @@ export async function showFileSelection(ctx: ExtensionCommandContext) {
139
163
  },
140
164
  };
141
165
  });
142
- closed = true;
143
- await saveQueue;
166
+ }
167
+
168
+ function updateDraft(draft: SelectionDraft, id: string, included: boolean) {
169
+ if (id.startsWith(BUILT_IN_PREFIX)) {
170
+ const fileName = id.slice(BUILT_IN_PREFIX.length);
171
+ if (included) draft.builtIns.add(fileName);
172
+ else draft.builtIns.delete(fileName);
173
+ return;
174
+ }
175
+ if (id.startsWith(EXTRA_PREFIX)) {
176
+ const fileName = id.slice(EXTRA_PREFIX.length);
177
+ if (included) draft.extras.add(fileName);
178
+ else draft.extras.delete(fileName);
179
+ return;
180
+ }
181
+ if (id === SESSIONS_ID) {
182
+ draft.sessions = included;
183
+ return;
184
+ }
185
+ throw new Error(`Unknown file selection: ${id}`);
186
+ }
187
+
188
+ async function persistSelectionDraft(
189
+ draft: SelectionDraft,
190
+ targetName: string | undefined,
191
+ sessionEnvironmentOverride: boolean,
192
+ ) {
193
+ await updateLocalConfig((current) => {
194
+ const selection = {
195
+ syncFiles: DEFAULT_SYNC_FILES.filter((candidate) => draft.builtIns.has(candidate)),
196
+ ...(!sessionEnvironmentOverride ? { syncSessions: draft.sessions } : {}),
197
+ extraFiles: [...draft.extras],
198
+ };
199
+ if (current.version !== 2) return { ...current, ...selection };
200
+ const targets = asObject(current.targets);
201
+ const selectedTarget =
202
+ targetName ?? (typeof current.activeTarget === "string" ? current.activeTarget : undefined);
203
+ if (!targets || !selectedTarget) throw new Error("Current sync target is not configured.");
204
+ const target = asObject(targets[selectedTarget]);
205
+ if (!target) throw new Error(`Sync target not found: ${selectedTarget}`);
206
+ return {
207
+ ...current,
208
+ targets: { ...targets, [selectedTarget]: { ...target, ...selection } },
209
+ };
210
+ });
211
+ }
212
+
213
+ function formatDraftPreview(original: SelectionDraft, draft: SelectionDraft) {
214
+ const lines = ["Review synced-content changes", ""];
215
+ for (const item of DEFAULT_SYNC_FILES) {
216
+ if (original.builtIns.has(item) === draft.builtIns.has(item)) continue;
217
+ lines.push(`${draft.builtIns.has(item) ? "Include" : "Exclude"}: ${item}`);
218
+ }
219
+ for (const item of new Set([...original.extras, ...draft.extras])) {
220
+ if (original.extras.has(item) === draft.extras.has(item)) continue;
221
+ lines.push(`${draft.extras.has(item) ? "Include" : "Exclude"}: ${safeTerminalText(item)}`);
222
+ }
223
+ if (original.sessions !== draft.sessions) {
224
+ lines.push(`${draft.sessions ? "Include" : "Exclude"}: sessions`);
225
+ if (draft.sessions)
226
+ lines.push("Warning: sessions may contain prompts, tool output, images, and secrets.");
227
+ }
228
+ lines.push("", "Saving does not start a network sync.");
229
+ return lines.join("\n");
144
230
  }
145
231
 
146
232
  async function listExtraFileCandidates(configured: Set<string>) {
@@ -158,28 +244,30 @@ async function listExtraFileCandidates(configured: Set<string>) {
158
244
  return [...candidates.values()].sort((left, right) => left.localeCompare(right));
159
245
  }
160
246
 
161
- async function persistSelectionChange(id: string, included: boolean) {
162
- await updateLocalConfig((current) => {
163
- if (id.startsWith(BUILT_IN_PREFIX)) {
164
- const fileName = id.slice(BUILT_IN_PREFIX.length);
165
- const selected = new Set(normalizeSyncFiles(current.syncFiles));
166
- if (included) selected.add(fileName as (typeof DEFAULT_SYNC_FILES)[number]);
167
- else selected.delete(fileName as (typeof DEFAULT_SYNC_FILES)[number]);
168
- return {
169
- ...current,
170
- syncFiles: DEFAULT_SYNC_FILES.filter((candidate) => selected.has(candidate)),
171
- };
172
- }
173
- if (id.startsWith(EXTRA_PREFIX)) {
174
- const fileName = id.slice(EXTRA_PREFIX.length);
175
- const selected = new Set(normalizeExtraFiles(current.extraFiles));
176
- if (included) selected.add(fileName);
177
- else selected.delete(fileName);
178
- return { ...current, extraFiles: [...selected] };
179
- }
180
- if (id === SESSIONS_ID) return { ...current, syncSessions: included };
181
- throw new Error(`Unknown file selection: ${id}`);
182
- });
247
+ function cloneDraft(value: SelectionDraft): SelectionDraft {
248
+ return {
249
+ builtIns: new Set(value.builtIns),
250
+ extras: new Set(value.extras),
251
+ sessions: value.sessions,
252
+ };
253
+ }
254
+
255
+ function sameDraft(left: SelectionDraft, right: SelectionDraft) {
256
+ return (
257
+ left.sessions === right.sessions &&
258
+ sameSet(left.builtIns, right.builtIns) &&
259
+ sameSet(left.extras, right.extras)
260
+ );
261
+ }
262
+
263
+ function sameSet(left: Set<string>, right: Set<string>) {
264
+ return left.size === right.size && [...left].every((item) => right.has(item));
265
+ }
266
+
267
+ function asObject(value: unknown): Record<string, unknown> | undefined {
268
+ return value && typeof value === "object" && !Array.isArray(value)
269
+ ? (value as Record<string, unknown>)
270
+ : undefined;
183
271
  }
184
272
 
185
273
  function safeTerminalText(value: string) {
package/src/lock.ts CHANGED
@@ -59,7 +59,11 @@ export type LockInspection =
59
59
  | { status: "unreadable" }
60
60
  | { status: "valid"; lock: LockFile };
61
61
 
62
- export async function withLock<T>(command: string, fn: () => Promise<T>): Promise<T> {
62
+ export async function withLock<T>(
63
+ command: string,
64
+ fn: () => Promise<T>,
65
+ options: { reclaimStale?: boolean } = {},
66
+ ): Promise<T> {
63
67
  await ensureStateDir();
64
68
  const lock: LockFile = {
65
69
  id: randomUUID(),
@@ -79,11 +83,24 @@ export async function withLock<T>(command: string, fn: () => Promise<T>): Promis
79
83
  throw await describeHeldLock();
80
84
  }
81
85
 
82
- const inspection = await inspectLock();
86
+ let inspection = await inspectLock();
83
87
  if (inspection.status === "valid" && isStaleLock(inspection.lock)) {
84
- throw new Error(
85
- `pi-sync lock is stale (pid ${inspection.lock.pid}). Run /sync unlock --stale, then retry.`,
86
- );
88
+ if (!options.reclaimStale) {
89
+ throw new Error(
90
+ `pi-sync lock is stale (pid ${inspection.lock.pid}). Run /sync unlock --stale, then retry.`,
91
+ );
92
+ }
93
+ guard.throwIfCompromised();
94
+ const rechecked = await inspectLock();
95
+ if (
96
+ rechecked.status !== "valid" ||
97
+ rechecked.lock.id !== inspection.lock.id ||
98
+ !isStaleLock(rechecked.lock)
99
+ ) {
100
+ throw new Error("pi-sync lock changed while preparing transaction recovery; retry.");
101
+ }
102
+ await fs.rm(lockPath(), { force: true });
103
+ inspection = { status: "missing" };
87
104
  }
88
105
  if (inspection.status === "valid") {
89
106
  throw new Error(