@gfgghgftgffg/amox-pi-sync 0.1.0 → 0.1.2

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,13 +5,13 @@
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.0`, standalone TypeScript entrypoint `index.ts` and `core.ts`; no runtime dependency on the original suite.
8
+ - Local package: `@gfgghgftgffg/amox-pi-sync@0.1.2`, standalone TypeScript entrypoint `index.ts` and `core.ts`; no runtime dependency on the original suite.
9
9
 
10
10
  ## Scope
11
11
 
12
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
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.
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 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. Recompute styles when rendering, keep RPC/native status text plain, and do not store styled strings in settings. Keep large diagnostic results in the action notification rather than copying JSON dumps into status details.
15
15
 
16
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
17
 
@@ -33,11 +33,19 @@ Safety requirements: private SSH remote, no working-directory mutation, no JSONL
33
33
  | 诊断 | 保留 | 用户需要查看待传输差异、Git 状态和错误,不能将失败或“未检查”显示为已同步。 |
34
34
  | 常驻三行 widget | 替换 | 将状态、操作与设置统一到 Starline 气泡,避免常驻重复信息;未安装 Starline 时保留原生命令界面。 |
35
35
 
36
- 精简目标是职责清晰和减少维护范围,未进行启动性能对比。后续移植上游改动时保持上述边界;新增功能应有明确需求,不随上游更新自动恢复已移除模块。
36
+ 精简目标是职责清晰和减少维护范围,未进行启动性能对比。每日监控上游正式发布,保持上述边界定向移植;新增功能应有明确需求,不随上游更新自动恢复已移除模块。
37
37
 
38
38
  ## Repository integration
39
39
 
40
- `maintenance/manifest.json` marks this package `maintenanceMode: manual-fork`. Automated npm/Git upstream discovery, source collection, adapter writes, version staging and context-footprint measurements skip it. **Never replace it with the full upstream suite or reintroduce removed features during automatic maintenance.** Upstream changes require manual source review and scoped porting.
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.
41
49
 
42
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.
43
51
 
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [简体中文](README.zh-CN.md)
4
4
 
5
- Standalone Pi agent-data sync over a private SSH Git repository. Package: `@gfgghgftgffg/amox-pi-sync@0.1.0`. 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).
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
6
 
7
7
  ## Install
8
8
 
@@ -14,7 +14,7 @@ From this repository checkout:
14
14
  node scripts/install-extension.mjs amox-pi-sync
15
15
  ```
16
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. This new package has not been published to npm yet. After its first publication, npm installation and updates will use:
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
18
 
19
19
  ```sh
20
20
  pi install npm:@gfgghgftgffg/amox-pi-sync
@@ -26,14 +26,14 @@ pi update --extensions
26
26
  | Command | Purpose |
27
27
  | --- | --- |
28
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 |
29
+ | `/sync-push` | Push selected raw agent data |
30
30
  | `/sync-pull` | Pull selected raw agent data |
31
31
  | `/sync-settings` | Open settings and maintenance actions |
32
32
  | `/sync-status` | View status and actions; also works without Starline |
33
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
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.
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. `/sync-status`, pill clicks, settings, submenus and confirmations share centered Starline bubbles above the footer; text entry still uses Pi input dialogs. 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
37
 
38
38
  ## Data and safety
39
39
 
@@ -47,4 +47,4 @@ Manual/off startup performs no network access. Auto startup computes a local fin
47
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
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
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 maintained local fork, not a runtime wrapper or a full replacement of the upstream suite.
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.
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md)
4
4
 
5
- 通过私有 SSH Git 仓库同步 Pi agent 数据的独立扩展。包名:`@gfgghgftgffg/amox-pi-sync@0.1.0`。基于 [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)。
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
6
 
7
7
  ## 安装
8
8
 
@@ -14,7 +14,7 @@
14
14
  node scripts/install-extension.mjs amox-pi-sync
15
15
  ```
16
16
 
