@narumitw/pi-sync 0.54.1 → 0.54.3

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.
Files changed (102) hide show
  1. package/README.md +19 -11
  2. package/dist/chunks/{chunk-ORQRHEEO.ts → chunk-26PMXY7E.ts} +3 -3
  3. package/dist/chunks/{chunk-SDZCTCYQ.ts → chunk-2FETCGGT.ts} +2 -2
  4. package/dist/chunks/{chunk-HUTYAPFP.ts → chunk-35GM6XKM.ts} +275 -151
  5. package/dist/chunks/chunk-35GM6XKM.ts.map +7 -0
  6. package/dist/chunks/{chunk-V6VZ5TLG.ts → chunk-A3TFXY4O.ts} +2 -2
  7. package/dist/chunks/{chunk-5RZPINUQ.ts → chunk-AAZDBIBJ.ts} +2 -2
  8. package/dist/chunks/{chunk-5ZWQ33XL.ts → chunk-CAGJOKB7.ts} +5 -5
  9. package/dist/chunks/{chunk-QE7NZZWT.ts → chunk-E6ONFG57.ts} +15 -2
  10. package/dist/chunks/{chunk-QE7NZZWT.ts.map → chunk-E6ONFG57.ts.map} +2 -2
  11. package/dist/chunks/{chunk-IJ6VV6FN.ts → chunk-GC3QOL6J.ts} +2 -2
  12. package/dist/chunks/{chunk-VHXBCEVF.ts → chunk-HMXUKU43.ts} +3 -3
  13. package/dist/chunks/{chunk-4SBTAVFL.ts → chunk-I4JYVFUS.ts} +5 -6
  14. package/dist/chunks/chunk-I4JYVFUS.ts.map +7 -0
  15. package/dist/chunks/{chunk-2DI6PDZV.ts → chunk-ITFR6NNQ.ts} +14 -2
  16. package/dist/chunks/chunk-ITFR6NNQ.ts.map +7 -0
  17. package/dist/chunks/{chunk-3B4Z2SKV.ts → chunk-KATKRR3D.ts} +18 -11
  18. package/dist/chunks/chunk-KATKRR3D.ts.map +7 -0
  19. package/dist/chunks/{chunk-ANWNQIR7.ts → chunk-KGE47GCZ.ts} +4 -4
  20. package/dist/chunks/chunk-KGE47GCZ.ts.map +7 -0
  21. package/dist/chunks/{chunk-CHSIAC4N.ts → chunk-R35N4CL3.ts} +2 -2
  22. package/dist/chunks/chunk-UZPMDLTL.ts +54 -0
  23. package/dist/chunks/chunk-UZPMDLTL.ts.map +7 -0
  24. package/dist/chunks/{chunk-4QVPTIZB.ts → chunk-VCL7TZYW.ts} +2 -2
  25. package/dist/chunks/{chunk-WFKUKVCP.ts → chunk-XU2VXGQV.ts} +14 -8
  26. package/dist/chunks/chunk-XU2VXGQV.ts.map +7 -0
  27. package/dist/chunks/{chunk-UQ3ALBIB.ts → chunk-YTHQEW35.ts} +3 -3
  28. package/dist/chunks/{conflict-artifacts-BB2FZMIE.ts → conflict-artifacts-4CZFB7WI.ts} +3 -3
  29. package/dist/chunks/{conflict-review-JRFJIBEN.ts → conflict-review-ZOTOHL2E.ts} +11 -10
  30. package/dist/chunks/{conflict-review-JRFJIBEN.ts.map → conflict-review-ZOTOHL2E.ts.map} +1 -1
  31. package/dist/chunks/{file-selection-WZX7HAY2.ts → file-selection-UNHBVVON.ts} +3 -3
  32. package/dist/chunks/{manager-result-dispatcher-6MQKFCWB.ts → manager-result-dispatcher-HVRTPEVK.ts} +7 -7
  33. package/dist/chunks/{manager-ui-IURS4WXY.ts → manager-ui-ZIN4BUSF.ts} +297 -136
  34. package/dist/chunks/manager-ui-ZIN4BUSF.ts.map +7 -0
  35. package/dist/chunks/{merge-baseline-store-EVQAAML6.ts → merge-baseline-store-ICXVTCML.ts} +5 -3
  36. package/dist/chunks/{remote-selection-ui-6LJJ4IMQ.ts → remote-selection-ui-H6AEVA3Q.ts} +6 -6
  37. package/dist/chunks/{s3-backend-AQGV6LFT.ts → s3-backend-Y4P3O5B5.ts} +2 -2
  38. package/dist/chunks/{setup-switch-KTUWL6DE.ts → setup-switch-ZMW5C4M7.ts} +4 -4
  39. package/dist/chunks/{setup-wizard-GSDPDFC4.ts → setup-wizard-VW7F3JN4.ts} +5 -5
  40. package/dist/chunks/{sync-inspection-K45QVLHB.ts → sync-inspection-3EMS2J5B.ts} +5 -4
  41. package/dist/chunks/{sync-operations-QQ2GXKVC.ts → sync-operations-2CWST333.ts} +140 -24
  42. package/dist/chunks/sync-operations-2CWST333.ts.map +7 -0
  43. package/dist/index.ts +103 -68
  44. package/dist/index.ts.map +2 -2
  45. package/docs/settings.md +47 -10
  46. package/docs/tui-contract.md +61 -0
  47. package/package.json +1 -1
  48. package/src/commands/command-execution.ts +2 -1
  49. package/src/commands/command-types.ts +4 -0
  50. package/src/commands/command.ts +3 -1
  51. package/src/settings/config.ts +13 -10
  52. package/src/settings/settings-store.ts +1 -0
  53. package/src/settings/settings-types.ts +2 -0
  54. package/src/settings/settings-validation.ts +3 -0
  55. package/src/snapshot/snapshot-transaction.ts +4 -2
  56. package/src/state/merge-baseline-store.ts +18 -0
  57. package/src/sync/apply-review.ts +63 -0
  58. package/src/sync/automatic-sync.ts +45 -33
  59. package/src/sync/merged-sync.ts +202 -104
  60. package/src/sync/startup-check.ts +39 -21
  61. package/src/sync/startup-timings.ts +90 -0
  62. package/src/sync/sync-inspection.ts +8 -4
  63. package/src/sync/sync-mutations.ts +49 -11
  64. package/src/sync/sync-queries.ts +14 -3
  65. package/src/ui/manager-state.ts +10 -6
  66. package/src/ui/manager-ui.ts +57 -7
  67. package/src/ui/merge-review.ts +3 -3
  68. package/src/ui/merged-sync-summary.ts +2 -18
  69. package/src/ui/settings-frame.ts +55 -0
  70. package/src/ui/settings-ui.ts +141 -43
  71. package/src/ui/setup/setup-prompts.ts +4 -3
  72. package/src/ui/sync-behavior.ts +15 -0
  73. package/src/ui/sync-format.ts +69 -38
  74. package/src/ui/transfer-changes.ts +33 -0
  75. package/src/ui/transfer-review.ts +27 -0
  76. package/dist/chunks/chunk-2DI6PDZV.ts.map +0 -7
  77. package/dist/chunks/chunk-3B4Z2SKV.ts.map +0 -7
  78. package/dist/chunks/chunk-4SBTAVFL.ts.map +0 -7
  79. package/dist/chunks/chunk-ANWNQIR7.ts.map +0 -7
  80. package/dist/chunks/chunk-HUTYAPFP.ts.map +0 -7
  81. package/dist/chunks/chunk-WFKUKVCP.ts.map +0 -7
  82. package/dist/chunks/manager-ui-IURS4WXY.ts.map +0 -7
  83. package/dist/chunks/sync-operations-QQ2GXKVC.ts.map +0 -7
  84. /package/dist/chunks/{chunk-ORQRHEEO.ts.map → chunk-26PMXY7E.ts.map} +0 -0
  85. /package/dist/chunks/{chunk-SDZCTCYQ.ts.map → chunk-2FETCGGT.ts.map} +0 -0
  86. /package/dist/chunks/{chunk-V6VZ5TLG.ts.map → chunk-A3TFXY4O.ts.map} +0 -0
  87. /package/dist/chunks/{chunk-5RZPINUQ.ts.map → chunk-AAZDBIBJ.ts.map} +0 -0
  88. /package/dist/chunks/{chunk-5ZWQ33XL.ts.map → chunk-CAGJOKB7.ts.map} +0 -0
  89. /package/dist/chunks/{chunk-IJ6VV6FN.ts.map → chunk-GC3QOL6J.ts.map} +0 -0
  90. /package/dist/chunks/{chunk-VHXBCEVF.ts.map → chunk-HMXUKU43.ts.map} +0 -0
  91. /package/dist/chunks/{chunk-CHSIAC4N.ts.map → chunk-R35N4CL3.ts.map} +0 -0
  92. /package/dist/chunks/{chunk-4QVPTIZB.ts.map → chunk-VCL7TZYW.ts.map} +0 -0
  93. /package/dist/chunks/{chunk-UQ3ALBIB.ts.map → chunk-YTHQEW35.ts.map} +0 -0
  94. /package/dist/chunks/{conflict-artifacts-BB2FZMIE.ts.map → conflict-artifacts-4CZFB7WI.ts.map} +0 -0
  95. /package/dist/chunks/{file-selection-WZX7HAY2.ts.map → file-selection-UNHBVVON.ts.map} +0 -0
  96. /package/dist/chunks/{manager-result-dispatcher-6MQKFCWB.ts.map → manager-result-dispatcher-HVRTPEVK.ts.map} +0 -0
  97. /package/dist/chunks/{merge-baseline-store-EVQAAML6.ts.map → merge-baseline-store-ICXVTCML.ts.map} +0 -0
  98. /package/dist/chunks/{remote-selection-ui-6LJJ4IMQ.ts.map → remote-selection-ui-H6AEVA3Q.ts.map} +0 -0
  99. /package/dist/chunks/{s3-backend-AQGV6LFT.ts.map → s3-backend-Y4P3O5B5.ts.map} +0 -0
  100. /package/dist/chunks/{setup-switch-KTUWL6DE.ts.map → setup-switch-ZMW5C4M7.ts.map} +0 -0
  101. /package/dist/chunks/{setup-wizard-GSDPDFC4.ts.map → setup-wizard-VW7F3JN4.ts.map} +0 -0
  102. /package/dist/chunks/{sync-inspection-K45QVLHB.ts.map → sync-inspection-3EMS2J5B.ts.map} +0 -0
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
- import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
2
+ import { defineMenu, type MenuSettingItem, runMenu } from "@narumitw/pi-tui-kit";
3
3
  import { loadConfig } from "../settings/config.js";
