@gfgghgftgffg/amox-pi-sync 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Murat
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/MAINTENANCE.md ADDED
@@ -0,0 +1,59 @@
1
+ # MAINTENANCE — amox-pi-sync
2
+
3
+ ## Source and attribution
4
+
5
+ - Upstream: [MuratAitov/pi-sync](https://github.com/MuratAitov/pi-sync), npm `pi-sync-suite@0.1.1`.
6
+ - Source ref: `npm:pi-sync-suite@0.1.1` (published source baseline, not the repository's moving HEAD).
7
+ - Upstream MIT notice is preserved verbatim in `LICENSE`: Copyright (c) 2026 Murat.
8
+ - Local package: `@gfgghgftgffg/amox-pi-sync@0.1.1`, standalone TypeScript entrypoint `index.ts` and `core.ts`; no runtime dependency on the original suite.
9
+
10
+ ## Scope
11
+
12
+ Keep raw agent-data push/pull, safe relative include/exclude paths (sessions + missions by default), manual/background automatic sync, backups/restoration and diagnostics. No configuration sync/settings merging, Markdown/JSON exports, Environment Tools or Local Packages. Cleanup is retained only for manually deleting local backup directories (Preview, Run, Delete Selected Backup, Retention); never sessions/missions, with no automatic deletion or archive restoration. Default cleanup candidates are complete backups older than 180 days or beyond the newest 20; counting individual files is not the retention unit. No three-line widget: optional Starline capsule/details, with `/sync-status` fallback.
13
+
14
+ Settings are stored in `amox-pi-sync.json`; legacy `pi-sync-suite.json` is read only as a migration fallback until new settings are saved. Backups use `amox-pi-sync-backups` and require manual retention management via Cleanup. Manual/off startup must not access the network; auto startup fingerprints locally then starts timers and a non-awaited background pull. Setup only saves configuration and stops timers, requiring reload to activate auto mode. The automatic loop must not block the UI. Migration uses an independent `amox-pi-sync-repo`, not the old snapshot directory. The `pi-sync` status key preserves existing Starline placement; the pill bridge takes priority over legacy command mapping. `off` disables automation, not explicit commands.
15
+
16
+ Safety requirements: private SSH remote, no working-directory mutation, no JSONL merging, preserve local-only files, back up replacements and never overwrite active sessions. Safe-path validation must exclude configuration and implementation data even when users select arbitrary relative data paths. Do not claim encryption, conflict-free multi-device merging or automatic secret removal.
17
+
18
+ ## 功能取舍与维护边界
19
+
20
+ 本 fork 负责原始 agent 数据的跨设备传输和本地恢复。部署、配置模板与机器路径适配由 `amox-agent-bootstrap` 等部署流程负责;阅读版会话导出使用 Pi 原生 `/export`。使用本扩展不要求安装 bootstrap。
21
+
22
+ | 上游功能 | 处理 | 原因 |
23
+ | --- | --- | --- |
24
+ | 配置同步、settings 合并 | 删除 | 避免本机同步快照与部署模板成为两个配置来源,也不将机器专属设置直接应用到另一台电脑。通用路径选择仍可传输用户明确选中的安全数据文件,但不提供配置合并或路径适配。 |
25
+ | Markdown/JSON 聊天归档 | 删除 | 本扩展的目标是保留可继续工作的原始会话;上游导出会省略、截断内容,不能代替原始数据。阅读和分享交给 Pi 原生导出,不在同步器内维护另一套渲染器。 |
26
+ | Environment Tools | 删除 | 工具安装和依赖适配归部署流程管理,数据拉取不应同时承担环境安装职责。 |
27
+ | Local Packages | 删除 | 它控制 settings 中本地扩展路径的同步;配置同步移除后不再需要,而且传输路径字符串不会安装路径所指向的代码。 |
28
+ | sessions、missions | 保留 | sessions 保存对话与工具记录;missions 保存工作流任务及关联索引。两者共同保留工作记录,但不保证恢复运行中的任务或临时目录中的外部产物。 |
29
+ | 通用路径选择 | 保留 | 允许用户按需要包含、排除安全的 agent 数据路径,不将范围写死为两个目录;敏感文件与实现目录仍受限制。 |
30
+ | 手动、自动同步 | 保留 | 支持用户主动换机时推拉,也保留可选后台同步;默认手动,不因精简功能改变用户的同步时机。 |
31
+ | 备份恢复 | 保留 | 同路径文件可能被远端内容替换,覆盖前的本地副本是恢复入口。 |
32
+ | Cleanup | 保留并修正 | 即使没有聊天归档,备份仍会积累,需要界面内的手动管理。按完整恢复点预览和确认删除,修正上游保留数量逻辑;不清理 sessions、missions,也不自动删除。 |
33
+ | 诊断 | 保留 | 用户需要查看待传输差异、Git 状态和错误,不能将失败或“未检查”显示为已同步。 |
34
+ | 常驻三行 widget | 替换 | 将状态、操作与设置统一到 Starline 气泡,避免常驻重复信息;未安装 Starline 时保留原生命令界面。 |
35
+
36
+ 精简目标是职责清晰和减少维护范围,未进行启动性能对比。每日监控上游正式发布,保持上述边界定向移植;新增功能应有明确需求,不随上游更新自动恢复已移除模块。
37
+
38
+ ## Repository integration
39
+
40
+ `maintenance/manifest.json` marks this package `maintenanceMode: selective-fork`. Daily maintenance monitors the latest stable npm publication, not unpublished moving repository HEAD. It compares the exact published baseline and target archives, verifies their SHA-512 integrity, and reads source as untrusted text without installing or executing the original suite. A separate read-only SDK review must cover every changed published file and apply the feature decisions above:
41
+
42
+ - **Port** worthwhile fixes and improvements within retained behavior, especially data/path safety, backups, diagnostics, background responsiveness and compatibility with the existing Starline UI. Preserve local guarantees; add focused regressions.
43
+ - **Skip** changes only to removed features or irrelevant material. Already-implemented behavior may be skipped only with a concrete source-based explanation. Record the decision; do not bump the package or move the integrated upstream baseline.
44
+ - **Ask** the owner about new features, conflicting goals, unclear value, behavior/safety tradeoffs, compatibility uncertainty or incomplete source/review evidence. Any unresolved item pauses the whole sync port, not other packages. ntfy includes the upstream version delta, reasons and files. Do not call an uncertain change irrelevant to bypass review.
45
+
46
+ Only a complete review with ports and no unresolved items enables adaptation of `index.ts`/`core.ts` and its scoped tests/docs. The owner’s feature-decisions section is immutable during automatic adaptation. An actual implementation change and focused regression are required; version-only or docs-only ports cannot be published. If implementation reveals new uncertainty, `ask_owner` records the reason, sends ntfy and pauses the existing candidate without further push or merge; it must not bypass a failed check by rebuilding. The fixed host independently stages its package version and publication baseline; local checks, exact-head three-platform CI and Release acceptance still apply. No upstream runtime dependency is added. **Never replace this fork with the full suite or reintroduce removed features.** Tool-context measurements still skip this command-only package.
47
+
48
+ Skip/ask reports are saved privately under the host maintenance state directory as `fork-review-amox-pi-sync.json`, including the baseline/target, integrity and per-change evidence. The same version and unchanged local implementation/policy reuse the report without another model call; pending owner decisions remain visible in daily ntfy summaries. Source acquisition failures are rechecked. A new target or changed local policy/implementation triggers review again, always against the last integrated baseline so skipped changes cannot hide later relevant fixes. Port decisions are not cached as successful integration. To resolve a pending decision, record the owner's rule in this file and merge it, then rerun maintenance; do not edit a report to fake an approved release.
49
+
50
+ All workspace packages retain their own versions; the root version identifies a release batch and its explicit package selection. This package also records `releaseVersioning: independent`. Release assets use each manifest/package version in the tarball name; the GitHub release tag remains tied to the root version. Installation and production checks still include this package. `toolContract: false` excludes this command-only fork from upstream-versus-lean tool-size comparisons; `registeredTools: []` is checked during isolated production loading.
51
+
52
+ 来源记录不代表当前 CI/发布结果或现行审批要求。更新前应对比 manifest 中当前固定版本/revision 与目标源码。
53
+
54
+ ## Verification before an update
55
+
56
+ 1. Read the exact published upstream source and its MIT notice, compare only relevant sync behavior, and record the reviewed ref. Do not use repository HEAD as the publication baseline.
57
+ 2. Update local implementation and focused regressions without weakening active-session, path, backup or migration guarantees. Keep UI tests isolated from user Pi settings; use temporary data directories and local Git fixtures rather than real remotes.
58
+ 3. Run `npm run check --workspace @gfgghgftgffg/amox-pi-sync`, repository integration tests, `npm run contracts:check` and `npm run verify:production`.
59
+ 4. Keep package version, manifest version, lockfile and bilingual documentation consistent. Do not publish, change real Pi settings or commit as part of verification.
package/README.md CHANGED
@@ -1,3 +1,50 @@
1
- # Temporary Holding Version
1
+ # amox-pi-sync
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [简体中文](README.zh-CN.md)
4
+
5
+ Standalone Pi agent-data sync over a private SSH Git repository. Package: `@gfgghgftgffg/amox-pi-sync`. Based on [MuratAitov/pi-sync](https://github.com/MuratAitov/pi-sync), `pi-sync-suite@0.1.1` (MIT; Copyright © 2026 Murat). The exact source reference is `npm:pi-sync-suite@0.1.1`; see [MAINTENANCE.md](MAINTENANCE.md).
6
+
7
+ ## Install
8
+
9
+ Requires Pi `>=1.1.0`, Node `>=22.19.0`, npm, Git and working SSH authentication.
10
+
11
+ From this repository checkout:
12
+
13
+ ```sh
14
+ node scripts/install-extension.mjs amox-pi-sync
15
+ ```
16
+
17
+ Only this extension is installed. Do not load it alongside the original `pi-sync-suite`; inspect existing sources with `pi list` and remove the old source yourself. If the first npm publication is pending, use the checkout installer above. Once available, npm installation and updates use:
18
+
19
+ ```sh
20
+ pi install npm:@gfgghgftgffg/amox-pi-sync
21
+ pi update --extensions
22
+ ```
23
+
24
+ ## Commands
25
+
26
+ | Command | Purpose |
27
+ | --- | --- |
28
+ | `/sync-setup git@host:owner/private-repo.git [interval]` | Configure an SSH repository and optional pull interval in minutes (no transfer) |
29
+ | `/sync-push` | Upload selected raw agent data |
30
+ | `/sync-pull` | Pull selected raw agent data |
31
+ | `/sync-settings` | Open settings and maintenance actions |
32
+ | `/sync-status` | View status and actions; also works without Starline |
33
+
34
+ The settings menu contains **View Status**, **Sync Mode** (`auto`, `manual`, `off`), **Sync Paths** (include/exclude safe relative agent-data paths), **Backups** (create/list/restore), **Diagnostics** (doctor/diff/log), and **Cleanup** (Preview, Run, Delete Selected Backup, Retention). `sessions` and `missions` are the default paths. Manual push and pull commands remain available in both `manual` and `off` modes.
35
+
36
+ Manual/off startup performs no network access. Auto startup computes a local fingerprint, starts timers and launches a background pull without awaiting network completion. `/sync-setup` only saves configuration and stops timers; reload to activate auto mode if enabled. With Starline installed, use its clickable status capsule to open details and actions: check status, upload, pull and settings. The status key remains `pi-sync` for existing Starline placement; the pill bridge takes precedence over legacy `extensionStatuses.commands`, with no Starline configuration changes required. There is no three-line widget. `/sync-status`, pill clicks, settings, submenus and confirmations share centered Starline bubbles above the footer; text entry still uses Pi input dialogs. Without Starline, the native UI remains available.
37
+
38
+ ## Data and safety
39
+
40
+ > [!WARNING]
41
+ > Sessions, missions and other selected files may contain secrets. Use a **private SSH repository**, review included paths, and secure both the remote and local backups. Private Git is not encryption or a secret scrubber.
42
+
43
+ - Paths are relative to Pi's agent-data directory, not the current project. Arbitrary safe data paths can be included or excluded; the extension does not change the working directory.
44
+ - Raw files are transferred without Markdown/JSON export or JSONL merging. Concurrent edits are not a semantic merge; coordinate devices and inspect diagnostics.
45
+ - Local-only files are retained; deletions are not propagated. Incoming content replaces differing same-path files after backup; active sessions must not be overwritten. Restore applies only the currently managed paths.
46
+ - Git commands disable automatic line-ending conversion so Windows `core.autocrlf` does not rewrite raw sessions. SSH uses system `ssh` with batch mode and a connection timeout, overriding a custom `core.sshCommand`.
47
+ - Backups use `amox-pi-sync-backups` under the agent-data directory. Cleanup only deletes local backups after manual preview/confirmation, using complete backup directories rather than individual files. The default limits are 20 complete backups and a maximum age of 180 days; backups older than this or beyond the newest 20 become cleanup candidates. There is no automatic deletion; sessions and missions are never cleanup targets.
48
+ - Settings use `amox-pi-sync.json`. If absent, legacy `pi-sync-suite.json` can be read for migration. The old file is not changed before new settings are saved; saving writes the new file. Migration uses a separate `amox-pi-sync-repo` directory rather than reusing the old snapshot.
49
+
50
+ Configuration sync/settings merging, Markdown/JSON archives, Environment Tools and Local Packages from the original suite are not included. Deployment tools such as `amox-agent-bootstrap` own environment setup and machine-specific configuration; Pi's native `/export` handles readable exports. Raw data selection, backups and manual backup cleanup remain part of sync. See [feature decisions](MAINTENANCE.md#功能取舍与维护边界) for the reasons and maintenance boundaries. This is a selectively maintained fork, not a runtime wrapper or a full replacement of the upstream suite. Daily reviews track published upstream changes, port worthwhile retained behavior, skip removed features and ask the owner through ntfy when uncertain; removed modules are not restored automatically.
@@ -0,0 +1,50 @@
1
+ # amox-pi-sync
2
+
3
+ [English](README.md)
4
+
5
+ 通过私有 SSH Git 仓库同步 Pi agent 数据的独立扩展。包名:`@gfgghgftgffg/amox-pi-sync`。基于 [MuratAitov/pi-sync](https://github.com/MuratAitov/pi-sync) 的 `pi-sync-suite@0.1.1`(MIT;Copyright © 2026 Murat)。来源 ref 为 `npm:pi-sync-suite@0.1.1`,详见[MAINTENANCE.md](MAINTENANCE.md)。
6
+
7
+ ## 安装
8
+
9
+ 需要 Pi `>=1.1.0`、Node `>=22.19.0`、npm、Git 和可用的 SSH 认证。
10
+
11
+ 在本仓库目录执行:
12
+
13
+ ```sh
14
+ node scripts/install-extension.mjs amox-pi-sync
15
+ ```
16
+
17
+ 只安装本扩展。不要同时加载原版 `pi-sync-suite`;通过 `pi list` 检查已有来源,自行移除旧来源。若首次 npm 发布尚未完成,使用上述本地安装方式。npm 包可用后可使用:
18
+
19
+ ```sh
20
+ pi install npm:@gfgghgftgffg/amox-pi-sync
21
+ pi update --extensions
22
+ ```
23
+
24
+ ## 命令
25
+
26
+ | 命令 | 用途 |
27
+ | --- | --- |
28
+ | `/sync-setup git@host:owner/private-repo.git [interval]` | 配置 SSH 仓库及可选拉取间隔(分钟),不传输数据 |
29
+ | `/sync-push` | 上传选中的原始 agent 数据 |
30
+ | `/sync-pull` | 拉取选中的原始 agent 数据 |
31
+ | `/sync-settings` | 打开设置与维护操作 |
32
+ | `/sync-status` | 查看状态和操作;未安装 Starline 时也可使用 |
33
+
34
+ 设置菜单保留 **View Status**、**Sync Mode**(`auto`、`manual`、`off`)、**Sync Paths**(包含/排除任意安全的 agent 数据相对路径)、**Backups**(创建/列表/恢复)、**Diagnostics**(doctor/diff/log)和 **Cleanup**(Preview、Run、Delete Selected Backup、Retention)。默认路径为 `sessions` 和 `missions`。`manual` 和 `off` 模式仍可手动上传、拉取。
35
+
36
+ manual/off 启动不访问网络。auto 启动先计算本地 fingerprint,再启动定时器并发起后台拉取,不等待网络完成。`/sync-setup` 只保存配置并停止定时器;已启用 auto 时需 reload 激活。安装 Starline 时,优先点击状态胶囊打开详情,执行状态检查、上传、拉取或设置。状态 key 保留为 `pi-sync`,兼容原 Starline placement;pill bridge 优先于旧 `extensionStatuses.commands`,无需修改 Starline 配置。没有三行 widget。`/sync-status`、点击胶囊、设置和子菜单统一使用底栏上方居中的 Starline 气泡,确认操作也使用相同样式;文本输入仍使用 Pi 输入框。未安装 Starline 时回退原生界面。
37
+
38
+ ## 数据与安全
39
+
40
+ > [!WARNING]
41
+ > 会话、missions 及其他选中文件可能包含秘密。请使用**私有 SSH 仓库**,检查同步路径,并保护远端和本地备份。私有 Git 不提供加密或秘密清除。
42
+
43
+ - 路径相对于 Pi agent 数据目录,不是当前项目。可包含/排除任意安全的数据相对路径;扩展不改写工作目录。
44
+ - 传输原始文件,不生成 Markdown/JSON 归档,不合并 JSONL。并发编辑不是语义合并;请协调多设备操作并查看诊断。
45
+ - 保留本地独有文件,不传播删除;同路径的不同内容以拉取数据为准,覆盖前备份;不得覆盖活跃会话。恢复备份只处理当前纳管范围。
46
+ - Git 操作关闭自动换行转换,避免 Windows 的 `core.autocrlf` 改写原始会话。SSH 使用系统 `ssh` 的非交互模式及连接超时,不复用自定义 `core.sshCommand`。
47
+ - 备份位于 agent 数据目录下的 `amox-pi-sync-backups`。Cleanup 仅在手动预览/确认后删除本地备份,按完整备份目录计数,不按单个文件计数。默认数量上限 20 份、最长 180 天;超龄或超出最新 20 份的备份纳入清理候选,不自动删除,不清理 sessions 或 missions。
48
+ - 新设置文件为 `amox-pi-sync.json`;不存在时可读取旧 `pi-sync-suite.json` 迁移。保存新设置前不改旧文件,保存时写入新文件。迁移使用独立的 `amox-pi-sync-repo` 目录,不复用旧快照。
49
+
50
+ 不包含原版的配置同步/settings 合并、Markdown/JSON 归档、Environment Tools 和 Local Packages。环境部署和机器专属配置交给 `amox-agent-bootstrap` 等部署流程,阅读版导出使用 Pi 原生 `/export`;同步器保留原始数据选择、备份恢复和手动备份清理。各项原因与后续维护边界见[功能取舍](MAINTENANCE.md#功能取舍与维护边界)。这是选择性跟进上游的独立 fork,不是调用原版运行时的包装,也不是原版全功能替代品。每日审查上游正式发布,仅移植保留范围内有价值的改动;舍弃功能跳过,不能判断时由 ntfy 请维护者决定,不自动恢复原版模块。
package/core.ts ADDED
@@ -0,0 +1,374 @@
1
+ /*
2
+ * Reduced derivative of pi-sync-suite (config, snapshot, backup, git and engine).
3
+ * MIT License — Copyright (c) 2026 Murat
4
+ *
5
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ * of this software and associated documentation files (the "Software"), to deal
7
+ * in the Software without restriction, including without limitation the rights
8
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ * copies of the Software, and to permit persons to whom the Software is
10
+ * furnished to do so, subject to the following conditions:
11
+ * The above copyright notice and this permission notice shall be included in all
12
+ * copies or substantial portions of the Software.
13
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ * SOFTWARE.
20
+ */
21
+ import * as fs from "node:fs/promises";
22
+ import * as path from "node:path";
23
+ import { homedir } from "node:os";
24
+ import { createHash, randomUUID } from "node:crypto";
25
+ import { realpathSync } from "node:fs";
26
+
27
+ export type Config = {
28
+ version: 1; repoUrl: string; repoDir: string; mode: "manual" | "auto" | "off";
29
+ paths: string[]; pullIntervalMinutes: number; watchIntervalMs: number; pushDebounceMs: number;
30
+ lastPushAt?: string; lastPullAt?: string; lastResult?: string;
31
+ retention?: { keepBackups: number; maxAgeDays: number };
32
+ };
33
+ export type Paths = { agentDir: string; configFile: string; backupDir: string };
34
+ export type GitExecutor = (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string; code: number }>;
35
+ export type Status = { busy?: string; error?: string; localChanges?: string; remoteChanges?: number; checkedAt?: string };
36
+ export type Backup = { id: string; createdAt: string; reason: string; includedPaths: string[] };
37
+ export type BackupSummary = Backup;
38
+ export type SyncResult = { changed: boolean; message: string; files: string[]; backupId?: string };
39
+
40
+ // Canonicalize the trusted agent root (including existing parents). macOS /var
41
+ // and /tmp are OS aliases; links inside managed data are still rejected below.
42
+ function canonicalRoot(input: string): string {
43
+ let parent = path.resolve(input);
44
+ const missing: string[] = [];
45
+ for (;;) {
46
+ try { return path.join(realpathSync.native(parent), ...missing.reverse()); }
47
+ catch (error) {
48
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
49
+ const next = path.dirname(parent);
50
+ if (next === parent) throw error;
51
+ missing.push(path.basename(parent));
52
+ parent = next;
53
+ }
54
+ }
55
+ }
56
+ export function defaultPaths(agentDir = process.env.PI_CODING_AGENT_DIR ?? path.join(homedir(), ".pi", "agent")): Paths {
57
+ agentDir = canonicalRoot(agentDir);
58
+ return { agentDir, configFile: path.join(agentDir, "amox-pi-sync.json"), backupDir: path.join(agentDir, "amox-pi-sync-backups") };
59
+ }
60
+ const forbidden = /^(?:auth(?:\..*)?|settings(?:\..*)?|credentials?(?:\..*)?|\.env(?:\..*)?|\.git(?:\..*)?|git|\.ssh|npm|bin|node_modules|packages|local-packages|pi-sync-suite\.json|pi-sync-environment(?:-ignore)?\.json|sync-suite-repo|sync-suite-backups|sync-suite-chat-exports|amox-pi-sync(?:\..*|-.*)?)$/i;
61
+ /** Agent-relative portable paths only; all components are checked, including nested files. */
62
+ export function validatePath(input: string): string {
63
+ if (typeof input !== "string" || !input || /[\x00-\x1f<>:"|?*]/.test(input) || /^[\\/]/.test(input)) throw new Error(`Unsafe relative path: ${input}`);
64
+ const clean = input.replaceAll("\\", "/");
65
+ if (clean.split("/").some(p => !p || p === "." || p === ".." || /[. ]$/.test(p) || forbidden.test(p) || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(p))) throw new Error(`Forbidden sync path: ${input}`);
66
+ return clean;
67
+ }
68
+ function inside(parent: string, child: string): boolean {
69
+ const rel = path.relative(path.resolve(parent), path.resolve(child));
70
+ return rel === "" || (!rel.startsWith(`..${path.sep}`) && rel !== ".." && !path.isAbsolute(rel));
71
+ }
72
+ function overlaps(a: string, b: string): boolean { return inside(a, b) || inside(b, a); }
73
+ function remote(url: string): void {
74
+ if (typeof url !== "string" || /[\s\x00-\x1f]/.test(url)) throw new Error("Use an SSH git URL: git@host:path or ssh://user@host/path");
75
+ if (/^[a-zA-Z0-9._-]+@[a-zA-Z0-9][a-zA-Z0-9.-]*:[a-zA-Z0-9_~][a-zA-Z0-9._~/-]*$/.test(url) && !url.split(":")[1]!.split("/").includes("..")) return;
76
+ try {
77
+ const u = new URL(url);
78
+ if (u.protocol === "ssh:" && u.hostname && !u.password && !u.search && !u.hash && /^\/[a-zA-Z0-9_~][a-zA-Z0-9._~/-]*$/.test(u.pathname) && !/%|\.\./.test(url) && /^[a-zA-Z0-9._-]*$/.test(u.username)) return;
79
+ } catch { /* reject below */ }
80
+ throw new Error("Use an SSH git URL: git@host:path or ssh://user@host/path");
81
+ }
82
+ function validate(config: Config, paths: Paths): void {
83
+ remote(config.repoUrl);
84
+ if (config.version !== 1 || !["manual", "auto", "off"].includes(config.mode)) throw new Error("Unsupported sync version or mode");
85
+ if (!path.isAbsolute(config.repoDir)) throw new Error("repoDir must be absolute");
86
+ if (!Array.isArray(config.paths)) throw new Error("paths must be an array");
87
+ const selected = config.paths.map(validatePath).map(p => path.resolve(paths.agentDir, p));
88
+ for (let i = 0; i < selected.length; i++) {
89
+ const p = selected[i]!;
90
+ if (selected.slice(i + 1).some(other => overlaps(p, other))) throw new Error("Managed paths must not overlap");
91
+ if ([config.repoDir, paths.backupDir, paths.configFile, `${paths.configFile}.lock`, path.join(paths.agentDir, "pi-sync-suite.json")].some(other => overlaps(p, other))) throw new Error("Managed paths overlap sync config, repository or backup directory");
92
+ }
93
+ if (overlaps(config.repoDir, paths.backupDir) || inside(config.repoDir, paths.agentDir) || inside(config.repoDir, paths.configFile) || inside(paths.backupDir, paths.agentDir)) throw new Error("Unsafe repository/backup directory nesting");
94
+ const retention = config.retention ?? { keepBackups: 20, maxAgeDays: 180 };
95
+ if (!Number.isInteger(retention.keepBackups) || retention.keepBackups < 0 || !Number.isFinite(retention.maxAgeDays) || retention.maxAgeDays <= 0) throw new Error("Invalid backup retention: keepBackups must be a nonnegative integer and maxAgeDays positive");
96
+ for (const key of ["pullIntervalMinutes", "watchIntervalMs", "pushDebounceMs"] as const) if (!Number.isFinite(config[key]) || config[key] <= 0) throw new Error(`${key} must be positive`);
97
+ }
98
+ export function createConfig(repoUrl: string, paths = defaultPaths()): Config {
99
+ const config: Config = { version: 1, repoUrl, repoDir: path.join(paths.agentDir, "amox-pi-sync-repo"), mode: "manual", paths: ["sessions", "missions"], retention: { keepBackups: 20, maxAgeDays: 180 }, pullIntervalMinutes: 1440, watchIntervalMs: 10000, pushDebounceMs: 2500 };
100
+ validate(config, paths); return config;
101
+ }
102
+ async function stat(file: string) { try { return await fs.lstat(file); } catch (e) { if ((e as NodeJS.ErrnoException).code === "ENOENT") return null; throw e; } }
103
+ async function noLinks(file: string): Promise<void> {
104
+ const full = path.resolve(file); const parent = path.dirname(full);
105
+ if (parent !== full) await noLinks(parent);
106
+ if ((await stat(full))?.isSymbolicLink()) throw new Error(`Refusing symlink: ${full}`);
107
+ }
108
+ export async function loadConfig(paths = defaultPaths()): Promise<Config | null> {
109
+ await noLinks(paths.configFile);
110
+ if (await stat(paths.configFile)) {
111
+ const config = JSON.parse(await fs.readFile(paths.configFile, "utf8")) as Config;
112
+ validate(config, paths); return config;
113
+ }
114
+ const oldFile = path.join(paths.agentDir, "pi-sync-suite.json");
115
+ await noLinks(oldFile);
116
+ if (!(await stat(oldFile))) return null;
117
+ const old = JSON.parse(await fs.readFile(oldFile, "utf8"));
118
+ if (old.version !== 1) throw new Error("Unsupported legacy config version");
119
+ const config = createConfig(old.repoUrl, paths);
120
+ config.mode = old.autoMode === "off" ? "off" : ["full-auto", "config-only-auto"].includes(old.autoMode) ? "auto" : "manual";
121
+ const allowed = Array.isArray(old.policy?.includedPaths) ? old.policy.includedPaths.filter((p: unknown) => p === "sessions" || p === "missions") : [];
122
+ if (allowed.length) config.paths = [...new Set<string>(allowed)];
123
+ for (const key of ["pullIntervalMinutes", "watchIntervalMs", "pushDebounceMs"] as const) if (old[key] !== undefined) config[key] = old[key];
124
+ validate(config, paths); return config;
125
+ }
126
+ export async function saveConfig(config: Config, paths = defaultPaths()): Promise<void> {
127
+ validate(config, paths); await noLinks(paths.configFile);
128
+ await fs.mkdir(path.dirname(paths.configFile), { recursive: true });
129
+ const temp = `${paths.configFile}.${randomUUID()}.tmp`;
130
+ try { await fs.writeFile(temp, JSON.stringify(config, null, 2) + "\n", { mode: 0o600, flag: "wx" }); await fs.rename(temp, paths.configFile); }
131
+ finally { await fs.rm(temp, { force: true }); }
132
+ }
133
+
134
+ // Raw sessions are opaque bytes and intentionally bypass content scanning. Use a
135
+ // trusted PRIVATE repository: sessions may contain credentials or private text.
136
+ const secret = /-----BEGIN (?:RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----|\bghp_[A-Za-z0-9_]{30,}\b|\bsk-[A-Za-z0-9_-]{20,}\b|\b(?:api[_-]?key|token|password|secret)\b\s*[:=]\s*["']?[^"'\s]{12,}/i;
137
+ const gitOptions = ["-c", "core.hooksPath=", "-c", "core.autocrlf=false", "-c", "credential.interactive=false", "-c", "core.sshCommand=ssh -oBatchMode=yes -oConnectTimeout=10"];
138
+ type Files = Map<string, Buffer>;
139
+ export class SyncService {
140
+ public status: Status = {};
141
+ public activeSessionFile?: string;
142
+ constructor(public config: Config, public paths: Paths, private exec: GitExecutor) { validate(config, paths); }
143
+ private async run<T>(name: string, work: () => Promise<T>): Promise<T> {
144
+ if (this.status.busy) throw new Error(`Sync busy: ${this.status.busy}; retry when it finishes`);
145
+ this.status.busy = name; this.status.error = undefined;
146
+ let lock: Awaited<ReturnType<typeof fs.open>> | undefined;
147
+ const lockFile = `${this.paths.configFile}.lock`;
148
+ try {
149
+ validate(this.config, this.paths);
150
+ await noLinks(lockFile); await fs.mkdir(path.dirname(lockFile), { recursive: true });
151
+ try { lock = await fs.open(lockFile, "wx", 0o600); }
152
+ catch (e) { if ((e as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Sync locked by another process: ${lockFile}. If its process exited, verify no sync is running before removing this lock.`); throw e; }
153
+ await lock.writeFile(JSON.stringify({ pid: process.pid, operation: name, createdAt: new Date().toISOString() }));
154
+ return await work();
155
+ } catch (e) { this.status.error = e instanceof Error ? e.message : String(e); throw e; }
156
+ finally { try { if (lock) { await lock.close(); await fs.unlink(lockFile); } } finally { this.status.busy = undefined; } }
157
+ }
158
+ private async git(args: string[], cwd = this.config.repoDir): Promise<string> {
159
+ const result = await this.exec([...gitOptions, ...args], cwd);
160
+ if (result.code !== 0) throw new Error(`git ${args[0]} failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`}. Resolve repository conflicts manually; no force push or JSONL merge is performed.`);
161
+ return result.stdout;
162
+ }
163
+ private async ensureRepo(): Promise<void> {
164
+ await noLinks(this.config.repoDir);
165
+ const gitDir = path.join(this.config.repoDir, ".git"); await noLinks(gitDir);
166
+ if (!(await stat(gitDir))) {
167
+ if (await stat(this.config.repoDir) && (await fs.readdir(this.config.repoDir)).length) throw new Error("Repository directory is nonempty but has no .git; choose an empty directory");
168
+ await fs.mkdir(path.dirname(this.config.repoDir), { recursive: true });
169
+ await this.git(["clone", "--", this.config.repoUrl, this.config.repoDir], path.dirname(this.config.repoDir));
170
+ }
171
+ const origin = (await this.git(["remote", "get-url", "origin"])).trim();
172
+ if (origin !== this.config.repoUrl) throw new Error("Repository origin differs from configured SSH URL; inspect it before syncing");
173
+ const pushUrls = (await this.git(["remote", "get-url", "--push", "--all", "origin"])).trim().split(/\r?\n/);
174
+ if (pushUrls.some(url => url !== this.config.repoUrl)) throw new Error("Repository push URL differs from configured SSH URL");
175
+ for (const file of (await this.git(["ls-files", "-z"])).split("\0").filter(Boolean)) validatePath(file);
176
+ }
177
+ private async collect(root: string, scan = false): Promise<Files> {
178
+ const files: Files = new Map();
179
+ const visit = async (relative: string): Promise<void> => {
180
+ validatePath(relative);
181
+ const absolute = path.join(root, relative); await noLinks(absolute);
182
+ const info = await stat(absolute); if (!info) return;
183
+ if (info.isDirectory()) { for (const child of (await fs.readdir(absolute)).sort()) await visit(`${relative}/${child}`); }
184
+ else if (info.isFile()) {
185
+ const data = await fs.readFile(absolute);
186
+ if (scan && relative.split("/")[0] !== "sessions" && secret.test(data.toString("utf8"))) throw new Error(`Secret-like content in ${relative}; remove credentials before syncing`);
187
+ files.set(relative, data);
188
+ } else throw new Error(`Not a regular file: ${relative}`);
189
+ };
190
+ for (const item of this.config.paths) await visit(validatePath(item));
191
+ return files;
192
+ }
193
+ private async write(root: string, files: Files): Promise<void> {
194
+ for (const [relative, data] of files) {
195
+ const file = path.join(root, validatePath(relative)); await noLinks(file);
196
+ await fs.mkdir(path.dirname(file), { recursive: true });
197
+ const tmp = `${file}.${randomUUID()}.tmp`;
198
+ try { await fs.writeFile(tmp, data, { flag: "wx", mode: 0o600 }); await fs.rename(tmp, file); }
199
+ finally { await fs.rm(tmp, { force: true }); }
200
+ }
201
+ }
202
+ private async makeBackup(reason: string): Promise<Backup> {
203
+ const files = await this.collect(this.paths.agentDir);
204
+ const id = `${new Date().toISOString().replaceAll(/[:.]/g, "-")}-${randomUUID()}`;
205
+ const root = path.join(this.paths.backupDir, id); await noLinks(root);
206
+ await fs.mkdir(path.join(root, "files"), { recursive: true });
207
+ await this.write(path.join(root, "files"), files);
208
+ const manifest: Backup = { id, createdAt: new Date().toISOString(), reason, includedPaths: [...this.config.paths] };
209
+ await fs.writeFile(path.join(root, "manifest.json"), JSON.stringify(manifest, null, 2), { flag: "wx", mode: 0o600 });
210
+ return manifest;
211
+ }
212
+ private async backups(): Promise<Backup[]> {
213
+ await noLinks(this.paths.backupDir);
214
+ if (!(await stat(this.paths.backupDir))) return [];
215
+ const result: Backup[] = [];
216
+ for (const entry of await fs.readdir(this.paths.backupDir, { withFileTypes: true })) {
217
+ if (entry.isSymbolicLink()) throw new Error(`Refusing backup symlink: ${entry.name}`);
218
+ if (!entry.isDirectory()) continue;
219
+ const file = path.join(this.paths.backupDir, entry.name, "manifest.json"); await noLinks(file);
220
+ if (!(await stat(file))) continue; // interrupted backup, not restorable
221
+ const manifest = JSON.parse(await fs.readFile(file, "utf8")) as Backup;
222
+ if (manifest.id !== entry.name || !Array.isArray(manifest.includedPaths) || !Number.isFinite(Date.parse(manifest.createdAt)) || typeof manifest.reason !== "string") throw new Error(`Invalid backup manifest: ${entry.name}`);
223
+ manifest.includedPaths.forEach(validatePath);
224
+ const filesDir = path.join(this.paths.backupDir, entry.name, "files"); await noLinks(filesDir);
225
+ if (!(await stat(filesDir))?.isDirectory()) throw new Error(`Incomplete backup: ${entry.name}`);
226
+ result.push(manifest);
227
+ }
228
+ return result.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
229
+ }
230
+ private async apply(files: Files, reason: string): Promise<SyncResult> {
231
+ const changes: Files = new Map(); const replaced: string[] = [];
232
+ for (const [relative, data] of files) {
233
+ const target = path.join(this.paths.agentDir, relative); await noLinks(target);
234
+ const info = await stat(target);
235
+ if (info && !info.isFile()) throw new Error(`File/directory conflict at ${relative}; move the local entry aside and retry`);
236
+ const local = info ? await fs.readFile(target) : undefined;
237
+ if (local?.equals(data)) continue;
238
+ const activePath = this.activeSessionFile && path.resolve(this.paths.agentDir, this.activeSessionFile);
239
+ let active = activePath;
240
+ if (activePath) {
241
+ try { active = await fs.realpath(activePath); }
242
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
243
+ }
244
+ const samePath = active && (process.platform === "win32" ? path.resolve(target).toLowerCase() === active.toLowerCase() : path.resolve(target) === active);
245
+ if (samePath) throw new Error(`Active session differs: ${relative}. Switch to a new session or close it before pull/restore; no files were applied.`);
246
+ changes.set(relative, data); if (local) replaced.push(relative);
247
+ }
248
+ if (!changes.size) return { changed: false, message: "Already up to date; no files applied or backup created", files: [] };
249
+ const backup = await this.makeBackup(reason);
250
+ await this.write(this.paths.agentDir, changes);
251
+ const message = `Applied ${changes.size} file(s); replaced ${replaced.length} differing local file(s) from remote/backup, preserved local-only files. Recovery backup: ${backup.id}${replaced.length ? `; replaced: ${replaced.join(", ")}` : ""}`;
252
+ this.config.lastResult = message;
253
+ return { changed: changes.size > 0, message, files: [...changes.keys()], backupId: backup.id };
254
+ }
255
+ async check(): Promise<Status> { return this.run("check", async () => {
256
+ this.status.checkedAt = undefined; this.status.remoteChanges = undefined;
257
+ await this.ensureRepo(); await this.git(["fetch", "origin"]);
258
+ const local = await this.collect(this.paths.agentDir, true);
259
+ const snapshot = await this.collect(this.config.repoDir, true);
260
+ let added = 0; let changed = 0; let remoteOnly = 0;
261
+ for (const [file, data] of local) {
262
+ const other = snapshot.get(file);
263
+ if (!other) added++;
264
+ else if (!data.equals(other)) changed++;
265
+ }
266
+ for (const file of snapshot.keys()) if (!local.has(file)) remoteOnly++;
267
+ const localChanges = added || changed || remoteOnly ? `added/local-only: ${added}; changed: ${changed}; remote-only: ${remoteOnly}` : "";
268
+ const upstream = await this.exec([...gitOptions, "rev-parse", "--verify", "@{u}"], this.config.repoDir);
269
+ const remoteChanges = upstream.code === 0 ? Number((await this.git(["rev-list", "--count", "HEAD..@{u}"])).trim()) : undefined;
270
+ this.status.localChanges = localChanges; this.status.remoteChanges = remoteChanges;
271
+ this.status.checkedAt = new Date().toISOString(); return { ...this.status, busy: undefined };
272
+ }); }
273
+ async push(): Promise<string> { return this.run("push", async () => {
274
+ await this.ensureRepo();
275
+ if ((await this.git(["diff", "--cached", "--name-only"])).trim()) throw new Error("Repository has staged changes; inspect/commit them manually before sync");
276
+ const files = await this.collect(this.paths.agentDir, true);
277
+ // Validate the existing snapshot too; never follow nested symlinks or stage secrets.
278
+ await this.collect(this.config.repoDir, true);
279
+ await this.write(this.config.repoDir, files);
280
+ let batch: string[] = []; let budget = 0;
281
+ for (const file of files.keys()) {
282
+ // Include quoting/argument overhead within Windows' command-line limit.
283
+ const cost = file.length * 2 + 3;
284
+ if (budget + cost > 6000 && batch.length) {
285
+ await this.git(["--literal-pathspecs", "add", "--", ...batch]); batch = []; budget = 0;
286
+ }
287
+ batch.push(file); budget += cost;
288
+ }
289
+ if (batch.length) await this.git(["--literal-pathspecs", "add", "--", ...batch]);
290
+ const staged = (await this.git(["diff", "--cached", "--name-only"])).trim();
291
+ if (staged) await this.git(["commit", "-m", `amox-pi-sync: ${new Date().toISOString()}`]);
292
+ // Always retry push, even when the previous attempt committed but failed offline.
293
+ await this.git(["push", "-u", "origin", "HEAD"]);
294
+ this.config.lastPushAt = new Date().toISOString();
295
+ this.config.lastResult = "Pushed managed snapshot (no force push; remote-only files preserved)";
296
+ await saveConfig(this.config, this.paths);
297
+ return this.config.lastResult;
298
+ }); }
299
+ async pull(): Promise<string> { return this.run("pull", async () => {
300
+ await this.ensureRepo();
301
+ if ((await this.git(["status", "--porcelain"])).trim()) throw new Error("Repository has uncommitted changes; resolve them before ff-only pull");
302
+ await this.git(["pull", "--ff-only", "origin"]);
303
+ const result = await this.apply(await this.collect(this.config.repoDir, true), "before applying remote snapshot");
304
+ this.config.lastPullAt = new Date().toISOString(); await saveConfig(this.config, this.paths); return result.message;
305
+ }); }
306
+ async backup(): Promise<Backup> { return this.run("backup", () => this.makeBackup("manual")); }
307
+ async listBackups(): Promise<Backup[]> { return this.run("listBackups", () => this.backups()); }
308
+ async restore(id = "latest"): Promise<string> { return this.run("restore", async () => {
309
+ const list = await this.backups(); const found = id === "latest" ? list[0] : list.find(b => b.id === id);
310
+ if (!found) throw new Error(`Backup not found: ${id}`);
311
+ // Never trust absolute source paths or a path list supplied by a manifest.
312
+ const result = await this.apply(await this.collect(path.join(this.paths.backupDir, found.id, "files")), `before restoring ${found.id}`);
313
+ await saveConfig(this.config, this.paths); return result.message;
314
+ }); }
315
+ /** Preview only. Nothing is removed automatically, including in auto mode. */
316
+ async planCleanup(): Promise<BackupSummary[]> { return this.run("planCleanup", async () => {
317
+ const retention = this.config.retention ?? { keepBackups: 20, maxAgeDays: 180 };
318
+ const cutoff = Date.now() - retention.maxAgeDays * 86400000;
319
+ return (await this.backups()).flatMap((backup, index) => {
320
+ const expired = Date.parse(backup.createdAt) < cutoff;
321
+ const excess = index >= retention.keepBackups;
322
+ return expired || excess ? [{ ...backup, reason: expired ? `older than ${retention.maxAgeDays} days` : `exceeds newest ${retention.keepBackups} backups` }] : [];
323
+ });
324
+ }); }
325
+ /** UI must preview and confirm first; IDs may explicitly select retained backups. */
326
+ async applyCleanup(ids: string[]): Promise<number> { return this.run("applyCleanup", async () => {
327
+ if (!Array.isArray(ids) || ids.some(id => typeof id !== "string" || !id || id === "." || id === ".." || /[\\/\\\\:\x00-\x1f]/.test(id))) throw new Error("Invalid backup ID for cleanup");
328
+ const known = new Set((await this.backups()).map(backup => backup.id));
329
+ const unique = [...new Set(ids)];
330
+ // Validate the complete selection before deleting anything.
331
+ for (const id of unique) {
332
+ if (!known.has(id)) throw new Error(`Unknown or incomplete backup: ${id}`);
333
+ const target = path.resolve(this.paths.backupDir, id);
334
+ if (path.dirname(target) !== path.resolve(this.paths.backupDir) || [this.paths.agentDir, this.config.repoDir, this.paths.configFile, ...this.config.paths.map(p => path.join(this.paths.agentDir, p))].some(p => inside(target, p))) throw new Error(`Unsafe backup deletion: ${id}`);
335
+ await noLinks(target);
336
+ }
337
+ for (const id of unique) await fs.rm(path.join(this.paths.backupDir, id), { recursive: true });
338
+ return unique.length;
339
+ }); }
340
+ /** Read-only polling: no operation lock or status changes, one file buffer at a time. */
341
+ async fingerprint(): Promise<string> {
342
+ validate(this.config, this.paths);
343
+ const hash = createHash("sha256");
344
+ const visit = async (relative: string): Promise<void> => {
345
+ validatePath(relative);
346
+ const absolute = path.join(this.paths.agentDir, relative); await noLinks(absolute);
347
+ const info = await stat(absolute); if (!info) return;
348
+ if (info.isDirectory()) {
349
+ for (const child of (await fs.readdir(absolute)).sort()) await visit(`${relative}/${child}`);
350
+ } else if (info.isFile()) {
351
+ const data = await fs.readFile(absolute);
352
+ hash.update(JSON.stringify([relative, data.length])).update(data);
353
+ } else throw new Error(`Not a regular file: ${relative}`);
354
+ };
355
+ for (const item of this.config.paths) await visit(validatePath(item));
356
+ return hash.digest("hex");
357
+ }
358
+ async log(): Promise<string> { return this.run("log", async () => { await this.ensureRepo(); return (await this.git(["log", "--oneline", "-8"])).trim(); }); }
359
+ async doctor(): Promise<{ name: string; ok: boolean; message: string }[]> { return this.run("doctor", async () => {
360
+ const checks: { name: string; ok: boolean; message: string }[] = [];
361
+ const check = async (name: string, work: () => Promise<string>) => { try { checks.push({ name, ok: true, message: await work() }); } catch (e) { checks.push({ name, ok: false, message: String(e) }); } };
362
+ await check("git", () => this.git(["--version"], this.paths.agentDir));
363
+ await check("paths", async () => { await this.collect(this.paths.agentDir); return this.config.paths.join(", "); });
364
+ await check("repository", async () => {
365
+ await noLinks(this.config.repoDir);
366
+ if (!(await stat(path.join(this.config.repoDir, ".git")))) throw new Error("Not cloned; check/push/pull will initialize it");
367
+ const url = (await this.git(["remote", "get-url", "origin"])).trim();
368
+ if (url !== this.config.repoUrl) throw new Error("origin differs from configured URL");
369
+ return await this.git(["status", "--short", "--branch"]);
370
+ });
371
+ await check("identity", async () => `${(await this.git(["var", "GIT_AUTHOR_IDENT"], await stat(this.config.repoDir) ? this.config.repoDir : this.paths.agentDir)).trim()}`);
372
+ return checks;
373
+ }); }
374
+ }
package/index.ts ADDED
@@ -0,0 +1,391 @@
1
+ // Derived from pi-sync-suite 0.1.1, Copyright (c) 2026 Murat (MIT).
2
+ // Upstream: https://github.com/MuratAitov/pi-sync
3
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { SyncService, createConfig, defaultPaths, loadConfig, saveConfig, validatePath, type Config } from "./core.ts";
5
+
6
+ type BubbleLine = string | { text: string; onSelect: () => void };
7
+ type BubbleContent = { title: string; body: BubbleLine[]; hint: string };
8
+ type BubbleHandle = { refresh(): void; close(): void; onClose?(callback: () => void): void };
9
+ type PillAPI = {
10
+ version: number;
11
+ register(key: string, action: (input: { ctx: ExtensionContext; pill?: unknown }) => void): () => void;
12
+ showBubble(ctx: ExtensionContext, source: () => BubbleContent, pill?: unknown): BubbleHandle | undefined;
13
+ };
14
+ const STATUS_KEY = "pi-sync";
15
+ const pillAPI = () => (globalThis as Record<symbol, unknown>)[Symbol.for("pi-starline.pill-api.v1")] as PillAPI | undefined;
16
+ const message = (error: unknown) => error instanceof Error ? error.message : String(error);
17
+ const displayTime = (value?: string) => value ? new Date(value).toLocaleString() : "never";
18
+
19
+ export default function amoxPiSync(pi: ExtensionAPI): void {
20
+ let ctx: ExtensionContext | undefined;
21
+ let config: Config | null = null;
22
+ let service: SyncService | undefined;
23
+ let paths = defaultPaths(process.env.PI_CODING_AGENT_DIR);
24
+ let busy: string | undefined;
25
+ let inFlight: Promise<void> | undefined;
26
+ let error: string | undefined;
27
+ let notice: string | undefined;
28
+ let bubble: BubbleHandle | undefined;
29
+ let unregister: (() => void) | undefined;
30
+ let pullTimer: ReturnType<typeof setInterval> | undefined;
31
+ let watchTimer: ReturnType<typeof setInterval> | undefined;
32
+ let debounce: ReturnType<typeof setTimeout> | undefined;
33
+ let fingerprint: string | undefined;
34
+ let watching = false;
35
+ let generation = 0;
36
+
37
+ const publish = () => {
38
+ ctx?.ui.setStatus(STATUS_KEY, `Sync: ${busy ?? (error ? "error" : config?.mode ?? "setup")}`);
39
+ bubble?.refresh();
40
+ };
41
+
42
+ function stopTimers(): void {
43
+ generation++;
44
+ if (pullTimer) clearInterval(pullTimer);
45
+ if (watchTimer) clearInterval(watchTimer);
46
+ if (debounce) clearTimeout(debounce);
47
+ pullTimer = watchTimer = debounce = undefined;
48
+ fingerprint = undefined;
49
+ }
50
+
51
+ function makeService(): void {
52
+ service = config ? new SyncService(config, paths, async (args, cwd) => {
53
+ const result = await pi.exec("git", args, { cwd, timeout: 60_000 });
54
+ return { stdout: result.stdout, stderr: result.stderr, code: result.code };
55
+ }) : undefined;
56
+ if (service) service.activeSessionFile = ctx?.sessionManager.getSessionFile();
57
+ }
58
+
59
+ async function run(label: string, action: (sync: SyncService) => Promise<unknown>, quiet = false): Promise<boolean> {
60
+ if (!service || !config || !ctx) {
61
+ if (!quiet) ctx?.ui.notify("Run /sync-setup <private-ssh-repository> first.", "warning");
62
+ return false;
63
+ }
64
+ if (busy) {
65
+ if (!quiet) ctx.ui.notify(`Sync is already ${busy}.`, "warning");
66
+ return false;
67
+ }
68
+ const active = service;
69
+ const context = ctx;
70
+ active.activeSessionFile = context.sessionManager.getSessionFile();
71
+ busy = label;
72
+ let finish!: () => void;
73
+ const settled = new Promise<void>(resolve => { finish = resolve; });
74
+ inFlight = settled;
75
+ error = undefined;
76
+ publish();
77
+ try {
78
+ const uploadedFingerprint = label === "uploading" ? await active.fingerprint() : undefined;
79
+ const result = await action(active);
80
+ notice = typeof result === "string" ? result : `${label} completed`;
81
+ // Mark only the pre-upload snapshot as observed. Changes made while Git
82
+ // runs must remain detectable by the next watcher pass.
83
+ if (uploadedFingerprint !== undefined) fingerprint = uploadedFingerprint;
84
+ if (!quiet) context.ui.notify(notice, "info");
85
+ return true;
86
+ } catch (failure) {
87
+ error = message(failure);
88
+ context.ui.notify(`amox-pi-sync ${label}: ${error}`, "error");
89
+ return false;
90
+ } finally {
91
+ busy = undefined;
92
+ publish();
93
+ finish();
94
+ if (inFlight === settled) inFlight = undefined;
95
+ }
96
+ }
97
+
98
+ async function startTimers(): Promise<void> {
99
+ stopTimers();
100
+ const token = generation;
101
+ if (!config || !service || config.mode !== "auto") return;
102
+ const active = service;
103
+ try {
104
+ fingerprint = await active.fingerprint();
105
+ } catch (failure) {
106
+ error = message(failure);
107
+ ctx?.ui.notify(`amox-pi-sync watcher: ${error}`, "error");
108
+ publish();
109
+ return;
110
+ }
111
+ if (token !== generation) return;
112
+ pullTimer = setInterval(() => {
113
+ if (!busy) void run("downloading", sync => sync.pull(), true);
114
+ }, config.pullIntervalMinutes * 60_000);
115
+ watchTimer = setInterval(() => {
116
+ if (busy || watching) return;
117
+ watching = true;
118
+ void active.fingerprint().then(next => {
119
+ if (token !== generation || next === fingerprint || busy) return;
120
+ if (debounce) clearTimeout(debounce);
121
+ debounce = setTimeout(() => {
122
+ if (token === generation && !busy) void run("uploading", sync => sync.push(), true);
123
+ }, config!.pushDebounceMs);
124
+ }).catch(failure => {
125
+ const text = message(failure);
126
+ if (text !== error) ctx?.ui.notify(`amox-pi-sync watcher: ${text}`, "error");
127
+ error = text;
128
+ publish();
129
+ }).finally(() => { watching = false; });
130
+ }, config.watchIntervalMs);
131
+ // Do not await network during session startup.
132
+ void run("downloading", sync => sync.pull(), true);
133
+ }
134
+
135
+ function detailLines(): string[] {
136
+ if (!config || !service) return ["Not configured", "Use /sync-setup <private-ssh-repository>", ...(error ? [`Error: ${error}`] : [])];
137
+ const status = service.status;
138
+ return [
139
+ `State: ${busy ?? (error ? "error" : `idle (${config.mode})`)}`,
140
+ `Objects: ${config.paths.join(", ") || "none"}`,
141
+ `Remote: ${config.repoUrl}`,
142
+ `Last upload: ${displayTime(config.lastPushAt)}`,
143
+ `Last pull: ${displayTime(config.lastPullAt)}`,
144
+ `Local: ${status.localChanges?.trim() || (status.checkedAt ? "no pending changes at last check" : "not checked")}`,
145
+ `Remote updates: ${status.remoteChanges === undefined ? "not checked" : status.remoteChanges}`,
146
+ `Checked: ${displayTime(status.checkedAt)}`,
147
+ ...(config.lastResult ? [`Last sync result: ${config.lastResult}`] : []),
148
+ ...(notice ? [`Last action: ${notice}`] : []),
149
+ ...(error ? [`Error: ${error}`] : []),
150
+ ];
151
+ }
152
+
153
+ function content(context: ExtensionContext): BubbleContent {
154
+ const body: BubbleLine[] = detailLines();
155
+ if (config && !busy) {
156
+ body.push(
157
+ { text: "Check status", onSelect: () => { void run("checking", sync => sync.check()); } },
158
+ { text: "Upload", onSelect: () => { void run("uploading", sync => sync.push()); } },
159
+ { text: "Pull", onSelect: () => { void run("downloading", sync => sync.pull()); } },
160
+ );
161
+ }
162
+ if (!busy) body.push({ text: "Settings", onSelect: () => settings(context).catch(report) });
163
+ return { title: "Amox Pi Sync", body, hint: "Last-check values are not a live guarantee · Esc to close" };
164
+ }
165
+
166
+ function showStatus(context: ExtensionContext): void {
167
+ const api = pillAPI();
168
+ if (api?.version === 1 && context.hasUI) {
169
+ // All command, pill and menu views share Starline's unanchored placement.
170
+ bubble = api.showBubble(context, () => content(context));
171
+ } else {
172
+ context.ui.notify(detailLines().join("\n"), error ? "warning" : "info");
173
+ }
174
+ }
175
+
176
+ async function select(context: ExtensionContext, title: string, options: string[], description?: string): Promise<string | undefined> {
177
+ const api = pillAPI();
178
+ if (api?.version !== 1 || !context.hasUI) return context.ui.select(title, options);
179
+ return new Promise(resolve => {
180
+ let selected = false;
181
+ const handle = api.showBubble(context, () => ({
182
+ title,
183
+ body: [
184
+ ...(description ? [description] : []),
185
+ ...options.map(text => ({ text, onSelect: () => { selected = true; resolve(text); } })),
186
+ ],
187
+ hint: "↑/↓ · Enter to select · Esc to close",
188
+ }));
189
+ bubble = handle;
190
+ if (handle?.onClose) {
191
+ // Starline closes the bubble before calling a target's onSelect.
192
+ // Defer cancellation until that synchronous callback has run.
193
+ handle.onClose(() => queueMicrotask(() => { if (!selected) resolve(undefined); }));
194
+ } else {
195
+ handle?.close();
196
+ void context.ui.select(title, options).then(resolve);
197
+ }
198
+ });
199
+ }
200
+
201
+ async function confirm(context: ExtensionContext, title: string, description: string): Promise<boolean> {
202
+ if (pillAPI()?.version !== 1 || !context.hasUI) return context.ui.confirm(title, description);
203
+ return await select(context, title, ["Cancel", "Confirm"], description) === "Confirm";
204
+ }
205
+
206
+ const report = (failure: unknown) => {
207
+ error = message(failure);
208
+ ctx?.ui.notify(`amox-pi-sync: ${error}`, "error");
209
+ publish();
210
+ };
211
+
212
+ async function persist(next: Config): Promise<void> {
213
+ if (busy) throw new Error(`Sync is already ${busy}; retry the settings change after it finishes.`);
214
+ await saveConfig(next, paths);
215
+ config = next;
216
+ makeService();
217
+ publish();
218
+ await startTimers();
219
+ }
220
+
221
+ async function settings(context: ExtensionContext): Promise<void> {
222
+ if (busy) { context.ui.notify(`Sync is already ${busy}.`, "warning"); return; }
223
+ for (;;) {
224
+ const choice = await select(context, "Amox Pi Sync", [
225
+ "View Status", `Sync Mode [${config?.mode ?? "not configured"}]`,
226
+ `Sync Paths [${config?.paths.length ?? 0}]`, "Backups", "Cleanup", "Diagnostics", "Back",
227
+ ]);
228
+ if (!choice || choice === "Back") return;
229
+ if (choice === "View Status") { showStatus(context); return; }
230
+ if (!config || !service) { context.ui.notify("Run /sync-setup first.", "warning"); continue; }
231
+ if (choice.startsWith("Sync Mode")) {
232
+ const selected = await select(context, "Sync mode", ["Manual", "Auto", "Off", "Back"]);
233
+ if (!selected || selected === "Back") continue;
234
+ const next = structuredClone(config);
235
+ if (selected === "Auto") {
236
+ const minutes = await context.ui.input("Pull interval in minutes", String(config.pullIntervalMinutes));
237
+ if (minutes === undefined) continue;
238
+ if (!/^\d+$/.test(minutes) || Number(minutes) < 1 || Number(minutes) > 35_790) {
239
+ context.ui.notify("Use a whole number from 1 to 35790 minutes.", "error"); continue;
240
+ }
241
+ next.pullIntervalMinutes = Number(minutes);
242
+ }
243
+ next.mode = selected.toLowerCase() as Config["mode"];
244
+ await persist(next);
245
+ } else if (choice.startsWith("Sync Paths")) {
246
+ const selected = await select(context, "Sync paths", [
247
+ ...config.paths.map(item => `Exclude ${item}`), "Include path", "Back",
248
+ ]);
249
+ if (!selected || selected === "Back") continue;
250
+ const next = structuredClone(config);
251
+ if (selected === "Include path") {
252
+ const entered = await context.ui.input("Relative path inside Pi agent directory", "missions");
253
+ if (!entered) continue;
254
+ const item = validatePath(entered);
255
+ if (!next.paths.includes(item)) next.paths.push(item);
256
+ } else next.paths = next.paths.filter(item => `Exclude ${item}` !== selected);
257
+ await persist(next);
258
+ } else if (choice === "Backups") {
259
+ const selected = await select(context, "Backups (local; no automatic cleanup)", ["Create", "List", "Restore", "Back"]);
260
+ if (selected === "Create") await run("backing up", async sync => {
261
+ const backup = await sync.backup(); return JSON.stringify(backup, null, 2);
262
+ });
263
+ if (selected === "List") await run("listing backups", async sync => JSON.stringify(await sync.listBackups(), null, 2));
264
+ if (selected === "Restore") {
265
+ const backups = await service.listBackups();
266
+ if (!backups.length) { context.ui.notify("No backups.", "info"); continue; }
267
+ const id = await context.ui.input("Backup ID", "latest");
268
+ if (!id) continue;
269
+ if (!await confirm(context, "Restore backup?", "Matching managed files can be overwritten. A pre-restore backup will be created. The active session cannot be replaced.")) continue;
270
+ await run("restoring", sync => sync.restore(id));
271
+ }
272
+ } else if (choice === "Cleanup") {
273
+ const selected = await select(context, "Cleanup (complete local backups only)", ["Preview", "Run", "Delete Selected Backup", "Retention", "Back"]);
274
+ if (selected === "Preview" || selected === "Run") {
275
+ let candidates: Awaited<ReturnType<SyncService["planCleanup"]>> = [];
276
+ const planned = await run("planning cleanup", async sync => {
277
+ candidates = await sync.planCleanup();
278
+ return candidates.length ? JSON.stringify(candidates, null, 2) : "No backup cleanup candidates.";
279
+ });
280
+ if (!planned || selected !== "Run" || !candidates.length) continue;
281
+ if (await confirm(context, "Delete complete backups?", `Delete ${candidates.length} previewed backup(s)? Sessions, missions and the sync repository will not be deleted.`)) {
282
+ await run("cleaning backups", async sync => `Deleted ${await sync.applyCleanup(candidates.map(item => item.id))} backup(s).`);
283
+ }
284
+ } else if (selected === "Delete Selected Backup") {
285
+ const backups = await service.listBackups();
286
+ if (!backups.length) { context.ui.notify("No backups.", "info"); continue; }
287
+ const id = await select(context, "Select complete backup to delete", [...backups.map(item => item.id), "Back"]);
288
+ if (!id || id === "Back") continue;
289
+ if (await confirm(context, "Delete backup?", `Permanently delete local backup ${id}?`)) {
290
+ await run("cleaning backups", async sync => `Deleted ${await sync.applyCleanup([id])} backup.`);
291
+ }
292
+ } else if (selected === "Retention") {
293
+ const keep = await context.ui.input("Keep newest complete backups (0 or more)", String(config.retention?.keepBackups ?? 20));
294
+ if (keep === undefined) continue;
295
+ const days = await context.ui.input("Maximum backup age in days (1 or more)", String(config.retention?.maxAgeDays ?? 180));
296
+ if (days === undefined) continue;
297
+ if (!/^\d+$/.test(keep) || !/^\d+$/.test(days) || !Number.isSafeInteger(Number(keep)) || !Number.isSafeInteger(Number(days)) || Number(days) < 1) {
298
+ context.ui.notify("Retention requires a nonnegative whole backup count and positive whole day count.", "error"); continue;
299
+ }
300
+ const next = structuredClone(config);
301
+ next.retention = { keepBackups: Number(keep), maxAgeDays: Number(days) };
302
+ await persist(next);
303
+ context.ui.notify("Retention saved. Cleanup remains manual; no files deleted.", "info");
304
+ }
305
+ } else if (choice === "Diagnostics") {
306
+ const selected = await select(context, "Diagnostics", ["Doctor", "Diff / Check", "Log", "Back"]);
307
+ if (selected === "Doctor") await run("diagnosing", async sync => JSON.stringify(await sync.doctor(), null, 2));
308
+ if (selected === "Diff / Check") await run("checking", async sync => { await sync.check(); return detailLines().join("\n"); });
309
+ if (selected === "Log") await run("reading log", sync => sync.log());
310
+ }
311
+ }
312
+ }
313
+
314
+ pi.on("session_start", async (_event, context) => {
315
+ stopTimers();
316
+ ctx = context;
317
+ paths = defaultPaths(process.env.PI_CODING_AGENT_DIR);
318
+ config = null;
319
+ service = undefined;
320
+ error = undefined;
321
+ try {
322
+ config = await loadConfig(paths);
323
+ makeService();
324
+ } catch (failure) { error = message(failure); context.ui.notify(`amox-pi-sync config: ${error}`, "error"); }
325
+ // Clear the old upstream widget without creating a replacement widget.
326
+ context.ui.setWidget(STATUS_KEY, undefined);
327
+ publish();
328
+ unregister?.();
329
+ const api = pillAPI();
330
+ unregister = api?.version === 1 ? api.register(STATUS_KEY, ({ ctx: current }) => showStatus(current)) : undefined;
331
+ await startTimers();
332
+ });
333
+
334
+ pi.on("session_shutdown", async () => {
335
+ stopTimers();
336
+ unregister?.();
337
+ unregister = undefined;
338
+ bubble?.close();
339
+ bubble = undefined;
340
+ await inFlight;
341
+ ctx?.ui.setStatus(STATUS_KEY, undefined);
342
+ ctx = undefined;
343
+ });
344
+
345
+ pi.registerCommand("sync-status", {
346
+ description: "Show sync state, managed paths, last results and actions",
347
+ handler: async (_args, context) => { showStatus(context); },
348
+ });
349
+ pi.registerCommand("sync-settings", {
350
+ description: "Manage sync mode, paths, backups and diagnostics",
351
+ handler: async (_args, context) => { await settings(context).catch(report); },
352
+ });
353
+ pi.registerCommand("sync-push", {
354
+ description: "Upload the managed raw data snapshot",
355
+ handler: async () => { await run("uploading", sync => sync.push()); },
356
+ });
357
+ pi.registerCommand("sync-pull", {
358
+ description: "Pull raw data, backing up files before replacement",
359
+ handler: async () => { await run("downloading", sync => sync.pull()); },
360
+ });
361
+ pi.registerCommand("sync-setup", {
362
+ description: "Configure a private SSH repository: /sync-setup <ssh-url> [pull-minutes]",
363
+ handler: async (args, context) => {
364
+ if (busy) { context.ui.notify(`Sync is already ${busy}.`, "warning"); return; }
365
+ try {
366
+ const [url, interval, ...extra] = args.trim().split(/\s+/);
367
+ if (!url || extra.length) throw new Error("Usage: /sync-setup <private-ssh-url> [pull-minutes]");
368
+ const next = createConfig(url, paths);
369
+ if (interval !== undefined) {
370
+ if (!/^\d+$/.test(interval) || Number(interval) < 1 || Number(interval) > 35_790) throw new Error("Pull interval must be 1–35790 whole minutes.");
371
+ next.pullIntervalMinutes = Number(interval);
372
+ }
373
+ if (config) {
374
+ if (!await confirm(context, "Replace sync repository configuration?", "Existing managed paths and mode will be retained; no data is transferred by setup.")) return;
375
+ next.paths = [...config.paths];
376
+ next.mode = config.mode;
377
+ }
378
+ if (busy) throw new Error(`Sync is already ${busy}; retry setup after it finishes.`);
379
+ stopTimers();
380
+ await saveConfig(next, paths);
381
+ config = next;
382
+ makeService();
383
+ error = undefined;
384
+ publish();
385
+ // Setup changes configuration only; automatic work starts on next reload/mode change.
386
+ stopTimers();
387
+ context.ui.notify("Configured. No transfer performed. Use /sync-status; reload to activate automatic mode if enabled.", "info");
388
+ } catch (failure) { report(failure); }
389
+ },
390
+ });
391
+ }
package/package.json CHANGED
@@ -1,6 +1,68 @@
1
1
  {
2
2
  "name": "@gfgghgftgffg/amox-pi-sync",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "description": "Independent Pi agent-data sync fork with SSH Git transport, backups and optional Starline status.",
6
+ "license": "MIT",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "author": "gfgghgftgffg",
11
+ "contributors": [
12
+ {
13
+ "name": "MuratAitov",
14
+ "url": "https://github.com/MuratAitov/pi-sync"
15
+ }
16
+ ],
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/gfgghgftgffg/amox_pi_extensions_lean.git",
20
+ "directory": "packages/amox-pi-sync"
21
+ },
22
+ "homepage": "https://github.com/gfgghgftgffg/amox_pi_extensions_lean/tree/main/packages/amox-pi-sync#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/gfgghgftgffg/amox_pi_extensions_lean/issues"
25
+ },
26
+ "keywords": [
27
+ "pi-package",
28
+ "pi-extension",
29
+ "sync",
30
+ "git"
31
+ ],
32
+ "files": [
33
+ "index.ts",
34
+ "core.ts",
35
+ "README.md",
36
+ "README.zh-CN.md",
37
+ "MAINTENANCE.md",
38
+ "LICENSE"
39
+ ],
40
+ "engines": {
41
+ "node": ">=22.19.0"
42
+ },
43
+ "scripts": {
44
+ "test": "node --test tests/*.test.mjs",
45
+ "typecheck": "tsc -p tsconfig.json",
46
+ "check": "npm run typecheck && npm test"
47
+ },
48
+ "dependencies": {},
49
+ "peerDependencies": {
50
+ "@earendil-works/pi-ai": ">=1.1.0",
51
+ "@earendil-works/pi-coding-agent": ">=1.1.0",
52
+ "@earendil-works/pi-tui": ">=1.1.0",
53
+ "typebox": "*"
54
+ },
55
+ "devDependencies": {
56
+ "@earendil-works/pi-ai": "1.1.0",
57
+ "@earendil-works/pi-coding-agent": "1.1.0",
58
+ "@earendil-works/pi-tui": "1.1.0",
59
+ "jiti": "2.7.0",
60
+ "typebox": "1.3.36",
61
+ "typescript": "7.0.2"
62
+ },
63
+ "pi": {
64
+ "extensions": [
65
+ "./index.ts"
66
+ ]
67
+ }
68
+ }