cinna-cli 0.1.3__tar.gz → 0.1.5__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 (40) hide show
  1. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/PKG-INFO +5 -2
  2. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/README.md +4 -1
  3. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/docs/README.md +17 -0
  4. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/pyproject.toml +1 -1
  5. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/bootstrap.py +1 -1
  6. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/client.py +24 -2
  7. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/main.py +78 -7
  8. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/sync_session.py +72 -7
  9. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/sync_tui.py +51 -6
  10. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/tests/test_main.py +63 -1
  11. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/tests/test_sync_session.py +103 -0
  12. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/uv.lock +1 -1
  13. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/.github/workflows/publish.yml +0 -0
  14. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/.gitignore +0 -0
  15. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/LICENSE.md +0 -0
  16. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/docs/interface.md +0 -0
  17. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/docs/mutagen_capabilities.md +0 -0
  18. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/__init__.py +0 -0
  19. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/auth.py +0 -0
  20. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/config.py +0 -0
  21. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/console.py +0 -0
  22. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/context.py +0 -0
  23. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/errors.py +0 -0
  24. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/logging.py +0 -0
  25. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/mcp_proxy.py +0 -0
  26. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/mutagen_runtime.py +0 -0
  27. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/sync.py +0 -0
  28. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/sync_ssh_shim.py +0 -0
  29. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/templates/CLAUDE.md.template +0 -0
  30. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/src/cinna/templates/__init__.py +0 -0
  31. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/tests/__init__.py +0 -0
  32. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/tests/conftest.py +0 -0
  33. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/tests/test_auth.py +0 -0
  34. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/tests/test_bootstrap.py +0 -0
  35. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/tests/test_client.py +0 -0
  36. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/tests/test_config.py +0 -0
  37. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/tests/test_context.py +0 -0
  38. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/tests/test_mutagen_runtime.py +0 -0
  39. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/tests/test_sync.py +0 -0
  40. {cinna_cli-0.1.3 → cinna_cli-0.1.5}/tests/test_sync_ssh_shim.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cinna-cli
3
- Version: 0.1.3
3
+ Version: 0.1.5
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
@@ -127,10 +127,13 @@ Read-only views onto the live sync session (started by `cinna dev`).
127
127
 
128
128
  Stream a command through the platform to the remote agent environment. Output streams back live; Ctrl+C aborts. Exit code matches the remote process.
129
129
 
130
+ Arguments pass through transparently — each token is re-quoted before being sent, so spaces and shell metacharacters inside an argument survive intact. Use ordinary single-level quoting, exactly as for a local command. To run a shell snippet (pipes, redirects, `&&`), pass it to a shell explicitly: `cinna exec bash -c '…'`.
131
+
130
132
  ```bash
131
133
  cinna exec python scripts/main.py
132
134
  cinna exec pip install pandas
133
- cinna exec 'bash -c "ls -la"'
135
+ cinna exec bash -c 'ls -la'
136
+ cinna exec python -c 'import sys; print(sys.argv)' "a b"
134
137
  ```
135
138
 
136
139
  ### `cinna status`
@@ -90,10 +90,13 @@ Read-only views onto the live sync session (started by `cinna dev`).
90
90
 
91
91
  Stream a command through the platform to the remote agent environment. Output streams back live; Ctrl+C aborts. Exit code matches the remote process.
92
92
 
93
+ Arguments pass through transparently — each token is re-quoted before being sent, so spaces and shell metacharacters inside an argument survive intact. Use ordinary single-level quoting, exactly as for a local command. To run a shell snippet (pipes, redirects, `&&`), pass it to a shell explicitly: `cinna exec bash -c '…'`.
94
+
93
95
  ```bash
94
96
  cinna exec python scripts/main.py
95
97
  cinna exec pip install pandas
96
- cinna exec 'bash -c "ls -la"'
98
+ cinna exec bash -c 'ls -la'
99
+ cinna exec python -c 'import sys; print(sys.argv)' "a b"
97
100
  ```
98
101
 
99
102
  ### `cinna status`
@@ -328,6 +328,23 @@ Two surfaces expose conflicts:
328
328
 
329
329
  Ctrl+C closes the SSE stream; the platform cleans up the remote process. Interactive stdin (REPLs, debuggers) is out of scope for the current `/exec` endpoint.
