@gfgghgftgffg/amox-pi-sync 0.1.5 → 0.1.7

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/MAINTENANCE.md CHANGED
@@ -5,15 +5,15 @@
5
5
  - Upstream: [MuratAitov/pi-sync](https://github.com/MuratAitov/pi-sync), npm `pi-sync-suite@0.1.1`.
6
6
  - Source ref: `npm:pi-sync-suite@0.1.1` (published source baseline, not the repository's moving HEAD).
7
7
  - Upstream MIT notice is preserved verbatim in `LICENSE`: Copyright (c) 2026 Murat.
8
- - Local package: `@gfgghgftgffg/amox-pi-sync@0.1.5`, standalone TypeScript entrypoint `index.ts` and `core.ts`; no runtime dependency on the original suite.
8
+ - Local package: `@gfgghgftgffg/amox-pi-sync@0.1.7`, standalone TypeScript entrypoint `index.ts` and `core.ts`; no runtime dependency on the original suite.
9
9
 
10
10
  ## Scope
11
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.
12
+ Keep raw agent-data push/pull, safe relative include/exclude paths (sessions + missions by default), manual/background automatic sync, deletion propagation from a per-device `syncedPaths` baseline, orphan subagent pruning, 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
13
 
14
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. Presentation separates labels and values with a colon; the path count is explicitly marked configured. Starline action rows have a leading bullet, while native Pi selectors retain their own indicators. Presentation uses semantic Pi theme colours for labels, values and errors, and bold targets without fixed foreground/background colours so Starline owns selection contrast. Keep Manual/Auto/Off and Push/Pull terminology consistent across capsule, details, menus and command feedback; never append idle to the mode. Anchor bubbles to the clicked Starline cell, including submenus and confirmations; commands reuse the last clicked anchor or center before one is known. Forward pill colours by default and offer semantic theme colour overrides through bubbleColor; do not override selected-row text contrast. Colour changes must not start timers or network work. Without Starline use native Pi UI and hide the Starline colour option. Recompute theme styles when rendering, keep RPC/native status text plain, and do not store styled strings in settings. On Windows prefer native OpenSSH when present for non-ASCII home paths; retain batch mode, timeout and host-key verification. Keep large diagnostic results in the action notification rather than copying JSON dumps into status details.
15
15
 
16
- Safety requirements: private SSH remote, no working-directory mutation, no semantic JSONL merging, preserve local-only files, back up replacements and never overwrite active sessions. Network transfers use a 10-minute timeout and local Git commands 60 seconds; a terminated or timed-out process must never be recorded as success, even when an executor reports code 0. Pull fast-forwards or performs a clean file-level three-way merge; conflicts abort and apply nothing. A rejected non-fast-forward push fetches, integrates without rebase or force, and retries once; files integrated during push are applied on the next pull. Git commands force `core.autocrlf=false` and `core.eol=lf`, with a repository-local `* -text` attribute; do not rewrite raw bytes or existing mixed-EOL history automatically. 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.
16
+ Safety requirements: private SSH remote, no working-directory mutation, no semantic JSONL merging, preserve files that were never synced, back up replacements/deletions and never overwrite or delete active sessions. Deletion propagation must use `syncedPaths`: Push removes only previously synced paths missing locally, Pull removes only baseline paths absent from the new snapshot, and Pull preserves locally missing baseline paths as pending push deletions. A missing baseline is seeded at session start from files present both locally and in an existing snapshot; local-only and unpulled remote-only paths stay outside it. Pre-upgrade deletions are not inferred automatically. Prune orphan sessions scans `sessions/**/*.jsonl`, compares parent basenames case-insensitively, accepts `type#8hex` names, iterates parent candidates to a fixed point, skips files modified within 10 minutes and the active session, refuses dirty repositories, commits snapshot removals before deleting local files, and never deletes symlinks or non-files. Before-push pruning runs outside `run("Push")`, is Off by default, and never blocks a push on failure. Network transfers use a 10-minute timeout and local Git commands 60 seconds; a terminated or timed-out process must never be recorded as success, even when an executor reports code 0. Pull fast-forwards or performs a clean file-level three-way merge; conflicts abort and apply nothing. A rejected non-fast-forward push fetches, integrates without rebase or force, and retries once; files integrated during push are applied on the next pull. Git commands force `core.autocrlf=false` and `core.eol=lf`, with a repository-local `* -text` attribute; do not rewrite raw bytes or existing mixed-EOL history automatically. 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
17
 
18
18
  ## 功能取舍与维护边界
19
19
 
@@ -28,6 +28,7 @@ Safety requirements: private SSH remote, no working-directory mutation, no seman
28
28
  | sessions、missions | 保留 | sessions 保存对话与工具记录;missions 保存工作流任务及关联索引。两者共同保留工作记录,但不保证恢复运行中的任务或临时目录中的外部产物。 |
29
29
  | 通用路径选择 | 保留 | 允许用户按需要包含、排除安全的 agent 数据路径,不将范围写死为两个目录;敏感文件与实现目录仍受限制。 |
30
30
  | 手动、自动同步 | 保留 | 支持用户主动换机时推拉,也保留可选后台同步;默认手动,不因精简功能改变用户的同步时机。 |
31
+ | 删除传播、孤儿会话清理 | 新增 | 手动删除的会话需要从远端和快照中移除,否则下次 Pull 会恢复;用 `syncedPaths` 基线只传播本机曾同步过的删除。Prune 依据父子会话关系清理孤儿子代理,并同步删除快照副本。 |
31
32
  | 备份恢复 | 保留 | 同路径文件可能被远端内容替换,覆盖前的本地副本是恢复入口。 |
32
33
  | Cleanup | 保留并修正 | 即使没有聊天归档,备份仍会积累,需要界面内的手动管理。按完整恢复点预览和确认删除,修正上游保留数量逻辑;不清理 sessions、missions,也不自动删除。 |
33
34
  | 诊断 | 保留 | 用户需要查看待传输差异、Git 状态和错误,不能将失败或“未检查”显示为已同步。 |
package/README.md CHANGED
@@ -28,12 +28,13 @@ pi update --extensions
28
28
  | `/sync-setup git@host:owner/private-repo.git [interval]` | Configure an SSH repository and optional pull interval in minutes (no transfer) |
29
29
  | `/sync-push` | Push selected raw agent data |
30
30
  | `/sync-pull` | Pull selected raw agent data |
31
+ | `/sync-prune [--yes]` | Preview and delete orphan subagent sessions plus their snapshot copies |
31
32
  | `/sync-settings` | Open settings and maintenance actions |
32
33
  | `/sync-status` | View status and actions; also works without Starline |
33
34
 
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
+ The settings menu contains **View Status**, **Sync Mode** (`auto`, `manual`, `off`), **Sync Paths** (include/exclude safe relative agent-data paths), **Prune before push** (default Off), **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
 
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, push, 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. Pill clicks anchor bubbles to the corresponding Starline cell, like Model; settings, submenus and confirmations retain that anchor. `/sync-status` uses the last clicked anchor, or a centered fallback before the first click. Text entry still uses Pi input dialogs. **Bubble colour** in settings defaults to **Follow pill**; alternatively choose the current theme’s accent, success, warning or error colour for borders and selection. Before a pill click, Follow pill uses theme accent. The optional `bubbleColor` setting stores `pill`, `accent`, `success`, `warning` or `error`; changing it performs no transfer. Without Starline this option is hidden and Pi renders its native dialogs. Labels and values are separated by a colon (for example, `Paths: 2 configured`); Starline action rows start with `•`. Labels and values use Pi theme colours, while bold action rows keep Starline’s selection contrast. State shows Manual, Auto or Off directly; active operations use Push/Pull, with the configured mode shown separately. Without Starline, the native UI remains available and status output stays plain text.
37
+ 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, push, pull, prune orphan sessions 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. Pill clicks anchor bubbles to the corresponding Starline cell, like Model; settings, submenus and confirmations retain that anchor. `/sync-status` uses the last clicked anchor, or a centered fallback before the first click. Text entry still uses Pi input dialogs. **Bubble colour** in settings defaults to **Follow pill**; alternatively choose the current theme’s accent, success, warning or error colour for borders and selection. Before a pill click, Follow pill uses theme accent. The optional `bubbleColor` setting stores `pill`, `accent`, `success`, `warning` or `error`; changing it performs no transfer. Without Starline this option is hidden and Pi renders its native dialogs. Labels and values are separated by a colon (for example, `Paths: 2 configured`); Starline action rows start with `•`. Labels and values use Pi theme colours, while bold action rows keep Starline’s selection contrast. State shows Manual, Auto or Off directly; active operations use Push/Pull, with the configured mode shown separately. Without Starline, the native UI remains available and status output stays plain text.
37
38
 
38
39
  ## Data and safety
39
40
 
@@ -42,7 +43,8 @@ Manual/off startup performs no network access. Auto startup computes a local fin
42
43
 
43
44
  - 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
45
  - Raw files are transferred without Markdown/JSON export or semantic JSONL merging. Concurrent edits are not a semantic merge; an automatic three-way merge is file-level and aborts if both sides changed the same file. 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
+ - Deletions of previously synced files propagate: Push removes them from the snapshot, and Pull deletes local copies removed remotely, after backup. Files that were never synced are retained. A per-device `syncedPaths` baseline prevents unpulled remote files from being mistaken for local deletions. On first session start a missing baseline is seeded from files present both locally and in an existing snapshot; local-only and unpulled remote-only files stay outside it. Pre-upgrade deletions are not inferred automatically. Incoming content replaces differing same-path files after backup; active sessions must not be deleted or overwritten. Restore applies only the currently managed paths.
47
+ - **Prune orphan sessions** (action or `/sync-prune`) scans `sessions/**/*.jsonl` for subagent sessions whose parent is missing or itself pruned. It compares parent basenames case-insensitively, accepts names matching `type#8hex`, skips files modified in the last 10 minutes and the active session, shows count/size/first names, commits snapshot removals first, then deletes local files. Staged or uncommitted repository changes abort without deleting local files. `--yes` skips confirmation. **Prune before push** runs it before manual, command and automatic pushes; a prune failure warns but does not block the push.
46
48
  - Network Git operations (`clone`, `fetch`, `pull`, `push`) use a 10-minute timeout; local Git operations use 60 seconds. A process terminated by timeout or cancellation is a failure and is never recorded as completed, even if Pi reports exit code 0. Pull fast-forwards when possible; otherwise it performs a clean three-way merge in the snapshot repository. Conflicts abort the merge and apply no files. A rejected non-fast-forward push fetches, integrates remote history without rebase or force, and retries once; files integrated during push are applied on the next pull.
47
49
  - Git commands force `core.autocrlf=false` and `core.eol=lf`, and the snapshot repository uses a local `* -text` attribute so Git does not convert raw bytes. Existing history with mixed CRLF/LF is not rewritten automatically; normalizing it requires an explicit manual migration and repack. On Windows, SSH prefers native OpenSSH (`System32/OpenSSH/ssh.exe`) when available to support non-ASCII user directories; otherwise it uses `ssh`. Batch mode, connection timeout and host-key verification remain enabled, overriding a custom `core.sshCommand`.
48
50
  - 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.
package/README.zh-CN.md CHANGED
@@ -28,12 +28,13 @@ pi update --extensions
28
28
  | `/sync-setup git@host:owner/private-repo.git [interval]` | 配置 SSH 仓库及可选拉取间隔(分钟),不传输数据 |
29
29
  | `/sync-push` | Push 选中的原始 agent 数据 |
30
30
  | `/sync-pull` | Pull 选中的原始 agent 数据 |
31
+ | `/sync-prune [--yes]` | 预览并删除孤儿子代理会话及其快照副本 |
31
32
  | `/sync-settings` | 打开设置与维护操作 |
32
33
  | `/sync-status` | 查看状态和操作;未安装 Starline 时也可使用 |
33
34
 
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
+ 设置菜单保留 **View Status**、**Sync Mode**(`auto`、`manual`、`off`)、**Sync Paths**(包含/排除任意安全的 agent 数据相对路径)、**Prune before push**(默认 Off)、**Backups**(创建/列表/恢复)、**Diagnostics**(doctor/diff/log)和 **Cleanup**(Preview、Run、Delete Selected Backup、Retention)。默认路径为 `sessions` 和 `missions`。`manual` 和 `off` 模式仍可手动上传、拉取。
35
36
 
36
- manual/off 启动不访问网络。auto 启动先计算本地 fingerprint,再启动定时器并发起后台拉取,不等待网络完成。`/sync-setup` 只保存配置并停止定时器;已启用 auto 时需 reload 激活。安装 Starline 时,优先点击状态胶囊打开详情,执行 Check status、Push、Pull 或 Settings。状态 key 保留为 `pi-sync`,兼容原 Starline placement;pill bridge 优先于旧 `extensionStatuses.commands`,无需修改 Starline 配置。没有三行 widget。点击胶囊时,气泡像 Model 一样锚定对应的 Starline cell;设置、子菜单及确认框沿用该锚点。`/sync-status` 使用最近点击的锚点,首次点击前回退居中。文本输入仍使用 Pi 输入框。设置中的 **Bubble colour** 默认 **Follow pill**(跟随胶囊),也可选择当前主题的 accent、success、warning 或 error 作为边框和选中项颜色;首次点击前使用主题 accent。可选配置 `bubbleColor` 保存为 `pill`、`accent`、`success`、`warning` 或 `error`,修改配色不触发传输。未安装 Starline 时隐藏此选项,由 Pi 渲染原生弹框。标签与值统一用冒号分隔(如 `Paths: 2 configured`,表示配置了 2 个同步路径),Starline 中的可选操作统一以 `•` 开头。标签、数值和异常使用 Pi 主题色区分,可选操作使用粗体并保留 Starline 自带的选中对比度。State 直接显示 Manual、Auto 或 Off;执行中显示 Push/Pull,另列当前模式,不追加 idle。未安装 Starline 时回退原生界面,状态输出保留纯文本。
37
+ manual/off 启动不访问网络。auto 启动先计算本地 fingerprint,再启动定时器并发起后台拉取,不等待网络完成。`/sync-setup` 只保存配置并停止定时器;已启用 auto 时需 reload 激活。安装 Starline 时,优先点击状态胶囊打开详情,执行 Check status、Push、Pull、Prune orphan sessions 或 Settings。状态 key 保留为 `pi-sync`,兼容原 Starline placement;pill bridge 优先于旧 `extensionStatuses.commands`,无需修改 Starline 配置。没有三行 widget。点击胶囊时,气泡像 Model 一样锚定对应的 Starline cell;设置、子菜单及确认框沿用该锚点。`/sync-status` 使用最近点击的锚点,首次点击前回退居中。文本输入仍使用 Pi 输入框。设置中的 **Bubble colour** 默认 **Follow pill**(跟随胶囊),也可选择当前主题的 accent、success、warning 或 error 作为边框和选中项颜色;首次点击前使用主题 accent。可选配置 `bubbleColor` 保存为 `pill`、`accent`、`success`、`warning` 或 `error`,修改配色不触发传输。未安装 Starline 时隐藏此选项,由 Pi 渲染原生弹框。标签与值统一用冒号分隔(如 `Paths: 2 configured`,表示配置了 2 个同步路径),Starline 中的可选操作统一以 `•` 开头。标签、数值和异常使用 Pi 主题色区分,可选操作使用粗体并保留 Starline 自带的选中对比度。State 直接显示 Manual、Auto 或 Off;执行中显示 Push/Pull,另列当前模式,不追加 idle。未安装 Starline 时回退原生界面,状态输出保留纯文本。
37
38
 
38
39
  ## 数据与安全
39
40
 
@@ -42,7 +43,8 @@ manual/off 启动不访问网络。auto 启动先计算本地 fingerprint,再
42
43
 
43
44
  - 路径相对于 Pi agent 数据目录,不是当前项目。可包含/排除任意安全的数据相对路径;扩展不改写工作目录。
44
45
  - 传输原始文件,不生成 Markdown/JSON 归档,不做语义化 JSONL 合并。并发编辑不是语义合并;自动三方合并按文件进行,双方修改同一文件时会中止。请协调多设备操作并查看诊断。
45
- - 保留本地独有文件,不传播删除;同路径的不同内容以拉取数据为准,覆盖前备份;不得覆盖活跃会话。恢复备份只处理当前纳管范围。
46
+ - 曾经同步过的文件被手动删除后,删除会传播:Push 从快照移除,Pull 会删除远端已移除的本地副本(删除前备份)。从未同步过的本地文件保留。每台设备用 `syncedPaths` 基线记录已同步路径,避免把“远端有、本机尚未 Pull”的文件误判为本地删除。首次启动时若没有基线,会从“本地和现有快照同时存在”的文件中初始化;本地独有和尚未 Pull 的远端文件不会进入基线。升级前已经发生的删除不会自动推断。同路径的不同内容以拉取数据为准,覆盖前备份;不得删除或覆盖活跃会话。恢复备份只处理当前纳管范围。
47
+ - **Prune orphan sessions**(操作项或 `/sync-prune`)扫描 `sessions/**/*.jsonl`,找出父会话不存在或父会话本身也被清理的子代理会话。父文件按 basename 不区分大小写比对,名字匹配 `type#8hex`;最近 10 分钟内修改过的文件和当前活跃会话会跳过;预览显示条数、体积和前几条名称;先提交快照删除,再删本地文件。仓库有暂存或未提交修改时拒绝执行,不删本地文件。`--yes` 跳过确认。**Prune before push** 会在手动、命令和自动 Push 前执行;清理失败只警告,不阻断 Push。
46
48
  - 网络 Git 操作(`clone`、`fetch`、`pull`、`push`)超时为 10 分钟,本地 Git 操作为 60 秒。被超时或取消终止的进程一律视为失败,即使 Pi 返回 exit code 0,也不会写入完成时间。能快进时 Pull 直接快进,否则在快照仓库中执行干净的三方合并;冲突会中止合并且不应用任何文件。Push 被非快进拒绝时会 fetch、在不 rebase、不 force 的前提下整合远端历史,并重试一次;Push 整合的文件在下一次 Pull 时应用。
47
49
  - Git 操作强制 `core.autocrlf=false`、`core.eol=lf`,快照仓库使用本地 `* -text` 属性,避免 Git 转换原始字节。已有 CRLF/LF 混合历史不会被自动改写;统一换行需要显式的手动迁移和 repack。Windows 优先使用原生 OpenSSH(`System32/OpenSSH/ssh.exe`),支持中文用户目录;不存在时及其他平台使用 `ssh`。保留非交互模式、连接超时及主机密钥验证,不复用自定义 `core.sshCommand`。
48
50
  - 备份位于 agent 数据目录下的 `amox-pi-sync-backups`。Cleanup 仅在手动预览/确认后删除本地备份,按完整备份目录计数,不按单个文件计数。默认数量上限 20 份、最长 180 天;超龄或超出最新 20 份的备份纳入清理候选,不自动删除,不清理 sessions 或 missions。
package/core.ts CHANGED
@@ -22,11 +22,15 @@ import * as fs from "node:fs/promises";
22
22
  import * as path from "node:path";
23
23
  import { homedir } from "node:os";
24
24
  import { createHash, randomUUID } from "node:crypto";
25
- import { existsSync, realpathSync } from "node:fs";
25
+ import { createReadStream, existsSync, realpathSync } from "node:fs";
26
+ import { createInterface } from "node:readline";
26
27
 
27
28
  export type BubbleColor = "pill" | "accent" | "success" | "warning" | "error";
28
29
  export type Config = {
29
30
  bubbleColor?: BubbleColor;
31
+ pruneOrphanSessionsBeforePush?: boolean;
32
+ /** Managed paths present in the last successful push/pull; used to propagate deletions safely. */
33
+ syncedPaths?: string[];
30
34
  version: 1; repoUrl: string; repoDir: string; mode: "manual" | "auto" | "off";
31
35
  paths: string[]; pullIntervalMinutes: number; watchIntervalMs: number; pushDebounceMs: number;
32
36
  lastPushAt?: string; lastPullAt?: string; lastResult?: string;
@@ -38,6 +42,10 @@ export type Status = { busy?: string; error?: string; localChanges?: string; rem
38
42
  export type Backup = { id: string; createdAt: string; reason: string; includedPaths: string[] };
39
43
  export type BackupSummary = Backup;
40
44
  export type SyncResult = { changed: boolean; message: string; files: string[]; backupId?: string };
45
+ export type SessionEntry = { path: string; parentSession?: string; name?: string; bytes?: number; mtimeMs?: number };
46
+ export type PruneCandidate = { path: string; name: string; bytes: number };
47
+ export type PrunePlan = { candidates: PruneCandidate[]; skipped: number };
48
+ export type PruneResult = { removed: string[]; repoRemoved: number; skipped: number };
41
49
 
42
50
  // Canonicalize the trusted agent root (including existing parents). macOS /var
43
51
  // and /tmp are OS aliases; links inside managed data are still rejected below.
@@ -72,6 +80,66 @@ function inside(parent: string, child: string): boolean {
72
80
  return rel === "" || (!rel.startsWith(`..${path.sep}`) && rel !== ".." && !path.isAbsolute(rel));
73
81
  }
74
82
  function overlaps(a: string, b: string): boolean { return inside(a, b) || inside(b, a); }
83
+ const ORPHAN_NAME = /^.+#[0-9a-f]{8}$/;
84
+ const SESSION_RECENT_MS = 10 * 60_000;
85
+ function baseName(value: string): string {
86
+ const parts = value.replaceAll("\\", "/").split("/");
87
+ return (parts.at(-1) ?? "").toLowerCase();
88
+ }
89
+ /** Pure candidate selection: parent basename + subagent name, iterated to a fixed point. */
90
+ export function selectOrphanSessions(entries: SessionEntry[], options: { activePath?: string; now?: number; recentMs?: number } = {}): PrunePlan {
91
+ const now = options.now ?? Date.now();
92
+ const recentMs = options.recentMs ?? SESSION_RECENT_MS;
93
+ const active = options.activePath?.toLowerCase();
94
+ const byBase = new Map<string, SessionEntry[]>();
95
+ for (const entry of entries) {
96
+ const base = baseName(entry.path);
97
+ const list = byBase.get(base) ?? [];
98
+ list.push(entry);
99
+ byBase.set(base, list);
100
+ }
101
+ const isRecent = (entry: SessionEntry) => now - (entry.mtimeMs ?? 0) < recentMs;
102
+ const isActive = (entry: SessionEntry) => active !== undefined && entry.path.toLowerCase() === active;
103
+ const parentOf = (entry: SessionEntry) => {
104
+ if (typeof entry.parentSession !== "string" || !entry.parentSession) return { state: "missing" as const };
105
+ const list = byBase.get(baseName(entry.parentSession));
106
+ if (!list?.length) return { state: "missing" as const };
107
+ // Duplicate basenames are ambiguous; never treat either copy as an orphan.
108
+ if (list.length !== 1) return { state: "ambiguous" as const };
109
+ return { state: "found" as const, parent: list[0]! };
110
+ };
111
+ const compute = (protectRecent: boolean): Set<SessionEntry> => {
112
+ const candidates = new Set<SessionEntry>();
113
+ let changed = true;
114
+ while (changed) {
115
+ changed = false;
116
+ for (const entry of entries) {
117
+ if (candidates.has(entry) || isActive(entry)) continue;
118
+ if (protectRecent && isRecent(entry)) continue;
119
+ if (typeof entry.name !== "string" || !ORPHAN_NAME.test(entry.name)) continue;
120
+ const parent = parentOf(entry);
121
+ if (parent.state === "ambiguous") continue;
122
+ if (parent.state === "missing" || candidates.has(parent.parent)) {
123
+ candidates.add(entry);
124
+ changed = true;
125
+ }
126
+ }
127
+ }
128
+ return candidates;
129
+ };
130
+ const structural = compute(false);
131
+ const selected = compute(true);
132
+ return {
133
+ candidates: [...selected]
134
+ .map(entry => ({ path: entry.path, name: entry.name!, bytes: Math.max(0, entry.bytes ?? 0) }))
135
+ .sort((a, b) => a.path.localeCompare(b.path)),
136
+ skipped: [...structural].filter(entry => !selected.has(entry)).length,
137
+ };
138
+ }
139
+ /** A path is managed when it equals or is below one of the configured relative paths. */
140
+ function managedPath(relative: string, managed: string[]): boolean {
141
+ return managed.some(item => relative === item || relative.startsWith(`${item}/`));
142
+ }
75
143
  function remote(url: string): void {
76
144
  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");
77
145
  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;
@@ -94,12 +162,18 @@ function validate(config: Config, paths: Paths): void {
94
162
  }
95
163
  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");
96
164
  if (config.bubbleColor !== undefined && !["pill", "accent", "success", "warning", "error"].includes(config.bubbleColor)) throw new Error("Unsupported bubble colour");
165
+ if (config.pruneOrphanSessionsBeforePush !== undefined && typeof config.pruneOrphanSessionsBeforePush !== "boolean") throw new Error("pruneOrphanSessionsBeforePush must be boolean");
166
+ if (config.syncedPaths !== undefined) {
167
+ if (!Array.isArray(config.syncedPaths) || config.syncedPaths.some(item => typeof item !== "string")) throw new Error("syncedPaths must be a string array");
168
+ const paths = config.syncedPaths.map(validatePath);
169
+ if (new Set(paths).size !== paths.length) throw new Error("syncedPaths must not contain duplicates");
170
+ }
97
171
  const retention = config.retention ?? { keepBackups: 20, maxAgeDays: 180 };
98
172
  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");
99
173
  for (const key of ["pullIntervalMinutes", "watchIntervalMs", "pushDebounceMs"] as const) if (!Number.isFinite(config[key]) || config[key] <= 0) throw new Error(`${key} must be positive`);
100
174
  }
101
175
  export function createConfig(repoUrl: string, paths = defaultPaths()): Config {
102
- 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 };
176
+ const config: Config = { version: 1, repoUrl, repoDir: path.join(paths.agentDir, "amox-pi-sync-repo"), mode: "manual", paths: ["sessions", "missions"], pruneOrphanSessionsBeforePush: false, retention: { keepBackups: 20, maxAgeDays: 180 }, pullIntervalMinutes: 1440, watchIntervalMs: 10000, pushDebounceMs: 2500 };
103
177
  validate(config, paths); return config;
104
178
  }
105
179
  async function stat(file: string) { try { return await fs.lstat(file); } catch (e) { if ((e as NodeJS.ErrnoException).code === "ENOENT") return null; throw e; } }
@@ -210,6 +284,125 @@ export class SyncService {
210
284
  await this.ensureAttributes();
211
285
  for (const file of (await this.git(["ls-files", "-z"])).split("\0").filter(Boolean)) validatePath(file);
212
286
  }
287
+ private managedPaths(): string[] { return this.config.paths.map(validatePath); }
288
+ private activeSessionRelative(): string | undefined {
289
+ if (!this.activeSessionFile) return undefined;
290
+ const absolute = path.resolve(this.paths.agentDir, this.activeSessionFile);
291
+ let resolved = absolute;
292
+ try { resolved = realpathSync.native(absolute); }
293
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
294
+ const relative = path.relative(this.paths.agentDir, resolved);
295
+ if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return undefined;
296
+ return validatePath(relative.split(path.sep).join("/"));
297
+ }
298
+ private async localFileExists(relative: string): Promise<boolean> {
299
+ const target = path.join(this.paths.agentDir, validatePath(relative));
300
+ await noLinks(target);
301
+ return Boolean(await stat(target));
302
+ }
303
+ private async readSessionHeader(file: string): Promise<{ parentSession?: string } | undefined> {
304
+ let handle: Awaited<ReturnType<typeof fs.open>> | undefined;
305
+ try {
306
+ handle = await fs.open(file, "r");
307
+ const buffer = Buffer.alloc(64 * 1024);
308
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
309
+ const first = buffer.subarray(0, bytesRead).toString("utf8").split("\n", 1)[0] ?? "";
310
+ const header = JSON.parse(first);
311
+ return { parentSession: typeof header?.parentSession === "string" && header.parentSession ? header.parentSession : undefined };
312
+ } catch (error) {
313
+ if ((error as NodeJS.ErrnoException).code === "ENOENT" || error instanceof SyntaxError) return undefined;
314
+ throw error;
315
+ } finally { await handle?.close(); }
316
+ }
317
+ private async readLastSessionName(file: string): Promise<string | undefined> {
318
+ let name: string | undefined;
319
+ let stream: ReturnType<typeof createReadStream> | undefined;
320
+ try {
321
+ stream = createReadStream(file, { encoding: "utf8" });
322
+ const lines = createInterface({ input: stream, crlfDelay: Infinity });
323
+ try {
324
+ for await (const line of lines) {
325
+ if (!line.includes("session_info")) continue;
326
+ try {
327
+ const entry = JSON.parse(line);
328
+ if (entry?.type === "session_info" && typeof entry.name === "string") name = entry.name;
329
+ } catch { /* ignore malformed lines while a transcript is being written */ }
330
+ }
331
+ } finally { lines.close(); }
332
+ return name;
333
+ } catch (error) {
334
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
335
+ throw error;
336
+ } finally { stream?.destroy(); }
337
+ }
338
+ private async scanSessionEntries(): Promise<SessionEntry[]> {
339
+ if (!this.managedPaths().includes("sessions")) throw new Error("Prune requires \"sessions\" in managed paths");
340
+ const root = path.join(this.paths.agentDir, "sessions");
341
+ await noLinks(root);
342
+ const rootInfo = await stat(root);
343
+ if (!rootInfo) return [];
344
+ if (!rootInfo.isDirectory()) throw new Error("sessions is not a directory");
345
+ const entries: SessionEntry[] = [];
346
+ const visit = async (absolute: string, relative: string): Promise<void> => {
347
+ await noLinks(absolute);
348
+ const info = await stat(absolute);
349
+ if (!info) return;
350
+ if (info.isDirectory()) {
351
+ for (const child of (await fs.readdir(absolute)).sort()) await visit(path.join(absolute, child), `${relative}/${child}`);
352
+ return;
353
+ }
354
+ if (!info.isFile() || !relative.toLowerCase().endsWith(".jsonl")) return;
355
+ const header = await this.readSessionHeader(absolute);
356
+ if (!header) return;
357
+ const name = header.parentSession ? await this.readLastSessionName(absolute) : undefined;
358
+ entries.push({ path: validatePath(`sessions/${relative}`), parentSession: header.parentSession, name, bytes: info.size, mtimeMs: info.mtimeMs });
359
+ };
360
+ for (const child of (await fs.readdir(root)).sort()) await visit(path.join(root, child), child);
361
+ return entries;
362
+ }
363
+ private async stageDeletions(files: string[]): Promise<void> {
364
+ let batch: string[] = []; let budget = 0;
365
+ const flush = async () => {
366
+ if (!batch.length) return;
367
+ await this.git(["--literal-pathspecs", "add", "-A", "--", ...batch]);
368
+ batch = []; budget = 0;
369
+ };
370
+ for (const file of files) {
371
+ const cost = file.length * 2 + 3;
372
+ if (budget + cost > 6000 && batch.length) await flush();
373
+ batch.push(file); budget += cost;
374
+ }
375
+ await flush();
376
+ }
377
+ /** Paths currently present under the managed roots, without reading file bodies. */
378
+ private async listPaths(root: string): Promise<Set<string>> {
379
+ const result = new Set<string>();
380
+ const visit = async (relative: string): Promise<void> => {
381
+ validatePath(relative);
382
+ const absolute = path.join(root, relative); await noLinks(absolute);
383
+ const info = await stat(absolute); if (!info) return;
384
+ if (info.isDirectory()) { for (const child of (await fs.readdir(absolute)).sort()) await visit(`${relative}/${child}`); }
385
+ else if (info.isFile()) result.add(relative);
386
+ else throw new Error(`Not a regular file: ${relative}`);
387
+ };
388
+ for (const item of this.config.paths) await visit(validatePath(item));
389
+ return result;
390
+ }
391
+ /**
392
+ * Seed a missing baseline from files present both locally and in an existing
393
+ * snapshot. Local-only and unpulled remote-only files stay outside the
394
+ * baseline, so later deletions propagate without deleting unseen remote data.
395
+ */
396
+ async initializeBaseline(): Promise<boolean> {
397
+ if (this.config.syncedPaths !== undefined) return false;
398
+ const gitDir = path.join(this.config.repoDir, ".git"); await noLinks(gitDir);
399
+ if (!(await stat(gitDir))) return false;
400
+ const local = await this.listPaths(this.paths.agentDir);
401
+ const snapshot = await this.listPaths(this.config.repoDir);
402
+ this.config.syncedPaths = [...local].filter(item => snapshot.has(item)).sort();
403
+ await saveConfig(this.config, this.paths);
404
+ return true;
405
+ }
213
406
  private async collect(root: string, scan = false): Promise<Files> {
214
407
  const files: Files = new Map();
215
408
  const visit = async (relative: string): Promise<void> => {
@@ -263,31 +456,106 @@ export class SyncService {
263
456
  }
264
457
  return result.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
265
458
  }
266
- private async apply(files: Files, reason: string): Promise<SyncResult> {
267
- const changes: Files = new Map(); const replaced: string[] = [];
459
+ private async apply(files: Files, reason: string, deletions: string[] = [], preserve: Set<string> = new Set()): Promise<SyncResult> {
460
+ const changes: Files = new Map(); const replaced: string[] = []; const removed: string[] = [];
461
+ let active = this.activeSessionFile ? path.resolve(this.paths.agentDir, this.activeSessionFile) : undefined;
462
+ if (active) {
463
+ try { active = await fs.realpath(active); }
464
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
465
+ }
466
+ const sameActive = (target: string) => active && (process.platform === "win32"
467
+ ? path.resolve(target).toLowerCase() === active.toLowerCase() : path.resolve(target) === active);
268
468
  for (const [relative, data] of files) {
469
+ if (preserve.has(relative)) continue;
269
470
  const target = path.join(this.paths.agentDir, relative); await noLinks(target);
270
471
  const info = await stat(target);
271
472
  if (info && !info.isFile()) throw new Error(`File/directory conflict at ${relative}; move the local entry aside and retry`);
272
473
  const local = info ? await fs.readFile(target) : undefined;
273
474
  if (local?.equals(data)) continue;
274
- const activePath = this.activeSessionFile && path.resolve(this.paths.agentDir, this.activeSessionFile);
275
- let active = activePath;
276
- if (activePath) {
277
- try { active = await fs.realpath(activePath); }
278
- catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
279
- }
280
- const samePath = active && (process.platform === "win32" ? path.resolve(target).toLowerCase() === active.toLowerCase() : path.resolve(target) === active);
281
- if (samePath) throw new Error(`Active session differs: ${relative}. Switch to a new session or close it before pull/restore; no files were applied.`);
475
+ if (sameActive(target)) throw new Error(`Active session differs: ${relative}. Switch to a new session or close it before pull/restore; no files were applied.`);
282
476
  changes.set(relative, data); if (local) replaced.push(relative);
283
477
  }
284
- if (!changes.size) return { changed: false, message: "Already up to date; no files applied or backup created", files: [] };
478
+ for (const relative of deletions) {
479
+ validatePath(relative);
480
+ const target = path.join(this.paths.agentDir, relative); await noLinks(target);
481
+ const info = await stat(target);
482
+ if (!info) continue;
483
+ if (!info.isFile()) throw new Error(`File/directory conflict at ${relative}; move the local entry aside and retry`);
484
+ if (sameActive(target)) throw new Error(`Active session cannot be deleted: ${relative}. Switch to a new session before pull/restore; no files were applied.`);
485
+ removed.push(relative);
486
+ }
487
+ const preserved = preserve.size ? `; preserved ${preserve.size} local deletion(s) pending push` : "";
488
+ if (!changes.size && !removed.length) return { changed: false, message: `Already up to date; no files applied or backup created${preserved}`, files: [] };
285
489
  const backup = await this.makeBackup(reason);
286
490
  await this.write(this.paths.agentDir, changes);
287
- 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(", ")}` : ""}`;
491
+ for (const relative of removed) await fs.rm(path.join(this.paths.agentDir, relative), { force: true });
492
+ const deleted = removed.length ? `; deleted ${removed.length} remote-deleted file(s)` : "";
493
+ const message = `Applied ${changes.size} file(s)${deleted}; replaced ${replaced.length} differing local file(s) from remote/backup, preserved local-only files${preserved}. Recovery backup: ${backup.id}${replaced.length ? `; replaced: ${replaced.join(", ")}` : ""}`;
288
494
  this.config.lastResult = message;
289
- return { changed: changes.size > 0, message, files: [...changes.keys()], backupId: backup.id };
495
+ return { changed: changes.size > 0 || removed.length > 0, message, files: [...changes.keys()], backupId: backup.id };
290
496
  }
497
+ async planPruneSessions(): Promise<PrunePlan> {
498
+ return selectOrphanSessions(await this.scanSessionEntries(), { activePath: this.activeSessionRelative() });
499
+ }
500
+ async pruneOrphanSessions(expectedPaths?: string[]): Promise<PruneResult> { return this.run("prune", async () => {
501
+ const plan = await this.planPruneSessions();
502
+ const expected = expectedPaths === undefined ? undefined : new Set(expectedPaths.map(validatePath));
503
+ const candidates = expected ? plan.candidates.filter(candidate => expected.has(candidate.path)) : plan.candidates;
504
+ if (!candidates.length) return { removed: [], repoRemoved: 0, skipped: plan.skipped };
505
+ const active = this.activeSessionRelative()?.toLowerCase();
506
+ let skipped = plan.skipped;
507
+ const selected: typeof candidates = [];
508
+ for (const candidate of candidates) {
509
+ const relative = validatePath(candidate.path);
510
+ if (active && relative.toLowerCase() === active) { skipped++; continue; }
511
+ const file = path.join(this.paths.agentDir, relative);
512
+ await noLinks(file);
513
+ const info = await stat(file);
514
+ if (info && !info.isFile()) throw new Error(`Not a regular file: ${relative}`);
515
+ // Recheck recency before touching the snapshot; a live subagent may have
516
+ // written the file while the preview was open.
517
+ if (info && Date.now() - info.mtimeMs < SESSION_RECENT_MS) { skipped++; continue; }
518
+ selected.push(candidate);
519
+ }
520
+ if (!selected.length) return { removed: [], repoRemoved: 0, skipped };
521
+ await this.ensureRepo();
522
+ if ((await this.git(["diff", "--cached", "--name-only"])).trim()) throw new Error("Repository has staged changes; run Push or resolve them before pruning");
523
+ if ((await this.git(["status", "--porcelain"])).trim()) throw new Error("Repository has uncommitted changes; run Push or resolve them before pruning");
524
+ const repoRemoved: string[] = [];
525
+ for (const candidate of selected) {
526
+ const relative = validatePath(candidate.path);
527
+ const file = path.join(this.config.repoDir, relative);
528
+ await noLinks(file);
529
+ const info = await stat(file);
530
+ if (!info) continue;
531
+ if (!info.isFile()) throw new Error(`Not a regular file in snapshot: ${relative}`);
532
+ await fs.rm(file);
533
+ repoRemoved.push(relative);
534
+ }
535
+ if (repoRemoved.length) {
536
+ await this.stageDeletions(repoRemoved);
537
+ await this.git(["commit", "-m", `amox-pi-sync: prune ${repoRemoved.length} orphan subagent session(s)`]);
538
+ }
539
+ const removed: string[] = [];
540
+ for (const candidate of selected) {
541
+ const relative = validatePath(candidate.path);
542
+ if (active && relative.toLowerCase() === active) { skipped++; continue; }
543
+ const file = path.join(this.paths.agentDir, relative);
544
+ await noLinks(file);
545
+ const info = await stat(file);
546
+ if (!info) continue;
547
+ if (!info.isFile()) throw new Error(`Not a regular file: ${relative}`);
548
+ if (Date.now() - info.mtimeMs < SESSION_RECENT_MS) { skipped++; continue; }
549
+ await fs.rm(file);
550
+ removed.push(relative);
551
+ }
552
+ if (removed.length) {
553
+ const deleted = new Set(removed);
554
+ this.config.syncedPaths = this.config.syncedPaths?.filter(item => !deleted.has(item));
555
+ await saveConfig(this.config, this.paths);
556
+ }
557
+ return { removed, repoRemoved: repoRemoved.length, skipped };
558
+ }); }
291
559
  async check(): Promise<Status> { return this.run("check", async () => {
292
560
  this.status.checkedAt = undefined; this.status.remoteChanges = undefined;
293
561
  await this.ensureRepo(); await this.git(["fetch", "origin"], this.config.repoDir, NETWORK_TIMEOUT_MS);
@@ -336,7 +604,22 @@ export class SyncService {
336
604
  const files = await this.collect(this.paths.agentDir, true);
337
605
  // Validate the existing snapshot too; never follow nested symlinks or stage secrets.
338
606
  await this.collect(this.config.repoDir, true);
607
+ const managed = this.managedPaths();
608
+ // Only delete paths this device previously synced. Files that exist only in
609
+ // the remote snapshot (not yet pulled) are never inferred as local deletions.
610
+ const deletions = this.config.syncedPaths === undefined ? [] : this.config.syncedPaths
611
+ .filter(item => managedPath(item, managed) && !files.has(item));
339
612
  await this.write(this.config.repoDir, files);
613
+ const repoDeleted: string[] = [];
614
+ for (const relative of deletions) {
615
+ const file = path.join(this.config.repoDir, validatePath(relative));
616
+ await noLinks(file);
617
+ const info = await stat(file);
618
+ if (!info) continue;
619
+ if (!info.isFile()) throw new Error(`Not a regular file in snapshot: ${relative}`);
620
+ await fs.rm(file);
621
+ repoDeleted.push(relative);
622
+ }
340
623
  let batch: string[] = []; let budget = 0;
341
624
  for (const file of files.keys()) {
342
625
  // Include quoting/argument overhead within Windows' command-line limit.
@@ -347,6 +630,7 @@ export class SyncService {
347
630
  batch.push(file); budget += cost;
348
631
  }
349
632
  if (batch.length) await this.git(["--literal-pathspecs", "add", "--", ...batch]);
633
+ if (repoDeleted.length) await this.stageDeletions(repoDeleted);
350
634
  const staged = (await this.git(["diff", "--cached", "--name-only"])).trim();
351
635
  if (staged) await this.git(["commit", "-m", `amox-pi-sync: ${new Date().toISOString()}`]);
352
636
  // A rejected non-fast-forward push is recoverable without force. Any other
@@ -363,9 +647,11 @@ export class SyncService {
363
647
  await this.git(["push", "-u", "origin", "HEAD"], this.config.repoDir, NETWORK_TIMEOUT_MS);
364
648
  }
365
649
  this.config.lastPushAt = new Date().toISOString();
650
+ this.config.syncedPaths = [...files.keys()].sort();
651
+ const removed = repoDeleted.length ? `; removed ${repoDeleted.length} locally deleted file(s) from the snapshot` : "";
366
652
  this.config.lastResult = integrated
367
- ? "Integrated remote history and pushed managed snapshot; run Pull to apply remote files (no force push)"
368
- : "Pushed managed snapshot (no force push; remote-only files preserved)";
653
+ ? `Integrated remote history and pushed managed snapshot${removed}; run Pull to apply remote files (no force push)`
654
+ : `Pushed managed snapshot${removed} (no force push; unpulled remote files preserved)`;
369
655
  await saveConfig(this.config, this.paths);
370
656
  return this.config.lastResult;
371
657
  }); }
@@ -373,8 +659,21 @@ export class SyncService {
373
659
  await this.ensureRepo();
374
660
  if ((await this.git(["status", "--porcelain"])).trim()) throw new Error("Repository has uncommitted changes; resolve them before pulling");
375
661
  const result = await this.integrateRemote();
376
- const applied = await this.apply(await this.collect(this.config.repoDir, true), "before applying remote snapshot");
662
+ const snapshot = await this.collect(this.config.repoDir, true);
663
+ const snapshotPaths = new Set(snapshot.keys());
664
+ const managed = this.managedPaths();
665
+ const preserve = new Set<string>();
666
+ const deletions: string[] = [];
667
+ if (this.config.syncedPaths !== undefined) {
668
+ for (const relative of this.config.syncedPaths) {
669
+ if (!managedPath(relative, managed)) continue;
670
+ if (!snapshotPaths.has(relative)) deletions.push(relative);
671
+ else if (!(await this.localFileExists(relative))) preserve.add(relative);
672
+ }
673
+ }
674
+ const applied = await this.apply(snapshot, "before applying remote snapshot", deletions, preserve);
377
675
  this.config.lastPullAt = new Date().toISOString();
676
+ this.config.syncedPaths = [...snapshotPaths].sort();
378
677
  this.config.lastResult = `${result.merged ? "Merged diverged snapshots cleanly. " : ""}${applied.message}`;
379
678
  await saveConfig(this.config, this.paths); return this.config.lastResult;
380
679
  }); }
package/index.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // Upstream: https://github.com/MuratAitov/pi-sync
3
3
  import { stripVTControlCharacters } from "node:util";
4
4
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
- import { SyncService, createConfig, defaultPaths, loadConfig, saveConfig, validatePath, type Config } from "./core.ts";
5
+ import { SyncService, createConfig, defaultPaths, loadConfig, saveConfig, validatePath, type Config, type PruneResult } from "./core.ts";
6
6
 
7
7
  type BubbleLine = string | { text: string; onSelect: () => void; current?: boolean };
8
8
  type Tone = "text" | "muted" | "dim" | "accent" | "success" | "warning" | "error";
@@ -25,6 +25,9 @@ const message = (error: unknown) => terminalText(error instanceof Error ? error.
25
25
  const displayTime = (value?: string) => value ? new Date(value).toLocaleString() : "Never";
26
26
  const modeLabel = (mode: Config["mode"]) => ({ manual: "Manual", auto: "Auto", off: "Off" })[mode];
27
27
  const terminalText = (value: string) => stripVTControlCharacters(value).replace(/\r\n?/g, "\n").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
28
+ const formatBytes = (bytes: number) => bytes < 1024 ? `${bytes} B` : bytes < 1024 ** 2 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1024 ** 2).toFixed(1)} MB`;
29
+ const formatPruneResult = (result: PruneResult) =>
30
+ `Pruned ${result.removed.length} local and ${result.repoRemoved} snapshot session(s)${result.skipped ? `; skipped ${result.skipped} recent/active` : ""}. Snapshot removal is committed locally; Push to publish.`;
28
31
  const menuHint = "↑↓ Enter Esc";
29
32
 
30
33
  export default function amoxPiSync(pi: ExtensionAPI): void {
@@ -84,12 +87,13 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
84
87
  fingerprint = undefined;
85
88
  }
86
89
 
87
- function makeService(): void {
90
+ function makeService(): SyncService | undefined {
88
91
  service = config ? new SyncService(config, paths, async (args, cwd, timeoutMs) => {
89
92
  const result = await pi.exec("git", args, { cwd, timeout: timeoutMs });
90
93
  return { stdout: result.stdout, stderr: result.stderr, code: result.code, killed: result.killed };
91
94
  }) : undefined;
92
95
  if (service) service.activeSessionFile = ctx?.sessionManager.getSessionFile();
96
+ return service;
93
97
  }
94
98
 
95
99
  async function run(label: string, action: (sync: SyncService) => Promise<unknown>, quiet = false): Promise<boolean> {
@@ -132,6 +136,37 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
132
136
  }
133
137
  }
134
138
 
139
+ async function pruneSessions(context: ExtensionContext, assumeYes = false): Promise<void> {
140
+ if (busy) { context.ui.notify(`Sync is busy: ${busy}.`, "warning"); return; }
141
+ if (!service || !config) { context.ui.notify("Run /sync-setup first.", "warning"); return; }
142
+ let plan;
143
+ try { plan = await service.planPruneSessions(); }
144
+ catch (failure) { report(failure); return; }
145
+ const count = plan.candidates.length;
146
+ const bytes = plan.candidates.reduce((total, candidate) => total + candidate.bytes, 0);
147
+ const skipped = plan.skipped ? `\nSkipped: ${plan.skipped} recent/active session(s).` : "";
148
+ if (!count) { context.ui.notify(`No orphan subagent sessions to prune.${skipped}`, "info"); return; }
149
+ const preview = plan.candidates.slice(0, 5).map(candidate => `• ${candidate.name} (${formatBytes(candidate.bytes)})`).join("\n");
150
+ const more = count > 5 ? `\n… and ${count - 5} more` : "";
151
+ const description = `${count} orphan subagent session(s), ${formatBytes(bytes)} total.\n${preview}${more}${skipped}\n\nSnapshot copies are committed locally first, then local files are deleted. Push afterwards to publish the deletion. Parent lookup is local; Pull first if another device may have the parent.`;
152
+ if (assumeYes) context.ui.notify(`Pruning ${count} orphan session(s), ${formatBytes(bytes)}.${skipped}`, "info");
153
+ else if (!await confirm(context, "Prune orphan sessions?", description)) return;
154
+ await run("Prune", async sync => formatPruneResult(await sync.pruneOrphanSessions(plan.candidates.map(candidate => candidate.path))));
155
+ }
156
+
157
+ async function pushWithPrune(quiet = false): Promise<boolean> {
158
+ if (busy) return await run("Push", sync => sync.push(), quiet);
159
+ if (config?.pruneOrphanSessionsBeforePush && service && ctx) {
160
+ try {
161
+ const result = await service.pruneOrphanSessions();
162
+ if (!quiet && (result.removed.length || result.repoRemoved)) ctx.ui.notify(formatPruneResult(result), "info");
163
+ } catch (failure) {
164
+ ctx.ui.notify(`amox-pi-sync prune: ${message(failure)}`, "warning");
165
+ }
166
+ }
167
+ return await run("Push", sync => sync.push(), quiet);
168
+ }
169
+
135
170
  async function startTimers(): Promise<void> {
136
171
  stopTimers();
137
172
  const token = generation;
@@ -156,7 +191,7 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
156
191
  if (token !== generation || next === fingerprint || busy) return;
157
192
  if (debounce) clearTimeout(debounce);
158
193
  debounce = setTimeout(() => {
159
- if (token === generation && !busy) void run("Push", sync => sync.push(), true);
194
+ if (token === generation && !busy) void pushWithPrune(true);
160
195
  }, config!.pushDebounceMs);
161
196
  }).catch(failure => {
162
197
  const text = message(failure);
@@ -221,8 +256,9 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
221
256
  if (config && !busy) {
222
257
  body.push("", context.ui.theme.fg("muted", "Actions"),
223
258
  { text: actionText(context, "Check status"), onSelect: () => { void run("Check", sync => sync.check()); } },
224
- { text: actionText(context, "Push"), onSelect: () => { void run("Push", sync => sync.push()); } },
259
+ { text: actionText(context, "Push"), onSelect: () => { void pushWithPrune(); } },
225
260
  { text: actionText(context, "Pull"), onSelect: () => { void run("Pull", sync => sync.pull()); } },
261
+ { text: actionText(context, "Prune orphan sessions"), onSelect: () => { void pruneSessions(context); } },
226
262
  );
227
263
  } else if (!busy) body.push("");
228
264
  if (!busy) body.push({ text: actionText(context, "Settings"), onSelect: () => settings(context).catch(report) });
@@ -299,13 +335,17 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
299
335
  { value: "status", label: "Status" },
300
336
  { value: "mode", label: `Mode: ${config ? modeLabel(config.mode) : "Not configured"}` },
301
337
  { value: "paths", label: `Paths: ${config?.paths.length ?? 0} configured` },
338
+ { value: "prune", label: `Prune before push: ${config?.pruneOrphanSessionsBeforePush ? "On" : "Off"}` },
302
339
  ...(bubbleAPI(context)?.version === 1 ? [{ value: "color", label: `Bubble colour: ${colorChoices.find(choice => choice.value === (config?.bubbleColor ?? "pill"))!.label}` }] : []),
303
340
  "Backups", "Cleanup", "Diagnostics", "Back",
304
341
  ], { rows: overviewRows });
305
342
  if (!choice || choice === "Back") return;
306
343
  if (choice === "status") { showStatus(context); return; }
307
344
  if (!config || !service) { context.ui.notify("Run /sync-setup first.", "warning"); continue; }
308
- if (choice === "color") {
345
+ if (choice === "prune") {
346
+ const next = { ...config, pruneOrphanSessionsBeforePush: !config.pruneOrphanSessionsBeforePush };
347
+ await persist(next);
348
+ } else if (choice === "color") {
309
349
  const selected = await select(context, "Sync bubble colour", [...colorChoices, "Back"], { current: config.bubbleColor ?? "pill" });
310
350
  if (!selected || selected === "Back") continue;
311
351
  const next = { ...config, bubbleColor: selected as NonNullable<Config["bubbleColor"]> };
@@ -406,7 +446,8 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
406
446
  error = undefined;
407
447
  try {
408
448
  config = await loadConfig(paths);
409
- makeService();
449
+ const active = makeService();
450
+ if (active) await active.initializeBaseline();
410
451
  } catch (failure) { error = message(failure); context.ui.notify(`amox-pi-sync config: ${error}`, "error"); }
411
452
  // Clear the old upstream widget without creating a replacement widget.
412
453
  context.ui.setWidget(STATUS_KEY, undefined);
@@ -442,7 +483,15 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
442
483
  });
443
484
  pi.registerCommand("sync-push", {
444
485
  description: "Push the managed raw data snapshot",
445
- handler: async () => { await run("Push", sync => sync.push()); },
486
+ handler: async () => { await pushWithPrune(); },
487
+ });
488
+ pi.registerCommand("sync-prune", {
489
+ description: "Preview and delete orphan subagent sessions and their snapshot copies",
490
+ handler: async (args, context) => {
491
+ const flag = args.trim();
492
+ if (flag && flag !== "--yes") { context.ui.notify("Usage: /sync-prune [--yes]", "warning"); return; }
493
+ await pruneSessions(context, flag === "--yes");
494
+ },
446
495
  });
447
496
  pi.registerCommand("sync-pull", {
448
497
  description: "Pull raw data, backing up files before replacement",
@@ -465,6 +514,7 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
465
514
  next.paths = [...config.paths];
466
515
  next.mode = config.mode;
467
516
  next.bubbleColor = config.bubbleColor;
517
+ next.pruneOrphanSessionsBeforePush = config.pruneOrphanSessionsBeforePush;
468
518
  }
469
519
  if (busy) throw new Error(`Sync is busy: ${busy}; retry setup after it finishes.`);
470
520
  stopTimers();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gfgghgftgffg/amox-pi-sync",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "description": "Independent Pi agent-data sync fork with SSH Git transport, backups and optional Starline status.",
6
6
  "license": "MIT",