@narumitw/pi-sync 0.54.3 → 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.
- package/README.md +21 -4
- package/dist/chunks/agent-candidate-3JN6DJKT.ts +26 -0
- package/dist/chunks/agent-plan-QCUJWBP4.ts +99 -0
- package/dist/chunks/agent-plan-QCUJWBP4.ts.map +7 -0
- package/dist/chunks/agent-runner-E2JU2VZ6.ts +182 -0
- package/dist/chunks/agent-runner-E2JU2VZ6.ts.map +7 -0
- package/dist/chunks/agent-store-A3F6IYWZ.ts +152 -0
- package/dist/chunks/agent-store-A3F6IYWZ.ts.map +7 -0
- package/dist/chunks/chunk-5Q5SSKNG.ts +128 -0
- package/dist/chunks/chunk-5Q5SSKNG.ts.map +7 -0
- package/dist/chunks/chunk-5XZOC7FD.ts +215 -0
- package/dist/chunks/chunk-5XZOC7FD.ts.map +7 -0
- package/dist/chunks/{chunk-GC3QOL6J.ts → chunk-AAEEHR7H.ts} +31 -87
- package/dist/chunks/chunk-AAEEHR7H.ts.map +7 -0
- package/dist/chunks/{chunk-R35N4CL3.ts → chunk-B4QZBDBT.ts} +2 -2
- package/dist/chunks/{chunk-KGE47GCZ.ts → chunk-CRKTAPSL.ts} +8 -8
- package/dist/chunks/chunk-DT7M4VQS.ts +69 -0
- package/dist/chunks/chunk-DT7M4VQS.ts.map +7 -0
- package/dist/chunks/chunk-ED3TYIVW.ts +229 -0
- package/dist/chunks/chunk-ED3TYIVW.ts.map +7 -0
- package/dist/chunks/{chunk-HMXUKU43.ts → chunk-EUMINX7L.ts} +6 -6
- package/dist/chunks/chunk-FQOSHGJ7.ts +215 -0
- package/dist/chunks/chunk-FQOSHGJ7.ts.map +7 -0
- package/dist/chunks/chunk-GNH73BJ5.ts +153 -0
- package/dist/chunks/chunk-GNH73BJ5.ts.map +7 -0
- package/dist/chunks/{chunk-YTHQEW35.ts → chunk-GP5DU62N.ts} +8 -9
- package/dist/chunks/chunk-GP5DU62N.ts.map +7 -0
- package/dist/chunks/{chunk-AAZDBIBJ.ts → chunk-GSNPASKB.ts} +2 -2
- package/dist/chunks/{chunk-E6ONFG57.ts → chunk-IWZH5KYS.ts} +11 -8
- package/dist/chunks/chunk-IWZH5KYS.ts.map +7 -0
- package/dist/chunks/{chunk-CAGJOKB7.ts → chunk-JJTARUSO.ts} +15 -14
- package/dist/chunks/chunk-JJTARUSO.ts.map +7 -0
- package/dist/chunks/{chunk-ITFR6NNQ.ts → chunk-NLWS55B6.ts} +2 -1
- package/dist/chunks/{chunk-ITFR6NNQ.ts.map → chunk-NLWS55B6.ts.map} +2 -2
- package/dist/chunks/{chunk-2FETCGGT.ts → chunk-OEWQVWLC.ts} +4 -4
- package/dist/chunks/{chunk-35GM6XKM.ts → chunk-OTZRCKYP.ts} +128 -382
- package/dist/chunks/chunk-OTZRCKYP.ts.map +7 -0
- package/dist/chunks/{chunk-XU2VXGQV.ts → chunk-OWBAZA6J.ts} +14 -8
- package/dist/chunks/chunk-OWBAZA6J.ts.map +7 -0
- package/dist/chunks/chunk-PRUEIIK7.ts +24 -0
- package/dist/chunks/chunk-PRUEIIK7.ts.map +7 -0
- package/dist/chunks/chunk-QGK445K7.ts +21 -0
- package/dist/chunks/chunk-QGK445K7.ts.map +7 -0
- package/dist/chunks/{chunk-KATKRR3D.ts → chunk-QO333NM4.ts} +32 -11
- package/dist/chunks/chunk-QO333NM4.ts.map +7 -0
- package/dist/chunks/{chunk-I4JYVFUS.ts → chunk-QSHMOL3I.ts} +21 -22
- package/dist/chunks/chunk-QSHMOL3I.ts.map +7 -0
- package/dist/chunks/chunk-S2FMH5FT.ts +97 -0
- package/dist/chunks/chunk-S2FMH5FT.ts.map +7 -0
- package/dist/chunks/{chunk-BVVP33U5.ts → chunk-SWRVXVFD.ts} +12 -9
- package/dist/chunks/chunk-SWRVXVFD.ts.map +7 -0
- package/dist/chunks/{chunk-ZX2Q6THB.ts → chunk-UBV24CG4.ts} +4 -5
- package/dist/chunks/chunk-UBV24CG4.ts.map +7 -0
- package/dist/chunks/chunk-UOHR7LRO.ts +70 -0
- package/dist/chunks/chunk-UOHR7LRO.ts.map +7 -0
- package/dist/chunks/{chunk-VCL7TZYW.ts → chunk-UPNVIUBN.ts} +3 -3
- package/dist/chunks/chunk-VJN7KU4J.ts +350 -0
- package/dist/chunks/chunk-VJN7KU4J.ts.map +7 -0
- package/dist/chunks/chunk-YHT4YTYG.ts +31 -0
- package/dist/chunks/chunk-YHT4YTYG.ts.map +7 -0
- package/dist/chunks/{chunk-A3TFXY4O.ts → chunk-YK52VDXG.ts} +6 -5
- package/dist/chunks/chunk-YK52VDXG.ts.map +7 -0
- package/dist/chunks/config-KGIYTTXQ.ts +37 -0
- package/dist/chunks/{conflict-artifacts-4CZFB7WI.ts → conflict-artifacts-FXFIIBCB.ts} +8 -7
- package/dist/chunks/{conflict-review-ZOTOHL2E.ts → conflict-review-6WIEQLXW.ts} +31 -25
- package/dist/chunks/{conflict-review-ZOTOHL2E.ts.map → conflict-review-6WIEQLXW.ts.map} +1 -1
- package/dist/chunks/continuous-record-LFRVXQBD.ts +30 -0
- package/dist/chunks/continuous-sync-AWAAOXK2.ts +545 -0
- package/dist/chunks/continuous-sync-AWAAOXK2.ts.map +7 -0
- package/dist/chunks/{file-selection-UNHBVVON.ts → file-selection-3JJYOVNR.ts} +5 -5
- package/dist/chunks/{git-backend-FD7AVSK6.ts → git-backend-KYDZ673X.ts} +28 -32
- package/dist/chunks/git-backend-KYDZ673X.ts.map +7 -0
- package/dist/chunks/{manager-result-dispatcher-HVRTPEVK.ts → manager-result-dispatcher-AGBBVDDS.ts} +10 -9
- package/dist/chunks/{manager-ui-ZIN4BUSF.ts → manager-ui-KRAW3NE2.ts} +184 -45
- package/dist/chunks/manager-ui-KRAW3NE2.ts.map +7 -0
- package/dist/chunks/{merge-baseline-store-ICXVTCML.ts → merge-baseline-store-RFCAH7KL.ts} +5 -5
- package/dist/chunks/{remote-selection-ui-H6AEVA3Q.ts → remote-selection-ui-5725JDNC.ts} +9 -8
- package/dist/chunks/remote-selection-ui-5725JDNC.ts.map +7 -0
- package/dist/chunks/{s3-backend-Y4P3O5B5.ts → s3-backend-Y7WSCLJI.ts} +38 -38
- package/dist/chunks/s3-backend-Y7WSCLJI.ts.map +7 -0
- package/dist/chunks/{setup-switch-ZMW5C4M7.ts → setup-switch-E7PHKI6J.ts} +7 -6
- package/dist/chunks/setup-switch-E7PHKI6J.ts.map +7 -0
- package/dist/chunks/{setup-wizard-VW7F3JN4.ts → setup-wizard-65DROCIJ.ts} +14 -13
- package/dist/chunks/setup-wizard-65DROCIJ.ts.map +7 -0
- package/dist/chunks/{sync-inspection-3EMS2J5B.ts → sync-inspection-XFJOAMJD.ts} +8 -8
- package/dist/chunks/sync-inspection-XFJOAMJD.ts.map +7 -0
- package/dist/chunks/{sync-operations-2CWST333.ts → sync-operations-HDUDT5PD.ts} +62 -63
- package/dist/chunks/sync-operations-HDUDT5PD.ts.map +7 -0
- package/dist/chunks/{webdav-backend-TBILVEQK.ts → webdav-backend-6K26F6LT.ts} +39 -48
- package/dist/chunks/webdav-backend-6K26F6LT.ts.map +7 -0
- package/dist/index.ts +165 -85
- package/dist/index.ts.map +3 -3
- package/docs/agent-mode-verification.md +99 -0
- package/docs/continuous-sync-verification.md +87 -0
- package/docs/settings.md +84 -1
- package/package.json +3 -2
- package/src/backends/git/git-backend.ts +1 -4
- package/src/backends/git/git-runner.ts +1 -5
- package/src/backends/s3/s3-backend.ts +3 -25
- package/src/backends/s3/s3-client.ts +14 -2
- package/src/backends/snapshot-pointer.ts +27 -0
- package/src/backends/webdav/webdav-backend.ts +3 -29
- package/src/backends/webdav/webdav-client.ts +11 -7
- package/src/commands/command-execution.ts +5 -2
- package/src/commands/command-handler.ts +15 -3
- package/src/commands/command-types.ts +6 -0
- package/src/commands/command.ts +1 -0
- package/src/settings/config.ts +7 -0
- package/src/settings/settings-store.ts +0 -34
- package/src/settings/settings-types.ts +8 -0
- package/src/settings/settings-validation.ts +1 -1
- package/src/snapshot/snapshot-codec.ts +1 -5
- package/src/state/lock.ts +13 -10
- package/src/state/merge-baseline-store.ts +2 -2
- package/src/state/state-directory.ts +8 -1
- package/src/sync/agent-candidate.ts +251 -0
- package/src/sync/agent-plan.ts +87 -0
- package/src/sync/agent-policy.ts +13 -0
- package/src/sync/agent-request.ts +17 -0
- package/src/sync/agent-runner.ts +188 -0
- package/src/sync/agent-store.ts +151 -0
- package/src/sync/agent-sync.ts +91 -0
- package/src/sync/automatic-sync.ts +2 -1
- package/src/sync/continuous-errors.ts +85 -0
- package/src/sync/continuous-lease.ts +221 -0
- package/src/sync/continuous-record.ts +179 -0
- package/src/sync/continuous-storage.ts +55 -0
- package/src/sync/continuous-sync.ts +516 -0
- package/src/sync/continuous-watch.ts +59 -0
- package/src/sync/merged-sync.ts +70 -12
- package/src/sync/remote-snapshot.ts +2 -3
- package/src/sync/settings-merge.ts +19 -3
- package/src/sync/setup-switch.ts +1 -5
- package/src/sync/startup-check.ts +15 -11
- package/src/sync/sync-inspection.ts +8 -1
- package/src/sync/sync-mutations.ts +6 -17
- package/src/sync/sync-queries.ts +5 -0
- package/src/sync/text-merge.ts +3 -3
- package/src/sync-extension.ts +75 -19
- package/src/ui/agent-mode-setting.ts +91 -0
- package/src/ui/manager-state.ts +2 -0
- package/src/ui/remote-selection-ui.ts +5 -5
- package/src/ui/settings-ui.ts +83 -1
- package/src/ui/setup/s3-credentials-ui.ts +1 -5
- package/src/ui/setup/setup-name-ui.ts +2 -5
- package/src/ui/setup/setup-switcher.ts +2 -0
- package/src/ui/sync-setups-ui.ts +3 -5
- package/dist/chunks/chunk-26PMXY7E.ts +0 -362
- package/dist/chunks/chunk-26PMXY7E.ts.map +0 -7
- package/dist/chunks/chunk-35GM6XKM.ts.map +0 -7
- package/dist/chunks/chunk-A3TFXY4O.ts.map +0 -7
- package/dist/chunks/chunk-BVVP33U5.ts.map +0 -7
- package/dist/chunks/chunk-CAGJOKB7.ts.map +0 -7
- package/dist/chunks/chunk-E6ONFG57.ts.map +0 -7
- package/dist/chunks/chunk-GC3QOL6J.ts.map +0 -7
- package/dist/chunks/chunk-I4JYVFUS.ts.map +0 -7
- package/dist/chunks/chunk-KATKRR3D.ts.map +0 -7
- package/dist/chunks/chunk-XU2VXGQV.ts.map +0 -7
- package/dist/chunks/chunk-YTHQEW35.ts.map +0 -7
- package/dist/chunks/chunk-ZX2Q6THB.ts.map +0 -7
- package/dist/chunks/git-backend-FD7AVSK6.ts.map +0 -7
- package/dist/chunks/manager-ui-ZIN4BUSF.ts.map +0 -7
- package/dist/chunks/s3-backend-Y4P3O5B5.ts.map +0 -7
- package/dist/chunks/setup-wizard-VW7F3JN4.ts.map +0 -7
- package/dist/chunks/sync-operations-2CWST333.ts.map +0 -7
- package/dist/chunks/webdav-backend-TBILVEQK.ts.map +0 -7
- /package/dist/chunks/{conflict-artifacts-4CZFB7WI.ts.map → agent-candidate-3JN6DJKT.ts.map} +0 -0
- /package/dist/chunks/{chunk-R35N4CL3.ts.map → chunk-B4QZBDBT.ts.map} +0 -0
- /package/dist/chunks/{chunk-KGE47GCZ.ts.map → chunk-CRKTAPSL.ts.map} +0 -0
- /package/dist/chunks/{chunk-HMXUKU43.ts.map → chunk-EUMINX7L.ts.map} +0 -0
- /package/dist/chunks/{chunk-AAZDBIBJ.ts.map → chunk-GSNPASKB.ts.map} +0 -0
- /package/dist/chunks/{chunk-2FETCGGT.ts.map → chunk-OEWQVWLC.ts.map} +0 -0
- /package/dist/chunks/{chunk-VCL7TZYW.ts.map → chunk-UPNVIUBN.ts.map} +0 -0
- /package/dist/chunks/{manager-result-dispatcher-HVRTPEVK.ts.map → config-KGIYTTXQ.ts.map} +0 -0
- /package/dist/chunks/{merge-baseline-store-ICXVTCML.ts.map → conflict-artifacts-FXFIIBCB.ts.map} +0 -0
- /package/dist/chunks/{remote-selection-ui-H6AEVA3Q.ts.map → continuous-record-LFRVXQBD.ts.map} +0 -0
- /package/dist/chunks/{file-selection-UNHBVVON.ts.map → file-selection-3JJYOVNR.ts.map} +0 -0
- /package/dist/chunks/{setup-switch-ZMW5C4M7.ts.map → manager-result-dispatcher-AGBBVDDS.ts.map} +0 -0
- /package/dist/chunks/{sync-inspection-3EMS2J5B.ts.map → merge-baseline-store-RFCAH7KL.ts.map} +0 -0
package/docs/settings.md
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
- [Required backend shapes](#required-backend-shapes)
|
|
7
7
|
- [Status display](#status-display)
|
|
8
8
|
- [Secret scanning](#secret-scanning)
|
|
9
|
+
- [Experimental Agent mode](#experimental-agent-mode)
|
|
10
|
+
- [Experimental Continuous sync](#experimental-continuous-sync)
|
|
9
11
|
- [Included content](#included-content)
|
|
10
12
|
- [Unsupported old settings and recovery](#unsupported-old-settings-and-recovery)
|
|
11
13
|
|
|
@@ -134,7 +136,7 @@ This check does not contact the server or discover setups configured only on oth
|
|
|
134
136
|
|
|
135
137
|
Every setup requires `sync.include` and explicit `sync.automatic`. Setup asks for automatic sync with Off first; existing values are not rewritten. When enabled, the current setup receives one background startup check in TUI/RPC, including reload/new/resume/fork. Startup no longer performs automatic transfers or opens dialogs; review changes through `/sync`. Print/JSON skip startup checks. Checks compare local hashes and remote metadata against the last sync baseline, so revision changes and missing baselines require review rather than proving a file conflict.
|
|
136
138
|
|
|
137
|
-
The setting still permits automatic shutdown pushes of selected content when `sessions` is included, in all modes; this is not limited to uploading session files. Reload skips shutdown push. Settings changes apply to subsequent operations; changing this setting does not schedule another startup check until the next session start. Foreground `/sync` cancels a pending check before opening Settings. The check deadline is 30 seconds plus bounded cleanup, with manual retry through `/sync status`;
|
|
139
|
+
The setting still permits automatic shutdown pushes of selected content when `sessions` is included, in all modes; this is not limited to uploading session files. Reload skips shutdown push. Settings changes apply to subsequent operations; changing this setting does not schedule another startup check until the next session start. Foreground `/sync` cancels a pending check before opening Settings. The check deadline is 30 seconds plus bounded cleanup, with manual retry through `/sync status`; this one-shot policy has no polling. Continuous sync is a separate opt-in. See [Background startup checks](../README.md#background-startup-checks) for recovery and Git cache exceptions.
|
|
138
140
|
|
|
139
141
|
Recommended includes the nine non-session Pi roots listed below; Minimal includes `settings.json` and `AGENTS.md`. The save review lists every selected path and the full backend destination. R2/S3 requires an explicitly entered existing bucket and never creates one. Saving setup or connection settings does not contact remote storage.
|
|
140
142
|
|
|
@@ -255,6 +257,87 @@ Across startup inspection and transfer, an eligible unchanged setup makes two fr
|
|
|
255
257
|
|
|
256
258
|
Turning **Startup transfer** off does not revert transfers or remove backups/journals. For interrupted operations, follow [merge recovery](merge-implementation-audit.md). Unknown fields remain preserved and malformed/invalid files block saves, just as for the existing policy.
|
|
257
259
|
|
|
260
|
+
### Experimental Agent mode
|
|
261
|
+
|
|
262
|
+
`sync.agentMode` is an optional per-setup boolean, default `false`, in settings versions 3, 4, and 5. Open **Settings → Sync behavior → Agent mode (experimental)** and review the setup, destination, selected paths, model disclosure, costs, and autonomous writes before enabling. RPC users can explicitly set the same field in the private settings file. Setting it to `true` is authorization, not just a display preference.
|
|
263
|
+
|
|
264
|
+
Agent mode authorizes one eligible merged sync at the next session start and the same autonomous flow from **Sync now** or `/sync sync`. It can publish and replace/delete selected content without another transfer confirmation. It is independent of `sync.automatic` and `sync.automaticTransfer`; neither existing preference is changed. Disabling Startup transfer alone does not disable Agent mode. Saving never starts a transfer from Settings. The atomic write cannot be cancelled after its final cancellation check. A completed save remains in effect after cancellation or session replacement; reopen Settings to read the saved value. TUI/RPC are supported; print/JSON and shutdown do not start a resolver. Agent mode alone adds no polling or daemon. Separately enabled Continuous sync provides recurring work while Pi remains open.
|
|
265
|
+
|
|
266
|
+
An accepted baseline, matching included-content/local-field policies, and conditional or lease-protected publication remain required. First-source selection, policy migrations, explicit push/pull, and rollback keep their existing reviewed flows. `--yes` does not enable Agent mode or authorize model disclosure. Completed transfers never reload resources automatically.
|
|
267
|
+
|
|
268
|
+
The resolver uses the initiating session's selected model through Pi's public provider/authentication registry. It has an independent in-memory SDK conversation, no tools, no parent conversation, and no discovered extensions, skills, project instructions, or MCP servers. Only conflict units are disclosed. Settings units contain only the conflicting root fields, with coupled fields kept together; the model selects a complete local or remote unit. Other fields are assembled by deterministic code. For text, the model can replace only overlapping edit hunks; code preserves the other bytes and independent edits. Parent model prompts and tools are not changed. This is configuration isolation, not an OS sandbox or proof of semantic correctness.
|
|
269
|
+
|
|
270
|
+
Eligible text is global `AGENTS.md` and `.md`/`.txt` files under `prompts/` or `skills/`. Global `settings.json` uses the existing strict JSON, atomic-value, numeric, coupled-field, unknown-field, and machine-local-field policies. Code/scripts, keybindings, binaries, unknown formats, session histories, deletion/modification conflicts, path collisions, and session-root transitions are not model-resolved. Existing deterministic merges still follow their own explicit settings. Unsupported or refused groups remain unresolved; only an explicitly enabled `partialSync` policy permits progress around them. Preserving versions does not mean a conflict is resolved.
|
|
271
|
+
|
|
272
|
+
A verified accepted ancestor is required. Enabling Agent mode cannot recover historical ancestor bytes that were never retained. If the original accepted bytes are still available on both sides, a conflict-free sync can seed the text ancestors for future runs; otherwise use the existing reviewed recovery flow rather than inventing a base from current edits.
|
|
273
|
+
|
|
274
|
+
Bounds are 8 KiB per input file, 16 conflict paths/units, 24 KiB of serialized model evidence, 16 KiB of candidate output, 8,192 maximum output tokens (or the selected model's smaller positive output limit), and one provider request per candidate. An unknown model limit of zero retains the resolver cap; invalid limits pause before disclosure. Each resolver has a 20-second deadline; the existing overall startup deadline is 30 seconds. At most two candidate requests are made during one triggered flow when remote commit competition changes the plan. Provider retries are disabled. Invalid output, detected stale evidence, missing model/auth, or recovery uncertainty pauses the flow rather than forcing acceptance. These limits are not an exact currency spending cap; in-flight disclosure or charges cannot be undone by cancellation.
|
|
275
|
+
|
|
276
|
+
The existing possible-secret scanner always checks model evidence, even when `skipSecretScan` permits uploads without scanning. It is a heuristic, not a guarantee that all secrets are detected. Do not enable Agent mode for content you cannot disclose to the selected provider. Provider errors and model reasoning are not copied into status or persistent records.
|
|
277
|
+
|
|
278
|
+
Pi sessions sharing one agent directory use one local resolver claim. Other sessions can report deferred without another model request. Inference does not hold the normal sync operation lock; commit rechecks settings, claim ownership, model identity, local bytes, baseline and remote revision. Foreground work, replacement, reload and shutdown cancel/drain the owning task. Another process rechecks authorization before requests and commit; disabling in that process cannot recall an already submitted request. A dead owner's claim can be replaced on a later trigger; unreadable ownership is retained for review. No live claim is removed just because it is old. Claim creation and cleanup share a short-lived cross-process guard. Cleanup does not need the normal operation lock, so another long-running transfer does not retain a completed resolver's claim.
|
|
279
|
+
|
|
280
|
+
Private claim and content-keyed candidate records reside under `<state-root>/agent-resolver/`, outside snapshots, with private permissions. Candidates can be reused only for matching evidence, policies and model identity, and are validated again. They can contain merged text; retain them as private data. Different candidates are retained without automatic retention pruning. Duplicate provider billing remains possible at crash boundaries. Corrupt or unsafe records block reuse rather than being overwritten blindly.
|
|
281
|
+
|
|
282
|
+
Pi-sync's cross-process operation lock excludes other pi-sync transfers, not arbitrary writers. Pi file mutation queues coordinate participating writers only within one process. Checks near rename reduce stale writes but do not provide an atomic compare-and-swap against another Pi process or editor. Session JSONL is not rewritten by the model; other active session writers remain subject to the existing protection limitations. Do not use Agent mode as a guarantee of lossless live editing.
|
|
283
|
+
|
|
284
|
+
Turn Agent mode Off to prevent subsequent requests and invalidate pending authorization. Keep backups, snapshots, conflicts and pending journals; disabling does not undo completed work or erase history. Unknown publication outcomes use the existing journal reconciliation and never authorize blind republishing. Pending journals can be reconciled on a read/check/write/verify backend because recovery does not publish. New autonomous transfers still require conditional or lease-protected publication; turn Agent mode Off before a reviewed manual transfer on a weaker backend. If recovery is blocked, retain evidence and use the existing reviewed recovery routes. Finish/reconcile pending merge journals before a downgrade. No Pi core changes or other extension are required.
|
|
285
|
+
|
|
286
|
+
### Experimental Continuous sync
|
|
287
|
+
|
|
288
|
+
`sync.continuous` is an optional per-setup boolean, default `false`, in settings versions 3, 4 and 5. Open **Settings → Sync behavior → Continuous sync (experimental)** after enabling Agent mode. RPC users can set the field explicitly in the private settings file. Existing Agent mode consent does not enable recurring requests.
|
|
289
|
+
|
|
290
|
+
The separate review names the setup, storage connection, destination, included paths, recurring costs and automatic writes. An elected idle session uses its selected Pi model; a different session can use a different provider/model. The save uses the existing atomic settings writer. Cancellation after the final publication check cannot undo a committed save. Reopen Settings to read the saved value.
|
|
291
|
+
|
|
292
|
+
#### Activation and ownership
|
|
293
|
+
|
|
294
|
+
Monitoring starts at an eligible TUI/RPC session startup, after awaited local transaction recovery, or after a foreground `/sync` command or manager closes. It replaces the duplicate one-shot startup transfer, not the legacy shutdown policy. Turning Continuous sync off stops its work without disabling Agent mode. Turning Agent mode off also stops monitoring; a saved Continuous sync On value remains ineffective until Agent mode is enabled again.
|
|
295
|
+
|
|
296
|
+
A control-file change during monitoring is detected by a watcher or the five-second local control check. If monitoring is already off, external enablement requires the next session start or a foreground `/sync` command. Print/JSON create no continuous resources. No daemon remains after Pi exits.
|
|
297
|
+
|
|
298
|
+
Upgrade all Pi processes sharing an agent directory before enabling Continuous sync. Older releases do not implement its election or automatic accounting. One scheduler lease coordinates participating sessions/processes sharing the same canonical agent directory. Standbys only check local eligibility/ownership; they do not duplicate remote polling, watchers or inference. Each computer has a separate scheduler. Remote conditional publication and revision checks remain authoritative across computers. The lease does not exclude arbitrary editors or writers in other Pi processes.
|
|
299
|
+
|
|
300
|
+
Agent work, foreground commands, replacement, reload and shutdown cancel and drain owned work before handoff. Idle work resumes through `agent_settled` or a later eligible check. A busy session does not retain leadership indefinitely. Completed atomic writes or remote commits remain committed even when cancellation occurs later.
|
|
301
|
+
|
|
302
|
+
#### Detection, retries and safety blocks
|
|
303
|
+
|
|
304
|
+
Local hints wait for one quiet second. Remote and local snapshots are checked every 60–66 seconds. Missing roots, atomic replacement, unsupported recursive watching and watcher errors retain periodic fallback. Only selected content and control settings generate hints; private caches, backups and scheduler state are excluded.
|
|
305
|
+
|
|
306
|
+
Local changes and retries can cause extra remote requests between polls. Cycles do not overlap. Each cycle has a 30-second deadline plus underlying cleanup. Changes during a cycle coalesce into one later fresh inspection rather than a queue of stale commands.
|
|
307
|
+
|
|
308
|
+
Known transient network/server errors and rate limits retry from five seconds with exponential backoff, bounded jitter and a five-minute cap. Valid server Retry-After delays are capped at five minutes. Backoff survives restart and is reset by successful work or relevant configuration changes, not every file event.
|
|
309
|
+
|
|
310
|
+
Authentication, permissions, disk errors, invalid settings, unsafe evidence and unknown errors stop automatic transfer. Relevant repair, changed evidence or an explicit Sync now action can permit a fresh attempt. Corrupt private records are retained, not replaced with defaults. Local prerequisite checks can detect a repair without network/model work.
|
|
311
|
+
|
|
312
|
+
Blocked conflicts are inspected for changed evidence, but unchanged refusal/invalid output does not spend another model request. Opaque Pi assistant errors and Git failures without structured transport evidence are not classified from text. Select an available model, repair access, or use the existing review routes as needed.
|
|
313
|
+
|
|
314
|
+
Missing baselines, changed selection/local-field rules, unsupported conflicts and weak publication capability remain safety blocks. Code, session histories, deletion conflicts and path collisions are not model-resolved. Existing explicit partial-sync policy alone permits independent progress. No background confirmation dialog or automatic resource reload is added.
|
|
315
|
+
|
|
316
|
+
#### Request limits and private records
|
|
317
|
+
|
|
318
|
+
The existing per-flow candidate limit, 20-second resolver deadline, input/output bounds, tool/resource isolation and mandatory disclosure scan still apply. Cached candidates must pass current evidence checks before reuse.
|
|
319
|
+
|
|
320
|
+
Before each automatic provider dispatch, Pi Sync reserves a request in private durable state. Timeouts, cancellation after reservation and uncertain dispatch outcomes consume that reservation. Automatic requests are limited to:
|
|
321
|
+
|
|
322
|
+
- Two for the same candidate evidence/model contract, across process restart and ownership handoff.
|
|
323
|
+
- Two per replica binding in a rolling 15-minute window, including requests for different candidate keys.
|
|
324
|
+
|
|
325
|
+
A refusal or invalid output blocks unchanged evidence rather than requesting another completion each poll. When the rolling window is full, Status shows the next eligible attempt. Checks, conflict-free transfers and journal recovery can continue without another model request. When the candidate's two attempts are spent, use changed evidence or an explicit retry. The explicit **Sync now** / `/sync sync` path retains its existing per-flow limits; it does not erase automatic accounting. Opening Status never reserves requests or clears limits.
|
|
326
|
+
|
|
327
|
+
These limits are not a currency spending cap or an exactly-once billing guarantee. Candidate accounting has a 128 KiB storage bound. A full record blocks work instead of pruning counts and silently granting more requests.
|
|
328
|
+
|
|
329
|
+
Private records under `<state-root>/continuous/` hold versioned owner/binding metadata, coalesced intent, hashes, safe error categories, timestamps and request counts. They contain no raw credentials, provider errors, reasoning or duplicate conflict text. The binding uses the canonical agent root and setup/destination identity; state-directory migration does not reset accounting. POSIX records are private and use temporary-file-plus-rename publication. Keep the directory as private evidence; do not delete it to repair a transfer.
|
|
330
|
+
|
|
331
|
+
#### Status, recovery and downgrade
|
|
332
|
+
|
|
333
|
+
The existing status key shows waiting, checking/syncing, scheduled retry, another local owner, or a stable blocked reason. **Check remote status** and the manager show advisory ownership, the last successful background check/transfer and safe next actions. Notifications are deduplicated by error/evidence/configuration. Status may itself need network access for its existing remote check, but does not call the model.
|
|
334
|
+
|
|
335
|
+
Pending journals are reconciled before new work, including a newly uncertain publication. Recovery never blindly republishes. An unchanged recovery block is not repeatedly applied. Preserve journals, backups and unresolved versions when proof is unavailable.
|
|
336
|
+
|
|
337
|
+
Turn Continuous sync off and drain its owner before downgrade. Reconcile pending transfers with the current release. Existing Agent mode and shutdown preferences remain separately effective. Private scheduler records are separate from baseline/journal formats and remain retained; no automatic filesystem rollback or state migration is performed.
|
|
338
|
+
|
|
339
|
+
For implementation evidence and test scope, see [Continuous sync verification](continuous-sync-verification.md).
|
|
340
|
+
|
|
258
341
|
### Settings field merge and machine-local fields
|
|
259
342
|
|
|
260
343
|
`sync.mergeSettings` is experimental, defaults to `false`, and is available in the owning Settings screen. With a verified accepted ancestor, global `settings.json` root fields merge conservatively: absent differs from null, arrays/nested objects are atomic, and provider/model and other coupled fields form one unit. Unknown fields are preserved. Pi accepts plain UTF-8 JSON with an optional BOM, not JSONC; duplicate/prototype-sensitive keys, invalid encodings, migration-dependent syntax, and over-bound inputs require directional review. Field merging and nonempty portable projections also refuse unsafe integers, nonfinite numbers, and numeric literals that change mathematical value when serialized through JavaScript; they never round those values into an accepted edit. `keybindings.json` remains file-level because alias and matcher semantics need a separate strategy.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-sync",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.56.0",
|
|
4
4
|
"description": "Pi extension that syncs Pi configuration through Git, WebDAV, Cloudflare R2, or S3-compatible storage.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -52,7 +52,8 @@
|
|
|
52
52
|
"typescript": "7.0.2"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
|
-
"@earendil-works/pi-
|
|
55
|
+
"@earendil-works/pi-ai": "*",
|
|
56
|
+
"@earendil-works/pi-coding-agent": ">=1.1.0",
|
|
56
57
|
"@earendil-works/pi-tui": "*"
|
|
57
58
|
},
|
|
58
59
|
"repository": {
|
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { posixJoin } from "../../paths.js";
|
|
5
5
|
import type { Snapshot, SnapshotFile } from "../../snapshot/snapshot-types.js";
|
|
6
6
|
import { stateDir } from "../../state/state-directory.js";
|
|
7
|
+
import { throwIfAborted } from "../../sync/signals.js";
|
|
7
8
|
import { syncErrorGuidance } from "../../sync/sync-error-guidance.js";
|
|
8
9
|
import type { ResolvedGitBackend } from "../backend-types.js";
|
|
9
10
|
import {
|
|
@@ -781,7 +782,3 @@ function sha256(value: Buffer) {
|
|
|
781
782
|
function abortReason(signal: AbortSignal) {
|
|
782
783
|
return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
783
784
|
}
|
|
784
|
-
|
|
785
|
-
function throwIfAborted(signal?: AbortSignal) {
|
|
786
|
-
if (signal?.aborted) throw abortReason(signal);
|
|
787
|
-
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import process from "node:process";
|
|
3
|
+
import { throwIfAborted } from "../../sync/signals.js";
|
|
3
4
|
|
|
4
5
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
5
6
|
const DEFAULT_OUTPUT_LIMIT = 1024 * 1024;
|
|
@@ -252,8 +253,3 @@ export async function readGitBlobs(
|
|
|
252
253
|
});
|
|
253
254
|
return parseGitBlobBatch(result.stdout, objects.length, options.maxOutputBytes);
|
|
254
255
|
}
|
|
255
|
-
|
|
256
|
-
function throwIfAborted(signal?: AbortSignal) {
|
|
257
|
-
if (!signal?.aborted) return;
|
|
258
|
-
throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
259
|
-
}
|
|
@@ -4,9 +4,11 @@ import { sessionTokenWarnings } from "../../settings/config.js";
|
|
|
4
4
|
import { decodeSnapshot, encodeSnapshot } from "../../snapshot/snapshot-codec.js";
|
|
5
5
|
import type { Snapshot } from "../../snapshot/snapshot-types.js";
|
|
6
6
|
import { validatePortableSnapshot } from "../../sync/local-fields.js";
|
|
7
|
+
import { throwIfAborted } from "../../sync/signals.js";
|
|
7
8
|
import { syncErrorGuidance } from "../../sync/sync-error-guidance.js";
|
|
8
9
|
import { portableSnapshotSelection } from "../../sync/sync-policy.js";
|
|
9
10
|
import type { LatestPointer, RemoteObject, ResolvedS3Backend } from "../backend-types.js";
|
|
11
|
+
import { createLatestPointer, remoteHistoryEntry } from "../snapshot-pointer.js";
|
|
10
12
|
import {
|
|
11
13
|
type BackendDiagnostic,
|
|
12
14
|
type ExpectedRemoteHead,
|
|
@@ -318,16 +320,7 @@ export function storageRoot(config: ResolvedS3Backend) {
|
|
|
318
320
|
}
|
|
319
321
|
|
|
320
322
|
export function pointerFor(config: ResolvedS3Backend, snapshot: Snapshot, checksum: string): LatestPointer {
|
|
321
|
-
return
|
|
322
|
-
version: VERSION,
|
|
323
|
-
profile: config.destination.namespace,
|
|
324
|
-
snapshot: snapshot.id,
|
|
325
|
-
sha256: checksum,
|
|
326
|
-
createdAt: snapshot.createdAt,
|
|
327
|
-
machine: snapshot.machine,
|
|
328
|
-
syncSessions: snapshot.syncSessions === true || snapshot.files.some((file) => file.path.startsWith("sessions/")),
|
|
329
|
-
...(snapshot.selection === undefined ? {} : { selection: snapshot.selection }),
|
|
330
|
-
};
|
|
323
|
+
return createLatestPointer(config.destination.namespace, snapshot, checksum);
|
|
331
324
|
}
|
|
332
325
|
|
|
333
326
|
export function s3BackendIdentity(config: ResolvedS3Backend) {
|
|
@@ -365,16 +358,6 @@ function trimSlashes(value: string) {
|
|
|
365
358
|
return value.replace(/^\/+|\/+$/g, "");
|
|
366
359
|
}
|
|
367
360
|
|
|
368
|
-
function remoteHistoryEntry(pointer: LatestPointer): RemoteHistoryEntry {
|
|
369
|
-
return {
|
|
370
|
-
snapshotRef: pointer.snapshot,
|
|
371
|
-
snapshotId: pointer.snapshot,
|
|
372
|
-
createdAt: pointer.createdAt,
|
|
373
|
-
machine: pointer.machine,
|
|
374
|
-
syncSessions: pointer.syncSessions === true,
|
|
375
|
-
};
|
|
376
|
-
}
|
|
377
|
-
|
|
378
361
|
function remoteHead(pointer: LatestPointer, identity: string, etag?: string): RemoteHead {
|
|
379
362
|
return {
|
|
380
363
|
snapshotRef: pointer.snapshot,
|
|
@@ -529,11 +512,6 @@ function hasControlCharacter(value: string) {
|
|
|
529
512
|
});
|
|
530
513
|
}
|
|
531
514
|
|
|
532
|
-
function throwIfAborted(signal?: AbortSignal) {
|
|
533
|
-
if (!signal?.aborted) return;
|
|
534
|
-
throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
535
|
-
}
|
|
536
|
-
|
|
537
515
|
function sha256(value: Buffer) {
|
|
538
516
|
return createHash("sha256").update(value).digest("hex");
|
|
539
517
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, createHmac } from "node:crypto";
|
|
2
2
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
3
3
|
import { encodeKey, posixJoin } from "../../paths.js";
|
|
4
|
+
import { retryAfter } from "../../sync/continuous-errors.js";
|
|
4
5
|
import type { RemoteObject, ResolvedS3Backend } from "../backend-types.js";
|
|
5
6
|
|
|
6
7
|
const MAX_JSON_RESPONSE_BYTES = 1024 * 1024;
|
|
@@ -34,6 +35,7 @@ export class S3HttpError extends Error {
|
|
|
34
35
|
constructor(
|
|
35
36
|
message: string,
|
|
36
37
|
readonly status: number,
|
|
38
|
+
readonly retryAfterMs?: number,
|
|
37
39
|
) {
|
|
38
40
|
super(message);
|
|
39
41
|
this.name = "S3HttpError";
|
|
@@ -81,7 +83,11 @@ export class S3Client {
|
|
|
81
83
|
const object = await this.request("GET", key);
|
|
82
84
|
if (object.status === 404) return { missing: true };
|
|
83
85
|
if (!object.ok) {
|
|
84
|
-
throw new S3HttpError(
|
|
86
|
+
throw new S3HttpError(
|
|
87
|
+
`S3 GET failed (${object.status}): ${await this.readErrorText(object)}`,
|
|
88
|
+
object.status,
|
|
89
|
+
retryAfter(object),
|
|
90
|
+
);
|
|
85
91
|
}
|
|
86
92
|
const body = await readBoundedText(object, MAX_JSON_RESPONSE_BYTES, "S3 JSON response");
|
|
87
93
|
// R2 can intermittently return an empty 200 body (read-after-write
|
|
@@ -111,7 +117,11 @@ export class S3Client {
|
|
|
111
117
|
const object = await this.request("GET", key);
|
|
112
118
|
if (object.status === 404) return { missing: true };
|
|
113
119
|
if (!object.ok) {
|
|
114
|
-
throw new S3HttpError(
|
|
120
|
+
throw new S3HttpError(
|
|
121
|
+
`S3 GET failed (${object.status}): ${await this.readErrorText(object)}`,
|
|
122
|
+
object.status,
|
|
123
|
+
retryAfter(object),
|
|
124
|
+
);
|
|
115
125
|
}
|
|
116
126
|
const buffer = await readBoundedBuffer(object, MAX_SNAPSHOT_RESPONSE_BYTES, "S3 snapshot response");
|
|
117
127
|
// R2 can intermittently return an empty 200 body on a long-lived
|
|
@@ -158,6 +168,7 @@ export class S3Client {
|
|
|
158
168
|
throw new S3HttpError(
|
|
159
169
|
`S3 PUT failed (${response.status}): ${await this.readErrorText(response)}`,
|
|
160
170
|
response.status,
|
|
171
|
+
retryAfter(response),
|
|
161
172
|
);
|
|
162
173
|
}
|
|
163
174
|
await response.body?.cancel();
|
|
@@ -169,6 +180,7 @@ export class S3Client {
|
|
|
169
180
|
throw new S3HttpError(
|
|
170
181
|
`S3 DELETE failed (${response.status}): ${await this.readErrorText(response)}`,
|
|
171
182
|
response.status,
|
|
183
|
+
retryAfter(response),
|
|
172
184
|
);
|
|
173
185
|
}
|
|
174
186
|
await response.body?.cancel();
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Snapshot } from "../snapshot/snapshot-types.js";
|
|
2
|
+
import type { LatestPointer } from "./backend-types.js";
|
|
3
|
+
import type { RemoteHistoryEntry } from "./sync-backend.js";
|
|
4
|
+
|
|
5
|
+
/** Shared latest/history metadata for the S3 and WebDAV object-store format. */
|
|
6
|
+
export function createLatestPointer(namespace: string, snapshot: Snapshot, checksum: string): LatestPointer {
|
|
7
|
+
return {
|
|
8
|
+
version: 1,
|
|
9
|
+
profile: namespace,
|
|
10
|
+
snapshot: snapshot.id,
|
|
11
|
+
sha256: checksum,
|
|
12
|
+
createdAt: snapshot.createdAt,
|
|
13
|
+
machine: snapshot.machine,
|
|
14
|
+
syncSessions: snapshot.syncSessions === true || snapshot.files.some((file) => file.path.startsWith("sessions/")),
|
|
15
|
+
...(snapshot.selection === undefined ? {} : { selection: snapshot.selection }),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function remoteHistoryEntry(pointer: LatestPointer): RemoteHistoryEntry {
|
|
20
|
+
return {
|
|
21
|
+
snapshotRef: pointer.snapshot,
|
|
22
|
+
snapshotId: pointer.snapshot,
|
|
23
|
+
createdAt: pointer.createdAt,
|
|
24
|
+
machine: pointer.machine,
|
|
25
|
+
syncSessions: pointer.syncSessions === true,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -2,9 +2,11 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { decodeSnapshot, encodeSnapshot } from "../../snapshot/snapshot-codec.js";
|
|
3
3
|
import type { Snapshot } from "../../snapshot/snapshot-types.js";
|
|
4
4
|
import { validatePortableSnapshot } from "../../sync/local-fields.js";
|
|
5
|
+
import { throwIfAborted } from "../../sync/signals.js";
|
|
5
6
|
import { syncErrorGuidance } from "../../sync/sync-error-guidance.js";
|
|
6
7
|
import { portableSnapshotSelection } from "../../sync/sync-policy.js";
|
|
7
8
|
import type { LatestPointer, RemoteObject, ResolvedWebDavBackend } from "../backend-types.js";
|
|
9
|
+
import { createLatestPointer, remoteHistoryEntry } from "../snapshot-pointer.js";
|
|
8
10
|
import {
|
|
9
11
|
type BackendDiagnostic,
|
|
10
12
|
type ExpectedRemoteHead,
|
|
@@ -84,7 +86,7 @@ export class WebDavSyncBackend implements SyncBackend {
|
|
|
84
86
|
await client.ensureCollection(snapshotsPath(this.config));
|
|
85
87
|
const encoded = await encodeSnapshot(snapshot);
|
|
86
88
|
throwIfAborted(options.signal);
|
|
87
|
-
const pointer =
|
|
89
|
+
const pointer = createLatestPointer(this.config.destination.namespace, snapshot, sha256(encoded));
|
|
88
90
|
const stagedPath = snapshotPath(this.config, snapshot.id);
|
|
89
91
|
try {
|
|
90
92
|
await client.putBuffer(stagedPath, encoded, "application/gzip", { ifAbsent: true });
|
|
@@ -392,19 +394,6 @@ function webDavStorageLocation(config: ResolvedWebDavBackend) {
|
|
|
392
394
|
return `${url.host} · ${config.destination.path}`;
|
|
393
395
|
}
|
|
394
396
|
|
|
395
|
-
function pointerFor(config: ResolvedWebDavBackend, snapshot: Snapshot, checksum: string): LatestPointer {
|
|
396
|
-
return {
|
|
397
|
-
version: VERSION,
|
|
398
|
-
profile: config.destination.namespace,
|
|
399
|
-
snapshot: snapshot.id,
|
|
400
|
-
sha256: checksum,
|
|
401
|
-
createdAt: snapshot.createdAt,
|
|
402
|
-
machine: snapshot.machine,
|
|
403
|
-
syncSessions: snapshot.syncSessions === true || snapshot.files.some((file) => file.path.startsWith("sessions/")),
|
|
404
|
-
...(snapshot.selection === undefined ? {} : { selection: snapshot.selection }),
|
|
405
|
-
};
|
|
406
|
-
}
|
|
407
|
-
|
|
408
397
|
function remoteHead(pointer: LatestPointer, identity: string, etag?: string): RemoteHead {
|
|
409
398
|
return {
|
|
410
399
|
snapshotRef: pointer.snapshot,
|
|
@@ -490,16 +479,6 @@ function requireHistory(value: { version: number; snapshots: LatestPointer[] } |
|
|
|
490
479
|
return pointers;
|
|
491
480
|
}
|
|
492
481
|
|
|
493
|
-
function remoteHistoryEntry(pointer: LatestPointer): RemoteHistoryEntry {
|
|
494
|
-
return {
|
|
495
|
-
snapshotRef: pointer.snapshot,
|
|
496
|
-
snapshotId: pointer.snapshot,
|
|
497
|
-
createdAt: pointer.createdAt,
|
|
498
|
-
machine: pointer.machine,
|
|
499
|
-
syncSessions: pointer.syncSessions === true,
|
|
500
|
-
};
|
|
501
|
-
}
|
|
502
|
-
|
|
503
482
|
function assertSnapshotIdentity(snapshot: Snapshot, namespace: string) {
|
|
504
483
|
validatePortableSnapshot(snapshot);
|
|
505
484
|
if (
|
|
@@ -637,11 +616,6 @@ function sha256(value: Buffer) {
|
|
|
637
616
|
return createHash("sha256").update(value).digest("hex");
|
|
638
617
|
}
|
|
639
618
|
|
|
640
|
-
function throwIfAborted(signal?: AbortSignal) {
|
|
641
|
-
if (!signal?.aborted) return;
|
|
642
|
-
throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
643
|
-
}
|
|
644
|
-
|
|
645
619
|
function errorMessage(error: unknown) {
|
|
646
620
|
if (error instanceof WebDavHttpError) return error.message;
|
|
647
621
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { XMLParser } from "fast-xml-parser";
|
|
2
|
+
import { ContinuousError, continuousFailure, retryAfter } from "../../sync/continuous-errors.js";
|
|
3
|
+
import { throwIfAborted } from "../../sync/signals.js";
|
|
2
4
|
import type { RemoteObject, ResolvedWebDavBackend } from "../backend-types.js";
|
|
3
5
|
|
|
4
6
|
const JSON_LIMIT = 1024 * 1024;
|
|
@@ -24,6 +26,7 @@ export class WebDavHttpError extends Error {
|
|
|
24
26
|
message: string,
|
|
25
27
|
readonly status: number,
|
|
26
28
|
options?: ErrorOptions,
|
|
29
|
+
readonly retryAfterMs?: number,
|
|
27
30
|
) {
|
|
28
31
|
super(message, options);
|
|
29
32
|
this.name = "WebDavHttpError";
|
|
@@ -231,7 +234,12 @@ export class WebDavClient {
|
|
|
231
234
|
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")
|
|
232
235
|
.trim();
|
|
233
236
|
const detail = body ? `: ${redactText(body, this.config)}` : "";
|
|
234
|
-
throw new WebDavHttpError(
|
|
237
|
+
throw new WebDavHttpError(
|
|
238
|
+
`WebDAV ${action} failed with HTTP ${response.status}${detail}`,
|
|
239
|
+
response.status,
|
|
240
|
+
undefined,
|
|
241
|
+
retryAfter(response),
|
|
242
|
+
);
|
|
235
243
|
}
|
|
236
244
|
}
|
|
237
245
|
|
|
@@ -342,7 +350,8 @@ function safeUrl(url: URL) {
|
|
|
342
350
|
|
|
343
351
|
function redactError(error: unknown, config: ResolvedWebDavBackend) {
|
|
344
352
|
const message = error instanceof Error ? error.message : String(error);
|
|
345
|
-
|
|
353
|
+
const failure = continuousFailure(error);
|
|
354
|
+
return new ContinuousError(failure.kind, redactText(message, config), failure.delay);
|
|
346
355
|
}
|
|
347
356
|
|
|
348
357
|
function redactText(value: string, config: ResolvedWebDavBackend) {
|
|
@@ -359,8 +368,3 @@ function redactText(value: string, config: ResolvedWebDavBackend) {
|
|
|
359
368
|
function escapeRegExp(value: string) {
|
|
360
369
|
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
361
370
|
}
|
|
362
|
-
|
|
363
|
-
function throwIfAborted(signal?: AbortSignal) {
|
|
364
|
-
if (!signal?.aborted) return;
|
|
365
|
-
throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
366
|
-
}
|
|
@@ -5,6 +5,7 @@ import { localConfigTemplate, readLocalConfigObject } from "../settings/settings
|
|
|
5
5
|
import type { AnySyncConfig } from "../settings/settings-types.js";
|
|
6
6
|
import { unlock, withLock } from "../state/lock.js";
|
|
7
7
|
import { migrateLegacyStateDirectory, stateDirectoryMigrationNotice } from "../state/state-directory.js";
|
|
8
|
+
import { withAgentSyncLock } from "../sync/agent-sync.js";
|
|
8
9
|
import { throwIfAborted } from "../sync/signals.js";
|
|
9
10
|
import { syncErrorGuidance } from "../sync/sync-error-guidance.js";
|
|
10
11
|
import { isSyncDecisionRequiredError, SetupPullRequiresUiError } from "../sync/sync-errors.js";
|
|
@@ -49,7 +50,7 @@ export async function executeRecoveryCommand(
|
|
|
49
50
|
const outcome = await withLock("pull", () => operations.pull(ctx, options));
|
|
50
51
|
return { kind: "completed", ...(outcome ? { outcome } : {}) };
|
|
51
52
|
}
|
|
52
|
-
await
|
|
53
|
+
await withAgentSyncLock(ctx, options, (current) => operations.syncBoth(ctx, current));
|
|
53
54
|
return { kind: "completed" };
|
|
54
55
|
} catch (error) {
|
|
55
56
|
if (signal?.aborted) return { kind: "failed" };
|
|
@@ -156,7 +157,7 @@ export async function executeCommand(
|
|
|
156
157
|
case "sync": {
|
|
157
158
|
const operations = await loaders.operations();
|
|
158
159
|
throwIfAborted(options.signal);
|
|
159
|
-
await
|
|
160
|
+
await withAgentSyncLock(ctx, options, (current) => operations.syncBoth(ctx, current));
|
|
160
161
|
return { kind: "completed" };
|
|
161
162
|
}
|
|
162
163
|
case "history": {
|
|
@@ -258,6 +259,8 @@ async function showConfig(ctx: ExtensionCommandContext, options: CommandOptions)
|
|
|
258
259
|
`settings field merge: ${config.mergeSettings ? "On (experimental)" : "Off"}`,
|
|
259
260
|
`machine-local settings.json fields: ${(config.localFields ?? []).map(safeTerminalText).join(", ") || "none"}`,
|
|
260
261
|
`startup transfer: ${startupTransferSummary(config)}`,
|
|
262
|
+
`agent mode: ${config.agentMode ? "On (experimental)" : "Off"}`,
|
|
263
|
+
`continuous sync: ${config.continuous ? (config.agentMode ? "On (experimental)" : "paused; Agent mode is Off") : "Off"}`,
|
|
261
264
|
`included content: ${config.include.join(", ") || "none"}`,
|
|
262
265
|
`sessions: ${config.include.includes("sessions") ? "included" : "not included"}`,
|
|
263
266
|
`settings file: ${localConfigPath()}`,
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
syncAttentionMatchesConfig,
|
|
14
14
|
} from "../ui/sync-attention.js";
|
|
15
15
|
import { setSyncStatus } from "../ui/sync-status.js";
|
|
16
|
-
import { parseOptions, splitArgs } from "./command.js";
|
|
16
|
+
import { parseOptions, splitArgs, validateCommandOptions } from "./command.js";
|
|
17
17
|
import { executeCommand, executeRecoveryCommand } from "./command-execution.js";
|
|
18
18
|
|
|
19
19
|
export async function handleCommand(
|
|
@@ -22,6 +22,7 @@ export async function handleCommand(
|
|
|
22
22
|
sessionSignal: AbortSignal,
|
|
23
23
|
loaders: SyncLoaders,
|
|
24
24
|
attention: SyncAttentionController,
|
|
25
|
+
onExplicitSync?: (setup?: string) => void,
|
|
25
26
|
) {
|
|
26
27
|
await reconcileObservation(attention, sessionSignal);
|
|
27
28
|
if (sessionSignal.aborted) return;
|
|
@@ -31,8 +32,18 @@ export async function handleCommand(
|
|
|
31
32
|
onCommit?: () => void,
|
|
32
33
|
target?: string,
|
|
33
34
|
onDialog?: (active: boolean) => void,
|
|
34
|
-
) =>
|
|
35
|
-
|
|
35
|
+
) => {
|
|
36
|
+
const [command, ...args] = splitArgs(route);
|
|
37
|
+
if (command === "sync") {
|
|
38
|
+
try {
|
|
39
|
+
const options = parseOptions(args);
|
|
40
|
+
validateCommandOptions("sync", options);
|
|
41
|
+
onExplicitSync?.(target ?? options.setup);
|
|
42
|
+
} catch {
|
|
43
|
+
/* Invalid routes do not request an automatic retry. */
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return executeCommand(
|
|
36
47
|
route,
|
|
37
48
|
ctx,
|
|
38
49
|
combineSignals(sessionSignal, signal),
|
|
@@ -41,6 +52,7 @@ export async function handleCommand(
|
|
|
41
52
|
target,
|
|
42
53
|
onDialog,
|
|
43
54
|
);
|
|
55
|
+
};
|
|
44
56
|
if (!rawArgs.trim()) {
|
|
45
57
|
try {
|
|
46
58
|
const { showSyncManager } = await import("../ui/manager-ui.js");
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { AnySyncConfig } from "../settings/settings-types.js";
|
|
2
|
+
import type { AgentAcceptance } from "../sync/agent-request.js";
|
|
1
3
|
import type { StartupTimings } from "../sync/startup-timings.js";
|
|
2
4
|
|
|
3
5
|
export interface CommandOptions {
|
|
@@ -12,6 +14,10 @@ export interface CommandOptions {
|
|
|
12
14
|
onCommit?: () => void;
|
|
13
15
|
/** In-memory startup diagnostics only; never persisted or sent to a backend. */
|
|
14
16
|
startupTimings?: StartupTimings;
|
|
17
|
+
/** Internal validated resolver result; never parsed from command arguments. */
|
|
18
|
+
agentAcceptance?: AgentAcceptance;
|
|
19
|
+
/** Internal cycle authorization, ownership and durable request reservation; never command arguments. */
|
|
20
|
+
background?: { validate(config?: AnySyncConfig): void; reserve(key: string): Promise<void> };
|
|
15
21
|
args: string[];
|
|
16
22
|
}
|
|
17
23
|
|
package/src/commands/command.ts
CHANGED
|
@@ -236,6 +236,7 @@ export function usage() {
|
|
|
236
236
|
`Automatic sync: ${AUTOMATIC_SYNC_DESCRIPTION}`,
|
|
237
237
|
`Startup transfer: ${STARTUP_TRANSFER_DESCRIPTION}`,
|
|
238
238
|
"Automatic transfer at startup is a separate opt-in: one conflict-free established-baseline transfer at idle in TUI/RPC, with conditional/lease publication and no resource reload.",
|
|
239
|
+
"Experimental Continuous sync is a separate default-Off opt-in in Settings. It requires Agent mode, monitors while Pi is open, and can incur recurring network/model costs. Safety blocks and durable automatic request limits remain.",
|
|
239
240
|
"Settings: use /sync init or edit storage connections and sync setups in ~/.pi/agent/pi-sync.json (or the configured Pi agent directory). Version 1 and version 2 settings are unsupported and are never rewritten.",
|
|
240
241
|
].join("\n");
|
|
241
242
|
}
|
package/src/settings/config.ts
CHANGED
|
@@ -80,6 +80,8 @@ export async function loadPartialConfig(setupName?: string): Promise<PartialConf
|
|
|
80
80
|
include: [...config.include],
|
|
81
81
|
automatic: config.automatic,
|
|
82
82
|
automaticTransfer: config.automaticTransfer ?? false,
|
|
83
|
+
agentMode: config.agentMode ?? false,
|
|
84
|
+
continuous: config.continuous ?? false,
|
|
83
85
|
onSwitch: config.onSwitch,
|
|
84
86
|
showStatus: config.showStatus,
|
|
85
87
|
};
|
|
@@ -115,10 +117,13 @@ export function syncConfigReviewIdentity(config: AnySyncConfig) {
|
|
|
115
117
|
config.include,
|
|
116
118
|
config.automatic,
|
|
117
119
|
config.automaticTransfer ?? false,
|
|
120
|
+
config.agentMode ?? false,
|
|
118
121
|
config.localFields,
|
|
119
122
|
config.mergeSettings ?? false,
|
|
120
123
|
config.mergeContent ?? false,
|
|
121
124
|
config.partialSync ?? false,
|
|
125
|
+
// Preserve existing Off identities and cached candidates across the upgrade.
|
|
126
|
+
...(config.continuous ? ["continuous"] : []),
|
|
122
127
|
]);
|
|
123
128
|
}
|
|
124
129
|
|
|
@@ -166,6 +171,8 @@ function resolveSyncConfig(
|
|
|
166
171
|
include,
|
|
167
172
|
automatic: setup.sync.automatic,
|
|
168
173
|
automaticTransfer: setup.sync.automaticTransfer ?? false,
|
|
174
|
+
agentMode: setup.sync.agentMode ?? false,
|
|
175
|
+
continuous: setup.sync.continuous ?? false,
|
|
169
176
|
localFields: setup.sync.localFields === undefined ? undefined : normalizeLocalFields(setup.sync.localFields),
|
|
170
177
|
mergeSettings: setup.sync.mergeSettings ?? false,
|
|
171
178
|
mergeContent: setup.sync.mergeContent ?? false,
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import fs from "node:fs/promises";
|
|
3
|
-
import path from "node:path";
|
|
4
1
|
import { localConfigPath, readMigratingLocalConfigDocument, updateLocalConfigDocument } from "./config-file.js";
|
|
5
2
|
import type { OnSwitchAction, PiSyncSettingsV3 } from "./settings-types.js";
|
|
6
3
|
import { DEFAULT_ON_SWITCH, validateSettingsDocument } from "./settings-validation.js";
|
|
@@ -63,34 +60,3 @@ export function updateLocalConfig(update: (current: PiSyncSettingsV3) => PiSyncS
|
|
|
63
60
|
async function performLocalConfigUpdate(update: (current: PiSyncSettingsV3) => PiSyncSettingsV3, signal?: AbortSignal) {
|
|
64
61
|
return updateLocalConfigDocument(localConfigTemplate(), update, validateSettingsDocument, signal);
|
|
65
62
|
}
|
|
66
|
-
|
|
67
|
-
export async function writeLocalConfigObject(value: PiSyncSettingsV3 | Record<string, unknown>) {
|
|
68
|
-
validateSettingsDocument(value as Record<string, unknown>);
|
|
69
|
-
const configPath = localConfigPath();
|
|
70
|
-
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
71
|
-
try {
|
|
72
|
-
const stat = await fs.lstat(configPath);
|
|
73
|
-
if (stat.isSymbolicLink()) throw new Error(`Refusing to overwrite symlinked pi-sync settings: ${configPath}`);
|
|
74
|
-
if (!stat.isFile()) throw new Error(`pi-sync settings are not a regular file: ${configPath}`);
|
|
75
|
-
} catch (error) {
|
|
76
|
-
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
77
|
-
}
|
|
78
|
-
const temporaryPath = path.join(
|
|
79
|
-
path.dirname(configPath),
|
|
80
|
-
`.${path.basename(configPath)}.${process.pid}.${randomUUID()}.tmp`,
|
|
81
|
-
);
|
|
82
|
-
let handle: fs.FileHandle | undefined;
|
|
83
|
-
try {
|
|
84
|
-
handle = await fs.open(temporaryPath, "wx", 0o600);
|
|
85
|
-
await handle.writeFile(`${JSON.stringify(value, null, "\t")}\n`, "utf8");
|
|
86
|
-
if (process.platform !== "win32") await handle.chmod(0o600);
|
|
87
|
-
await handle.sync();
|
|
88
|
-
await handle.close();
|
|
89
|
-
handle = undefined;
|
|
90
|
-
await fs.rename(temporaryPath, configPath);
|
|
91
|
-
} catch (error) {
|
|
92
|
-
await handle?.close().catch(() => undefined);
|
|
93
|
-
await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
94
|
-
throw error;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
@@ -81,6 +81,10 @@ export interface SyncPolicySettings {
|
|
|
81
81
|
automatic: boolean;
|
|
82
82
|
/** Explicit opt-in, separate from the legacy observe/shutdown policy. */
|
|
83
83
|
automaticTransfer?: boolean;
|
|
84
|
+
/** Experimental autonomous merged-sync authorization. */
|
|
85
|
+
agentMode?: boolean;
|
|
86
|
+
/** Separate explicit authorization for recurring background work. */
|
|
87
|
+
continuous?: boolean;
|
|
84
88
|
/** Version 4: settings.json root fields never uploaded. */
|
|
85
89
|
localFields?: string[];
|
|
86
90
|
mergeSettings?: boolean;
|
|
@@ -116,6 +120,8 @@ export interface SyncConfig<Backend extends ResolvedSyncBackend = ResolvedS3Back
|
|
|
116
120
|
include: string[];
|
|
117
121
|
automatic: boolean;
|
|
118
122
|
automaticTransfer?: boolean;
|
|
123
|
+
agentMode?: boolean;
|
|
124
|
+
continuous?: boolean;
|
|
119
125
|
localFields?: string[];
|
|
120
126
|
mergeSettings?: boolean;
|
|
121
127
|
mergeContent?: boolean;
|
|
@@ -139,6 +145,8 @@ export interface PartialConfig {
|
|
|
139
145
|
include: string[];
|
|
140
146
|
automatic: boolean;
|
|
141
147
|
automaticTransfer?: boolean;
|
|
148
|
+
agentMode?: boolean;
|
|
149
|
+
continuous?: boolean;
|
|
142
150
|
onSwitch: OnSwitchAction;
|
|
143
151
|
showStatus: boolean;
|
|
144
152
|
bucket?: string;
|