4
4
  import { localConfigPath } from "../settings/config-file.js";
5
5
  import { updateSyncSetup } from "../settings/settings-management.js";
@@ -14,9 +14,10 @@ import {
14
14
  import { captureMutationOwner } from "../sync/sync-local.js";
15
15
  import type { RunRoute } from "./cancellable-operation.js";
16
16
  import { dispatchManagerResult } from "./manager-result-dispatcher.js";
17
+ import { boundedSettingsUI } from "./settings-frame.js";
17
18
  import { showEditSyncSetupStorage, showSyncSetupManager } from "./setup/setup-actions.js";
18
- import { AUTOMATIC_SYNC_DESCRIPTION } from "./setup/setup-prompts.js";
19
19
  import { showStorageConnections } from "./storage-connections-ui.js";
20
+ import { AUTOMATIC_SYNC_DESCRIPTION, STARTUP_TRANSFER_DESCRIPTION, startupTransferSummary } from "./sync-behavior.js";
20
21
  import { configureSyncStatus } from "./sync-status.js";
21
22
  import { safeTerminalText } from "./terminal-text.js";
22
23
 
@@ -75,50 +76,64 @@ export async function showSyncSettings(
75
76
  | "content-policy"
76
77
  | "skip-secret-scan"
77
78
  | "show-status"
79
+ | "debug"
78
80
  | "on-switch"
79
81
  | "include"
80
82
  | "remote-include"
81
83
  | "storage-location"
82
84
  | "setups"
83
85
  | "connections";
84
- const menu = defineMenu<Awaited<ReturnType<typeof loadConfig>>, "settings", Action, ExtensionCommandContext>({
85
- start: "settings",
86
- screens: {
87
- settings: ({ state }) => ({
88
- kind: "settings",
89
- title: "Pi Sync Settings",
90
- lines: [
91
- `Sync setup: ${safeTerminalText(state.setupName)} · Storage connection: ${safeTerminalText(state.connectionName)}`,
92
- ],
93
- // Flat, searchable task ordering: Content, Destination, Automation, Merge, Other.
94
- // Kit has no heading rows; avoid focusable fake headings or a new component.
95
- items: [
86
+ type Group = "content" | "behavior" | "storage" | "advanced" | "all";
87
+ type Screen = "categories" | Group;
88
+ const groups: Record<Group, { label: string; ids: readonly string[] }> = {
89
+ content: { label: "Sync content", ids: ["include", "remoteInclude", "localFields"] },
90
+ behavior: { label: "Sync behavior", ids: ["automatic", "automaticTransfer", "onSwitch"] },
91
+ storage: { label: "Storage & setups", ids: ["storageLocation", "setups", "connections"] },
92
+ advanced: { label: "Advanced", ids: ["mergeSettings", "contentPolicy", "skipSecretScan", "showStatus", "debug"] },
93
+ all: { label: "All settings (search)", ids: [] },
94
+ };
95
+ let saveStatus = "Simple choices save immediately. Back does not undo saved changes.";
96
+ // Keep Kit's existing settings adapter: native SettingsList has no async save/rollback
97
+ // contract or injected keybindings. Kit preserves these safety and accessibility behaviors.
98
+ // Categories use public navigation; All settings retains cross-category search.
99
+ const settingsScreen = (state: Awaited<ReturnType<typeof loadConfig>>, group: Group) =>
100
+ ({
101
+ kind: "settings",
102
+ title: `Pi Sync Settings · ${groups[group].label}`,
103
+ lines: [
104
+ `Setup: ${safeTerminalText(state.setupName)} · Connection: ${safeTerminalText(state.connectionName)}`,
105
+ saveStatus,
106
+ ],
107
+ items: (
108
+ [
96
109
  {
97
110
  id: "include",
98
- label: "Included content",
99
- description: `${state.include.length} selected path${state.include.length === 1 ? "" : "s"}. Choose which paths this setup syncs.`,
100
- currentValue: "Open editor",
111
+ label: "Included paths",
112
+ description:
113
+ "This setup: choose paths in a draft, then review and Save or Discard. No transfer is started.",
114
+ currentValue: `${state.include.length} paths · Edit`,
101
115
  action: "include",
102
116
  },
103
117
  {
104
118
  id: "remoteInclude",
105
- label: "Compare synced content",
106
- description: "Review this device and remote content lists before choosing either one.",
119
+ label: "Compare included paths",
120
+ description:
121
+ "This setup: compare included-path policies, not file contents. Review before saving either list; no files transfer unless you explicitly Continue.",
107
122
  currentValue: "Review",
108
123
  action: "remote-include",
109
124
  },
110
125
  {
111
126
  id: "localFields",
112
- label: "Machine-local settings fields",
127
+ label: "Machine-local fields",
113
128
  description:
114
- "Root field names omitted from future portable snapshots. Policy changes require directional migration; old history remains.",
129
+ "This setup: settings.json root fields excluded from future portable snapshots, not project overrides. Policy changes require reviewed directional migration; old history remains.",
115
130
  currentValue: `${state.localFields?.length ?? 0} fields · Edit`,
116
131
  action: "local-fields",
117
132
  },
118
133
  {
119
134
  id: "storageLocation",
120
135
  label: "Storage location",
121
- description: "Destination: edit this setup's bucket, branch, or path; remote history is not moved.",
136
+ description: "This setup: review and save bucket, branch, or path changes. Remote history is not moved.",
122
137
  currentValue:
123
138
  state.backend.type === "s3"
124
139
  ? `${safeTerminalText(state.backend.destination.bucket)} · ${safeTerminalText(state.backend.destination.prefix)}`
@@ -130,7 +145,8 @@ export async function showSyncSettings(
130
145
  {
131
146
  id: "setups",
132
147
  label: "Manage sync setups",
133
- description: "Destination: add, switch, edit, or remove sync setups.",
148
+ description:
149
+ "This device: add, switch, edit, or remove sync setups. Each edit identifies its owner before saving.",
134
150
  currentValue: "Open",
135
151
  action: "setups",
136
152
  },
@@ -138,7 +154,7 @@ export async function showSyncSettings(
138
154
  id: "connections",
139
155
  label: "Manage storage connections",
140
156
  description:
141
- "Destination: manage shared server addresses and credentials; edits may affect multiple setups.",
157
+ "Shared connections on this device: review affected setups before saving server or credential changes.",
142
158
  currentValue: "Open",
143
159
  action: "connections",
144
160
  },
@@ -152,63 +168,98 @@ export async function showSyncSettings(
152
168
  },
153
169
  {
154
170
  id: "automaticTransfer",
155
- label: "Automatic transfer at startup",
156
- description:
157
- "May upload, replace, or delete selected files once at idle startup in TUI/RPC. Requires a baseline and conditional/lease publication; never reloads resources. Turning off cancels pending work.",
171
+ label: "Startup transfer",
172
+ description: `${STARTUP_TRANSFER_DESCRIPTION} Saved preference and requirements: ${startupTransferSummary(state)}.`,
158
173
  currentValue: state.automaticTransfer ? "On" : "Off",
159
174
  values: ["On", "Off"],
160
175
  action: "automatic-transfer",
161
176
  },
162
177
  {
163
178
  id: "onSwitch",
164
- label: "After switching setup (all setups)",
165
- description: "All setups: ask before a pull review, open it automatically, or switch without pulling.",
179
+ label: "On setup switch",
180
+ description:
181
+ "All setups on this device: ask before a pull review, open it automatically, or switch without pulling. Applies to subsequent switches.",
166
182
  currentValue: setupSwitchActionLabel(state.onSwitch),
167
183
  values: SETUP_SWITCH_ACTION_OPTIONS.map(({ label }) => label),
168
184
  action: "on-switch",
169
185
  },
170
186
  {
171
187
  id: "mergeSettings",
172
- label: "Settings field merge (experimental)",
188
+ label: "Merge settings fields",
173
189
  description:
174
- "Combine independent global settings.json fields using a verified private ancestor; arrays and nested objects remain atomic. No reload.",
190
+ "This setup, experimental opt-in: merge independent global settings.json root fields with a verified private ancestor. Arrays and nested objects remain atomic. Applies to subsequent sync; no reload.",
175
191
  currentValue: state.mergeSettings ? "On" : "Off",
176
192
  values: ["On", "Off"],
177
193
  action: "merge-settings",
178
194
  },
179
195
  {
180
196
  id: "contentPolicy",
181
- label: "Content / partial sync (experimental)",
197
+ label: "Content merge / partial progress",
182
198
  description:
183
- "Version-5 opt-in: bounded text and prefix-only sessions; partial progress keeps full withheld versions and old baseline hashes.",
199
+ "This setup, experimental opt-in: review the version-5 upgrade before saving. Merge bounded text and prefix-only sessions; partial progress keeps withheld versions and baselines. Applies to subsequent sync; disabling does not clear recovery evidence.",
184
200
  currentValue: state.mergeContent
185
201
  ? state.partialSync
186
- ? "Content & partial"
187
- : "Content only"
202
+ ? "Merge + partial"
203
+ : "Merge only"
188
204
  : state.partialSync
189
205
  ? "Partial only"
190
206
  : "Off",
191
- values: ["Off", "Content only", "Content & partial", "Partial only"],
207
+ values: ["Off", "Merge only", "Merge + partial", "Partial only"],
192
208
  action: "content-policy",
193
209
  },
194
210
  {
195
211
  id: "skipSecretScan",
196
- label: "Skip secret scan (all setups)",
197
- description: "All setups: allow pushes without checking managed local files for possible secrets.",
212
+ label: "Skip secret scan",
213
+ description:
214
+ "All setups on this device: skip possible-secret checks on subsequent pushes, including automatic pushes. Other safety checks remain. Diagnostics still scans.",
198
215
  currentValue: state.skipSecretScan ? "On" : "Off",
199
216
  values: ["On", "Off"],
200
217
  action: "skip-secret-scan",
201
218
  },
202
219
  {
203
220
  id: "showStatus",
204
- label: "Show status (all setups)",
205
- description: "All setups: show sync progress and attention in Pi status.",
221
+ label: "Show sync status",
222
+ description:
223
+ "All setups on this device: saved and applied immediately to Pi status. Review widgets and notifications remain available.",
206
224
  currentValue: state.showStatus ? "On" : "Off",
207
225
  values: ["On", "Off"],
208
226
  action: "show-status",
209
227
  },
228
+ {
229
+ id: "debug",
230
+ label: "Debug startup timings",
231
+ description:
232
+ "All setups on this device: show startup durations in TUI/RPC notifications. Applies next session start; no credentials or file contents.",
233
+ currentValue: state.debug ? "On" : "Off",
234
+ values: ["On", "Off"],
235
+ action: "debug",
236
+ },
237
+ ] satisfies MenuSettingItem<Action>[]
238
+ ).filter((item) => group === "all" || groups[group].ids.includes(item.id)),
239
+ }) as const;
240
+ const menu = defineMenu<Awaited<ReturnType<typeof loadConfig>>, Screen, Action, ExtensionCommandContext>({
241
+ start: "categories",
242
+ screens: {
243
+ categories: ({ state }) => ({
244
+ kind: "actions",
245
+ title: "Pi Sync Settings",
246
+ lines: [
247
+ `Setup: ${safeTerminalText(state.setupName)} · Connection: ${safeTerminalText(state.connectionName)}`,
248
+ "Choose a task, or search all settings. Scope is shown in each setting's description.",
249
+ saveStatus,
210
250
  ],
251
+ items: (Object.entries(groups) as [Group, (typeof groups)[Group]][]).map(([id, group]) => ({
252
+ id,
253
+ label: group.label,
254
+ to: id,
255
+ })),
256
+ hint: "back",
211
257
  }),
258
+ content: ({ state }) => settingsScreen(state, "content"),
259
+ behavior: ({ state }) => settingsScreen(state, "behavior"),
260
+ storage: ({ state }) => settingsScreen(state, "storage"),
261
+ advanced: ({ state }) => settingsScreen(state, "advanced"),
262
+ all: ({ state }) => settingsScreen(state, "all"),
212
263
  },
213
264
  actions: {
214
265
  "storage-location": async ({ signal: actionSignal }) => {
@@ -232,8 +283,8 @@ export async function showSyncSettings(
232
283
  try {
233
284
  const previous = await loadConfig(setupName);
234
285
  validate();
235
- const mergeContent = value === "Content only" || value === "Content & partial";
236
- const partialSync = value === "Partial only" || value === "Content & partial";
286
+ const mergeContent = value === "Merge only" || value === "Merge + partial";
287
+ const partialSync = value === "Partial only" || value === "Merge + partial";
237
288
  if (
238
289
  !(await ctx.ui.confirm(
239
290
  "Save experimental content policy?",
@@ -243,6 +294,7 @@ export async function showSyncSettings(
243
294
  )
244
295
  return { kind: "rejected" };
245
296
  validate();
297
+ ctx.ui.notify("Saving reviewed Pi Sync policy…", "info");
246
298
  await updateLocalConfig((current) => {
247
299
  validate();
248
300
  const setup = current.syncSetups[setupName];
@@ -262,6 +314,9 @@ export async function showSyncSettings(
262
314
  };
263
315
  }, mutationSignal);
264
316
  validate();
317
+ saveStatus =
318
+ "Saved reviewed policy for this setup. Applies to subsequent sync operations; recovery evidence is retained.";
319
+ ctx.ui.notify(saveStatus, "info");
265
320
  return { kind: "stay" };
266
321
  } catch (error) {
267
322
  if (!mutationSignal.aborted) notifySaveFailure(ctx, error);
@@ -312,6 +367,7 @@ export async function showSyncSettings(
312
367
  );
313
368
  validate();
314
369
  if (!confirmed) return { kind: "rejected" };
370
+ ctx.ui.notify("Saving reviewed Pi Sync policy…", "info");
315
371
  await updateLocalConfig((current) => {
316
372
  validate();
317
373
  const setup = current.syncSetups[setupName];
@@ -327,6 +383,9 @@ export async function showSyncSettings(
327
383
  };
328
384
  }, mutationSignal);
329
385
  validate();
386
+ saveStatus =
387
+ "Saved reviewed policy for this setup. Applies to subsequent sync operations; recovery evidence is retained.";
388
+ ctx.ui.notify(saveStatus, "info");
330
389
  return { kind: "stay" };
331
390
  } catch (error) {
332
391
  if (!mutationSignal.aborted) notifySaveFailure(ctx, error);
@@ -405,6 +464,18 @@ export async function showSyncSettings(
405
464
  return { kind: "rejected" };
406
465
  }
407
466
  },
467
+ debug: async ({ value, signal: actionSignal }) => {
468
+ const debug = value === "On";
469
+ const mutationSignal = signal ? AbortSignal.any([signal, actionSignal]) : actionSignal;
470
+ try {
471
+ await updateLocalConfig((settings) => ({ ...settings, debug }), mutationSignal);
472
+ if (mutationSignal.aborted) return { kind: "rejected" };
473
+ return { kind: "stay" };
474
+ } catch (error) {
475
+ if (!mutationSignal.aborted) notifySaveFailure(ctx, error);
476
+ return { kind: "rejected" };
477
+ }
478
+ },
408
479
  "on-switch": async ({ value, signal: actionSignal }) => {
409
480
  const action = value ? setupSwitchActionFromLabel(value) : undefined;
410
481
  if (!action) return { kind: "rejected" };
@@ -444,6 +515,33 @@ export async function showSyncSettings(
444
515
  },
445
516
  },
446
517
  });
518
+ for (const id of [
519
+ "automatic",
520
+ "automatic-transfer",
521
+ "merge-settings",
522
+ "skip-secret-scan",
523
+ "show-status",
524
+ "debug",
525
+ "on-switch",
526
+ ] as const) {
527
+ const save = menu.actions[id];
528
+ menu.actions[id] = async (action) => {
529
+ const validate = captureMutationOwner(ctx, action.signal);
530
+ validate();
531
+ ctx.ui.notify("Saving Pi Sync setting…", "info");
532
+ const result = await save(action);
533
+ if (action.signal.aborted || signal?.aborted) return { kind: "rejected" };
534
+ validate();
535
+ saveStatus =
536
+ result?.kind === "rejected"
537
+ ? "Save failed or rejected. Previous value retained; retry is available."
538
+ : id === "automatic-transfer" || id === "automatic" || id === "debug"
539
+ ? "Saved. Enabling startup work applies next session start; completed transfers are not undone."
540
+ : "Saved. Applies to subsequent operations; status visibility applies immediately.";
541
+ ctx.ui.notify(saveStatus, result?.kind === "rejected" ? "warning" : "info");
542
+ return result;
543
+ };
544
+ }
447
545
  async function resumeSettings(editorSignal: AbortSignal) {
448
546
  if (editorSignal.aborted) return { kind: "close" as const };
449
547
  try {
@@ -455,7 +553,7 @@ export async function showSyncSettings(
455
553
  return { kind: "close" as const };
456
554
  }
457
555
  }
458
- await runMenu(ctx, menu, {
556
+ await runMenu({ ...ctx, ui: boundedSettingsUI(ctx) }, menu, {
459
557
  getState: () => loadConfig(setupName),
460
558
  signal,
461
559
  isCurrent: () => !signal?.aborted,
@@ -2,7 +2,11 @@ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { readLocalConfigObject } from "../../settings/settings-store.js";
3
3
  import { validateConfigName } from "../../settings/settings-validation.js";
4
4
  import { DEFAULT_SYNC_INCLUDE } from "../../sync/sync-policy.js";
5
+ import { AUTOMATIC_SYNC_DESCRIPTION } from "../sync-behavior.js";
5
6
  import { safeTerminalText } from "../terminal-text.js";
7
+
8
+ export { AUTOMATIC_SYNC_DESCRIPTION } from "../sync-behavior.js";
9
+
6
10
  import { promptTextInput } from "./text-input.js";
7
11
 
8
12
  export async function readSetupConnection(name: string, signal?: AbortSignal) {
@@ -12,9 +16,6 @@ export async function readSetupConnection(name: string, signal?: AbortSignal) {
12
16
  return connection;
13
17
  }
14
18
 
15
- export const AUTOMATIC_SYNC_DESCRIPTION =
16
- "Check the current setup in the background at startup; review changes in /sync before transferring. At shutdown, automatically push selected content only when sessions are included. No startup dialogs.";
17
-
18
19
  export async function chooseSetupContent(ctx: ExtensionCommandContext, signal?: AbortSignal) {
19
20
  const choice = await ctx.ui.select(
20
21
  "Choose included content\n\nRecommended: Pi settings, instructions, skills, prompts, themes, and extensions.\nMinimal: settings.json and AGENTS.md.\nReview the exact paths before saving.",
@@ -0,0 +1,15 @@
1
+ export const AUTOMATIC_SYNC_DESCRIPTION =
2
+ "This setup: check remote state once at startup in TUI/RPC. At shutdown, push all selected content only when sessions are included; reload skips that push. Saves apply to subsequent operations; a new check waits for the next session start. This does not enable startup transfer.";
3
+
4
+ export const STARTUP_TRANSFER_DESCRIPTION =
5
+ "This setup: separate opt-in for one idle startup transfer in TUI/RPC. Requires an accepted baseline, matching remote included paths, and conditional or lease-protected publication (Git, verified WebDAV, or R2/S3 with verified conditional writes). May upload, replace, or remove selected files. Never reloads resources. Enabling applies next session start; opening Settings cancels pending work. Completed transfers are not undone.";
6
+
7
+ export function startupTransferSummary(config: {
8
+ automaticTransfer?: boolean;
9
+ backend: { type: "s3" | "git" | "webdav" };
10
+ }) {
11
+ if (!config.automaticTransfer) return "Off";
12
+ return config.backend.type === "s3"
13
+ ? "On (next idle startup; R2/S3 conditional-write verification required)"
14
+ : "On (next idle startup; baseline and remote policy checks required)";
15
+ }
@@ -5,12 +5,17 @@ import type { Snapshot } from "../snapshot/snapshot-types.js";
5
5
  import { inspectRemoteSelection } from "../sync/sync-policy.js";
6
6
  import { fileHashMap } from "../sync/sync-state.js";
7
7
  import { safeTerminalText, snapshotPathLabel } from "./terminal-text.js";
8
+ import { transferChanges, transferCounts } from "./transfer-changes.js";
8
9
 
9
10
  export function formatDiff(local: Snapshot, remote: Snapshot) {
10
11
  const localMap = fileHashMap(local);
11
12
  const remoteMap = fileHashMap(remote);
12
13
  const allPaths = [...new Set([...Object.keys(localMap), ...Object.keys(remoteMap)])].sort();
13
- const lines = [`local: ${local.files.length} files`, `remote: ${remote.id} (${remote.files.length} files)`, ""];
14
+ const lines = [
15
+ `local: ${local.files.length} files`,
16
+ `remote: ${safeTerminalText(remote.id)} (${remote.files.length} files)`,
17
+ "",
18
+ ];
14
19
  let changed = 0;
15
20
  for (const filePath of allPaths) {
16
21
  if (!Object.hasOwn(localMap, filePath)) {
@@ -29,9 +34,10 @@ export function formatDiff(local: Snapshot, remote: Snapshot) {
29
34
  }
30
35
 
31
36
  export function formatSnapshotOnlyDiff(title: string, snapshot: Snapshot) {
32
- return [`${title}: ${snapshot.id}`, ...snapshot.files.map((file) => `Add: ${snapshotPathLabel(file.path)}`)].join(
33
- "\n",
34
- );
37
+ return [
38
+ `${safeTerminalText(title)}: ${safeTerminalText(snapshot.id)}`,
39
+ ...snapshot.files.map((file) => `Add: ${snapshotPathLabel(file.path)}`),
40
+ ].join("\n");
35
41
  }
36
42
 
37
43
  export function formatPushSummary(
@@ -45,26 +51,40 @@ export function formatPushSummary(
45
51
  return [
46
52
  `Sync setup: ${safeTerminalText(config.setupName)}`,
47
53
  `Storage location: ${safeTerminalText(destination)}`,
48
- `Upload ${upload.files.length} files from ${safeTerminalText(agentDir())}.`,
54
+ `Direction: Local → Remote. Source: ${safeTerminalText(agentDir())}.`,
55
+ `Publish a snapshot containing ${upload.files.length} files; unchanged and preserved files are not counted as changes.`,
49
56
  `Sessions: ${upload.syncSessions ? "included — may contain private conversations" : "not included"}`,
50
- head ? `Remote latest: ${head.snapshotId}` : "Remote latest: empty",
51
- "Publication effect: the backend's active head will reference the new immutable snapshot.",
57
+ head ? `Remote latest: ${safeTerminalText(head.snapshotId)}` : "Remote latest: empty",
58
+ "Publication effect: the backend's active head will reference the new immutable snapshot. Removed paths leave the active snapshot; retained history is not erased.",
52
59
  ...(remote && inspectRemoteSelection(config.include, remote).kind === "different"
53
60
  ? ["Included-content policy effect: replace the differing remote selection with this setup's local selection."]
54
61
  : []),
55
62
  formatPublicationPreview(remote, upload),
56
- preservedRemoteFileCount > 0
57
- ? `Possible secrets in locally managed files were scanned before this prompt; ${preservedRemoteFileCount} preserved remote file(s) were not rescanned.`
58
- : "Possible secrets were scanned before this prompt.",
63
+ config.skipSecretScan
64
+ ? "Warning: possible-secret scanning is disabled for this push."
65
+ : preservedRemoteFileCount > 0
66
+ ? `Possible secrets in locally managed files were scanned before this prompt; ${preservedRemoteFileCount} preserved remote file(s) were not rescanned.`
67
+ : "Possible secrets were scanned before this prompt.",
59
68
  ].join("\n");
60
69
  }
61
70
 
62
- export function formatApplyPreview(local: Snapshot, remote: Snapshot) {
63
- return formatDirectionalChanges(local, remote, {
64
- add: "Add locally",
65
- update: "Update locally",
66
- remove: "Remove locally",
67
- });
71
+ export function formatApplyPreview(
72
+ local: Snapshot,
73
+ remote: Snapshot,
74
+ protectedPaths: ReadonlySet<string> = new Set(),
75
+ additionalRemovals: readonly string[] = [],
76
+ ) {
77
+ return formatDirectionalChanges(
78
+ local,
79
+ remote,
80
+ {
81
+ add: "Add locally",
82
+ update: "Update locally",
83
+ remove: "Remove locally",
84
+ },
85
+ protectedPaths,
86
+ additionalRemovals,
87
+ );
68
88
  }
69
89
 
70
90
  export function formatPullSummary(
@@ -72,15 +92,20 @@ export function formatPullSummary(
72
92
  destination: string,
73
93
  local: Snapshot,
74
94
  remote: Snapshot,
75
- protectedSessionCount: number,
95
+ protectedPaths: ReadonlySet<string>,
96
+ additionalRemovals: readonly string[] = [],
76
97
  ) {
77
98
  return [
78
99
  `Sync setup: ${safeTerminalText(config.setupName)}`,
79
100
  `Storage location: ${safeTerminalText(destination)}`,
101
+ "Direction: Remote → Local.",
80
102
  `Snapshot: ${safeTerminalText(remote.id)}`,
81
103
  `Sessions: ${remote.syncSessions ? "included — may contain private conversations" : "not included"}`,
82
- `Protected live sessions: ${protectedSessionCount || "none"}`,
83
- formatApplyPreview(local, remote),
104
+ `Protected live sessions: ${protectedPaths.size || "none"}`,
105
+ formatApplyPreview(local, remote, protectedPaths, additionalRemovals),
106
+ ...(additionalRemovals.length
107
+ ? ["Case-variant aliases listed above are removed before writing canonical paths."]
108
+ : []),
84
109
  "A local backup is created before these writes/deletes. The remote active snapshot is unchanged.",
85
110
  ].join("\n");
86
111
  }
@@ -91,25 +116,25 @@ export function formatRollbackSummary(
91
116
  local: Snapshot,
92
117
  remote: Snapshot,
93
118
  requestedSnapshot: string,
94
- protectedSessionCount: number,
119
+ protectedPaths: ReadonlySet<string>,
120
+ additionalRemovals: readonly string[] = [],
95
121
  ) {
96
122
  return [
97
123
  `Sync setup: ${safeTerminalText(config.setupName)}`,
98
124
  `Storage location: ${safeTerminalText(destination)}`,
125
+ "Direction: Historical snapshot → Local and remote active head.",
99
126
  `Snapshot: ${safeTerminalText(requestedSnapshot)}`,
100
127
  `Sessions: ${remote.syncSessions ? "included — may contain private conversations" : "not included"}`,
101
- `Protected live sessions: ${protectedSessionCount || "none"}`,
102
- formatApplyPreview(local, remote),
128
+ `Protected live sessions: ${protectedPaths.size || "none"}`,
129
+ formatApplyPreview(local, remote, protectedPaths, additionalRemovals),
130
+ ...(additionalRemovals.length
131
+ ? ["Case-variant aliases listed above are removed before writing canonical paths."]
132
+ : []),
103
133
  "A local backup is created before applying; the backend's active head will change.",
104
134
  ].join("\n");
105
135
  }
106
136
 
107
137
  export function formatPublicationPreview(remote: Snapshot | undefined, upload: Snapshot) {
108
- if (!remote) {
109
- return ["Remote is empty.", ...upload.files.map((file) => `Add remotely: ${snapshotPathLabel(file.path)}`)].join(
110
- "\n",
111
- );
112
- }
113
138
  return formatDirectionalChanges(remote, upload, {
114
139
  add: "Add remotely",
115
140
  update: "Update remotely",
@@ -118,21 +143,27 @@ export function formatPublicationPreview(remote: Snapshot | undefined, upload: S
118
143
  }
119
144
 
120
145
  function formatDirectionalChanges(
121
- before: Snapshot,
146
+ before: Snapshot | undefined,
122
147
  after: Snapshot,
123
148
  labels: { add: string; update: string; remove: string },
149
+ protectedPaths: ReadonlySet<string> = new Set(),
150
+ additionalRemovals: readonly string[] = [],
124
151
  ) {
125
- const beforeMap = fileHashMap(before);
126
- const afterMap = fileHashMap(after);
127
- const paths = [...new Set([...Object.keys(beforeMap), ...Object.keys(afterMap)])].sort();
128
- const lines: string[] = [];
129
- for (const filePath of paths) {
130
- if (!Object.hasOwn(beforeMap, filePath)) lines.push(`${labels.add}: ${snapshotPathLabel(filePath)}`);
131
- else if (!Object.hasOwn(afterMap, filePath)) lines.push(`${labels.remove}: ${snapshotPathLabel(filePath)}`);
132
- else if (beforeMap[filePath] !== afterMap[filePath]) lines.push(`${labels.update}: ${snapshotPathLabel(filePath)}`);
152
+ const changes = transferChanges(before, after, protectedPaths);
153
+ const known = new Set(changes.map((change) => change.path));
154
+ for (const filePath of additionalRemovals) {
155
+ if (!known.has(filePath) && !protectedPaths.has(filePath)) {
156
+ changes.push({ path: filePath, action: "Delete" });
157
+ known.add(filePath);
158
+ }
133
159
  }
134
- if (lines.length === 0) lines.push("No file changes.");
135
- return lines.join("\n");
160
+ const counts = transferCounts(changes);
161
+ const label = { Add: labels.add, Update: labels.update, Delete: labels.remove };
162
+ return [
163
+ `Changes: ${counts.add} add · ${counts.update} update · ${counts.remove} remove`,
164
+ ...changes.map((change) => `${label[change.action]}: ${snapshotPathLabel(change.path)}`),
165
+ ...(changes.length ? [] : ["No file changes."]),
166
+ ].join("\n");
136
167
  }
137
168
 
138
169
  export function countPreservedRemoteFiles(local: Snapshot, upload: Snapshot) {
@@ -0,0 +1,33 @@
1
+ import type { Snapshot } from "../snapshot/snapshot-types.js";
2
+ import { fileHashMap } from "../sync/sync-state.js";
3
+
4
+ export interface TransferChange {
5
+ path: string;
6
+ action: "Add" | "Update" | "Delete";
7
+ }
8
+
9
+ /** Compare effective before/after snapshots, not raw planner decisions. */
10
+ export function transferChanges(
11
+ before: Snapshot | undefined,
12
+ after: Snapshot,
13
+ protectedPaths: ReadonlySet<string> = new Set(),
14
+ ): TransferChange[] {
15
+ const beforeMap = before ? fileHashMap(before) : {};
16
+ const afterMap = fileHashMap(after);
17
+ return [...new Set([...Object.keys(beforeMap), ...Object.keys(afterMap)])]
18
+ .sort()
19
+ .flatMap((filePath): TransferChange[] => {
20
+ if (protectedPaths.has(filePath)) return [];
21
+ if (!Object.hasOwn(beforeMap, filePath)) return [{ path: filePath, action: "Add" }];
22
+ if (!Object.hasOwn(afterMap, filePath)) return [{ path: filePath, action: "Delete" }];
23
+ return beforeMap[filePath] !== afterMap[filePath] ? [{ path: filePath, action: "Update" }] : [];
24
+ });
25
+ }
26
+
27
+ export function transferCounts(changes: readonly TransferChange[]) {
28
+ return {
29
+ add: changes.filter((change) => change.action === "Add").length,
30
+ update: changes.filter((change) => change.action === "Update").length,
31
+ remove: changes.filter((change) => change.action === "Delete").length,
32
+ };
33
+ }
@@ -0,0 +1,27 @@
1
+ import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { confirmMergeReview } from "./merge-review.js";
3
+
4
+ /** Reuse the scrollable safe-default review in TUI; retain RPC's confirmation protocol. */
5
+ export async function confirmTransferReview(
6
+ ctx: ExtensionCommandContext | ExtensionContext,
7
+ title: string,
8
+ content: string,
9
+ signal: AbortSignal | undefined,
10
+ validate: () => void,
11
+ ) {
12
+ validate();
13
+ const isCurrent = () => {
14
+ try {
15
+ validate();
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ };
21
+ const confirmed =
22
+ ctx.mode === "tui" && ctx.hasUI
23
+ ? await confirmMergeReview(ctx, title, content, signal, isCurrent, "apply reviewed transfer", content.split("\n"))
24
+ : await ctx.ui.confirm(title, content, { signal });
25
+ validate();
26
+ return confirmed;
27
+ }