17
- 只安装本扩展。不要同时加载原版 `pi-sync-suite`;通过 `pi list` 检查已有来源,自行移除旧来源。这个新包尚未发布到 npm,当前使用上述本地安装方式。首次发布后可使用:
17
+ 只安装本扩展。不要同时加载原版 `pi-sync-suite`;通过 `pi list` 检查已有来源,自行移除旧来源。若首次 npm 发布尚未完成,使用上述本地安装方式。npm 包可用后可使用:
18
18
 
19
19
  ```sh
20
20
  pi install npm:@gfgghgftgffg/amox-pi-sync
@@ -26,14 +26,14 @@ pi update --extensions
26
26
  | 命令 | 用途 |
27
27
  | --- | --- |
28
28
  | `/sync-setup git@host:owner/private-repo.git [interval]` | 配置 SSH 仓库及可选拉取间隔(分钟),不传输数据 |
29
- | `/sync-push` | 上传选中的原始 agent 数据 |
30
- | `/sync-pull` | 拉取选中的原始 agent 数据 |
29
+ | `/sync-push` | Push 选中的原始 agent 数据 |
30
+ | `/sync-pull` | Pull 选中的原始 agent 数据 |
31
31
  | `/sync-settings` | 打开设置与维护操作 |
32
32
  | `/sync-status` | 查看状态和操作;未安装 Starline 时也可使用 |
33
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
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 时回退原生界面。
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。`/sync-status`、点击胶囊、设置和子菜单统一使用底栏上方居中的 Starline 气泡,确认操作也使用相同样式;文本输入仍使用 Pi 输入框。标签、数值和异常使用 Pi 主题色区分,可选操作使用粗体并保留 Starline 自带的选中对比度。State 直接显示 Manual、Auto 或 Off;执行中显示 Push/Pull,另列当前模式,不追加 idle。未安装 Starline 时回退原生界面,状态输出保留纯文本。
37
37
 
38
38
  ## 数据与安全
39
39
 
@@ -47,4 +47,4 @@ manual/off 启动不访问网络。auto 启动先计算本地 fingerprint,再
47
47
  - 备份位于 agent 数据目录下的 `amox-pi-sync-backups`。Cleanup 仅在手动预览/确认后删除本地备份,按完整备份目录计数,不按单个文件计数。默认数量上限 20 份、最长 180 天;超龄或超出最新 20 份的备份纳入清理候选,不自动删除,不清理 sessions 或 missions。
48
48
  - 新设置文件为 `amox-pi-sync.json`;不存在时可读取旧 `pi-sync-suite.json` 迁移。保存新设置前不改旧文件,保存时写入新文件。迁移使用独立的 `amox-pi-sync-repo` 目录,不复用旧快照。
49
49
 
50
- 不包含原版的配置同步/settings 合并、Markdown/JSON 归档、Environment Tools 和 Local Packages。环境部署和机器专属配置交给 `amox-agent-bootstrap` 等部署流程,阅读版导出使用 Pi 原生 `/export`;同步器保留原始数据选择、备份恢复和手动备份清理。各项原因与后续维护边界见[功能取舍](MAINTENANCE.md#功能取舍与维护边界)。这是本地维护的独立 fork,不是调用原版运行时的包装,也不是原版全功能替代品。
50
+ 不包含原版的配置同步/settings 合并、Markdown/JSON 归档、Environment Tools 和 Local Packages。环境部署和机器专属配置交给 `amox-agent-bootstrap` 等部署流程,阅读版导出使用 Pi 原生 `/export`;同步器保留原始数据选择、备份恢复和手动备份清理。各项原因与后续维护边界见[功能取舍](MAINTENANCE.md#功能取舍与维护边界)。这是选择性跟进上游的独立 fork,不是调用原版运行时的包装,也不是原版全功能替代品。每日审查上游正式发布,仅移植保留范围内有价值的改动;舍弃功能跳过,不能判断时由 ntfy 请维护者决定,不自动恢复原版模块。
package/index.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  // Derived from pi-sync-suite 0.1.1, Copyright (c) 2026 Murat (MIT).
2
2
  // Upstream: https://github.com/MuratAitov/pi-sync
3
+ import { stripVTControlCharacters } from "node:util";
3
4
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
5
  import { SyncService, createConfig, defaultPaths, loadConfig, saveConfig, validatePath, type Config } from "./core.ts";
5
6
 
6
- type BubbleLine = string | { text: string; onSelect: () => void };
7
+ type BubbleLine = string | { text: string; onSelect: () => void; current?: boolean };
8
+ type Tone = "text" | "muted" | "dim" | "accent" | "success" | "warning" | "error";
9
+ type DetailRow = { label: string; value: string; tone?: Tone };
10
+ type Choice = string | { value: string; label: string };
11
+ type SelectDetails = { description?: string; rows?: () => DetailRow[]; current?: string };
7
12
  type BubbleContent = { title: string; body: BubbleLine[]; hint: string };
8
13
  type BubbleHandle = { refresh(): void; close(): void; onClose?(callback: () => void): void };
9
14
  type PillAPI = {
@@ -13,8 +18,12 @@ type PillAPI = {
13
18
  };
14
19
  const STATUS_KEY = "pi-sync";
15
20
  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";
21
+ const bubbleAPI = (context: ExtensionContext) => context.mode === "rpc" ? undefined : pillAPI();
22
+ const message = (error: unknown) => terminalText(error instanceof Error ? error.message : String(error));
23
+ const displayTime = (value?: string) => value ? new Date(value).toLocaleString() : "Never";
24
+ const modeLabel = (mode: Config["mode"]) => ({ manual: "Manual", auto: "Auto", off: "Off" })[mode];
25
+ const terminalText = (value: string) => stripVTControlCharacters(value).replace(/\r\n?/g, "\n").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
26
+ const menuHint = "↑↓ Enter Esc";
18
27
 
19
28
  export default function amoxPiSync(pi: ExtensionAPI): void {
20
29
  let ctx: ExtensionContext | undefined;
@@ -34,8 +43,10 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
34
43
  let watching = false;
35
44
  let generation = 0;
36
45
 
46
+ const stateLabel = () => busy ?? (error ? "Error" : config ? modeLabel(config.mode) : "Not configured");
47
+ const stateTone = (): Tone => error && !busy ? "error" : busy ? "accent" : config?.mode === "off" ? "muted" : config ? "accent" : "warning";
37
48
  const publish = () => {
38
- ctx?.ui.setStatus(STATUS_KEY, `Sync: ${busy ?? (error ? "error" : config?.mode ?? "setup")}`);
49
+ ctx?.ui.setStatus(STATUS_KEY, `Sync: ${stateLabel()}`);
39
50
  bubble?.refresh();
40
51
  };
41
52
 
@@ -62,7 +73,7 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
62
73
  return false;
63
74
  }
64
75
  if (busy) {
65
- if (!quiet) ctx.ui.notify(`Sync is already ${busy}.`, "warning");
76
+ if (!quiet) ctx.ui.notify(`Sync is busy: ${busy}.`, "warning");
66
77
  return false;
67
78
  }
68
79
  const active = service;
@@ -75,16 +86,17 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
75
86
  error = undefined;
76
87
  publish();
77
88
  try {
78
- const uploadedFingerprint = label === "uploading" ? await active.fingerprint() : undefined;
89
+ const pushedFingerprint = label === "Push" ? await active.fingerprint() : undefined;
79
90
  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
91
+ notice = `${label} completed`;
92
+ // Mark only the pre-push snapshot as observed. Changes made while Git
82
93
  // runs must remain detectable by the next watcher pass.
83
- if (uploadedFingerprint !== undefined) fingerprint = uploadedFingerprint;
84
- if (!quiet) context.ui.notify(notice, "info");
94
+ if (pushedFingerprint !== undefined) fingerprint = pushedFingerprint;
95
+ if (!quiet) context.ui.notify(typeof result === "string" ? terminalText(result) : notice, "info");
85
96
  return true;
86
97
  } catch (failure) {
87
98
  error = message(failure);
99
+ notice = `${label} failed`;
88
100
  context.ui.notify(`amox-pi-sync ${label}: ${error}`, "error");
89
101
  return false;
90
102
  } finally {
@@ -110,7 +122,7 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
110
122
  }
111
123
  if (token !== generation) return;
112
124
  pullTimer = setInterval(() => {
113
- if (!busy) void run("downloading", sync => sync.pull(), true);
125
+ if (!busy) void run("Pull", sync => sync.pull(), true);
114
126
  }, config.pullIntervalMinutes * 60_000);
115
127
  watchTimer = setInterval(() => {
116
128
  if (busy || watching) return;
@@ -119,7 +131,7 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
119
131
  if (token !== generation || next === fingerprint || busy) return;
120
132
  if (debounce) clearTimeout(debounce);
121
133
  debounce = setTimeout(() => {
122
- if (token === generation && !busy) void run("uploading", sync => sync.push(), true);
134
+ if (token === generation && !busy) void run("Push", sync => sync.push(), true);
123
135
  }, config!.pushDebounceMs);
124
136
  }).catch(failure => {
125
137
  const text = message(failure);
@@ -129,42 +141,72 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
129
141
  }).finally(() => { watching = false; });
130
142
  }, config.watchIntervalMs);
131
143
  // Do not await network during session startup.
132
- void run("downloading", sync => sync.pull(), true);
144
+ void run("Pull", sync => sync.pull(), true);
133
145
  }
134
146
 
135
- function detailLines(): string[] {
136
- if (!config || !service) return ["Not configured", "Use /sync-setup <private-ssh-repository>", ...(error ? [`Error: ${error}`] : [])];
147
+ function overviewRows(): DetailRow[] {
148
+ if (!config || !service) return [
149
+ { label: "State", value: stateLabel(), tone: stateTone() },
150
+ { label: "Setup", value: "/sync-setup <private-ssh-repository>", tone: "accent" },
151
+ ...(error ? [{ label: "Error", value: error, tone: "error" as const }] : []),
152
+ ];
153
+ return [
154
+ { label: "State", value: stateLabel(), tone: stateTone() },
155
+ ...(busy || error ? [{ label: "Mode", value: modeLabel(config.mode), tone: "muted" as const }] : []),
156
+ { label: "Paths", value: config.paths.join(", ") || "None" },
157
+ { label: "Remote", value: config.repoUrl },
158
+ ];
159
+ }
160
+
161
+ function detailRows(): DetailRow[] {
162
+ const rows = overviewRows();
163
+ if (!config || !service) return rows;
137
164
  const status = service.status;
165
+ const local = status.localChanges?.trim();
166
+ const remoteChecked = Boolean(status.checkedAt) && status.remoteChanges !== undefined;
138
167
  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}`] : []),