330
330
 
331
+ ### Argument quoting
332
+
333
+ There are **two shell passes** between the keyboard and the remote process, and only one round of quoting belongs to each:
334
+
335
+ 1. **Local shell** (the caller's terminal / agent Bash tool) splits the command line into argv and strips one layer of quotes. `exec` is declared `nargs=-1`, so Click receives these already-split tokens as a tuple.
336
+ 2. **Remote shell** — the platform runs the `command` string through `/bin/sh -c`, re-parsing it a second time.
337
+
338
+ `exec_cmd` (`main.py`) bridges the two with `shlex.join(command)`: it re-quotes each token so the remote `sh -c` reconstructs the *exact* argv the caller typed. This makes `cinna exec` a transparent passthrough — callers write ordinary single-level quoting, exactly as for a local command:
339
+
340
+ ```bash
341
+ cinna exec python -c 'import sys; print(sys.argv)' "a b" '[{"x":"y z"}]'
342
+ ```
343
+
344
+ The historical `" ".join(command)` dropped the word boundaries that the local shell's quoting had implied, so any argument containing a space or a shell metacharacter (`;`, `(`, `{`, …) was re-split or mis-parsed by the remote shell — e.g. `print(sys.argv)` failing with `/bin/sh: Syntax error: word unexpected (expecting ")")`. Regression coverage: `test_exec_command_requotes_args` in `tests/test_main.py`.
345
+
346
+ To run an actual remote shell snippet (pipes, redirects, `&&`), pass it explicitly to a shell: `cinna exec bash -c 'a | b > c'`.
347
+
331
348
  ---
332
349
 
333
350
  ## Bootstrap Flow
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cinna-cli"
3
- version = "0.1.3"
3
+ version = "0.1.5"
4
4
  description = "Local development CLI for Cinna Core agents"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -272,7 +272,7 @@ def run_setup(setup_input: str, machine_name: str) -> None:
272
272
  # in the shared Mutagen daemon.
273
273
  if sync_started:
274
274
  console.status("Live sync attached — press Ctrl-C to stop.")
275
- sync_session.run_foreground(config)
275
+ sync_session.run_foreground(config, workspace_root)
276
276
  console.status("Sync session terminated.")
277
277
  finally:
278
278
  client.close()
@@ -125,7 +125,9 @@ class PlatformClient:
125
125
 
126
126
  # --- Remote exec (SSE stream) ---
127
127
 
128
- def stream_exec(self, agent_id: str, command: str) -> Iterator[dict]:
128
+ def stream_exec(
129
+ self, agent_id: str, command: str, timeout: int | None = None
130
+ ) -> Iterator[dict]:
129
131
  """POST /api/v1/cli/agents/{id}/exec — stream command output events.
130
132
 
131
133
  Yields parsed event dicts. Known shapes:
@@ -137,9 +139,21 @@ class PlatformClient:
137
139
 
138
140
  The caller is responsible for interpreting `done`/`interrupted` and
139
141
  mapping to a process exit code.
142
+
143
+ ``timeout`` (seconds) bounds the remote command's wall-clock run
144
+ time on the platform side. When omitted, the platform applies its
145
+ default.
140
146
  """
141
147
  url = f"/api/v1/cli/agents/{agent_id}/exec"
142
- payload = {"command": command}
148
+ payload: dict = {"command": command}
149
+ if timeout is not None:
150
+ payload["timeout"] = timeout
151
+ logger.info(
152
+ "stream_exec open: agent=%s timeout=%s cmd=%.200s",
153
+ agent_id,
154
+ timeout,
155
+ command,
156
+ )
143
157
  with self._client.stream(
144
158
  "POST", url, json=payload, timeout=EXEC_STREAM_TIMEOUT
145
159
  ) as response:
@@ -149,15 +163,23 @@ class PlatformClient:
149
163
  self._handle_response(response)
150
164
  return
151
165
 
166
+ logger.debug(
167
+ "stream_exec connected: agent=%s status=%s", agent_id, response.status_code
168
+ )
169
+ event_count = 0
152
170
  for line in response.iter_lines():
153
171
  if not line:
154
172
  continue
155
173
  if line.startswith("data: "):
156
174
  data_str = line[6:]
157
175
  try:
176
+ event_count += 1
158
177
  yield json.loads(data_str)
159
178
  except json.JSONDecodeError:
160
179
  logger.warning("Could not parse SSE event: %s", data_str[:200])
180
+ logger.debug(
181
+ "stream_exec closed: agent=%s events=%d", agent_id, event_count
182
+ )
161
183
 
162
184
  def close(self):
163
185
  self._client.close()
@@ -1,9 +1,12 @@
1
1
  """cinna CLI — local development for Cinna Core agents."""
2
2
 
3
+ import logging
3
4
  import os
4
5
  import platform
6
+ import shlex
5
7
  import shutil
6
8
  import sys
9
+ import time
7
10
  from pathlib import Path
8
11
 
9
12
  import click
@@ -21,6 +24,8 @@ from cinna.config import (
21
24
  from cinna.mcp_proxy import run_mcp_proxy
22
25
  from cinna.mutagen_runtime import ensure_mutagen_ready
23
26
 
27
+ logger = logging.getLogger("cinna.exec")
28
+
24
29
 
25
30
  @click.group()
26
31
  @click.version_option(version=__version__)
@@ -106,35 +111,71 @@ def set_token(setup_input: tuple[str, ...], name: str | None):
106
111
 
107
112
 
108
113
  @cli.command(name="exec", context_settings={"ignore_unknown_options": True})
114
+ @click.option(
115
+ "--timeout",
116
+ "-t",
117
+ type=click.IntRange(min=1, max=86400),
118
+ default=1800,
119
+ show_default=True,
120
+ help="Max wall-clock seconds the remote command may run before being killed.",
121
+ )
109
122
  @click.argument("command", nargs=-1, required=True)
110
- def exec_cmd(command: tuple[str, ...]):
123
+ def exec_cmd(timeout: int, command: tuple[str, ...]):
111
124
  """Run a command in the remote agent environment.
112
125
 
113
126
  Output streams back in real time via the platform. Exit code matches the
114
127
  remote process's exit code. Ctrl+C aborts the stream.
115
128
 
129
+ Arguments are passed through transparently: each token you type is
130
+ re-quoted (``shlex.quote``) before being sent, so spaces and shell
131
+ metacharacters inside an argument survive the remote shell intact. Use
132
+ ordinary single-level quoting, exactly as for a local command.
133
+
116
134
  Examples:
117
135
  cinna exec python scripts/main.py
118
136
  cinna exec pip install pandas
119
137
  cinna exec bash -c 'ls -la'
138
+ cinna exec python -c 'import sys; print(sys.argv)' "a b"
139
+ cinna exec --timeout 3600 python long_backfill.py
140
+
141
+ If your remote command takes its own ``--timeout`` flag, separate it
142
+ from cinna's option with ``--``:
143
+
144
+ cinna exec --timeout 3600 -- python tool.py --timeout 30
120
145
  """
121
146
  root = find_workspace_root()
122
147
  config = load_config(root)
123
148
 
124
- exit_code = _run_remote_exec(config, " ".join(command))
149
+ exit_code = _run_remote_exec(config, shlex.join(command), timeout=timeout)
125
150
  sys.exit(exit_code)
126
151
 
127
152
 
128
- def _run_remote_exec(config, command_str: str) -> int:
153
+ def _run_remote_exec(config, command_str: str, timeout: int = 1800) -> int:
129
154
  """Drive the /exec SSE stream and mirror events to the local terminal."""
130
155
  exit_code = 0
156
+ exec_id: str | None = None
157
+ started_at = time.monotonic()
158
+ stdout_bytes = 0
159
+ stderr_bytes = 0
160
+ first_delta_at: float | None = None
161
+ terminal_event: str = "no-terminal-event"
162
+
163
+ logger.info(
164
+ "exec start: agent=%s timeout=%ds cmd=%r",
165
+ config.agent_id,
166
+ timeout,
167
+ command_str,
168
+ )
169
+
131
170
  with PlatformClient(config) as client:
132
171
  try:
133
- for event in client.stream_exec(config.agent_id, command_str):
172
+ for event in client.stream_exec(
173
+ config.agent_id, command_str, timeout=timeout
174
+ ):
134
175
  etype = event.get("type")
135
176
  if etype == "exec_id":
136
- # First event — nothing to print. Remember it in case we
137
- # later ship an /exec-interrupt endpoint.
177
+ exec_id = event.get("exec_id")
178
+ logger.debug("exec_id assigned: %s", exec_id)
138
179
  continue
139
180
  if etype == "tool_result_delta":
140
181
  chunk = event.get("content", "")
@@ -142,15 +183,45 @@ def _run_remote_exec(config, command_str: str) -> int:
142
183
  target = sys.stderr if stream == "stderr" else sys.stdout
143
184
  target.write(chunk)
144
185
  target.flush()
186
+ nbytes = len(chunk.encode("utf-8", errors="replace"))
187
+ if stream == "stderr":
188
+ stderr_bytes += nbytes
189
+ else:
190
+ stdout_bytes += nbytes
191
+ if first_delta_at is None:
192
+ first_delta_at = time.monotonic()
193
+ logger.debug(
194
+ "exec first output (stream=%s, %d bytes) after %.3fs",
195
+ stream, nbytes, first_delta_at - started_at,
196
+ )
145
197
  elif etype == "done":
146
198
  exit_code = int(event.get("exit_code", 0))
199
+ terminal_event = "done"
200
+ logger.debug("exec done event: exit_code=%s", exit_code)
147
201
  elif etype == "interrupted":
148
202
  exit_code = int(event.get("exit_code", 130))
203
+ terminal_event = "interrupted"
204
+ logger.info("exec interrupted by remote: exit_code=%s", exit_code)
149
205
  elif etype == "error":
150
- console.error(event.get("content", "unknown error"))
206
+ msg = event.get("content", "unknown error")
207
+ console.error(msg)
151
208
  exit_code = 1
209
+ terminal_event = "error"
210
+ logger.error("exec remote error: %s", msg)
211
+ else:
212
+ logger.debug("exec unknown event type=%r: %.200s", etype, event)
152
213
  except KeyboardInterrupt:
153
214
  exit_code = 130
215
+ terminal_event = "keyboard-interrupt"
216
+ logger.info("exec interrupted locally (Ctrl-C)")
217
+
218
+ duration = time.monotonic() - started_at
219
+ logger.info(
220
+ "exec stop: agent=%s exec_id=%s exit_code=%s duration=%.3fs "
221
+ "stdout=%dB stderr=%dB terminal=%s",
222
+ config.agent_id, exec_id, exit_code, duration,
223
+ stdout_bytes, stderr_bytes, terminal_event,
224
+ )
154
225
  return exit_code
155
226
 
156
227
 
@@ -12,6 +12,7 @@ import shlex
12
12
  import shutil
13
13
  import subprocess
14
14
  import sys
15
+ import time
15
16
  from dataclasses import dataclass, field
16
17
  from pathlib import Path
17
18
 
@@ -179,6 +180,29 @@ def _looks_like_stale_daemon_error(stderr: str) -> bool:
179
180
  return any(marker in text for marker in _STALE_DAEMON_MARKERS)
180
181
 
181
182
 
183
+ # The backend closes the sync-stream WebSocket with code 1013 ("try again later")
184
+ # while it auto-activates a suspended agent environment. The shim surfaces this
185
+ # as a "received 1013 (try again later)" line and Mutagen reports it as a
186
+ # handshake EOF. Detect it so we can retry transparently instead of dumping the
187
+ # raw stack on the user.
188
+ _AGENT_ENV_WAKING_MARKERS = (
189
+ "received 1013",
190
+ "(try again later)",
191
+ )
192
+
193
+
194
+ def _looks_like_agent_env_waking(stderr: str, stdout: str = "") -> bool:
195
+ text = (stderr or "") + "\n" + (stdout or "")
196
+ return any(marker in text for marker in _AGENT_ENV_WAKING_MARKERS)
197
+
198
+
199
+ # Two retries spaced 5s apart give the backend ~10s to finish auto-activation
200
+ # before we give up. Auto-activation polls for "running" status with a 120s
201
+ # deadline server-side, but the WS handshake fails fast — each client retry
202
+ # re-triggers ensure_environment_running and re-polls.
203
+ _WAKING_RETRY_DELAYS_SECONDS = (5, 5)
204
+
205
+
182
206
  def _restart_daemon(config: CinnaConfig) -> None:
183
207
  """Bounce the Mutagen daemon so it picks up our env on next spawn.
184
208
 
@@ -278,15 +302,56 @@ def start(config: CinnaConfig, workspace_root: Path) -> SyncStatus:
278
302
  str(local_path),
279
303
  remote_url,
280
304
  ]
281
- result = _run_mutagen(args, config, cwd=workspace_root)
282
- if result.returncode != 0 and _looks_like_stale_daemon_error(result.stderr):
283
- # Daemon was started before our current MUTAGEN_SSH_PATH wiring. Bounce
284
- # it and retry once; the second pass runs against a fresh env.
285
- _restart_daemon(config)
305
+
306
+ stale_daemon_restarted = False
307
+ waking_attempt = 0 # how many "agent env waking" retries we've already burned
308
+ while True:
286
309
  result = _run_mutagen(args, config, cwd=workspace_root)
287
- if result.returncode != 0:
310
+ if result.returncode == 0:
311
+ break
312
+
313
+ if (
314
+ not stale_daemon_restarted
315
+ and _looks_like_stale_daemon_error(result.stderr)
316
+ ):
317
+ # Daemon was started before our current MUTAGEN_SSH_PATH wiring.
318
+ # Bounce it and retry; the second pass runs against a fresh env.
319
+ _restart_daemon(config)
320
+ stale_daemon_restarted = True
321
+ continue
322
+
323
+ if _looks_like_agent_env_waking(result.stderr, result.stdout):
324
+ if waking_attempt < len(_WAKING_RETRY_DELAYS_SECONDS):
325
+ delay = _WAKING_RETRY_DELAYS_SECONDS[waking_attempt]
326
+ total = len(_WAKING_RETRY_DELAYS_SECONDS)
327
+ waking_attempt += 1
328
+ console.warn(
329
+ "Agent environment is not ready yet (waking up?). "
330
+ f"Retrying in {delay}s ({waking_attempt}/{total})…"
331
+ )
332
+ logger.info(
333
+ "Agent env not ready (1013); retry %d/%d after %ds",
334
+ waking_attempt, total, delay,
335
+ )
336
+ time.sleep(delay)
337
+ # A failed `sync create` may leave a half-registered session in
338
+ # the daemon. Terminate it so the retry starts from a clean
339
+ # slate; ignore the result since "not found" is fine.
340
+ _run_mutagen(
341
+ ["sync", "terminate", session_name(config.agent_id)], config
342
+ )
343
+ continue
344
+ raise click.ClickException(
345
+ "Cannot reach the agent environment.\n"
346
+ "The platform reported the environment is still waking up or "
347
+ "unavailable after several retries.\n"
348
+ "Open the agent in the platform UI and confirm its environment "
349
+ "is running, then re-run 'cinna dev'."
350
+ )
351
+
288
352
  raise click.ClickException(
289
- f"Failed to create Mutagen session:\n{result.stderr.strip() or result.stdout.strip()}"
353
+ "Failed to create Mutagen session:\n"
354
+ f"{result.stderr.strip() or result.stdout.strip()}"
290
355
  )
291
356
 
292
357
  return status(config)
@@ -17,6 +17,7 @@ the sync after the TUI closes.
17
17
  from __future__ import annotations
18
18
 
19
19
  import asyncio
20
+ import contextlib
20
21
  import json
21
22
  import logging
22
23
  import os
@@ -199,8 +200,8 @@ class SyncApp(App):
199
200
  # 1/5 only do anything on the Conflicts tab with a row highlighted;
200
201
  # the actions no-op otherwise so the user can mash them harmlessly.
201
202
  # 1 and 5 are spaced apart on the keyboard to make a misfire unlikely.
202
- Binding("1", "take_remote", "1 take REMOTE", show=True, priority=True),
203
- Binding("5", "take_local", "5 take LOCAL", show=True, priority=True),
203
+ Binding("1", "take_remote", "take REMOTE", show=True, priority=True),
204
+ Binding("5", "take_local", "take LOCAL", show=True, priority=True),
204
205
  ]
205
206
 
206
207
  # JSON state comes from a long-running `mutagen sync monitor` subprocess
@@ -289,15 +290,30 @@ class SyncApp(App):
289
290
 
290
291
  async def on_unmount(self) -> None:
291
292
  self._shutting_down = True
292
- for task in (self._monitor_task, self._details_task):
293
- if task is not None:
294
- task.cancel()
295
293
  if self._monitor_proc is not None and self._monitor_proc.returncode is None:
296
294
  try:
297
295
  self._monitor_proc.terminate()
298
296
  except ProcessLookupError:
299
297
  pass
300
298
 
299
+ # Cancel the data loops AND await them. If we return before they finish,
300
+ # textual closes the asyncio loop while a `mutagen` subprocess is still
301
+ # running in the background; when that process eventually exits, its
302
+ # SIGCHLD is dispatched to a closed loop and we get "Loop <...> that
303
+ # handles pid N is closed" on stderr. Awaiting the tasks here gives
304
+ # their finally-blocks a chance to fully reap.
305
+ pending = [t for t in (self._monitor_task, self._details_task) if t is not None]
306
+ for task in pending:
307
+ task.cancel()
308
+ if pending:
309
+ try:
310
+ await asyncio.wait_for(
311
+ asyncio.gather(*pending, return_exceptions=True),
312
+ timeout=2.0,
313
+ )
314
+ except asyncio.TimeoutError:
315
+ logger.debug("data-loop tasks did not finish cleanly within 2s")
316
+
301
317
  def _disable_mouse_tracking(self) -> None:
302
318
  """Turn off the mouse-tracking modes textual enabled on startup.
303
319
 
@@ -361,6 +377,22 @@ class SyncApp(App):
361
377
  self.query_one("#conflicts-list", OptionList).focus()
362
378
  except Exception:
363
379
  pass
380
+ # Footer reflects bindings as of the last refresh; tab changes flip
381
+ # whether take_remote/take_local are applicable (see check_action).
382
+ self.refresh_bindings()
383
+
384
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
385
+ # 1/5 only resolve conflicts, which only makes sense on that tab.
386
+ # Returning False hides them from the footer entirely (None would
387
+ # leave them grayed out, which is still noisy on the other tabs).
388
+ if action in ("take_remote", "take_local"):
389
+ try:
390
+ active = self.query_one(TabbedContent).active
391
+ except Exception:
392
+ return True
393
+ if active != "conflicts-tab":
394
+ return False
395
+ return True
364
396
 
365
397
  # ── Data loops ────────────────────────────────────────────────────────
366
398
 
@@ -456,9 +488,22 @@ class SyncApp(App):
456
488
  env=self._env,
457
489
  start_new_session=True,
458
490
  )
459
- stdout, _ = await proc.communicate()
460
491
  except (FileNotFoundError, OSError) as exc:
461
492
  return f"(mutagen unavailable: {exc})"
493
+
494
+ try:
495
+ stdout, _ = await proc.communicate()
496
+ except asyncio.CancelledError:
497
+ # The TUI is shutting down; reap the child before it outlives the
498
+ # event loop and triggers a "Loop ... is closed" SIGCHLD warning.
499
+ if proc.returncode is None:
500
+ try:
501
+ proc.kill()
502
+ except ProcessLookupError:
503
+ pass
504
+ with contextlib.suppress(Exception):
505
+ await proc.wait()
506
+ raise
462
507
  if proc.returncode != 0:
463
508
  return ""
464
509
  return stdout.decode("utf-8", errors="replace").strip()
@@ -215,7 +215,69 @@ def test_exec_command(mock_load, mock_find, mock_exec, runner, workspace_root, s
215
215
 
216
216
  result = runner.invoke(cli, ["exec", "python", "scripts/main.py"])
217
217
  assert result.exit_code == 0
218
- mock_exec.assert_called_once_with(sample_config, "python scripts/main.py")
218
+ mock_exec.assert_called_once_with(
219
+ sample_config, "python scripts/main.py", timeout=1800
220
+ )
221
+
222
+
223
+ @patch("cinna.main._run_remote_exec")
224
+ @patch("cinna.main.find_workspace_root")
225
+ @patch("cinna.main.load_config")
226
+ def test_exec_command_requotes_args(
227
+ mock_load, mock_find, mock_exec, runner, workspace_root, sample_config
228
+ ):
229
+ """Tokens with spaces / shell metacharacters must be re-quoted so the
230
+ remote shell reconstructs the exact argv the caller intended, rather than
231
+ re-splitting them. Regression for `cinna exec` mangling quoted arguments.
232
+ """
233
+ mock_find.return_value = workspace_root
234
+ mock_load.return_value = sample_config
235
+ mock_exec.return_value = 0
236
+
237
+ result = runner.invoke(
238
+ cli,
239
+ ["exec", "python", "-c", "import sys; print(sys.argv)", "a b", '[{"x":"y z"}]'],
240
+ )
241
+ assert result.exit_code == 0
242
+ mock_exec.assert_called_once_with(
243
+ sample_config,
244
+ """python -c 'import sys; print(sys.argv)' 'a b' '[{"x":"y z"}]'""",
245
+ timeout=1800,
246
+ )
247
+
248
+
249
+ @patch("cinna.main.PlatformClient")
250
+ def test_run_remote_exec_logs_start_and_stop(mock_client_cls, sample_config, caplog):
251
+ """`cinna exec` must emit start/stop log records — without them this whole
252
+ code path is invisible in cinna.log, which has previously made remote
253
+ failures very hard to debug.
254
+ """
255
+ import logging
256
+ from cinna.main import _run_remote_exec
257
+
258
+ mock_client = mock_client_cls.return_value.__enter__.return_value
259
+ mock_client.stream_exec.return_value = iter([
260
+ {"type": "exec_id", "exec_id": "ex-1"},
261
+ {"type": "tool_result_delta", "content": "hi\n", "metadata": {"stream": "stdout"}},
262
+ {"type": "tool_result_delta", "content": "warn\n", "metadata": {"stream": "stderr"}},
263
+ {"type": "done", "exit_code": 0},
264
+ ])
265
+
266
+ with caplog.at_level(logging.DEBUG, logger="cinna.exec"):
267
+ exit_code = _run_remote_exec(sample_config, "echo hi")
268
+
269
+ assert exit_code == 0
270
+ messages = [r.getMessage() for r in caplog.records if r.name == "cinna.exec"]
271
+ assert any(m.startswith("exec start:") and "echo hi" in m for m in messages)
272
+ assert any("exec_id assigned: ex-1" in m for m in messages)
273
+ assert any(
274
+ m.startswith("exec stop:")
275
+ and "exit_code=0" in m
276
+ and "stdout=3B" in m
277
+ and "stderr=5B" in m
278
+ and "terminal=done" in m
279
+ for m in messages
280
+ )
219
281
 
220
282
 
221
283
  @patch("cinna.main.sync_session.run_foreground")
@@ -185,3 +185,106 @@ def test_start_retries_after_stale_daemon(mock_run, sample_config, tmp_path):
185
185
  assert any(args[:2] == ["daemon", "stop"] for args in mutagen_invocations)
186
186
  # Must have invoked sync create at least twice (retry after bounce).
187
187
  assert sum(1 for args in mutagen_invocations if args[:2] == ["sync", "create"]) == 2
188
+
189
+
190
+ def test_looks_like_agent_env_waking():
191
+ from cinna.sync_session import _looks_like_agent_env_waking
192
+
193
+ real_world_stderr = (
194
+ "Error: unable to connect to beta: unable to connect to endpoint: "
195
+ "unable to dial agent endpoint: unable to handshake with agent process: "
196
+ "unable to receive server magic number: EOF (error output: "
197
+ "cinna-sync-ssh: ws pump ended: received 1013 (try again later); "
198
+ "then sent 1013 (try again later))"
199
+ )
200
+ assert _looks_like_agent_env_waking(real_world_stderr)
201
+ assert _looks_like_agent_env_waking("", real_world_stderr)
202
+ assert not _looks_like_agent_env_waking("auth failed: 401")
203
+ assert not _looks_like_agent_env_waking("")
204
+
205
+
206
+ @patch("cinna.sync_session.time.sleep", lambda _s: None)
207
+ @patch("cinna.sync_session._run_mutagen")
208
+ def test_start_retries_when_agent_env_waking_then_succeeds(
209
+ mock_run, sample_config, tmp_path
210
+ ):
211
+ """When the backend closes the WS with 1013 ('try again later') because the
212
+ agent env is auto-activating, the CLI must retry a couple of times before
213
+ surfacing an error."""
214
+ from cinna.sync_session import start
215
+
216
+ (tmp_path / "workspace").mkdir()
217
+ waking_err = (
218
+ "Error: unable to handshake with agent process: "
219
+ "unable to receive server magic number: EOF (error output: "
220
+ "cinna-sync-ssh: ws pump ended: received 1013 (try again later))"
221
+ )
222
+
223
+ create_calls = {"n": 0}
224
+
225
+ def fake_run(args, *_a, **_kw):
226
+ first = args[0]
227
+ second = args[1] if len(args) > 1 else ""
228
+ if first == "daemon":
229
+ return MagicMock(returncode=0, stdout="", stderr="")
230
+ if first == "sync" and second == "list":
231
+ return MagicMock(returncode=0, stdout="[]", stderr="")
232
+ if first == "sync" and second == "terminate":
233
+ return MagicMock(returncode=0, stdout="", stderr="")
234
+ if first == "sync" and second == "create":
235
+ create_calls["n"] += 1
236
+ # First create fails with 1013; second one succeeds.
237
+ if create_calls["n"] == 1:
238
+ return MagicMock(returncode=1, stdout="", stderr=waking_err)
239
+ return MagicMock(returncode=0, stdout="", stderr="")
240
+ return MagicMock(returncode=0, stdout="", stderr="")
241
+
242
+ mock_run.side_effect = fake_run
243
+ start(sample_config, tmp_path)
244
+
245
+ assert create_calls["n"] == 2
246
+ # Between attempts we terminate the half-registered session.
247
+ invocations = [c.args[0] for c in mock_run.call_args_list]
248
+ assert any(args[:2] == ["sync", "terminate"] for args in invocations)
249
+
250
+
251
+ @patch("cinna.sync_session.time.sleep", lambda _s: None)
252
+ @patch("cinna.sync_session._run_mutagen")
253
+ def test_start_gives_up_with_friendly_error_after_max_waking_retries(
254
+ mock_run, sample_config, tmp_path
255
+ ):
256
+ """After exhausting the waking-env retries, the user sees a friendly
257
+ message instead of the raw Mutagen handshake-EOF stack."""
258
+ import click
259
+ import pytest
260
+
261
+ from cinna.sync_session import start
262
+
263
+ (tmp_path / "workspace").mkdir()
264
+ waking_err = (
265
+ "unable to handshake with agent process: received 1013 (try again later)"
266
+ )
267
+
268
+ def fake_run(args, *_a, **_kw):
269
+ first = args[0]
270
+ second = args[1] if len(args) > 1 else ""
271
+ if first == "daemon":
272
+ return MagicMock(returncode=0, stdout="", stderr="")
273
+ if first == "sync" and second == "list":
274
+ return MagicMock(returncode=0, stdout="[]", stderr="")
275
+ if first == "sync" and second == "terminate":
276
+ return MagicMock(returncode=0, stdout="", stderr="")
277
+ if first == "sync" and second == "create":
278
+ return MagicMock(returncode=1, stdout="", stderr=waking_err)
279
+ return MagicMock(returncode=0, stdout="", stderr="")
280
+
281
+ mock_run.side_effect = fake_run
282
+ with pytest.raises(click.ClickException) as exc_info:
283
+ start(sample_config, tmp_path)
284
+
285
+ msg = exc_info.value.format_message()
286
+ assert "Cannot reach the agent environment" in msg
287
+ # The raw Mutagen "magic number" / 1013 detail must not be in the surfaced
288
+ # error — that's the noise we're hiding from the user.
289
+ assert "1013" not in msg
290
+ assert "magic number" not in msg
@@ -136,7 +136,7 @@ wheels = [
136
136
 
137
137
  [[package]]
138
138
  name = "cinna-cli"
139
- version = "0.1.3"
139
+ version = "0.1.5"
140
140
  source = { editable = "." }
141
141
  dependencies = [
142
142
  { name = "click" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes