cinna-cli 0.1.1__tar.gz → 0.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/.github/workflows/publish.yml +4 -4
  2. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/PKG-INFO +1 -1
  3. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/docs/README.md +9 -2
  4. cinna_cli-0.1.3/docs/interface.md +160 -0
  5. cinna_cli-0.1.3/docs/mutagen_capabilities.md +248 -0
  6. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/pyproject.toml +1 -1
  7. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/main.py +1 -1
  8. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/sync_session.py +77 -8
  9. cinna_cli-0.1.3/src/cinna/sync_tui.py +858 -0
  10. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/tests/test_main.py +1 -1
  11. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/uv.lock +1 -1
  12. cinna_cli-0.1.1/src/cinna/sync_tui.py +0 -352
  13. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/.gitignore +0 -0
  14. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/LICENSE.md +0 -0
  15. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/README.md +0 -0
  16. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/__init__.py +0 -0
  17. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/auth.py +0 -0
  18. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/bootstrap.py +0 -0
  19. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/client.py +0 -0
  20. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/config.py +0 -0
  21. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/console.py +0 -0
  22. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/context.py +0 -0
  23. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/errors.py +0 -0
  24. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/logging.py +0 -0
  25. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/mcp_proxy.py +0 -0
  26. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/mutagen_runtime.py +0 -0
  27. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/sync.py +0 -0
  28. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/sync_ssh_shim.py +0 -0
  29. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/templates/CLAUDE.md.template +0 -0
  30. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/templates/__init__.py +0 -0
  31. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/tests/__init__.py +0 -0
  32. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/tests/conftest.py +0 -0
  33. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/tests/test_auth.py +0 -0
  34. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/tests/test_bootstrap.py +0 -0
  35. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/tests/test_client.py +0 -0
  36. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/tests/test_config.py +0 -0
  37. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/tests/test_context.py +0 -0
  38. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/tests/test_mutagen_runtime.py +0 -0
  39. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/tests/test_sync.py +0 -0
  40. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/tests/test_sync_session.py +0 -0
  41. {cinna_cli-0.1.1 → cinna_cli-0.1.3}/tests/test_sync_ssh_shim.py +0 -0
@@ -11,10 +11,10 @@ jobs:
11
11
  name: Build distribution
12
12
  runs-on: ubuntu-latest
13
13
  steps:
14
- - uses: actions/checkout@v4
14
+ - uses: actions/checkout@v6
15
15
 
16
16
  - name: Install uv
17
- uses: astral-sh/setup-uv@v5
17
+ uses: astral-sh/setup-uv@v8.1.0
18
18
  with:
19
19
  python-version: "3.11"
20
20
 