168
+ ...rows,
169
+ { label: "Last push", value: displayTime(config.lastPushAt), tone: config.lastPushAt ? "text" : "dim" },
170
+ { label: "Last pull", value: displayTime(config.lastPullAt), tone: config.lastPullAt ? "text" : "dim" },
171
+ { label: "Last check", value: displayTime(status.checkedAt), tone: status.checkedAt ? "text" : "dim" },
172
+ { label: "Local changes", value: local || (status.checkedAt ? "None at last check" : "Not checked"), tone: local ? "warning" : status.checkedAt ? "success" : "dim" },
173
+ { label: "Remote updates", value: remoteChecked ? String(status.remoteChanges) : "Not checked", tone: remoteChecked ? status.remoteChanges ? "warning" : "success" : "dim" },
174
+ ...(config.lastResult ? [{ label: "Last sync", value: config.lastResult }] : []),
175
+ ...(notice ? [{ label: "Last action", value: notice }] : []),
176
+ ...(error ? [{ label: "Error", value: error, tone: "error" as const }] : []),
150
177
  ];
151
178
  }
152
179
 
180
+ function detailLines(): string[] {
181
+ return detailRows().map(row => `${row.label}: ${terminalText(row.value)}`);
182
+ }
183
+
184
+ function renderRows(context: ExtensionContext, rows: DetailRow[]): string[] {
185
+ const theme = context.ui.theme;
186
+ return rows.map(row => theme.fg("muted", `${row.label.padEnd(14)} `)
187
+ + theme.fg(row.tone ?? "text", row.label === "State" ? theme.bold(terminalText(row.value)) : terminalText(row.value)));
188
+ }
189
+
190
+ // Targets inherit Starline's foreground so a selected row remains legible on
191
+ // its pill-coloured background. Bold adds hierarchy without overriding it.
192
+ const actionText = (context: ExtensionContext, text: string) => context.ui.theme.bold(terminalText(text));
193
+
153
194
  function content(context: ExtensionContext): BubbleContent {
154
- const body: BubbleLine[] = detailLines();
195
+ const body: BubbleLine[] = renderRows(context, detailRows());
155
196
  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()); } },
