@narumitw/pi-sync 0.54.2 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/README.md +40 -15
  2. package/dist/chunks/agent-candidate-3JN6DJKT.ts +26 -0
  3. package/dist/chunks/agent-plan-QCUJWBP4.ts +99 -0
  4. package/dist/chunks/agent-plan-QCUJWBP4.ts.map +7 -0
  5. package/dist/chunks/agent-runner-E2JU2VZ6.ts +182 -0
  6. package/dist/chunks/agent-runner-E2JU2VZ6.ts.map +7 -0
  7. package/dist/chunks/agent-store-A3F6IYWZ.ts +152 -0
  8. package/dist/chunks/agent-store-A3F6IYWZ.ts.map +7 -0
  9. package/dist/chunks/chunk-5Q5SSKNG.ts +128 -0
  10. package/dist/chunks/chunk-5Q5SSKNG.ts.map +7 -0
  11. package/dist/chunks/chunk-5XZOC7FD.ts +215 -0
  12. package/dist/chunks/chunk-5XZOC7FD.ts.map +7 -0
  13. package/dist/chunks/{chunk-IJ6VV6FN.ts → chunk-AAEEHR7H.ts} +31 -87
  14. package/dist/chunks/chunk-AAEEHR7H.ts.map +7 -0
  15. package/dist/chunks/{chunk-CHSIAC4N.ts → chunk-B4QZBDBT.ts} +2 -2
  16. package/dist/chunks/{chunk-CL7BZRRQ.ts → chunk-CRKTAPSL.ts} +8 -8
  17. package/dist/chunks/chunk-DT7M4VQS.ts +69 -0
  18. package/dist/chunks/chunk-DT7M4VQS.ts.map +7 -0
  19. package/dist/chunks/chunk-ED3TYIVW.ts +229 -0
  20. package/dist/chunks/chunk-ED3TYIVW.ts.map +7 -0
  21. package/dist/chunks/{chunk-VHXBCEVF.ts → chunk-EUMINX7L.ts} +6 -6
  22. package/dist/chunks/chunk-FQOSHGJ7.ts +215 -0
  23. package/dist/chunks/chunk-FQOSHGJ7.ts.map +7 -0
  24. package/dist/chunks/chunk-GNH73BJ5.ts +153 -0
  25. package/dist/chunks/chunk-GNH73BJ5.ts.map +7 -0
  26. package/dist/chunks/{chunk-UQ3ALBIB.ts → chunk-GP5DU62N.ts} +8 -9
  27. package/dist/chunks/chunk-GP5DU62N.ts.map +7 -0
  28. package/dist/chunks/{chunk-5RZPINUQ.ts → chunk-GSNPASKB.ts} +2 -2
  29. package/dist/chunks/{chunk-QE7NZZWT.ts → chunk-IWZH5KYS.ts} +23 -7
  30. package/dist/chunks/chunk-IWZH5KYS.ts.map +7 -0
  31. package/dist/chunks/{chunk-5ZWQ33XL.ts → chunk-JJTARUSO.ts} +15 -14
  32. package/dist/chunks/chunk-JJTARUSO.ts.map +7 -0
  33. package/dist/chunks/{chunk-2DI6PDZV.ts → chunk-NLWS55B6.ts} +15 -2
  34. package/dist/chunks/chunk-NLWS55B6.ts.map +7 -0
  35. package/dist/chunks/{chunk-SDZCTCYQ.ts → chunk-OEWQVWLC.ts} +4 -4
  36. package/dist/chunks/{chunk-MYPSMJHJ.ts → chunk-OTZRCKYP.ts} +380 -510
  37. package/dist/chunks/chunk-OTZRCKYP.ts.map +7 -0
  38. package/dist/chunks/{chunk-WFKUKVCP.ts → chunk-OWBAZA6J.ts} +24 -12
  39. package/dist/chunks/chunk-OWBAZA6J.ts.map +7 -0
  40. package/dist/chunks/chunk-PRUEIIK7.ts +24 -0
  41. package/dist/chunks/chunk-PRUEIIK7.ts.map +7 -0
  42. package/dist/chunks/chunk-QGK445K7.ts +21 -0
  43. package/dist/chunks/chunk-QGK445K7.ts.map +7 -0
  44. package/dist/chunks/{chunk-3B4Z2SKV.ts → chunk-QO333NM4.ts} +49 -21
  45. package/dist/chunks/chunk-QO333NM4.ts.map +7 -0
  46. package/dist/chunks/{chunk-4SBTAVFL.ts → chunk-QSHMOL3I.ts} +22 -24
  47. package/dist/chunks/chunk-QSHMOL3I.ts.map +7 -0
  48. package/dist/chunks/chunk-S2FMH5FT.ts +97 -0
  49. package/dist/chunks/chunk-S2FMH5FT.ts.map +7 -0
  50. package/dist/chunks/{chunk-BVVP33U5.ts → chunk-SWRVXVFD.ts} +12 -9
  51. package/dist/chunks/chunk-SWRVXVFD.ts.map +7 -0
  52. package/dist/chunks/{chunk-ZX2Q6THB.ts → chunk-UBV24CG4.ts} +4 -5
  53. package/dist/chunks/chunk-UBV24CG4.ts.map +7 -0
  54. package/dist/chunks/chunk-UOHR7LRO.ts +70 -0
  55. package/dist/chunks/chunk-UOHR7LRO.ts.map +7 -0
  56. package/dist/chunks/{chunk-4QVPTIZB.ts → chunk-UPNVIUBN.ts} +3 -3
  57. package/dist/chunks/chunk-UZPMDLTL.ts +54 -0
  58. package/dist/chunks/chunk-UZPMDLTL.ts.map +7 -0
  59. package/dist/chunks/chunk-VJN7KU4J.ts +350 -0
  60. package/dist/chunks/chunk-VJN7KU4J.ts.map +7 -0
  61. package/dist/chunks/chunk-YHT4YTYG.ts +31 -0
  62. package/dist/chunks/chunk-YHT4YTYG.ts.map +7 -0
  63. package/dist/chunks/{chunk-V6VZ5TLG.ts → chunk-YK52VDXG.ts} +6 -5
  64. package/dist/chunks/chunk-YK52VDXG.ts.map +7 -0
  65. package/dist/chunks/config-KGIYTTXQ.ts +37 -0
  66. package/dist/chunks/{conflict-artifacts-BB2FZMIE.ts → conflict-artifacts-FXFIIBCB.ts} +8 -7
  67. package/dist/chunks/{conflict-review-4PLVLMRO.ts → conflict-review-6WIEQLXW.ts} +31 -24
  68. package/dist/chunks/{conflict-review-4PLVLMRO.ts.map → conflict-review-6WIEQLXW.ts.map} +1 -1
  69. package/dist/chunks/continuous-record-LFRVXQBD.ts +30 -0
  70. package/dist/chunks/continuous-sync-AWAAOXK2.ts +545 -0
  71. package/dist/chunks/continuous-sync-AWAAOXK2.ts.map +7 -0
  72. package/dist/chunks/{file-selection-WZX7HAY2.ts → file-selection-3JJYOVNR.ts} +5 -5
  73. package/dist/chunks/{git-backend-FD7AVSK6.ts → git-backend-KYDZ673X.ts} +28 -32
  74. package/dist/chunks/git-backend-KYDZ673X.ts.map +7 -0
  75. package/dist/chunks/{manager-result-dispatcher-6MQKFCWB.ts → manager-result-dispatcher-AGBBVDDS.ts} +10 -9
  76. package/dist/chunks/{manager-ui-IURS4WXY.ts → manager-ui-KRAW3NE2.ts} +464 -164
  77. package/dist/chunks/manager-ui-KRAW3NE2.ts.map +7 -0
  78. package/dist/chunks/{merge-baseline-store-EVQAAML6.ts → merge-baseline-store-RFCAH7KL.ts} +7 -5
  79. package/dist/chunks/{remote-selection-ui-6LJJ4IMQ.ts → remote-selection-ui-5725JDNC.ts} +9 -8
  80. package/dist/chunks/remote-selection-ui-5725JDNC.ts.map +7 -0
  81. package/dist/chunks/{s3-backend-AQGV6LFT.ts → s3-backend-Y7WSCLJI.ts} +38 -38
  82. package/dist/chunks/s3-backend-Y7WSCLJI.ts.map +7 -0
  83. package/dist/chunks/{setup-switch-KTUWL6DE.ts → setup-switch-E7PHKI6J.ts} +7 -6
  84. package/dist/chunks/setup-switch-E7PHKI6J.ts.map +7 -0
  85. package/dist/chunks/{setup-wizard-GSDPDFC4.ts → setup-wizard-65DROCIJ.ts} +14 -13
  86. package/dist/chunks/setup-wizard-65DROCIJ.ts.map +7 -0
  87. package/dist/chunks/{sync-inspection-K45QVLHB.ts → sync-inspection-XFJOAMJD.ts} +9 -8
  88. package/dist/chunks/sync-inspection-XFJOAMJD.ts.map +7 -0
  89. package/dist/chunks/{sync-operations-47OUX76C.ts → sync-operations-HDUDT5PD.ts} +188 -73
  90. package/dist/chunks/sync-operations-HDUDT5PD.ts.map +7 -0
  91. package/dist/chunks/{webdav-backend-TBILVEQK.ts → webdav-backend-6K26F6LT.ts} +39 -48
  92. package/dist/chunks/webdav-backend-6K26F6LT.ts.map +7 -0
  93. package/dist/index.ts +246 -131
  94. package/dist/index.ts.map +3 -3
  95. package/docs/agent-mode-verification.md +99 -0
  96. package/docs/continuous-sync-verification.md +87 -0
  97. package/docs/settings.md +131 -11
  98. package/docs/tui-contract.md +61 -0
  99. package/package.json +3 -2
  100. package/src/backends/git/git-backend.ts +1 -4
  101. package/src/backends/git/git-runner.ts +1 -5
  102. package/src/backends/s3/s3-backend.ts +3 -25
  103. package/src/backends/s3/s3-client.ts +14 -2
  104. package/src/backends/snapshot-pointer.ts +27 -0
  105. package/src/backends/webdav/webdav-backend.ts +3 -29
  106. package/src/backends/webdav/webdav-client.ts +11 -7
  107. package/src/commands/command-execution.ts +7 -3
  108. package/src/commands/command-handler.ts +15 -3
  109. package/src/commands/command-types.ts +10 -0
  110. package/src/commands/command.ts +4 -1
  111. package/src/settings/config.ts +20 -10
  112. package/src/settings/settings-store.ts +1 -34
  113. package/src/settings/settings-types.ts +10 -0
  114. package/src/settings/settings-validation.ts +4 -1
  115. package/src/snapshot/snapshot-codec.ts +1 -5
  116. package/src/state/lock.ts +13 -10
  117. package/src/state/merge-baseline-store.ts +19 -1
  118. package/src/state/state-directory.ts +8 -1
  119. package/src/sync/agent-candidate.ts +251 -0
  120. package/src/sync/agent-plan.ts +87 -0
  121. package/src/sync/agent-policy.ts +13 -0
  122. package/src/sync/agent-request.ts +17 -0
  123. package/src/sync/agent-runner.ts +188 -0
  124. package/src/sync/agent-store.ts +151 -0
  125. package/src/sync/agent-sync.ts +91 -0
  126. package/src/sync/apply-review.ts +63 -0
  127. package/src/sync/automatic-sync.ts +46 -33
  128. package/src/sync/continuous-errors.ts +85 -0
  129. package/src/sync/continuous-lease.ts +221 -0
  130. package/src/sync/continuous-record.ts +179 -0
  131. package/src/sync/continuous-storage.ts +55 -0
  132. package/src/sync/continuous-sync.ts +516 -0
  133. package/src/sync/continuous-watch.ts +59 -0
  134. package/src/sync/merged-sync.ts +268 -112
  135. package/src/sync/remote-snapshot.ts +2 -3
  136. package/src/sync/settings-merge.ts +19 -3
  137. package/src/sync/setup-switch.ts +1 -5
  138. package/src/sync/startup-check.ts +51 -29
  139. package/src/sync/startup-timings.ts +90 -0
  140. package/src/sync/sync-inspection.ts +16 -5
  141. package/src/sync/sync-mutations.ts +55 -28
  142. package/src/sync/sync-queries.ts +19 -3
  143. package/src/sync/text-merge.ts +3 -3
  144. package/src/sync-extension.ts +75 -19
  145. package/src/ui/agent-mode-setting.ts +91 -0
  146. package/src/ui/manager-state.ts +12 -6
  147. package/src/ui/manager-ui.ts +57 -7
  148. package/src/ui/merge-review.ts +3 -3
  149. package/src/ui/merged-sync-summary.ts +2 -18
  150. package/src/ui/remote-selection-ui.ts +5 -5
  151. package/src/ui/settings-frame.ts +55 -0
  152. package/src/ui/settings-ui.ts +223 -43
  153. package/src/ui/setup/s3-credentials-ui.ts +1 -5
  154. package/src/ui/setup/setup-name-ui.ts +2 -5
  155. package/src/ui/setup/setup-prompts.ts +4 -3
  156. package/src/ui/setup/setup-switcher.ts +2 -0
  157. package/src/ui/sync-behavior.ts +15 -0
  158. package/src/ui/sync-format.ts +69 -38
  159. package/src/ui/sync-setups-ui.ts +3 -5
  160. package/src/ui/transfer-changes.ts +33 -0
  161. package/src/ui/transfer-review.ts +27 -0
  162. package/dist/chunks/chunk-2DI6PDZV.ts.map +0 -7
  163. package/dist/chunks/chunk-3B4Z2SKV.ts.map +0 -7
  164. package/dist/chunks/chunk-4SBTAVFL.ts.map +0 -7
  165. package/dist/chunks/chunk-5ZWQ33XL.ts.map +0 -7
  166. package/dist/chunks/chunk-BVVP33U5.ts.map +0 -7
  167. package/dist/chunks/chunk-IJ6VV6FN.ts.map +0 -7
  168. package/dist/chunks/chunk-MYPSMJHJ.ts.map +0 -7
  169. package/dist/chunks/chunk-ORQRHEEO.ts +0 -362
  170. package/dist/chunks/chunk-ORQRHEEO.ts.map +0 -7
  171. package/dist/chunks/chunk-QE7NZZWT.ts.map +0 -7
  172. package/dist/chunks/chunk-UQ3ALBIB.ts.map +0 -7
  173. package/dist/chunks/chunk-V6VZ5TLG.ts.map +0 -7
  174. package/dist/chunks/chunk-WFKUKVCP.ts.map +0 -7
  175. package/dist/chunks/chunk-ZX2Q6THB.ts.map +0 -7
  176. package/dist/chunks/git-backend-FD7AVSK6.ts.map +0 -7
  177. package/dist/chunks/manager-ui-IURS4WXY.ts.map +0 -7
  178. package/dist/chunks/s3-backend-AQGV6LFT.ts.map +0 -7
  179. package/dist/chunks/setup-wizard-GSDPDFC4.ts.map +0 -7
  180. package/dist/chunks/sync-operations-47OUX76C.ts.map +0 -7
  181. package/dist/chunks/webdav-backend-TBILVEQK.ts.map +0 -7
  182. /package/dist/chunks/{conflict-artifacts-BB2FZMIE.ts.map → agent-candidate-3JN6DJKT.ts.map} +0 -0
  183. /package/dist/chunks/{chunk-CHSIAC4N.ts.map → chunk-B4QZBDBT.ts.map} +0 -0
  184. /package/dist/chunks/{chunk-CL7BZRRQ.ts.map → chunk-CRKTAPSL.ts.map} +0 -0
  185. /package/dist/chunks/{chunk-VHXBCEVF.ts.map → chunk-EUMINX7L.ts.map} +0 -0
  186. /package/dist/chunks/{chunk-5RZPINUQ.ts.map → chunk-GSNPASKB.ts.map} +0 -0
  187. /package/dist/chunks/{chunk-SDZCTCYQ.ts.map → chunk-OEWQVWLC.ts.map} +0 -0
  188. /package/dist/chunks/{chunk-4QVPTIZB.ts.map → chunk-UPNVIUBN.ts.map} +0 -0
  189. /package/dist/chunks/{manager-result-dispatcher-6MQKFCWB.ts.map → config-KGIYTTXQ.ts.map} +0 -0
  190. /package/dist/chunks/{merge-baseline-store-EVQAAML6.ts.map → conflict-artifacts-FXFIIBCB.ts.map} +0 -0
  191. /package/dist/chunks/{remote-selection-ui-6LJJ4IMQ.ts.map → continuous-record-LFRVXQBD.ts.map} +0 -0
  192. /package/dist/chunks/{file-selection-WZX7HAY2.ts.map → file-selection-3JJYOVNR.ts.map} +0 -0
  193. /package/dist/chunks/{setup-switch-KTUWL6DE.ts.map → manager-result-dispatcher-AGBBVDDS.ts.map} +0 -0
  194. /package/dist/chunks/{sync-inspection-K45QVLHB.ts.map → merge-baseline-store-RFCAH7KL.ts.map} +0 -0