@@ -25,7 +25,7 @@ jobs:
25
25
  run: uv tool run --from twine twine check dist/*
26
26
 
27
27
  - name: Upload dist
28
- uses: actions/upload-artifact@v4
28
+ uses: actions/upload-artifact@v7
29
29
  with:
30
30
  name: dist
31
31
  path: dist/
@@ -41,7 +41,7 @@ jobs:
41
41
  id-token: write
42
42
  steps:
43
43
  - name: Download dist
44
- uses: actions/download-artifact@v4
44
+ uses: actions/download-artifact@v8
45
45
  with:
46
46
  name: dist
47
47
  path: dist/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cinna-cli
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: Local development CLI for Cinna Core agents
5
5
  Project-URL: Homepage, https://github.com/opencinna/cinna-cli
6
6
  Project-URL: Repository, https://github.com/opencinna/cinna-cli
@@ -195,6 +195,7 @@ main.py (CLI commands — Click)
195
195
  ├── client.py — PlatformClient: HTTP + SSE stream_exec
196
196
  ├── mutagen_runtime.py — detect/install Mutagen; gate on version match
197
197
  ├── sync_session.py — wrap the `mutagen` CLI (start/stop/status/conflicts)
198
+ ├── sync_tui.py — live Textual TUI shown by `cinna dev` (Sync/Details/Conflicts tabs)
198
199
  ├── sync_ssh_shim.py — `cinna-sync-ssh` entry point (WebSocket transport)
199
200
  ├── sync.py — tarball/zip extraction helpers (initial clone only)
200
201
  ├── context.py — CLAUDE.md, BUILDING_AGENT.md, .mcp.json, opencode.json
@@ -304,7 +305,12 @@ Opening the sync WebSocket keeps the remote env warm. The platform heartbeats `l
304
305
 
305
306
  ### Conflicts
306
307
 
307
- Mutagen's `two-way-safe` mode writes `<name>.conflict.<side>.<timestamp>` when it can't auto-merge. `cinna sync conflicts` walks the workspace and surfaces them. Users resolve by opening in their editor, picking a winner, and deleting the conflict copy. There is deliberately no magic UI editors already have good 3-way merge tooling.
308
+ Mutagen 0.18.1 in `two-way-safe` records conflicts in its session state (the `conflicts[]` array of `mutagen sync list --template '{{json .}}'`) but, contrary to older mutagen behavior, does **not** write `<name>.conflict.<side>.<timestamp>` files to disk both sides simply retain their divergent content. See [`mutagen_capabilities.md` §7](./mutagen_capabilities.md#7--two-way-safe-does-not-write-conflictsidets-files) for the empirical proof and re-verification steps.
309
+
310
+ Two surfaces expose conflicts:
311
+
312
+ - **`cinna sync conflicts`** — a read-only Click subcommand that walks the workspace for any `*.conflict.*` files mutagen *does* end up writing (other sync modes, or future mutagen versions, may still produce them). Implementation lives in `sync_session.list_conflicts`.
313
+ - **The Conflicts tab in `cinna dev`** — sourced from mutagen's JSON `conflicts[]`, always populated when a conflict exists. The user navigates the list with `↑`/`↓` and resolves with `1` (take REMOTE) or `5` (take LOCAL). Resolution mechanism: delete the file on the losing side (locally with `unlink()`, remotely via `cinna exec rm`) then `mutagen sync reset <session>`; mutagen sees one side empty, no common ancestor, and propagates the survivor. Verified against 0.18.1; see [`interface.md`](./interface.md) for the full element-to-mutagen-capability mapping.
308
314
 
309
315
  ---
310
316
 
@@ -428,6 +434,7 @@ uv run ruff format --check src/
428
434
  | WebSocket client | websockets | Minimal dep for the sync shim |
429
435
  | Sync engine | Mutagen | Battle-tested bidirectional sync; OSS; cross-platform |
430
436
  | Terminal | Rich | Spinners, panels, tables |
437
+ | Live TUI | Textual | Three-tab interactive view (`cinna dev`); built on Rich |
431
438
  | MCP | mcp SDK | Official protocol SDK |
432
439
  | Tests | pytest + respx | Standard; respx is purpose-built for httpx |
433
440
  | Build | Hatchling | Minimal, standards-compliant |
@@ -440,7 +447,7 @@ uv run ruff format --check src/
440
447
  - **Interactive stdin for `cinna exec`** — REPLs, debuggers.
441
448
  - **Port forwarding** — `cinna forward local:remote` (Mutagen supports it).
442
449
  - **Post-sync hooks** — e.g. `uv sync` on `pyproject.toml` change.
443
- - **Web UI conflict resolution** — TUI and editor-based only for now.
450
+ - **Web UI conflict resolution** — the in-TUI Conflicts tab covers the common case; editor-based 3-way merge stays the fallback for ones the TUI can't handle (e.g. asymmetric directory/file conflicts).
444
451
  - **Multi-device presence UI**.
445
452
  - **Bundled Mutagen daemon** — stay with a user-installed daemon.
446
453
  - **Telemetry pipe** to the backend.
@@ -0,0 +1,160 @@
1
+ # cinna sync TUI — Interface Reference
2
+
3
+ > Maps every visible element of the live sync TUI (the screen shown by `cinna dev`) to the underlying Mutagen capability it depends on. Use this together with [`mutagen_capabilities.md`](./mutagen_capabilities.md) when bumping Mutagen versions: each feature here points to the section that proves the capability still works.
4
+
5
+ The TUI is implemented in `src/cinna/sync_tui.py` and rendered by [Textual](https://textual.textualize.io/). It opens when the user runs `cinna dev` and closes on `q` / Ctrl-C; closing the TUI terminates the Mutagen sync session.
6
+
7
+ ---
8
+
9
+ ## Layout
10
+
11
+ Three tabs, switched with `←` / `→`:
12
+
13
+ ```
14
+ ┌─────────────────────────────────────────────────────────────────┐
15
+ │ cinna sync — <agent name> <clock> │
16
+ ├─────────────────────────────────────────────────────────────────┤
17
+ │ [ Sync ] [ Details ] [ Conflicts ] │
18
+ ├─────────────────────────────────────────────────────────────────┤
19
+ │ │
20
+ │ <tab content> │
21
+ │ │
22
+ ├─────────────────────────────────────────────────────────────────┤
23
+ │ q Quit ◀ Tab Tab ▶ 1 take REMOTE 5 take LOCAL │
24
+ └─────────────────────────────────────────────────────────────────┘
25
+ ```
26
+
27
+ ---
28
+
29
+ ## Sync tab (default)
30
+
31
+ The status-and-activity view. Layout:
32
+
33
+ ```
34
+ ┌─ Status pill, agent identity, endpoints ────────────────────────┐
35
+ │ ⬤ Watching for changes │
36
+ │ Agent: my-agent @ https://platform.example.com │
37
+ │ Local: /Users/me/work/my-agent/workspace │
38
+ │ Remote: cinna@cinna-agent-<uuid>:/app/workspace │
39
+ └─────────────────────────────────────────────────────────────────┘
40
+ 127 files · 14 dirs · 3.4 MB Successful cycles: 12
41
+ · receiving 17/60 (53.4 MB so far, current file 3.0 MB)
42
+ ┌─ Activity log (scrolling) ──────────────────────────────────────┐
43
+ │ 19:21:08 sync attached — both endpoints connected │
44
+ │ 19:21:09 status: watching → staging-beta [local→remote] │
45
+ │ 19:21:09 → remote [1/12] scripts/main.py (4.2 KB) │
46
+ │ 19:21:09 → remote [2/12] docs/notes.md (1.1 KB) │
47
+ │ 19:21:10 cycle #3 complete — synced 12 files (12.4 KB) │
48
+ └─────────────────────────────────────────────────────────────────┘
49
+ ```
50
+
51
+ ### Elements and their Mutagen dependencies
52
+
53
+ | Element | Source | Mutagen feature |
54
+ |---------------------------------------|-------------------------------------------------------------------|----------------------------------------------------------------------------------|
55
+ | Status pill (`⬤ <text>`) | `_state_pill` reading `status` + `alpha.connected`, `beta.connected` | [`mutagen_capabilities.md` §1, §3](./mutagen_capabilities.md#3-side-suffixed-status-values) |
56
+ | Direction tag (`local→remote`) | `_side_label` parsing the `-alpha` / `-beta` suffix of `status` | [§3 side-suffixed status](./mutagen_capabilities.md#3-side-suffixed-status-values) |
57
+ | Local / Remote URL lines | `alpha.path`, `beta.{user,host,path}` from session JSON | [§1 JSON shape](./mutagen_capabilities.md#1-mutagen-sync-list--template-json--output-shape) |
58
+ | Stats line (files / dirs / total size)| `alpha.{files,directories,totalFileSize}`, `successfulCycles` | [§1 JSON shape](./mutagen_capabilities.md#1-mutagen-sync-list--template-json--output-shape) |
59
+ | Live progress (`receiving N/M …`) | `(alpha|beta).stagingProgress.{receivedFiles,expectedFiles,totalReceivedSize,expectedSize}` | [§4 per-side stagingProgress](./mutagen_capabilities.md#4-per-side-stagingprogress) |
60
+ | Per-file activity lines | `_emit_staging_events` diffing `stagingProgress.path` between consecutive state records | [§4 stagingProgress.path](./mutagen_capabilities.md#4-per-side-stagingprogress) **and** [§2 monitor streams every change](./mutagen_capabilities.md#2-mutagen-sync-monitor-streams-state-on-every-change) — polling alone misses paths |
61
+ | Status-transition lines | `_emit_events` diffing `status` between consecutive records | [§3 status](./mutagen_capabilities.md#3-side-suffixed-status-values) |
62
+ | Endpoint connect/disconnect lines | `_emit_events` diffing `alpha.connected` / `beta.connected` | [§1 JSON shape](./mutagen_capabilities.md#1-mutagen-sync-list--template-json--output-shape) |
63
+ | Cycle-complete line with byte delta | `_emit_cycle_complete` diffing `(alpha|beta).{files,totalFileSize}` against a pre-cycle snapshot | [§1 JSON shape](./mutagen_capabilities.md#1-mutagen-sync-list--template-json--output-shape); `successfulCycles` as edge trigger |
64
+ | Scan / transition problem lines | `_emit_problem_events` reading `(alpha|beta)(Scan|Transition)Problems[]` | [§1 JSON shape](./mutagen_capabilities.md#1-mutagen-sync-list--template-json--output-shape) |
65
+ | Conflict log lines (`conflict: …`) | `_emit_conflict_events` reading `conflicts[]` | [§6 conflict JSON shape](./mutagen_capabilities.md#6-conflict-json-shape) |
66
+ | `error: …` line | `lastError` field | [§1 JSON shape](./mutagen_capabilities.md#1-mutagen-sync-list--template-json--output-shape) |
67
+ | Activity log file (`cinna.log`) | Every TUI line is also routed to the `cinna.sync_tui` logger | Independent of Mutagen — survives TUI close |
68
+
69
+ ### How updates are delivered
70
+
71
+ A single subprocess streams JSON state into the TUI: `mutagen sync monitor --template '{{json .}}{{"\n"}}' <session>`. The TUI reads `proc.stdout` line by line in `_monitor_loop`. Each parsed record is fed to `_render_sync_tab`, which renders the static pieces and then calls `_emit_events` — the per-file / per-cycle event emitter. Depends on [§2 monitor streaming](./mutagen_capabilities.md#2-mutagen-sync-monitor-streams-state-on-every-change).
72
+
73
+ ---
74
+
75
+ ## Details tab
76
+
77
+ Verbatim output of `mutagen sync list --long <session>`, refreshed every 2 seconds (`DETAILS_INTERVAL`). This is what a power user would see if they ran the command themselves — full session metadata, scan results, raw conflict listings. Pure mutagen feature, no cinna interpretation.
78
+
79
+ | Element | Source | Mutagen feature |
80
+ |-------------|----------------------------------|----------------------------------------|
81
+ | Rendered text | `mutagen sync list --long` | Native human-readable output — stable contract across 0.18.x |
82
+
83
+ ---
84
+
85
+ ## Conflicts tab
86
+
87
+ ```
88
+ ┌─ Conflicts (2) ──────────────────────────────────────────────────┐
89
+ │ ▶ app-data/storage/workflow.db (local+remote modified) │
90
+ │ docs/WORKFLOW_PROMPT.md (local+remote modified) │
91
+ │ │
92
+ │ ↑/↓ navigate · 1 take REMOTE (server) · 5 take LOCAL (yours) │
93
+ │ Resolution deletes the losing side's file and resets mutagen │
94
+ │ history so the survivor propagates. │
95
+ └──────────────────────────────────────────────────────────────────┘
96
+ ```
97
+
98
+ ### Elements
99
+
100
+ | Element | Source | Mutagen feature |
101
+ |--------------------------------|-------------------------------------------------------------|---------------------------------------------------------------------------|
102
+ | Conflicts list (one row / path)| `_extract_conflicts(session)` flattening `conflicts[].alphaChanges[].path` and `betaChanges[].path` | [§6 conflict JSON shape](./mutagen_capabilities.md#6-conflict-json-shape) |
103
+ | Per-side modification tag | Presence of paths in `alphaChanges` vs `betaChanges` | [§6](./mutagen_capabilities.md#6-conflict-json-shape) |
104
+ | `1 take REMOTE` action | `_resolve_selected("beta")`: `rm <local file>` then `mutagen sync reset <session>` | [§8 per-file delete + reset](./mutagen_capabilities.md#8-per-file-conflict-resolution-via-delete--mutagen-sync-reset) |
105
+ | `5 take LOCAL` action | `_resolve_selected("alpha")`: `cinna exec rm <remote file>` then `mutagen sync reset <session>` | [§8](./mutagen_capabilities.md#8-per-file-conflict-resolution-via-delete--mutagen-sync-reset); requires cinna's own remote-exec channel |
106
+ | Auto-refresh | `_maybe_refresh_conflicts` is called on every monitor record; re-renders the tab only when the set actually changes | [§2 monitor](./mutagen_capabilities.md#2-mutagen-sync-monitor-streams-state-on-every-change), [§6 conflicts](./mutagen_capabilities.md#6-conflict-json-shape) |
107
+
108
+ ### Why not source from disk?
109
+
110
+ Because Mutagen 0.18.1 in `two-way-safe` does **not** write `.conflict.<side>.<ts>` files — both sides keep their own version and only the JSON state records the divergence. See [§7](./mutagen_capabilities.md#7--two-way-safe-does-not-write-conflictsidets-files). If a future mutagen version starts writing these copies again, `src/cinna/sync_session.py:list_conflicts` and `group_conflicts` are still in place and can be wired back in as a secondary data source.
111
+
112
+ ---
113
+
114
+ ## Keybindings
115
+
116
+ | Key | Action | Where it lives |
117
+ |-----------|-------------------|-----------------------------------------------------------------------|
118
+ | `q` | Quit | App-level `action_quit`; on exit, the caller (`run_foreground`) calls `stop(config)` to terminate the Mutagen session. |
119
+ | `Ctrl-C` | Quit | Same as `q`. |
120
+ | `←` / `→` | Cycle tabs | `action_cycle_tab(±1)`; wraps at ends. |
121
+ | `1` | Take REMOTE | No-op outside the Conflicts tab. See [§8](./mutagen_capabilities.md#8-per-file-conflict-resolution-via-delete--mutagen-sync-reset). |
122
+ | `5` | Take LOCAL | No-op outside the Conflicts tab. `1` and `5` are placed far apart on the keyboard to make a misfire unlikely. |
123
+ | `↑` / `↓` | Navigate Conflicts list | Native to Textual's `OptionList`; only the Conflicts tab's list is focusable, so these keys do nothing on Sync / Details. |
124
+
125
+ The `←` / `→` / `1` / `5` bindings are declared with `priority=True` so they fire even when the Conflicts `OptionList` has focus.
126
+
127
+ ---
128
+
129
+ ## Lifecycle
130
+
131
+ ```
132
+ cinna dev
133
+ └─ sync_session.start(config, workspace_root) # create / refresh Mutagen session
134
+ └─ run_foreground(config, workspace_root)
135
+ └─ run_tui(config, session_name, env, workspace_root)
136
+ └─ SyncApp.run()
137
+ on_mount:
138
+ · spawn _monitor_loop (mutagen sync monitor --template …)
139
+ · spawn _details_loop (mutagen sync list --long, every 2 s)
140
+ user presses q / Ctrl-C
141
+ on_unmount:
142
+ · cancel both loops
143
+ · terminate the monitor subprocess
144
+ └─ stop(config) # mutagen sync terminate <session-name>
145
+ ```
146
+
147
+ The TUI does not outlive its terminal. Once the user quits, sync stops. To observe an existing sync session from another terminal without affecting it, the user runs `cinna sync status` (a read-only Click subcommand defined in `main.py`, not part of the TUI).
148
+
149
+ ---
150
+
151
+ ## Adding a new TUI element
152
+
153
+ Procedure when you want to expose a new piece of Mutagen state:
154
+
155
+ 1. Look at the raw JSON: `mutagen sync list --template '{{json .}}' <session>` while the relevant state is active. Note the exact JSON key and where it lives (top-level, under `alpha`/`beta`, etc.).
156
+ 2. Add a row to [`mutagen_capabilities.md`](./mutagen_capabilities.md) if the field isn't already documented there. Include a reproduction command — future you, on the next mutagen bump, needs to verify it didn't move.
157
+ 3. Wire the read into `_render_sync_tab` (for static display) or `_emit_events` (for diff-driven log lines). Use `_safe_int` from `sync_session.py` for numeric fields to keep the defensive-accessor pattern consistent.
158
+ 4. Add a row to the appropriate table in this document linking the new element back to the capabilities reference.
159
+
160
+ If a feature would need a Mutagen capability that doesn't yet exist in our pinned version, document the gap in [`mutagen_capabilities.md`](./mutagen_capabilities.md) and link to it from here so we know what to look for on the next bump.
@@ -0,0 +1,248 @@
1
+ # Mutagen Capabilities — Verified Findings
2
+
3
+ > A reference of every Mutagen behavior cinna-cli currently depends on, with the exact commands to re-verify each one. The goal: when we bump Mutagen, run through this doc top to bottom and confirm nothing regressed.
4
+
5
+ **Currently pinned version:** Mutagen `0.18.1` (verified May 2026 against `mutagen version` on macOS arm64).
6
+
7
+ All claims below were reproduced locally against that version. The reproduction commands assume the `mutagen` daemon is running (`mutagen daemon start`). They use temp dirs `/tmp/c-a` and `/tmp/c-b` as alpha/beta — clean them up between tests.
8
+
9
+ ---
10
+
11
+ ## 1. `mutagen sync list --template '{{json .}}'` output shape
12
+
13
+ **What we rely on:** the JSON state is a top-level **array of session objects** (even when listing a single session by name). Each object flattens both the session config (alpha, beta endpoints, ignore rules) and the runtime state (status, conflicts, stagingProgress) at the top level — `state` is **not** a nested object.
14
+
15
+ **Reproduce:**
16
+ ```bash
17
+ mkdir -p /tmp/c-a /tmp/c-b && echo hi > /tmp/c-a/x.txt
18
+ mutagen sync create --name=cap-test /tmp/c-a /tmp/c-b
19
+ sleep 2
20
+ mutagen sync list --template '{{json .}}' cap-test
21
+ mutagen sync terminate cap-test
22
+ ```
23
+
24
+ **Expected top-level keys (per session):**
25
+ - `identifier`, `version`, `creationTime`, `creatingVersion`, `name`
26
+ - `alpha`, `beta` — each contains `protocol`, `path`, `connected`, `scanned`, `directories`, `files`, `totalFileSize`, and during a transfer `stagingProgress`
27
+ - `status` — string, see §3
28
+ - `successfulCycles` — int counter (only emitted after first non-zero cycle)
29
+ - `paused` — bool
30
+ - `conflicts` — array, see §6
31
+ - `lastError` — string (optional)
32
+
33
+ **Where we read it:** `src/cinna/sync_session.py:_list_sessions`, `_to_status`; `src/cinna/sync_tui.py:_parse_monitor_payload`.
34
+
35
+ ---
36
+
37
+ ## 2. `mutagen sync monitor` streams state on every change
38
+
39
+ **What we rely on:** `mutagen sync monitor --template '{{json .}}{{"\n"}}' <session>` is a long-running subprocess that writes one JSON-array record to stdout every time the daemon's session state changes — including progress ticks while a transfer is in flight. This is what lets the Sync tab show per-file paths in real time; polling at any practical interval misses fast files.
40
+
41
+ **Reproduce:**
42
+ ```bash
43
+ mkdir -p /tmp/c-a /tmp/c-b
44
+ for i in $(seq 1 60); do dd if=/dev/urandom of=/tmp/c-a/file$i.bin bs=1M count=8 2>/dev/null; done
45
+ mutagen sync create --name=cap-monitor /tmp/c-a /tmp/c-b
46
+ ( mutagen sync monitor --template '{{json .}}{{"\n"}}' cap-monitor & MPID=$!
47
+ sleep 12; kill $MPID 2>/dev/null
48
+ ) | head -20
49
+ mutagen sync terminate cap-monitor
50
+ rm -rf /tmp/c-a /tmp/c-b
51
+ ```
52
+
53
+ **Expected:** multiple `[{...}]\n\n` payloads. During staging, you'll see `stagingProgress.path` change between records.
54
+
55
+ **Where we read it:** `src/cinna/sync_tui.py:_monitor_loop`.
56
+
57
+ **Known limit:** during very fast local-to-local syncs (60 files transferring in ~1 second on an SSD) the daemon coalesces updates and we observed roughly 1 distinct path emitted per 5–10 files. For cinna's actual use case (remote sync over network) each file takes long enough that monitor emits multiple progress records per file and we catch them all.
58
+
59
+ ---
60
+
61
+ ## 3. Side-suffixed `status` values
62
+
63
+ **What we rely on:** `status` can take side-suffixed values `staging-alpha`, `staging-beta`, `transitioning-alpha`, `transitioning-beta`. These mean "currently writing to that side" — alpha = local, beta = remote. The base prefix (`staging`/`transitioning`/`scanning`/`watching`/`reconciling`/`saving`) is the phase.
64
+
65
+ **Reproduce:** see §2 — during the transfer, watch the `status` field flip between `scanning` → `staging-beta` → `scanning` → `watching`.
66
+
67
+ **Why we normalize:** `_to_status` and `_state_pill` both call `base_status(raw)` (in `sync_session.py`) which strips the `-<side>` suffix before matching against the set of healthy states. The side suffix drives the "local→remote" / "remote→local" direction label shown in the Sync tab.
68
+
69
+ ---
70
+
71
+ ## 4. Per-side `stagingProgress`
72
+
73
+ **What we rely on:** when a side is staging, that side's object carries a `stagingProgress` block with these fields:
74
+ ```json
75
+ {
76
+ "path": "file36.bin",
77
+ "receivedSize": 1638400,
78
+ "expectedSize": 3145728,
79
+ "receivedFiles": 17,
80
+ "expectedFiles": 60,
81
+ "totalReceivedSize": 53477376
82
+ }
83
+ ```
84
+
85
+ `path` is the file currently being received on that side. `expectedSize` is the *current file's* total, not the session's. `totalReceivedSize` is the cumulative bytes since staging started.
86
+
87
+ **Reproduce:** §2 — watch any single record where `status` is `staging-beta`. `stagingProgress` will appear under `beta`.
88
+
89
+ **Where we use it:** `src/cinna/sync_tui.py:_emit_staging_events` (logs each new `path` once, deduped against the previous record); `_render_sync_tab` appends the live progress hint to the stats line.
90
+
91
+ ---
92
+
93
+ ## 5. `mutagen.yml` and ignore semantics
94
+
95
+ **What we rely on:** the per-workspace `mutagen.yml` written by `cinna sync` configures sync mode, scan mode, and ignore patterns. Mutagen reads it from the workspace root at session creation.
96
+
97
+ **Currently written** (`src/cinna/sync_session.py:MUTAGEN_YML_TEMPLATE`):
98
+ ```yaml
99
+ sync:
100
+ defaults:
101
+ mode: two-way-safe
102
+ permissions:
103
+ mode: portable
104
+ ignore:
105
+ vcs: true
106
+ paths:
107
+ - __pycache__/
108
+ - node_modules/
109
+ - .venv/
110
+ - .cinna/
111
+ - .mypy_cache/
112
+ - .pytest_cache/
113
+ - .DS_Store
114
+ scan:
115
+ mode: full
116
+ ```
117
+
118
+ **Available sync modes** (`mutagen sync create --help`):
119
+ - `two-way-safe` (cinna default) — both sides authoritative, conflicts halt
120
+ - `two-way-resolved` — alpha wins on conflict (no manual intervention)
121
+ - `one-way-safe` — alpha authoritative, beta cannot diverge
122
+ - `one-way-replica` — alpha mirrored to beta exactly, beta overwritten
123
+
124
+ **Available scan modes**: `full` (cinna default) and `accelerated`. `accelerated` uses filesystem watches and short-circuits unchanged directories; `full` is slower but more accurate for cases where watches misbehave (network filesystems, container bind mounts).
125
+
126
+ ---
127
+
128
+ ## 6. Conflict JSON shape
129
+
130
+ **What we rely on:** when both sides modify the same file under `two-way-safe`, mutagen records a conflict in the session's `conflicts[]` array.
131
+
132
+ **Shape:**
133
+ ```json
134
+ {
135
+ "root": "shared.txt",
136
+ "alphaChanges": [
137
+ {"path": "shared.txt",
138
+ "old": {"kind": "file", "digest": "c9e870..."},
139
+ "new": {"kind": "file", "digest": "534ec1..."}}
140
+ ],
141
+ "betaChanges": [
142
+ {"path": "shared.txt",
143
+ "old": {"kind": "file", "digest": "c9e870..."},
144
+ "new": {"kind": "file", "digest": "e52d59..."}}
145
+ ]
146
+ }
147
+ ```
148
+
149
+ Some conflict kinds (directory/file disagreement, asymmetric delete) only populate `root`; the per-side change arrays may be empty.
150
+
151
+ **Reproduce:**
152
+ ```bash
153
+ mkdir -p /tmp/c-a /tmp/c-b && echo "orig" > /tmp/c-a/shared.txt
154
+ mutagen sync create --name=cap-conflict --sync-mode=two-way-safe /tmp/c-a /tmp/c-b
155
+ sleep 2
156
+ mutagen sync pause cap-conflict
157
+ echo "LOCAL" > /tmp/c-a/shared.txt
158
+ echo "REMOTE" > /tmp/c-b/shared.txt
159
+ mutagen sync resume cap-conflict
160
+ sleep 2
161
+ mutagen sync list --template '{{json .}}' cap-conflict
162
+ mutagen sync terminate cap-conflict
163
+ rm -rf /tmp/c-a /tmp/c-b
164
+ ```
165
+
166
+ **Where we read it:** `src/cinna/sync_tui.py:_extract_conflicts`.
167
+
168
+ ---
169
+
170
+ ## 7. ⚠ `two-way-safe` does NOT write `.conflict.<side>.<ts>` files
171
+
172
+ **What we rely on:** in mutagen 0.18.1's `two-way-safe` mode, when a conflict occurs, mutagen records it in `conflicts[]` JSON but **leaves both sides' canonical files untouched**. No `.conflict.alpha.<ts>` or `.conflict.beta.<ts>` files are written.
173
+
174
+ This contradicts older docs / forum posts about mutagen that describe conflict-marker files. We verified empirically that it does not happen in 0.18.1 two-way-safe.
175
+
176
+ **Implication:** populating the Conflicts tab from a disk walk (`*.conflict.*`) returns an empty list even when conflicts exist. The tab must source from JSON, not the filesystem.
177
+
178
+ **Reproduce:** §6, then check `ls -la /tmp/c-a /tmp/c-b` after the conflict appears — only `shared.txt` is present on each side.
179
+
180
+ **If a future mutagen starts writing those files again:** `src/cinna/sync_session.py:list_conflicts` already walks for `*.conflict.<side>.<ts>` and is the path to surface them. The `cinna sync conflicts` CLI subcommand still uses it. The TUI currently does not — it sources conflicts from JSON via `_extract_conflicts`.
181
+
182
+ ---
183
+
184
+ ## 8. Per-file conflict resolution via delete + `mutagen sync reset`
185
+
186
+ **What we rely on:** `mutagen sync reset <session>` clears the session's sync history (the common-ancestor record) without recreating the session. On the next scan, mutagen has no notion of what changed since when.
187
+
188
+ By itself this does nothing useful for conflicts — both sides still have divergent content, so mutagen re-detects the conflict. But combined with deleting the *losing* side's file first, it forces propagation:
189
+
190
+ | User picks | Action | Effect |
191
+ |------------|-----------------------------------------------------|------------------------------------------------------------------------------------------|
192
+ | REMOTE | `rm <alpha file>` then `mutagen sync reset <s>` | Alpha empty, beta has content, no ancestor → mutagen propagates beta → alpha. |
193
+ | LOCAL | `rm <beta file>` then `mutagen sync reset <s>` | Beta empty, alpha has content, no ancestor → mutagen propagates alpha → beta. |
194
+
195
+ **Reproduce — Take REMOTE:**
196
+ ```bash
197
+ # Set up conflict as in §6, then:
198
+ rm /tmp/c-a/shared.txt
199
+ mutagen sync reset cap-conflict
200
+ sleep 3
201
+ cat /tmp/c-a/shared.txt /tmp/c-b/shared.txt # both = REMOTE
202
+ mutagen sync list --template '{{json .}}' cap-conflict | python3 -c 'import json,sys; print(len(json.load(sys.stdin)[0].get("conflicts",[])))'
203
+ # → 0
204
+ ```
205
+
206
+ **Reproduce — Take LOCAL:** symmetric — `rm /tmp/c-b/shared.txt` instead.
207
+
208
+ **Multi-conflict safety**: deleting one conflict file + reset only converges that one path. Other conflicts in the same session remain because both their sides still hold content. Verified by setting up two conflicts (`x.txt` and `y.txt`), resolving one with delete+reset, and observing the other untouched in the post-reset JSON.
209
+
210
+ **Why we don't switch sync mode:** `two-way-resolved` would auto-resolve in alpha's favor, but its effect is session-wide — every conflict would resolve the same way. There's no per-file mode flag. The delete + reset approach is per-file.
211
+
212
+ **Where we use it:** `src/cinna/sync_tui.py:_resolve_selected`. For the beta-side delete cinna shells out to `cinna exec rm -f -- <path>` since beta lives on the remote agent.
213
+
214
+ ---
215
+
216
+ ## 9. The SSH-shim contract
217
+
218
+ **What we rely on:** mutagen invokes its SSH transport via the binary named `ssh` found in `$MUTAGEN_SSH_PATH`. `MUTAGEN_SSH_PATH` is a **directory** (not a binary path); mutagen searches it for an executable literally named `ssh`. The daemon captures its environment at startup, so any change to `MUTAGEN_SSH_PATH` requires restarting `mutagen daemon`.
219
+
220
+ The remote URL must use OpenSSH-style `user@host:/path`, not `ssh://`. Mutagen's URL parser distinguishes the two and treats `ssh://` as a different transport.
221
+
222
+ **Where we use it:** `src/cinna/sync_session.py:_ensure_ssh_shim_dir`, `_mutagen_env`, `_restart_daemon`, and the failure marker constants `_STALE_DAEMON_MARKERS` that trigger an automatic daemon restart on `sync create`.
223
+
224
+ ---
225
+
226
+ ## 10. `mutagen daemon` is shared across sessions
227
+
228
+ **What we rely on:** a single `mutagen daemon` instance manages all sessions on the host, regardless of which user / project created them. `mutagen daemon stop` terminates ALL sessions across all projects; they auto-resume on the next `mutagen sync list` (or any subcommand that talks to the daemon).
229
+
230
+ **Implication:** when cinna restarts the daemon to refresh stale env (`_restart_daemon`), other consumers of mutagen on the same machine experience a brief pause in their syncs. The pause is silent — they don't get a notification.
231
+
232
+ **Reproduce:**
233
+ ```bash
234
+ mutagen sync list # note any non-cinna sessions
235
+ mutagen daemon stop
236
+ mutagen daemon start
237
+ mutagen sync list # same sessions reappear, all paused for a moment
238
+ ```
239
+
240
+ ---
241
+
242
+ ## When to revisit this doc
243
+
244
+ - Bumping `mutagen` past `0.18.1` (any minor or major version).
245
+ - Adding a UI feature that needs a Mutagen behavior we haven't documented.
246
+ - After any user-reported sync bug that turns out to be a Mutagen-version-specific behavior change.
247
+
248
+ Walking through §1–§8 with the reproduction commands takes ~5 minutes and catches the breaking changes that have historically bit us.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cinna-cli"
3
- version = "0.1.1"
3
+ version = "0.1.3"
4
4
  description = "Local development CLI for Cinna Core agents"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -408,7 +408,7 @@ def dev():
408
408
 
409
409
  st = sync_session.start(config, root)
410
410
  console.status(f"Sync session created ({st.state}) — attaching live view. Press Ctrl-C to stop.")
411
- sync_session.run_foreground(config)
411
+ sync_session.run_foreground(config, root)
412
412
  console.status("Sync session terminated.")
413
413
 
414
414
 
@@ -46,7 +46,7 @@ sync:
46
46
  - .pytest_cache/
47
47
  - .DS_Store
48
48
  scan:
49
- mode: accelerated
49
+ mode: full
50
50
  """
51
51
 
52
52
 
@@ -297,12 +297,12 @@ def stop(config: CinnaConfig) -> None:
297
297
  _run_mutagen(["sync", "terminate", session_name(config.agent_id)], config)
298
298
 
299
299
 
300
- def run_foreground(config: CinnaConfig) -> int:
301
- """Attach the terminal to the Mutagen sync session via a two-tab TUI.
300
+ def run_foreground(config: CinnaConfig, workspace_root: Path) -> int:
301
+ """Attach the terminal to the Mutagen sync session via a live TUI.
302
302
 
303
- The TUI polls ``mutagen sync list`` once per second. Tab 1 renders a
304
- friendly status block and a derived activity log; Tab 2 shows the raw
305
- ``mutagen sync list --long`` output for power users.
303
+ Three tabs: Sync (status + per-file activity log), Details (raw
304
+ ``mutagen sync list --long``), and Conflicts (interactive resolution
305
+ for files Mutagen couldn't auto-merge).
306
306
 
307
307
  Blocks until the user presses ``q`` / Ctrl-C. On return the Mutagen
308
308
  session is terminated so sync does not outlive the TUI.
@@ -315,7 +315,7 @@ def run_foreground(config: CinnaConfig) -> int:
315
315
  env = _mutagen_env(config)
316
316
  rc = 0
317
317
  try:
318
- rc = run_tui(config, session, env)
318
+ rc = run_tui(config, session, env, workspace_root)
319
319
  except KeyboardInterrupt:
320
320
  rc = 0
321
321
  finally:
@@ -343,9 +343,10 @@ def _to_status(config: CinnaConfig, session: dict) -> SyncStatus:
343
343
  """
344
344
  raw_state = (session.get("status") or session.get("state") or "").lower()
345
345
  paused = bool(session.get("paused"))
346
+ base = base_status(raw_state)
346
347
  if paused:
347
348
  state = "paused"
348
- elif raw_state in {"watching", "scanning", "staging", "transitioning", "saving", "reconciling", "connected", "watching-changes"}:
349
+ elif base in {"watching", "scanning", "staging", "transitioning", "saving", "reconciling", "connected"}:
349
350
  state = "connected"
350
351
  elif raw_state in {"disconnected", "connecting"}:
351
352
  state = "disconnected"
@@ -380,6 +381,16 @@ def _safe_int(v) -> int:
380
381
  return 0
381
382
 
382
383
 
384
+ def base_status(raw: str) -> str:
385
+ """Strip Mutagen's side suffix (e.g. ``staging-beta`` → ``staging``).
386
+
387
+ Mutagen distinguishes the receiving side in its status string while a
388
+ transfer is in flight, but most consumers care about the phase, not the
389
+ direction.
390
+ """
391
+ return raw.split("-", 1)[0] if "-" in raw else raw
392
+
393
+
383
394
  @dataclass
384
395
  class Conflict:
385
396
  path: Path
@@ -413,6 +424,64 @@ def list_conflicts(config: CinnaConfig, workspace_root: Path) -> list[Conflict]:
413
424
  return results
414
425
 
415
426
 
427
+ @dataclass
428
+ class ConflictGroup:
429
+ """Two-sided view of one conflicted path.
430
+
431
+ Mutagen may write one or both ``.conflict.<side>.<ts>`` copies depending
432
+ on which side it considered the winner. ``canonical`` is the real
433
+ workspace path the two copies are versions of.
434
+ """
435
+ canonical: Path
436
+ alpha_copy: Path | None = None
437
+ beta_copy: Path | None = None
438
+
439
+
440
+ def _canonical_from_conflict(p: Path) -> Path:
441
+ """``foo/bar.txt.conflict.alpha.20260101`` → ``foo/bar.txt``."""
442
+ name = p.name
443
+ idx = name.find(".conflict.")
444
+ if idx <= 0:
445
+ return p
446
+ return p.parent / name[:idx]
447
+
448
+
449
+ def group_conflicts(conflicts: list[Conflict]) -> list[ConflictGroup]:
450
+ """Bucket flat conflict-copy paths by their canonical workspace path."""
451
+ groups: dict[Path, ConflictGroup] = {}
452
+ for c in conflicts:
453
+ canonical = _canonical_from_conflict(c.path)
454
+ g = groups.setdefault(canonical, ConflictGroup(canonical=canonical))
455
+ if c.kind == "alpha":
456
+ g.alpha_copy = c.path
457
+ elif c.kind == "beta":
458
+ g.beta_copy = c.path
459
+ return sorted(groups.values(), key=lambda g: str(g.canonical))
460
+
461
+
462
+ def resolve_conflict(group: ConflictGroup, side: str) -> None:
463
+ """Apply the user's choice: keep ``side``'s version at ``canonical``.
464
+
465
+ side: ``"alpha"`` (local) or ``"beta"`` (remote).
466
+
467
+ When the named side's conflict copy exists, it replaces the canonical
468
+ file. When it doesn't, the canonical file is already that side's content,
469
+ so we only delete the loser's copy. Mutagen picks up the change on its
470
+ next scan and propagates the resolution to the other endpoint.
471
+ """
472
+ if side not in ("alpha", "beta"):
473
+ raise ValueError(f"side must be 'alpha' or 'beta', got {side!r}")
474
+ target = group.alpha_copy if side == "alpha" else group.beta_copy
475
+ other = group.beta_copy if side == "alpha" else group.alpha_copy
476
+
477
+ if target is not None and target.exists():
478
+ if group.canonical.exists() and group.canonical.is_file():
479
+ group.canonical.unlink()
480
+ target.rename(group.canonical)
481
+ if other is not None and other.exists():
482
+ other.unlink()
483
+
484
+
416
485
  def session_log_dir(workspace_root: Path) -> Path:
417
486
  """Where we cache per-session breadcrumbs (exec history, etc.)."""
418
487
  return config_dir(workspace_root) / "sync"