197
+ body.push("", context.ui.theme.fg("muted", "Actions"),
198
+ { text: actionText(context, "Check status"), onSelect: () => { void run("Check", sync => sync.check()); } },
199
+ { text: actionText(context, "Push"), onSelect: () => { void run("Push", sync => sync.push()); } },
200
+ { text: actionText(context, "Pull"), onSelect: () => { void run("Pull", sync => sync.pull()); } },
160
201
  );
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" };
202
+ } else if (!busy) body.push("");
203
+ if (!busy) body.push({ text: actionText(context, "Settings"), onSelect: () => settings(context).catch(report) });
204
+ body.push("", context.ui.theme.fg("dim", "Values reflect the last check."));
205
+ return { title: "Amox Pi Sync", body, hint: menuHint };
164
206
  }
165
207
 
166
208
  function showStatus(context: ExtensionContext): void {
167
- const api = pillAPI();
209
+ const api = bubbleAPI(context);
168
210
  if (api?.version === 1 && context.hasUI) {
169
211
  // All command, pill and menu views share Starline's unanchored placement.
170
212
  bubble = api.showBubble(context, () => content(context));
@@ -173,18 +215,26 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
173
215
  }
174
216
  }
175
217
 
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);
218
+ async function select(context: ExtensionContext, title: string, options: Choice[], details: SelectDetails = {}): Promise<string | undefined> {
219
+ const choices = options.map(option => typeof option === "string" ? { value: option, label: option } : option);
220
+ const nativeSelect = async () => {
221
+ const heading = details.description ? `${title}\n${terminalText(details.description)}` : title;
222
+ const label = await context.ui.select(heading, choices.map(choice => terminalText(choice.label)));
223
+ return choices.find(choice => terminalText(choice.label) === label)?.value;
224
+ };
225
+ const api = bubbleAPI(context);
226
+ if (api?.version !== 1 || !context.hasUI) return nativeSelect();
179
227
  return new Promise(resolve => {
180
228
  let selected = false;
181
229
  const handle = api.showBubble(context, () => ({
182
230
  title,
183
231
  body: [
184
- ...(description ? [description] : []),
185
- ...options.map(text => ({ text, onSelect: () => { selected = true; resolve(text); } })),
232
+ ...(details.rows ? [...renderRows(context, details.rows()), ""] : []),
233
+ ...(details.description ? [context.ui.theme.fg("muted", terminalText(details.description)), ""] : []),
234
+ ...choices.map(choice => ({ text: actionText(context, choice.label), current: choice.value === details.current,
235
+ onSelect: () => { selected = true; resolve(choice.value); } })),
186
236
  ],
187
- hint: "↑/↓ · Enter to select · Esc to close",
237
+ hint: menuHint,
188
238
  }));
189
239
  bubble = handle;
190
240
  if (handle?.onClose) {
@@ -193,14 +243,14 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
193
243
  handle.onClose(() => queueMicrotask(() => { if (!selected) resolve(undefined); }));
194
244
  } else {
195
245
  handle?.close();
196
- void context.ui.select(title, options).then(resolve);
246
+ void nativeSelect().then(resolve);
197
247
  }
198
248
  });
199
249
  }