@@ -10,7 +10,7 @@ export type SettingsMergeResult =
10
10
  | { kind: "merged"; content: Buffer }
11
11
  | { kind: "review"; reason: "unsupported-format" | "field-conflict"; fields: string[] };
12
12
 
13
- const COUPLED_FIELDS = [
13
+ export const COUPLED_FIELDS = [
14
14
  ["defaultProvider", "defaultModel"],
15
15
  ["queueMode", "steeringMode"],
16
16
  ["websockets", "transport"],
@@ -19,7 +19,12 @@ const COUPLED_FIELDS = [
19
19
  ] as const;
20
20
 
21
21
  /** Only global settings.json; objects/arrays are atomic units until their invariants are proven. */
22
- export function mergeSettingsJson(ancestor: Buffer, local: Buffer, remote: Buffer): SettingsMergeResult {
22
+ export function mergeSettingsJson(
23
+ ancestor: Buffer,
24
+ local: Buffer,
25
+ remote: Buffer,
26
+ selections: ReadonlyMap<string, "local" | "remote"> = new Map(),
27
+ ): SettingsMergeResult {
23
28
  let documents: JsonDocument[];
24
29
  try {
25
30
  documents = [ancestor, local, remote].map(parseSettingsDocument);
@@ -38,7 +43,18 @@ export function mergeSettingsJson(ancestor: Buffer, local: Buffer, remote: Buffe
38
43
  unit.every((field) =>
39
44
  jsonEqual(first.root.members.get(field)?.node.value, second.root.members.get(field)?.node.value),
40
45
  );
41
- const source = equal(left, right) ? left : equal(left, base) ? right : equal(right, base) ? left : undefined;
46
+ const choice = selections.get(unit[0] ?? "");
47
+ const source = equal(left, right)
48
+ ? left
49
+ : equal(left, base)
50
+ ? right
51
+ : equal(right, base)
52
+ ? left
53
+ : choice === "local"
54
+ ? left
55
+ : choice === "remote"
56
+ ? right
57
+ : undefined;
42
58
  if (!source) {
43
59
  conflicts.push(...unit.filter((field) => fields.has(field)));
44
60
  continue;
@@ -5,6 +5,7 @@ import { configuredSyncSetupNames, updateLocalConfig } from "../settings/setting
5
5
  import type { OnSwitchAction } from "../settings/settings-types.js";
6
6
  import { normalizeOnSwitch } from "../settings/settings-validation.js";
7
7
  import { safeTerminalText } from "../ui/terminal-text.js";
8
+ import { throwIfAborted } from "./signals.js";
8
9
  import { SetupPullRequiresUiError } from "./sync-errors.js";
9
10
 
10
11
  export { SetupPullRequiresUiError } from "./sync-errors.js";
@@ -128,8 +129,3 @@ export async function useSyncSetup(
128
129
  }
129
130
  return { pullApplied: pullOutcome === "applied" };
130
131
  }
131
-
132
- function throwIfAborted(signal?: AbortSignal) {
133
- if (!signal?.aborted) return;
134
- throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
135
- }
@@ -9,7 +9,10 @@ import { readStateForConfig, syncStateFingerprint } from "../state/sync-state-st
9
9
  import type { SyncAttentionController } from "../ui/sync-attention.js";
10
10
  import { setSyncStatus } from "../ui/sync-status.js";
11
11
  import { safeTerminalText } from "../ui/terminal-text.js";
12
+ import { startupTransferEnabled } from "./agent-policy.js";
13
+ import { withAgentSyncLock } from "./agent-sync.js";
12
14
  import { combineSignals, throwIfAborted } from "./signals.js";
15
+ import { createStartupTimings } from "./startup-timings.js";
13
16
  import { errorMessage } from "./sync-errors.js";
14
17
  import type { SyncLoaders } from "./sync-loaders.js";
15
18
 
@@ -41,18 +44,19 @@ export function createStartupCheck(loaders: SyncLoaders, attention: SyncAttentio
41
44
  },
42
45
  start(ctx: ExtensionContext, sessionSignal: AbortSignal, initialConfig?: AnySyncConfig) {
43
46
  if (!ctx.hasUI || sessionSignal.aborted) return;
44
- if (initialConfig?.automaticTransfer && !ctx.isIdle()) {
47
+ if (initialConfig && startupTransferEnabled(initialConfig) && !ctx.isIdle()) {
45
48
  pending = { ctx, signal: sessionSignal, config: initialConfig };
46
49
  return;
47
50
  }
48
51
  if (active) throw new Error("Previous startup check has not been drained.");
52
+ const timings = createStartupTimings();
49
53
  const controller = new AbortController();
50
54
  const signal = combineSignals(sessionSignal, controller.signal);
51
55
  const task = {
52
56
  controller,
53
57
  owner: ctx.sessionManager,
54
58
  promise: Promise.resolve(),
55
- transfer: Boolean(initialConfig?.automaticTransfer),
59
+ transfer: Boolean(initialConfig && startupTransferEnabled(initialConfig)),
56
60
  };
57
61
  active = task;
58
62
  const isCurrent = () => !sessionSignal.aborted && active === task && task.owner === ctx.sessionManager;
@@ -62,33 +66,36 @@ export function createStartupCheck(loaders: SyncLoaders, attention: SyncAttentio
62
66
  );
63
67
  let config: AnySyncConfig | undefined;
64
68
  let checking = false;
69
+ let stopped = false;
65
70
  // Handle every detached rejection here; Pi's awaited event runner cannot catch it.
66
71
  task.promise = Promise.resolve()
67
72
  .then(async () => {
68
73
  try {
69
74
  throwIfAborted(signal);
70
75
  config = initialConfig ?? (await loadConfigForCheck());
71
- task.transfer = Boolean(config.automaticTransfer);
76
+ task.transfer = startupTransferEnabled(config);
72
77
  throwIfAborted(signal);
73
- if (!config.automatic && !config.automaticTransfer) return;
78
+ if (!config.automatic && !startupTransferEnabled(config)) return;
74
79
  const captured = config;
75
80
  const identity = syncCheckConfigFingerprint(captured);
76
81
  checking = true;
77
82
  setSyncStatus(ctx, "sync ...");
78
- const { inspectSync } = await loaders.inspection();
83
+ const { inspectSync } = await timings.measure("inspection import", () => loaders.inspection());
79
84
  throwIfAborted(signal);
80
85
  const currentConfig = await loadConfigForCheck();
81
86
  throwIfAborted(signal);
82
87
  if (syncCheckConfigFingerprint(currentConfig) !== identity) return;
83
88
  // Lock order: state migration guard → sync operation → backend cache.
84
89
  // Read heads may write only private cache data. Never call doctor/syncBoth.
85
- const inspection = await withStateDirectoryAccess(() => {
86
- throwIfAborted(signal);
87
- return withLock("startup-check", () => {
90
+ const inspection = await timings.measure("inspection and guards", () =>
91
+ withStateDirectoryAccess(() => {
88
92
  throwIfAborted(signal);
89
- return inspectSync(captured, snapshotOptionsForContext(ctx, captured), signal);
90
- });
91
- });
93
+ return withLock("startup-check", () => {
94
+ throwIfAborted(signal);
95
+ return inspectSync(captured, snapshotOptionsForContext(ctx, captured), signal, undefined, timings);
96
+ });
97
+ }),
98
+ );
92
99
  throwIfAborted(signal);
93
100
  const latest = await loadConfigForCheck();
94
101
  throwIfAborted(signal);
@@ -101,34 +108,39 @@ export function createStartupCheck(loaders: SyncLoaders, attention: SyncAttentio
101
108
  configIdentity: identity,
102
109
  checkedAt: new Date().toISOString(),
103
110
  inspection,
104
- automaticTransfer: captured.automaticTransfer,
111
+ automaticTransfer: startupTransferEnabled(captured),
105
112
  });
106
113
  if (
107
- captured.automaticTransfer &&
114
+ startupTransferEnabled(captured) &&
108
115
  !inspection.firstSync &&
109
116
  inspection.head &&
110
117
  inspection.selectionState?.kind === "same"
111
118
  ) {
112
- const operations = await loaders.operations();
119
+ const operations = await timings.measure("operations import", () => loaders.operations());
113
120
  throwIfAborted(signal);
114
121
  if (!isCurrent()) return;
115
122
  if (!ctx.isIdle()) {
116
123
  pending = { ctx, signal: sessionSignal, config: captured };
117
124
  return;
118
125
  }
119
- await withStateDirectoryAccess(() =>
120
- withLock("startup-transfer", () =>
121
- operations.syncBoth(ctx, {
122
- args: [],
123
- yes: true,
124
- force: false,
125
- stale: false,
126
- silent: true,
127
- reload: false,
128
- auto: true,
129
- signal,
130
- onCommit: () => attention.clearObservation(),
131
- }),
126
+ await timings.measure("automatic transfer", () =>
127
+ withStateDirectoryAccess(() =>
128
+ withAgentSyncLock(
129
+ ctx,
130
+ {
131
+ args: [],
132
+ yes: true,
133
+ force: false,
134
+ stale: false,
135
+ silent: true,
136
+ reload: false,
137
+ auto: true,
138
+ signal,
139
+ startupTimings: timings,
140
+ onCommit: () => attention.clearObservation(),
141
+ },
142
+ (options) => operations.syncBoth(ctx, options),
143
+ ),
132
144
  ),
133
145
  );
134
146
  throwIfAborted(signal);
@@ -141,10 +153,11 @@ export function createStartupCheck(loaders: SyncLoaders, attention: SyncAttentio
141
153
  configIdentity: identity,
142
154
  checkedAt: new Date().toISOString(),
143
155
  inspection,
144
- automaticTransfer: captured.automaticTransfer,
156
+ automaticTransfer: startupTransferEnabled(captured),
145
157
  });
146
158
  if (ctx.mode === "rpc") attention.notifyObservation(ctx);
147
159
  } catch (error) {
160
+ stopped = true;
148
161
  if (!isCurrent() || sessionSignal.aborted || isMissingConfigError(error)) return;
149
162
  if (signal.aborted && controller.signal.reason?.name !== "TimeoutError") return;
150
163
  // A settings edit invalidates failures as well as successful observations.
@@ -154,7 +167,7 @@ export function createStartupCheck(loaders: SyncLoaders, attention: SyncAttentio
154
167
  if (!latest || syncCheckConfigFingerprint(latest) !== syncCheckConfigFingerprint(config)) return;
155
168
  }
156
169
  ctx.ui.notify(
157
- config?.automaticTransfer
170
+ config && startupTransferEnabled(config)
158
171
  ? `pi-sync automatic transfer stopped: ${safeTerminalText(errorMessage(error))}. Run /sync to review or reconcile retained recovery evidence. No resources were reloaded.`
159
172
  : `pi-sync startup check skipped: ${safeTerminalText(errorMessage(error))}. Run /sync status to retry. No startup transfer was performed.`,
160
173
  "warning",
@@ -177,6 +190,15 @@ export function createStartupCheck(loaders: SyncLoaders, attention: SyncAttentio
177
190
  }
178
191
  } else setSyncStatus(ctx, undefined);
179
192
  }
193
+ if (isCurrent()) {
194
+ timings.publish(
195
+ ctx,
196
+ config?.debug ?? false,
197
+ sessionSignal,
198
+ "background startup",
199
+ signal.aborted || stopped ? "stopped" : "completed",
200
+ );
201
+ }
180
202
  if (active === task) active = undefined;
181
203
  }
182
204
  })
@@ -0,0 +1,90 @@
1
+ import { performance } from "node:perf_hooks";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+
4
+ type StartupStage =
5
+ | "transaction recovery"
6
+ | "settings read"
7
+ | "config resolution"
8
+ | "inspection import"
9
+ | "inspection and guards"
10
+ | "backend initialization"
11
+ | "local snapshot"
12
+ | "sync state read"
13
+ | "remote head"
14
+ | "operations import"
15
+ | "automatic transfer"
16
+ | "transfer authorization"
17
+ | "transfer backend initialization"
18
+ | "transfer journal read"
19
+ | "transfer journal recovery"
20
+ | "transfer state read"
21
+ | "transfer local snapshot"
22
+ | "transfer remote head"
23
+ | "transfer snapshot download"
24
+ | "transfer unchanged verification"
25
+ | "transfer merge planning"
26
+ | "transfer settings merge"
27
+ | "transfer content merge"
28
+ | "transfer preflight"
29
+ | "transfer backup"
30
+ | "transfer journal write"
31
+ | "transfer remote publication"
32
+ | "transfer local apply"
33
+ | "transfer baseline write"
34
+ | "transfer cleanup";
35
+
36
+ /** Fixed labels and durations only: never retain errors, paths, settings, or payloads. */
37
+ export function createStartupTimings() {
38
+ const started = performance.now();
39
+ const stages = new Map<StartupStage, { ms: number; calls: number; interrupted: boolean }>();
40
+ let unchanged = false;
41
+ return {
42
+ markUnchanged() {
43
+ unchanged = true;
44
+ },
45
+ async measure<T>(stage: StartupStage, run: () => T | Promise<T>): Promise<T> {
46
+ const start = performance.now();
47
+ let completed = false;
48
+ try {
49
+ const result = await run();
50
+ completed = true;
51
+ return result;
52
+ } finally {
53
+ const previous = stages.get(stage);
54
+ stages.set(stage, {
55
+ ms: (previous?.ms ?? 0) + performance.now() - start,
56
+ calls: (previous?.calls ?? 0) + 1,
57
+ interrupted: Boolean(previous?.interrupted || !completed),
58
+ });
59
+ }
60
+ },
61
+ publish(
62
+ ctx: ExtensionContext,
63
+ enabled: boolean,
64
+ sessionSignal: AbortSignal,
65
+ scope: "local initialization" | "background startup",
66
+ outcome: "completed" | "stopped" = "completed",
67
+ ) {
68
+ if (!enabled || !ctx.hasUI || sessionSignal.aborted) return;
69
+ try {
70
+ ctx.ui.notify(
71
+ [
72
+ `pi-sync debug: ${scope} (${outcome})`,
73
+ ...Array.from(
74
+ stages,
75
+ ([stage, value]) =>
76
+ `${stage}: ${value.ms.toFixed(1)} ms${value.calls > 1 ? ` (${value.calls} calls)` : ""}${value.interrupted ? " (interrupted)" : ""}`,
77
+ ),
78
+ ...(unchanged ? ["transfer result: unchanged (fast path)"] : []),
79
+ `total: ${(performance.now() - started).toFixed(1)} ms`,
80
+ ].join("\n"),
81
+ "info",
82
+ );
83
+ } catch {
84
+ // Diagnostics must not change sync behavior if the UI is unavailable during teardown.
85
+ }
86
+ },
87
+ };
88
+ }
89
+
90
+ export type StartupTimings = ReturnType<typeof createStartupTimings>;
@@ -1,11 +1,13 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { createSyncBackend, type SyncBackendFactory } from "../backends/backend-factory.js";
2
3
  import type { AnySyncConfig } from "../settings/settings-types.js";
3
4
  import { createSnapshot } from "../snapshot/snapshot.js";
4
5
  import type { SnapshotOptions } from "../snapshot/snapshot-types.js";
5
6
  import { readStateForConfig, syncStateFingerprint } from "../state/sync-state-store.js";
6
7
  import { throwIfAborted } from "./signals.js";
8
+ import { createStartupTimings, type StartupTimings } from "./startup-timings.js";
7
9
  import { inspectRemoteSelection } from "./sync-policy.js";
8
- import { hasLocalChanges, remoteChangedSinceState } from "./sync-state.js";
10
+ import { fileHashMap, hasLocalChanges, remoteChangedSinceState } from "./sync-state.js";
9
11
 
10
12
  /** Advisory baseline-relative observation, never an authorization to transfer files. */
11
13
  export async function inspectSync(
@@ -13,17 +15,26 @@ export async function inspectSync(
13
15
  options: SnapshotOptions,
14
16
  signal?: AbortSignal,
15
17
  factory: SyncBackendFactory = createSyncBackend,
18
+ timings: StartupTimings = createStartupTimings(),
16
19
  ) {
17
20
  throwIfAborted(signal);
18
- const backend = await factory(config);
21
+ const backend = await timings.measure("backend initialization", () => factory(config));
19
22
  throwIfAborted(signal);
20
- const local = await createSnapshot(config.snapshotIdentity, { ...options, signal });
23
+ const local = await timings.measure("local snapshot", () =>
24
+ createSnapshot(config.snapshotIdentity, { ...options, signal }),
25
+ );
21
26
  throwIfAborted(signal);
22
- const state = await readStateForConfig(config);
27
+ const state = await timings.measure("sync state read", () => readStateForConfig(config));
23
28
  throwIfAborted(signal);
24
- const head = await backend.readHead(signal);
29
+ const head = await timings.measure("remote head", () => backend.readHead(signal));
25
30
  throwIfAborted(signal);
31
+ const freshness: { localIdentity?: string } = {
32
+ localIdentity: createHash("sha256")
33
+ .update(JSON.stringify(fileHashMap(local)))
34
+ .digest("hex"),
35
+ };
26
36
  return {
37
+ ...freshness,
27
38
  head,
28
39
  selectionState: head ? inspectRemoteSelection(config.include, { selection: head.selection, files: [] }) : undefined,
29
40
  localFiles: local.files.length,
@@ -23,7 +23,7 @@ import { applySnapshot } from "../snapshot/snapshot-apply.js";
23
23
  import type { Snapshot } from "../snapshot/snapshot-types.js";
24
24
  import { pruneMergeBaselines } from "../state/merge-baseline-store.js";
25
25
  import type { SyncState } from "../state/state-types.js";
26
- import { readStateForConfig, writeStateForConfig } from "../state/sync-state-store.js";
26
+ import { readStateForConfig, syncStateFingerprint, writeStateForConfig } from "../state/sync-state-store.js";
27
27
  import {
28
28
  countPreservedRemoteFiles,
29
29
  formatPullSummary,
@@ -31,6 +31,9 @@ import {
31
31
  formatRollbackSummary,
32
32
  } from "../ui/sync-format.js";
33
33
  import { setSyncStatus } from "../ui/sync-status.js";
34
+ import { confirmTransferReview } from "../ui/transfer-review.js";
35
+ import { startupTransferEnabled } from "./agent-policy.js";
36
+ import { inspectCaseCleanup, revalidateCaseCleanup } from "./apply-review.js";
34
37
  import { confirmFieldMigration } from "./field-migration.js";
35
38
  import { overlayLocalFields, portableSnapshot, sameLocalFields, validatePortableSnapshot } from "./local-fields.js";
36
39
  import { readMergeJournal, requireNoMergeJournal, retireMergeJournal } from "./merge-journal.js";
@@ -155,7 +158,7 @@ export async function push(
155
158
  }
156
159
  }
157
160
 
158
- if (!remoteForUpload && head && config.localFields !== undefined)
161
+ if (!remoteForUpload && head && (config.localFields !== undefined || !options.yes))
159
162
  remoteForUpload = await readSnapshotForHead(backend, head, options.signal);
160
163
  if (!(await confirmFieldMigration(ctx, config, state, remoteForUpload, options.force, options.signal))) {
161
164
  setSyncStatus(ctx, undefined);
@@ -208,12 +211,15 @@ export async function push(
208
211
  if (configIdentity !== syncCheckConfigFingerprint(await loadConfig(options.setup)))
209
212
  throw new Error("Settings changed during push review.");
210
213
  validateMutation();
211
- if (config.localFields !== undefined || (await readMergeJournal(config))) {
214
+ if (!options.yes || config.localFields !== undefined || (await readMergeJournal(config))) {
212
215
  const current = await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config));
213
216
  if (!sameHashes(fileHashMap(current), fileHashMap(localRaw)))
214
- throw new Error("Local content changed during recovery review; retry from current bytes.");
217
+ throw new Error("Local content changed during push review; retry from current bytes.");
215
218
  }
216
- requireResolvedDirectionalProgress(await readStateForConfig(config), options);
219
+ const currentState = await readStateForConfig(config);
220
+ if (!options.yes && syncStateFingerprint(currentState) !== syncStateFingerprint(state))
221
+ throw new Error("Baseline changed during push review.");
222
+ requireResolvedDirectionalProgress(currentState, options);
217
223
  validateMutation();
218
224
  const result = await backend.publishSnapshot(upload, expectedRemoteHead(head), {
219
225
  signal: options.signal,
@@ -316,11 +322,23 @@ export async function pull(
316
322
  });
317
323
  }
318
324
 
325
+ const caseCleanup = options.yes ? [] : await inspectCaseCleanup(physical, options.signal);
326
+ validateMutation();
319
327
  if (
320
328
  !options.yes &&
321
- !(await ctx.ui.confirm(
329
+ !(await confirmTransferReview(
330
+ ctx,
322
331
  snapshotIncludesSessions(remote) ? "Pull pi settings and sessions?" : "Pull pi settings?",
323
- formatPullSummary(config, backend.destination, local, physical, protectedSessionPaths(ctx).size),
332
+ formatPullSummary(
333
+ config,
334
+ backend.destination,
335
+ local,
336
+ physical,
337
+ protectedSessionPaths(ctx),
338
+ caseCleanup.map((item) => item.path),
339
+ ),
340
+ options.signal,
341
+ validateMutation,
324
342
  ))
325
343
  ) {
326
344
  setSyncStatus(ctx, undefined);
@@ -353,7 +371,11 @@ export async function pull(
353
371
  const backup = await backupLocal(config.snapshotIdentity, snapshotOptionsForContext(ctx, config), options.signal);
354
372
  const applySessionDir = await sessionDirForApply(ctx, physical);
355
373
  if (recoveringMerge) await requireFreshRecovery();
356
- requireResolvedDirectionalProgress(await readStateForConfig(config), options);
374
+ const currentState = await readStateForConfig(config);
375
+ if (!options.yes && syncStateFingerprint(currentState) !== syncStateFingerprint(state))
376
+ throw new Error("Baseline changed during pull review.");
377
+ requireResolvedDirectionalProgress(currentState, options);
378
+ if (!options.yes) await revalidateCaseCleanup(caseCleanup, physical, options.signal);
357
379
  validateMutation();
358
380
  options.onCommit?.();
359
381
  const lastFileHashes = await applySnapshot(physical, protectedSessionPaths(ctx), {
@@ -408,7 +430,9 @@ export async function syncBoth(
408
430
  const validateMutation = captureMutationOwner(ctx, options.signal);
409
431
  const config = await loadConfig(options.setup);
410
432
  throwIfAborted(options.signal);
411
- if (config.include.length > 0 && options.auto && config.automaticTransfer) return mergeSync(ctx, options, factory);
433
+ options.background?.validate(config);
434
+ if (config.include.length > 0 && options.auto && startupTransferEnabled(config))
435
+ return mergeSync(ctx, options, factory);
412
436
  if (!options.auto && config.include.length > 0 && (await readMergeJournal(config)))
413
437
  return mergeSync(ctx, options, factory);
414
438
  const state = await readStateForConfig(config);
@@ -430,15 +454,13 @@ export async function syncBoth(
430
454
  throwIfAborted(options.signal);
431
455
  const localChanged = hasLocalChanges(local, state, config);
432
456
  const remoteChanged = remote ? hasRemoteChanges(remote, state, config, protectedSessionPaths(ctx)) : false;
433
- const firstSync = !state.lastAppliedSnapshot;
434
457
  if (options.auto) throw new Error("Automatic transfer is not authorized by the current settings.");
435
458
 
459
+ // Established baselines return through mergeSync above; only initial-source selection remains.
436
460
  const portableLocal = portableSnapshot(local, config.localFields);
437
461
  const acceptMatchingState = async (snapshot: Snapshot) => {
438
462
  requireCompatibleRemoteSelection(config, snapshot);
439
463
  const accepted = portableSnapshot(snapshot, config.localFields);
440
- if (state.lastAppliedSnapshot && !sameLocalFields(state.localFields, config.localFields))
441
- throw new Error("Local-field rules changed; review and confirm a directional migration first.");
442
464
  await writeStateForConfig(
443
465
  config,
444
466
  {
@@ -456,7 +478,7 @@ export async function syncBoth(
456
478
  await pruneMergeBaselines(config, await readStateForConfig(config), validateMutation);
457
479
  validateMutation();
458
480
  };
459
- if (firstSync && remote && remote.files.length > 0 && local.files.length > 0) {
481
+ if (remote && remote.files.length > 0 && local.files.length > 0) {
460
482
  requireCompatibleRemoteSelection(config, remote);
461
483
  const portableRemote = portableSnapshot(remote, config.localFields);
462
484
  if (!canPullRemoteSettingsOnFirstSync(portableLocal, portableRemote)) {
@@ -504,18 +526,6 @@ export async function syncBoth(
504
526
  if (!options.silent) ctx.ui.notify("pi-sync is already up to date.", "info");
505
527
  return;
506
528
  }
507
- if (localChanged && remoteChanged && state.lastAppliedSnapshot) {
508
- throw createSyncDecision({
509
- kind: "both-changed",
510
- config,
511
- state,
512
- local,
513
- remote,
514
- localChanged,
515
- remoteChanged,
516
- directMessage: "Both local and remote changed. Run /sync diff and resolve with push --force or pull --force.",
517
- });
518
- }
519
529
  if (remoteChanged) {
520
530
  await pull(ctx, options, factory);
521
531
  return;
@@ -573,11 +583,21 @@ export async function rollback(
573
583
  if (!(await confirmFieldMigration(ctx, config, state, currentRemote, true, options.signal))) return;
574
584
  validateMutation();
575
585
  const physical = overlayLocalFields(remote, local, config.localFields);
586
+ const caseCleanup = options.yes ? [] : await inspectCaseCleanup(physical, options.signal);
587
+ validateMutation();
576
588
 
577
589
  if (!options.yes) {
578
590
  const confirmed = await ctx.ui.confirm(
579
591
  snapshotIncludesSessions(remote) ? "Rollback pi settings and sessions?" : "Rollback pi settings?",
580
- formatRollbackSummary(config, backend.destination, local, physical, target, protectedSessionPaths(ctx).size),
592
+ formatRollbackSummary(
593
+ config,
594
+ backend.destination,
595
+ local,
596
+ physical,
597
+ target,
598
+ protectedSessionPaths(ctx),
599
+ caseCleanup.map((item) => item.path),
600
+ ),
581
601
  { signal: options.signal },
582
602
  );
583
603
  validateMutation();
@@ -599,7 +619,11 @@ export async function rollback(
599
619
  throwIfAborted(options.signal);
600
620
  const backup = await backupLocal(config.snapshotIdentity, snapshotOptionsForContext(ctx, config), options.signal);
601
621
  const applySessionDir = await sessionDirForApply(ctx, remote);
602
- requireResolvedDirectionalProgress(await readStateForConfig(config), options, false);
622
+ const currentState = await readStateForConfig(config);
623
+ if (!options.yes && syncStateFingerprint(currentState) !== syncStateFingerprint(state))
624
+ throw new Error("Baseline changed during rollback review.");
625
+ requireResolvedDirectionalProgress(currentState, options, false);
626
+ if (!options.yes) await revalidateCaseCleanup(caseCleanup, physical, options.signal);
603
627
  validateMutation();
604
628
  options.onCommit?.();
605
629
  const lastFileHashes = await applySnapshot(physical, protectedSessionPaths(ctx), {
@@ -724,9 +748,12 @@ async function confirmPush(
724
748
  ) {
725
749
  throwIfAborted(options.signal);
726
750
  if (options.yes) return true;
727
- const confirmed = await ctx.ui.confirm(
751
+ const confirmed = await confirmTransferReview(
752
+ ctx,
728
753
  title,
729
754
  formatPushSummary(config, backend.destination, upload, head, countPreservedRemoteFiles(local, upload), remote),
755
+ options.signal,
756
+ captureMutationOwner(ctx, options.signal),
730
757
  );
731
758
  throwIfAborted(options.signal);
732
759
  if (confirmed) return true;
@@ -7,6 +7,7 @@ import { snapshotOptionsForContext } from "../snapshot/session-paths.js";
7
7
  import { createSnapshot, scanSnapshot } from "../snapshot/snapshot.js";
8
8
  import type { SnapshotOptions } from "../snapshot/snapshot-types.js";
9
9
  import { inspectLock, isLockGuardHeld, isStaleLock, withLock } from "../state/lock.js";
10
+ import { readStateForConfig } from "../state/sync-state-store.js";
10
11
  import { formatDiff, formatSnapshotOnlyDiff, publicationCapabilityDescription } from "../ui/sync-format.js";
11
12
  import { setSyncStatus } from "../ui/sync-status.js";
12
13
  import { safeTerminalText } from "../ui/terminal-text.js";
@@ -35,6 +36,10 @@ export async function status(
35
36
  );
36
37
  throwIfAborted(options.signal);
37
38
 
39
+ const continuous = config.continuous
40
+ ? await (await import("./continuous-record.js")).continuousStatusLines(config)
41
+ : [];
42
+ throwIfAborted(options.signal);
38
43
  const remoteText = head ? `remote: ${head.snapshotId} from ${head.machine} at ${head.createdAt}` : "remote: empty";
39
44
  const warnings = syncSessionsWarnings(config);
40
45
  setSyncStatus(ctx, undefined);
@@ -46,6 +51,7 @@ export async function status(
46
51
  `publication safety: ${publicationCapabilityDescription(capability)}`,
47
52
  `included content: ${config.include.join(", ") || "none"}`,
48
53
  `sessions: ${config.include.includes("sessions") ? "included" : "excluded"}`,
54
+ ...continuous,
49
55
  remoteText,
50
56
  formatRemoteSelectionStatus(selectionState),
51
57
  `local files: ${localFiles}`,
@@ -185,8 +191,15 @@ export async function history(
185
191
  const backend = await factory(config);
186
192
  const snapshots = (await backend.listHistory(options.signal)).slice(-20).reverse();
187
193
  throwIfAborted(options.signal);
194
+ const lastApplied = await readStateForConfig(config)
195
+ .then((state) => state.lastAppliedSnapshot)
196
+ .catch(() => undefined);
197
+ throwIfAborted(options.signal);
198
+ const appliedDetails = lastApplied
199
+ ? `Last applied snapshot ID: ${safeTerminalText(lastApplied)}\nApplication time: unavailable. Snapshot dates are creation times.`
200
+ : "Last applied snapshot ID: unavailable.";
188
201
  if (snapshots.length === 0) {
189
- ctx.ui.notify("No remote pi-sync history found.", "info");
202
+ ctx.ui.notify(`No remote pi-sync history found.\n${appliedDetails}`, "info");
190
203
  return;
191
204
  }
192
205
 
@@ -197,7 +210,7 @@ export async function history(
197
210
  `${index + 1}. ${item.createdAt} · ${safeTerminalText(item.machine)} · ${item.snapshotId}${item.snapshotId === currentSnapshot ? " (current)" : ""}${item.syncSessions ? " · sessions" : ""}`,
198
211
  );
199
212
  const selected = await ctx.ui.select(
200
- `History for sync setup “${safeTerminalText(config.setupName)}”\n\nChoose a snapshot to preview rollback.`,
213
+ `History for sync setup “${safeTerminalText(config.setupName)}”\n\n${appliedDetails}\n\nChoose a snapshot to preview rollback.`,
201
214
  [...labels, "Back"],
202
215
  { signal: options.signal },
203
216
  );
@@ -215,7 +228,10 @@ export async function history(
215
228
  return;
216
229
  }
217
230
  ctx.ui.notify(
218
- snapshots.map((item) => `${item.snapshotRef} ${item.createdAt} ${safeTerminalText(item.machine)}`).join("\n"),
231
+ [
232
+ appliedDetails,
233
+ ...snapshots.map((item) => `${item.snapshotRef} ${item.createdAt} ${safeTerminalText(item.machine)}`),
234
+ ].join("\n"),
219
235
  "info",
220
236
  );
221
237
  }
@@ -30,11 +30,11 @@ export function text(bytes: Buffer) {
30
30
  throw new Error("Unsupported text encoding.");
31
31
  return decoded;
32
32
  }
33
- type Hunk = { start: number; end: number; lines: string[] };
34
- function lines(value: string) {
33
+ export type Hunk = { start: number; end: number; lines: string[] };
34
+ export function lines(value: string) {
35
35
  return value.match(/[^\n]*\n|[^\n]+$/gu) ?? [];
36
36
  }
37
- function edits(base: string[], next: string[], signal?: AbortSignal): Hunk[] {
37
+ export function edits(base: string[], next: string[], signal?: AbortSignal): Hunk[] {
38
38
  const width = next.length + 1;
39
39
  if ((base.length + 1) * width > 4_000_000) throw new Error("Text merge complexity bound exceeded.");
40
40
  // Intern once so each charged DP cell compares fixed-size IDs, not long line strings.