200
250
 
201
251
  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";
252
+ if (bubbleAPI(context)?.version !== 1 || !context.hasUI) return context.ui.confirm(title, description);
253
+ return await select(context, title, ["Cancel", "Confirm"], { description, current: "Cancel" }) === "Confirm";
204
254
  }
205
255
 
206
256
  const report = (failure: unknown) => {
@@ -210,7 +260,7 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
210
260
  };
211
261
 
212
262
  async function persist(next: Config): Promise<void> {
213
- if (busy) throw new Error(`Sync is already ${busy}; retry the settings change after it finishes.`);
263
+ if (busy) throw new Error(`Sync is busy: ${busy}; retry the settings change after it finishes.`);
214
264
  await saveConfig(next, paths);
215
265
  config = next;
216
266
  makeService();
@@ -219,17 +269,21 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
219
269
  }
220
270
 
221
271
  async function settings(context: ExtensionContext): Promise<void> {
222
- if (busy) { context.ui.notify(`Sync is already ${busy}.`, "warning"); return; }
272
+ if (busy) { context.ui.notify(`Sync is busy: ${busy}.`, "warning"); return; }
223
273
  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
- ]);
274
+ const choice = await select(context, "Sync settings", [
275
+ { value: "status", label: "Status" },
276
+ { value: "mode", label: `Mode ${config ? modeLabel(config.mode) : "Not configured"}` },
277
+ { value: "paths", label: `Paths ${config?.paths.length ?? 0}` },
278
+ "Backups", "Cleanup", "Diagnostics", "Back",
279
+ ], { rows: overviewRows });
228
280
  if (!choice || choice === "Back") return;
229
- if (choice === "View Status") { showStatus(context); return; }
281
+ if (choice === "status") { showStatus(context); return; }
230
282
  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"]);
283
+ if (choice === "mode") {
284
+ const selected = await select(context, "Sync mode", ["Manual", "Auto", "Off", "Back"], {
285
+ current: modeLabel(config.mode), description: "Auto runs in the background. Manual and Off still allow Push and Pull.",
286
+ });
233
287
  if (!selected || selected === "Back") continue;
234
288
  const next = structuredClone(config);
235
289
  if (selected === "Auto") {
@@ -242,7 +296,7 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
242
296
  }
243
297
  next.mode = selected.toLowerCase() as Config["mode"];
244
298
  await persist(next);
245
- } else if (choice.startsWith("Sync Paths")) {
299
+ } else if (choice === "paths") {
246
300
  const selected = await select(context, "Sync paths", [
247
301
  ...config.paths.map(item => `Exclude ${item}`), "Include path", "Back",
248
302
  ]);
@@ -256,38 +310,38 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
256
310
  } else next.paths = next.paths.filter(item => `Exclude ${item}` !== selected);
257
311
  await persist(next);
258
312
  } 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 => {
313
+ const selected = await select(context, "Backups", ["Create", "List", "Restore", "Back"], { description: "Local backups. Cleanup is manual; nothing is deleted automatically." });
314
+ if (selected === "Create") await run("Create backup", async sync => {
261
315
  const backup = await sync.backup(); return JSON.stringify(backup, null, 2);
262
316
  });
263
- if (selected === "List") await run("listing backups", async sync => JSON.stringify(await sync.listBackups(), null, 2));
317
+ if (selected === "List") await run("List backups", async sync => JSON.stringify(await sync.listBackups(), null, 2));
264
318
  if (selected === "Restore") {
265
319
  const backups = await service.listBackups();
266
320
  if (!backups.length) { context.ui.notify("No backups.", "info"); continue; }
267
321
  const id = await context.ui.input("Backup ID", "latest");
268
322
  if (!id) continue;
269
323
  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));
324
+ await run("Restore backup", sync => sync.restore(id));
271
325
  }
272
326
  } 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") {
327
+ const selected = await select(context, "Backup cleanup", ["Preview", "Delete eligible backups", "Delete backup", "Retention", "Back"], { description: "Only complete local backups can be deleted. Sessions and missions are never cleanup targets." });
328
+ if (selected === "Preview" || selected === "Delete eligible backups") {
275
329
  let candidates: Awaited<ReturnType<SyncService["planCleanup"]>> = [];
276
- const planned = await run("planning cleanup", async sync => {
330
+ const planned = await run("Preview cleanup", async sync => {
277
331
  candidates = await sync.planCleanup();
278
332
  return candidates.length ? JSON.stringify(candidates, null, 2) : "No backup cleanup candidates.";
279
333
  });
280
- if (!planned || selected !== "Run" || !candidates.length) continue;
334
+ if (!planned || selected !== "Delete eligible backups" || !candidates.length) continue;
281
335
  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).`);
336
+ await run("Delete backups", async sync => `Deleted ${await sync.applyCleanup(candidates.map(item => item.id))} backup(s).`);
283
337
  }
284
- } else if (selected === "Delete Selected Backup") {
338
+ } else if (selected === "Delete backup") {
285
339
  const backups = await service.listBackups();
286
340
  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"]);
341
+ const id = await select(context, "Choose backup to delete", [...backups.map(item => item.id), "Back"]);
288
342
  if (!id || id === "Back") continue;
289
343
  if (await confirm(context, "Delete backup?", `Permanently delete local backup ${id}?`)) {
290
- await run("cleaning backups", async sync => `Deleted ${await sync.applyCleanup([id])} backup.`);
344
+ await run("Delete backups", async sync => `Deleted ${await sync.applyCleanup([id])} backup.`);
291
345
  }
292
346
  } else if (selected === "Retention") {
293
347
  const keep = await context.ui.input("Keep newest complete backups (0 or more)", String(config.retention?.keepBackups ?? 20));
@@ -303,10 +357,10 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
303
357
  context.ui.notify("Retention saved. Cleanup remains manual; no files deleted.", "info");
304
358
  }
305
359
  } 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());
360
+ const selected = await select(context, "Diagnostics", ["Doctor", "Check status", "View log", "Back"]);
361
+ if (selected === "Doctor") await run("Doctor", async sync => JSON.stringify(await sync.doctor(), null, 2));
362
+ if (selected === "Check status") await run("Check", async sync => { await sync.check(); return detailLines().join("\n"); });
363
+ if (selected === "View log") await run("View log", sync => sync.log());
310
364
  }
311
365
  }
312
366
  }
@@ -326,7 +380,7 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
326
380
  context.ui.setWidget(STATUS_KEY, undefined);
327
381
  publish();
328
382
  unregister?.();
329
- const api = pillAPI();
383
+ const api = bubbleAPI(context);
330
384
  unregister = api?.version === 1 ? api.register(STATUS_KEY, ({ ctx: current }) => showStatus(current)) : undefined;
331
385
  await startTimers();
332
386
  });
@@ -351,17 +405,17 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
351
405
  handler: async (_args, context) => { await settings(context).catch(report); },
352
406
  });
353
407
  pi.registerCommand("sync-push", {
354
- description: "Upload the managed raw data snapshot",
355
- handler: async () => { await run("uploading", sync => sync.push()); },
408
+ description: "Push the managed raw data snapshot",
409
+ handler: async () => { await run("Push", sync => sync.push()); },
356
410
  });
357
411
  pi.registerCommand("sync-pull", {
358
412
  description: "Pull raw data, backing up files before replacement",
359
- handler: async () => { await run("downloading", sync => sync.pull()); },
413
+ handler: async () => { await run("Pull", sync => sync.pull()); },
360
414
  });
361
415
  pi.registerCommand("sync-setup", {
362
416
  description: "Configure a private SSH repository: /sync-setup <ssh-url> [pull-minutes]",
363
417
  handler: async (args, context) => {
364
- if (busy) { context.ui.notify(`Sync is already ${busy}.`, "warning"); return; }
418
+ if (busy) { context.ui.notify(`Sync is busy: ${busy}.`, "warning"); return; }
365
419
  try {
366
420
  const [url, interval, ...extra] = args.trim().split(/\s+/);
367
421
  if (!url || extra.length) throw new Error("Usage: /sync-setup <private-ssh-url> [pull-minutes]");
@@ -375,7 +429,7 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
375
429
  next.paths = [...config.paths];
376
430
  next.mode = config.mode;
377
431
  }
378
- if (busy) throw new Error(`Sync is already ${busy}; retry setup after it finishes.`);
432
+ if (busy) throw new Error(`Sync is busy: ${busy}; retry setup after it finishes.`);
379
433
  stopTimers();
380
434
  await saveConfig(next, paths);
381
435
  config = next;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gfgghgftgffg/amox-pi-sync",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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",