ai-cli-toolkit 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,1111 @@
1
+ """Remote package — assemble an isolated $HOME for tool sessions on remote hosts.
2
+
3
+ When launching a tool on a remote host, this module:
4
+ 1. Computes a deterministic session directory on the remote.
5
+ 2. Builds a manifest of local config/credential/instruction files to include.
6
+ 3. Pushes the package via rsync (single invocation from a staging tmpdir).
7
+ 4. Provides a probe to detect whether a tmux session is already running.
8
+ 5. Pulls session artifacts (logs, projects) back to the local host after exit.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ import os
16
+ import re
17
+ import shlex
18
+ import shutil
19
+ import subprocess
20
+ import tempfile
21
+ import textwrap
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path, PurePosixPath
24
+ from typing import Optional
25
+
26
+ from ai_cli.instructions import ensure_project_instructions_file
27
+ from ai_cli.remote import RemoteSpec, print_sync_status
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Data structures
31
+ # ---------------------------------------------------------------------------
32
+
33
+ _SSH_OPTS = "ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new"
34
+ _RSYNC_CHMOD = "Du=rwx,Dgo=,Fu=rw,Fgo="
35
+ _SKIP_FILE_NAMES = {".DS_Store"}
36
+ _MUX_SOURCE_FILES = ("Cargo.toml", "Cargo.lock", "src")
37
+
38
+ _TOOL_PACKAGE_FILES: dict[str, list[tuple[str, str]]] = {
39
+ "claude": [
40
+ ("~/.claude/.credentials.json", ".claude/.credentials.json"),
41
+ ("~/.claude/.credentials.json.enc", ".claude/.credentials.json.enc"),
42
+ ("~/.claude/.credentials.key", ".claude/.credentials.key"),
43
+ ("~/.claude/CLAUDE.md", ".claude/CLAUDE.md"),
44
+ ("~/.claude/settings.json", ".claude/settings.json"),
45
+ ("~/.claude/settings.local.json", ".claude/settings.local.json"),
46
+ ("~/.claude/developer_instructions.txt", ".claude/developer_instructions.txt"),
47
+ ("~/.claude/system_instructions.txt", ".claude/system_instructions.txt"),
48
+ ("~/.claude/statusline-command.sh", ".claude/statusline-command.sh"),
49
+ ("~/.claude/mcp-needs-auth-cache.json", ".claude/mcp-needs-auth-cache.json"),
50
+ ("~/.claude/stats-cache.json", ".claude/stats-cache.json"),
51
+ ("~/.claude/plugins/config.json", ".claude/plugins/config.json"),
52
+ ("~/.claude/plugins/blocklist.json", ".claude/plugins/blocklist.json"),
53
+ ("~/.claude/plugins/known_marketplaces.json", ".claude/plugins/known_marketplaces.json"),
54
+ ],
55
+ "codex": [
56
+ ("~/.codex/auth.json", ".codex/auth.json"),
57
+ ("~/.codex/config.toml", ".codex/config.toml"),
58
+ ("~/.codex/config.json", ".codex/config.json"),
59
+ ("~/.codex/.codex-global-state.json", ".codex/.codex-global-state.json"),
60
+ ("~/.codex/.personality_migration", ".codex/.personality_migration"),
61
+ ("~/.codex/AGENTS.md", ".codex/AGENTS.md"),
62
+ ("~/.codex/history.jsonl", ".codex/history.jsonl"),
63
+ ("~/.codex/instructions.md", ".codex/instructions.md"),
64
+ ("~/.codex/internal_storage.json", ".codex/internal_storage.json"),
65
+ ("~/.codex/models_cache.json", ".codex/models_cache.json"),
66
+ ("~/.codex/update-check.json", ".codex/update-check.json"),
67
+ ("~/.codex/version.json", ".codex/version.json"),
68
+ ("~/.codex/policy/default.codexpolicy", ".codex/policy/default.codexpolicy"),
69
+ ("~/.codex/rules/default.rules", ".codex/rules/default.rules"),
70
+ ],
71
+ "copilot": [
72
+ ("~/.copilot/config.json", ".copilot/config.json"),
73
+ ("~/.copilot/command-history-state.json", ".copilot/command-history-state.json"),
74
+ ("~/.copilot/AGENTS.md", ".copilot/AGENTS.md"),
75
+ ("~/.copilot/agents/dev-instructions.agent.md", ".copilot/agents/dev-instructions.agent.md"),
76
+ ("~/.copilot/.system/.codex-system-skills.marker", ".copilot/.system/.codex-system-skills.marker"),
77
+ ("~/.config/github-copilot/apps.json", ".config/github-copilot/apps.json"),
78
+ ("~/.config/github-copilot/byok.json", ".config/github-copilot/byok.json"),
79
+ ("~/.config/github-copilot/versions.json", ".config/github-copilot/versions.json"),
80
+ ("~/.config/github-copilot/xcode/mcp.json", ".config/github-copilot/xcode/mcp.json"),
81
+ ],
82
+ "gemini": [
83
+ ("~/.gemini/GEMINI.md", ".gemini/GEMINI.md"),
84
+ ("~/.gemini/google_accounts.json", ".gemini/google_accounts.json"),
85
+ ("~/.gemini/installation_id", ".gemini/installation_id"),
86
+ ("~/.gemini/oauth_creds.json", ".gemini/oauth_creds.json"),
87
+ ("~/.gemini/projects.json", ".gemini/projects.json"),
88
+ ("~/.gemini/settings.json", ".gemini/settings.json"),
89
+ ("~/.gemini/state.json", ".gemini/state.json"),
90
+ ("~/.gemini/trustedFolders.json", ".gemini/trustedFolders.json"),
91
+ ("~/.gemini/antigravity/installation_id", ".gemini/antigravity/installation_id"),
92
+ ("~/.gemini/antigravity/mcp_config.json", ".gemini/antigravity/mcp_config.json"),
93
+ ("~/.gemini/extensions/extension-enablement.json", ".gemini/extensions/extension-enablement.json"),
94
+ ("~/.gemini/policies/auto-saved.toml", ".gemini/policies/auto-saved.toml"),
95
+ ],
96
+ }
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class PackageFileEntry:
101
+ """One file to include in the remote package."""
102
+
103
+ remote_rel_path: str # relative to session dir (the fake $HOME)
104
+ local_path: Optional[Path] = None
105
+ content: Optional[str] = None
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class RemotePackage:
110
+ """Complete package definition for a remote tool session."""
111
+
112
+ tool_name: str
113
+ real_home: str # absolute remote home for the actual user account
114
+ session_dir: str # absolute path on remote, e.g. ~/.ai-cli/remote-sessions/claude-a1b2c3d4
115
+ tmux_socket: str # tmux -L socket name, e.g. "ai-cli-claude"
116
+ session_name: str # tmux session name inside the socket
117
+ project_prompt_rel_path: str # relative to session_dir
118
+ entries: list[PackageFileEntry] = field(default_factory=list)
119
+
120
+
121
+ def _normalize_target(system_name: str, machine_name: str) -> tuple[str, str]:
122
+ system = system_name.strip().lower()
123
+ machine = machine_name.strip().lower()
124
+ if system == "linux":
125
+ system = "linux"
126
+ elif system == "darwin":
127
+ system = "darwin"
128
+ if machine in {"x86_64", "amd64"}:
129
+ machine = "x86_64"
130
+ elif machine in {"aarch64", "arm64"}:
131
+ machine = "arm64"
132
+ return system, machine
133
+
134
+
135
+ def local_ai_mux_asset_path(system_name: str, machine_name: str) -> Path:
136
+ system, machine = _normalize_target(system_name, machine_name)
137
+ return Path(__file__).resolve().parent / "bin" / f"ai-mux-{system}-{machine}"
138
+
139
+
140
+ def resolve_remote_target(remote_spec: RemoteSpec) -> tuple[str, str]:
141
+ proc = subprocess.run(
142
+ [*_ssh_base(remote_spec), "uname -s && uname -m"],
143
+ capture_output=True,
144
+ text=True,
145
+ check=False,
146
+ )
147
+ if proc.returncode != 0:
148
+ raise RuntimeError(
149
+ f"Could not resolve remote target for {remote_spec.ssh_target}: "
150
+ f"{proc.stderr.strip() or 'ssh command failed'}"
151
+ )
152
+ parts = [line.strip() for line in proc.stdout.splitlines() if line.strip()]
153
+ if len(parts) < 2:
154
+ raise RuntimeError(
155
+ f"Could not resolve remote target for {remote_spec.ssh_target}: "
156
+ f"unexpected output {proc.stdout!r}"
157
+ )
158
+ return _normalize_target(parts[0], parts[1])
159
+
160
+
161
+ def ensure_remote_ai_mux_asset(remote_spec: RemoteSpec) -> Path:
162
+ system, machine = resolve_remote_target(remote_spec)
163
+ asset_path = local_ai_mux_asset_path(system, machine)
164
+
165
+ if (system, machine) != ("linux", "x86_64"):
166
+ raise RuntimeError(
167
+ f"No ai-mux asset available for remote target {system}/{machine}"
168
+ )
169
+
170
+ rsync_bin = shutil.which("rsync")
171
+ if not rsync_bin:
172
+ raise FileNotFoundError("rsync is required to build remote ai-mux assets.")
173
+
174
+ repo_root = Path(__file__).resolve().parent.parent
175
+ remote_src = "~/.ai-cli/build/ai-mux-src"
176
+ remote_bin = f"{remote_src}/target/release/ai-mux"
177
+ mkdir_proc = subprocess.run(
178
+ [*_ssh_base(remote_spec), f"mkdir -p {remote_src}"],
179
+ capture_output=True,
180
+ text=True,
181
+ check=False,
182
+ )
183
+ if mkdir_proc.returncode != 0:
184
+ raise RuntimeError(
185
+ f"Could not prepare remote ai-mux build dir: {mkdir_proc.stderr.strip()}"
186
+ )
187
+
188
+ with tempfile.TemporaryDirectory(prefix="ai-mux-src-") as staging:
189
+ staging_path = Path(staging)
190
+ mux_root = staging_path / "mux"
191
+ mux_root.mkdir(parents=True, exist_ok=True)
192
+ source_root = repo_root / "mux"
193
+ for rel in _MUX_SOURCE_FILES:
194
+ src = source_root / rel
195
+ dest = mux_root / rel
196
+ if src.is_dir():
197
+ shutil.copytree(src, dest, dirs_exist_ok=True)
198
+ else:
199
+ shutil.copy2(src, dest)
200
+ proc = subprocess.run(
201
+ [
202
+ rsync_bin,
203
+ "-az",
204
+ "-e",
205
+ _SSH_OPTS,
206
+ str(mux_root) + "/",
207
+ f"{remote_spec.ssh_target}:{remote_src}/",
208
+ ],
209
+ capture_output=True,
210
+ text=True,
211
+ check=False,
212
+ )
213
+ if proc.returncode != 0:
214
+ raise RuntimeError(
215
+ f"Could not sync ai-mux source to {remote_spec.ssh_target}: {proc.stderr.strip()}"
216
+ )
217
+
218
+ build_proc = subprocess.run(
219
+ [
220
+ *_ssh_base(remote_spec),
221
+ f"cd {remote_src} && cargo build --release",
222
+ ],
223
+ capture_output=True,
224
+ text=True,
225
+ check=False,
226
+ )
227
+ if build_proc.returncode != 0:
228
+ raise RuntimeError(
229
+ f"Could not build remote ai-mux asset on {remote_spec.ssh_target}: "
230
+ f"{build_proc.stderr.strip() or build_proc.stdout.strip()}"
231
+ )
232
+
233
+ asset_path.parent.mkdir(parents=True, exist_ok=True)
234
+ fetch_proc = subprocess.run(
235
+ [
236
+ rsync_bin,
237
+ "-az",
238
+ "-e",
239
+ _SSH_OPTS,
240
+ f"{remote_spec.ssh_target}:{remote_bin}",
241
+ str(asset_path),
242
+ ],
243
+ capture_output=True,
244
+ text=True,
245
+ check=False,
246
+ )
247
+ if fetch_proc.returncode != 0:
248
+ raise RuntimeError(
249
+ f"Could not fetch remote ai-mux asset from {remote_spec.ssh_target}: "
250
+ f"{fetch_proc.stderr.strip()}"
251
+ )
252
+ asset_path.chmod(0o755)
253
+ return asset_path
254
+
255
+
256
+ # ---------------------------------------------------------------------------
257
+ # Deterministic naming
258
+ # ---------------------------------------------------------------------------
259
+
260
+
261
+ def compute_session_dir(tool_name: str, remote_spec: RemoteSpec) -> str:
262
+ """Return the remote session directory path.
263
+
264
+ Deterministic: same tool + remote spec always gives the same path,
265
+ allowing session reuse across invocations.
266
+ """
267
+ digest = hashlib.sha256(
268
+ f"{tool_name}:{remote_spec.display}".encode()
269
+ ).hexdigest()[:12]
270
+ return f"~/.ai-cli/remote-sessions/{tool_name}-{digest}"
271
+
272
+
273
+ def compute_tmux_socket(tool_name: str) -> str:
274
+ """Return the tmux socket name for a tool (``tmux -L <name>``)."""
275
+ return f"ai-cli-{tool_name}"
276
+
277
+
278
+ def compute_session_name(tool_name: str, remote_spec: RemoteSpec) -> str:
279
+ """Return the tmux session name (matches the directory basename)."""
280
+ digest = hashlib.sha256(
281
+ f"{tool_name}:{remote_spec.display}".encode()
282
+ ).hexdigest()[:12]
283
+ return f"{tool_name}-{digest}"
284
+
285
+
286
+ # ---------------------------------------------------------------------------
287
+ # Manifest builder
288
+ # ---------------------------------------------------------------------------
289
+
290
+ def _add_if_exists(entries: list[PackageFileEntry], local: Path, rel: str) -> None:
291
+ if (
292
+ local.is_file()
293
+ and local.name not in _SKIP_FILE_NAMES
294
+ and not local.name.startswith("._")
295
+ and rel not in {entry.remote_rel_path for entry in entries}
296
+ ):
297
+ entries.append(PackageFileEntry(local_path=local, remote_rel_path=rel))
298
+
299
+
300
+ def _add_generated(entries: list[PackageFileEntry], rel: str, content: str) -> None:
301
+ if rel in {entry.remote_rel_path for entry in entries}:
302
+ return
303
+ entries.append(PackageFileEntry(content=content, remote_rel_path=rel))
304
+
305
+
306
+ def _add_pairs(entries: list[PackageFileEntry], pairs: list[tuple[str, str]]) -> None:
307
+ for local_raw, remote_rel in pairs:
308
+ _add_if_exists(entries, Path(local_raw).expanduser(), remote_rel)
309
+
310
+
311
+ def _render_shell_common(
312
+ tool_name: str,
313
+ remote_spec: RemoteSpec,
314
+ session_name: str,
315
+ project_prompt_remote_path: str,
316
+ ) -> str:
317
+ tool_q = shlex.quote(tool_name)
318
+ root_q = shlex.quote(remote_spec.path)
319
+ session_q = shlex.quote(session_name)
320
+ return textwrap.dedent(
321
+ f"""\
322
+ # ai-cli remote shell helpers
323
+ [ "${{AI_CLI_REMOTE_SHELL_COMMON_LOADED:-0}}" = 1 ] && return 0 2>/dev/null
324
+ export AI_CLI_REMOTE_SHELL_COMMON_LOADED=1
325
+
326
+ # Ensure real-home tool paths are available (nvm, .local/bin, cargo, etc.)
327
+ _rh="${{REAL_HOME:-$HOME}}"
328
+ [ -s "${{NVM_DIR:-$_rh/.nvm}}/nvm.sh" ] && . "${{NVM_DIR:-$_rh/.nvm}}/nvm.sh" 2>/dev/null
329
+ [ -s "$_rh/.config/fnm/fnm_multishells" ] && eval "$(fnm env 2>/dev/null)" 2>/dev/null
330
+ [ -d "$_rh/.volta" ] && export VOLTA_HOME="$_rh/.volta" && export PATH="$VOLTA_HOME/bin:$PATH"
331
+ case ":$PATH:" in *":$_rh/.local/bin:"*) ;; *) export PATH="$_rh/.local/bin:$PATH" ;; esac
332
+ case ":$PATH:" in *":$_rh/.cargo/bin:"*) ;; *) export PATH="$_rh/.cargo/bin:$PATH" ;; esac
333
+ unset _rh
334
+
335
+ export AI_CLI_REMOTE_TOOL={tool_q}
336
+ export AI_CLI_REMOTE_PROJECT_ROOT={root_q}
337
+ export AI_CLI_REMOTE_SESSION_NAME={session_q}
338
+ export AI_CLI_WORKDIR={root_q}
339
+ export AI_CLI_BASE_PROMPT_FILE="$HOME/.ai-cli/base_instructions.txt"
340
+ export AI_CLI_GLOBAL_PROMPT_FILE="$HOME/.ai-cli/system_instructions.txt"
341
+ export AI_CLI_TOOL_PROMPT_FILE="$HOME/.ai-cli/instructions/{tool_name}.txt"
342
+ export AI_CLI_PROJECT_PROMPT_FILE="{project_prompt_remote_path}"
343
+ export AI_CLI_PYTHON="${{AI_CLI_PYTHON:-python3}}"
344
+ export AI_CLI_PROMPT_EDITOR_LAUNCHER="$HOME/.ai-cli/bin/ai-prompt-editor"
345
+ export AI_CLI_REMOTE_COMMAND_LOG="$HOME/.ai-cli/logs/shell-commands.log"
346
+ export AI_CLI_REMOTE_SESSION_LOG="$HOME/.ai-cli/logs/shell-session.log"
347
+ export AI_CLI_REMOTE_TMUX_CONF="$HOME/.config/ai-cli/tmux.conf"
348
+
349
+ _ai_cli_log_dir() {{
350
+ printf '%s' "$HOME/.ai-cli/logs"
351
+ }}
352
+
353
+ _ai_cli_ensure_logs() {{
354
+ mkdir -p "$(_ai_cli_log_dir)" 2>/dev/null || true
355
+ }}
356
+
357
+ _ai_cli_ts() {{
358
+ date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null || date
359
+ }}
360
+
361
+ _ai_cli_scope_state() {{
362
+ case "$PWD" in
363
+ "$AI_CLI_REMOTE_PROJECT_ROOT"|"$AI_CLI_REMOTE_PROJECT_ROOT"/*) printf '%s' "in-scope" ;;
364
+ *) printf '%s' "OUT-OF-SCOPE" ;;
365
+ esac
366
+ }}
367
+
368
+ _ai_cli_log_command() {{
369
+ [ "${{AI_CLI_REMOTE_LOG_GUARD:-0}}" = 1 ] && return 0
370
+ AI_CLI_REMOTE_LOG_GUARD=1
371
+ _ai_cli_ensure_logs
372
+ _ai_cli_shell_name="${{2:-shell}}"
373
+ _ai_cli_cmd="${{1:-}}"
374
+ [ -n "$_ai_cli_cmd" ] || {{
375
+ AI_CLI_REMOTE_LOG_GUARD=0
376
+ unset _ai_cli_shell_name _ai_cli_cmd
377
+ return 0
378
+ }}
379
+ printf '%s shell=%s scope=%s cwd=%s cmd=%s\\n' \\
380
+ "$(_ai_cli_ts)" "$_ai_cli_shell_name" "$(_ai_cli_scope_state)" "$PWD" "$_ai_cli_cmd" >> "$AI_CLI_REMOTE_COMMAND_LOG"
381
+ AI_CLI_REMOTE_LOG_GUARD=0
382
+ unset _ai_cli_shell_name _ai_cli_cmd
383
+ }}
384
+
385
+ ai_rules() {{
386
+ printf '%s\\n' \\
387
+ "[ai-cli remote rules]" \\
388
+ "1. Default scope root: $AI_CLI_REMOTE_PROJECT_ROOT" \\
389
+ "2. Commands are logged to: $AI_CLI_REMOTE_COMMAND_LOG" \\
390
+ "3. Use ai_scope, ai_root, ai_scope_check, and ai_log_tail to manage session scope." \\
391
+ "4. Treat leaving the scope root as explicit and temporary."
392
+ }}
393
+
394
+ ai_scope() {{
395
+ printf '%s\\n' \\
396
+ "tool=$AI_CLI_REMOTE_TOOL" \\
397
+ "session=$AI_CLI_REMOTE_SESSION_NAME" \\
398
+ "home=$HOME" \\
399
+ "root=$AI_CLI_REMOTE_PROJECT_ROOT" \\
400
+ "cwd=$PWD" \\
401
+ "scope=$(_ai_cli_scope_state)" \\
402
+ "command_log=$AI_CLI_REMOTE_COMMAND_LOG" \\
403
+ "tmux_conf=$AI_CLI_REMOTE_TMUX_CONF"
404
+ }}
405
+
406
+ ai_root() {{
407
+ cd "$AI_CLI_REMOTE_PROJECT_ROOT" || return 1
408
+ }}
409
+
410
+ ai_scope_check() {{
411
+ printf '%s\\n' "scope=$(_ai_cli_scope_state) cwd=$PWD root=$AI_CLI_REMOTE_PROJECT_ROOT"
412
+ }}
413
+
414
+ ai_log_tail() {{
415
+ _ai_cli_ensure_logs
416
+ tail -n "${{1:-40}}" "$AI_CLI_REMOTE_COMMAND_LOG"
417
+ }}
418
+
419
+ _ai_cli_banner() {{
420
+ [ -t 1 ] || return 0
421
+ [ "${{AI_CLI_REMOTE_BANNER_SHOWN:-0}}" = 1 ] && return 0
422
+ export AI_CLI_REMOTE_BANNER_SHOWN=1
423
+ _ai_cli_ensure_logs
424
+ printf '[ai-cli remote] tool=%s root=%s log=%s\\n' \\
425
+ "$AI_CLI_REMOTE_TOOL" "$AI_CLI_REMOTE_PROJECT_ROOT" "$AI_CLI_REMOTE_COMMAND_LOG"
426
+ ai_rules
427
+ _ai_cli_log_command "__shell_start__" "shell"
428
+ }}
429
+
430
+ _ai_cli_scope_notice() {{
431
+ case "$PWD" in
432
+ "$AI_CLI_REMOTE_PROJECT_ROOT"|"$AI_CLI_REMOTE_PROJECT_ROOT"/*) ;;
433
+ *)
434
+ printf '[ai-cli remote] warning: outside scope root %s\\n' "$AI_CLI_REMOTE_PROJECT_ROOT" >&2
435
+ ;;
436
+ esac
437
+ }}
438
+ """
439
+ )
440
+
441
+
442
+ def _shell_escape(value: str) -> str:
443
+ if not value:
444
+ return "''"
445
+ safe = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.:/=+,@")
446
+ if all(ch in safe for ch in value):
447
+ return value
448
+ return "'" + value.replace("'", "'\\''") + "'"
449
+
450
+
451
+ def _render_editor_shell_cmd(
452
+ script_expr: str,
453
+ tmux_socket: str,
454
+ window_name: str,
455
+ target: str,
456
+ ) -> str:
457
+ return (
458
+ f"{script_expr} open --tmux-socket {shlex.quote(tmux_socket)} "
459
+ f"--window-name {shlex.quote(window_name)} --target {shlex.quote(target)}"
460
+ )
461
+
462
+
463
+ def _render_tmux_editor_binding(key: str, window_name: str, editor_cmd: str) -> str:
464
+ guard = "#{m:^edit-(global|base|tool|project)$,#{window_name}}"
465
+ return (
466
+ f"bind -n {key} if-shell -F {_shell_escape(guard)} "
467
+ f"{{ run-shell true }} "
468
+ f"{{ run-shell -b {_shell_escape(editor_cmd)} }}"
469
+ )
470
+
471
+
472
+ def _render_tmux_conf(tool_name: str, remote_spec: RemoteSpec) -> str:
473
+ script_expr = "$HOME/.ai-cli/bin/ai-prompt-editor"
474
+ tmux_socket = compute_tmux_socket(tool_name)
475
+ edit_global_cmd = _render_editor_shell_cmd(
476
+ script_expr, tmux_socket, "edit-global", "global"
477
+ )
478
+ edit_base_cmd = _render_editor_shell_cmd(
479
+ script_expr, tmux_socket, "edit-base", "base"
480
+ )
481
+ edit_tool_cmd = _render_editor_shell_cmd(
482
+ script_expr,
483
+ tmux_socket,
484
+ "edit-tool",
485
+ "tool",
486
+ )
487
+ edit_project_cmd = _render_editor_shell_cmd(
488
+ script_expr,
489
+ tmux_socket,
490
+ "edit-project",
491
+ "project",
492
+ )
493
+ lines = [
494
+ "# ai-mux tmux configuration (auto-generated, do not edit)",
495
+ "# User overrides: ~/.config/ai-cli/tmux.conf",
496
+ "",
497
+ "set -g mouse on",
498
+ "set -g status-position top",
499
+ "set -sg escape-time 0",
500
+ "set -g xterm-keys on",
501
+ "set -g default-terminal 'screen-256color'",
502
+ "set -g focus-events on",
503
+ "set -g history-limit 50000",
504
+ "set -g remain-on-exit off",
505
+ "set -g renumber-windows off",
506
+ "set -g base-index 0",
507
+ "set -g allow-rename off",
508
+ "set -g automatic-rename off",
509
+ "",
510
+ "# Status bar",
511
+ "set -g status-style 'bg=#333333,fg=white,dim'",
512
+ "set -g status-left ''",
513
+ "set -g status-right '#[fg=#ffffff,bold] F5:global F6:base F7:tool F8:project │ C-] prefix '",
514
+ "set -g status-left-length 0",
515
+ "set -g status-right-length 60",
516
+ "set -g window-status-format ' #W '",
517
+ "set -g window-status-current-format '#[bold,noreverse] #W '",
518
+ "set -g window-status-current-style 'bg=default,fg=white,bold'",
519
+ "set -g window-status-style 'bg=#333333,fg=#999999'",
520
+ "set -g window-status-separator ''",
521
+ "",
522
+ "# Use C-] as prefix (avoids conflicts with tools)",
523
+ "unbind C-b",
524
+ "set -g prefix C-]",
525
+ "bind C-] send-prefix",
526
+ "",
527
+ "# Key bindings (no prefix)",
528
+ "bind -n F2 select-window -t :0",
529
+ "bind -n F3 select-window -t :1",
530
+ "bind -n F4 select-window -t :2",
531
+ "bind -n F10 select-window -t :5",
532
+ "bind -n F11 select-window -t :6",
533
+ "bind -n F9 select-window -t :7",
534
+ _render_tmux_editor_binding("F5", "edit-global", edit_global_cmd),
535
+ _render_tmux_editor_binding("F6", "edit-base", edit_base_cmd),
536
+ _render_tmux_editor_binding("F7", "edit-tool", edit_tool_cmd),
537
+ _render_tmux_editor_binding("F8", "edit-project", edit_project_cmd),
538
+ "bind -n M-2 select-window -t :0",
539
+ "bind -n M-3 select-window -t :1",
540
+ "bind -n M-4 select-window -t :2",
541
+ "bind -n M-5 select-window -t :3",
542
+ "bind -n M-6 select-window -t :4",
543
+ "bind -n M-7 select-window -t :5",
544
+ "bind -n M-8 select-window -t :6",
545
+ "bind -n M-9 select-window -t :7",
546
+ "bind -n M-1 choose-tree -s",
547
+ "bind -n F1 choose-tree -s",
548
+ "bind -n C-n next-window",
549
+ "bind -n C-p previous-window",
550
+ "bind -n M-Left previous-window",
551
+ "bind -n M-Right next-window",
552
+ "bind q detach-client",
553
+ "",
554
+ "# User overrides",
555
+ "if-shell 'test -f ~/.config/ai-cli/tmux.conf' 'source-file ~/.config/ai-cli/tmux.conf'",
556
+ ]
557
+ return "\n".join(lines) + "\n"
558
+
559
+
560
+ def _generated_shell_files(
561
+ tool_name: str,
562
+ remote_spec: RemoteSpec,
563
+ session_name: str,
564
+ project_prompt_remote_path: str,
565
+ ) -> list[PackageFileEntry]:
566
+ shell_common = _render_shell_common(
567
+ tool_name,
568
+ remote_spec,
569
+ session_name,
570
+ project_prompt_remote_path,
571
+ )
572
+ tmux_conf = _render_tmux_conf(tool_name, remote_spec)
573
+ profile = textwrap.dedent(
574
+ """\
575
+ [ -f "$HOME/.ai-cli/shell-common.sh" ] && . "$HOME/.ai-cli/shell-common.sh"
576
+ export BASH_ENV="$HOME/.bash_env"
577
+ export ENV="$HOME/.shrc"
578
+ export KSHRC="$HOME/.kshrc"
579
+ export ZDOTDIR="$HOME"
580
+ _ai_cli_banner
581
+ """
582
+ )
583
+ bash_rc = textwrap.dedent(
584
+ """\
585
+ [ -f "$HOME/.ai-cli/shell-common.sh" ] && . "$HOME/.ai-cli/shell-common.sh"
586
+ export BASH_ENV="$HOME/.bash_env"
587
+ export ENV="$HOME/.shrc"
588
+ export KSHRC="$HOME/.kshrc"
589
+ _ai_cli_bash_prompt_hook() {
590
+ _ai_cli_banner
591
+ _ai_cli_scope_notice
592
+ _ai_cli_bash_last="$(history 1 2>/dev/null | sed 's/^ *[0-9][0-9]* *//')"
593
+ if [ -n "$_ai_cli_bash_last" ] && [ "$_ai_cli_bash_last" != "${AI_CLI_REMOTE_LAST_BASH_COMMAND:-}" ]; then
594
+ AI_CLI_REMOTE_LAST_BASH_COMMAND="$_ai_cli_bash_last"
595
+ _ai_cli_log_command "$_ai_cli_bash_last" "bash"
596
+ fi
597
+ unset _ai_cli_bash_last
598
+ }
599
+ case ";${PROMPT_COMMAND:-};" in
600
+ *";_ai_cli_bash_prompt_hook;"*) ;;
601
+ *) PROMPT_COMMAND="_ai_cli_bash_prompt_hook${PROMPT_COMMAND:+;$PROMPT_COMMAND}" ;;
602
+ esac
603
+ """
604
+ )
605
+ bash_env = textwrap.dedent(
606
+ """\
607
+ [ -f "$HOME/.ai-cli/shell-common.sh" ] && . "$HOME/.ai-cli/shell-common.sh"
608
+ export BASH_ENV="$HOME/.bash_env"
609
+ export ENV="$HOME/.shrc"
610
+ export KSHRC="$HOME/.kshrc"
611
+ _ai_cli_bash_debug_hook() {
612
+ [ "${AI_CLI_REMOTE_LOG_GUARD:-0}" = 1 ] && return 0
613
+ _ai_cli_bash_debug_cmd="${BASH_COMMAND:-}"
614
+ case "$_ai_cli_bash_debug_cmd" in
615
+ ""|_ai_cli_*|history\\ *|trap\\ *|hash\\ -r*|unalias\\ *) return 0 ;;
616
+ esac
617
+ _ai_cli_log_command "$_ai_cli_bash_debug_cmd" "bash"
618
+ }
619
+ trap '_ai_cli_bash_debug_hook' DEBUG
620
+ """
621
+ )
622
+ bash_profile = textwrap.dedent(
623
+ """\
624
+ [ -f "$HOME/.profile" ] && . "$HOME/.profile"
625
+ [ -f "$HOME/.bashrc" ] && . "$HOME/.bashrc"
626
+ """
627
+ )
628
+ zshenv = textwrap.dedent(
629
+ """\
630
+ [ -f "$HOME/.ai-cli/shell-common.sh" ] && . "$HOME/.ai-cli/shell-common.sh"
631
+ export ENV="$HOME/.shrc"
632
+ export BASH_ENV="$HOME/.bash_env"
633
+ export KSHRC="$HOME/.kshrc"
634
+ export ZDOTDIR="$HOME"
635
+ """
636
+ )
637
+ zshrc = textwrap.dedent(
638
+ """\
639
+ [ -f "$HOME/.ai-cli/shell-common.sh" ] && . "$HOME/.ai-cli/shell-common.sh"
640
+ export ENV="$HOME/.shrc"
641
+ export BASH_ENV="$HOME/.bash_env"
642
+ export KSHRC="$HOME/.kshrc"
643
+ autoload -Uz add-zsh-hook 2>/dev/null || true
644
+ if [ "${AI_CLI_REMOTE_ZSH_HOOKS:-0}" != 1 ]; then
645
+ _ai_cli_zsh_preexec() {
646
+ _ai_cli_log_command "$1" "zsh"
647
+ }
648
+ _ai_cli_zsh_precmd() {
649
+ _ai_cli_banner
650
+ _ai_cli_scope_notice
651
+ }
652
+ add-zsh-hook preexec _ai_cli_zsh_preexec 2>/dev/null || true
653
+ add-zsh-hook precmd _ai_cli_zsh_precmd 2>/dev/null || true
654
+ export AI_CLI_REMOTE_ZSH_HOOKS=1
655
+ fi
656
+ """
657
+ )
658
+ zprofile = textwrap.dedent(
659
+ """\
660
+ [ -f "$HOME/.profile" ] && . "$HOME/.profile"
661
+ [ -f "$HOME/.zshrc" ] && . "$HOME/.zshrc"
662
+ """
663
+ )
664
+ shrc = textwrap.dedent(
665
+ """\
666
+ [ -f "$HOME/.ai-cli/shell-common.sh" ] && . "$HOME/.ai-cli/shell-common.sh"
667
+ export ENV="$HOME/.shrc"
668
+ export KSHRC="$HOME/.kshrc"
669
+ _ai_cli_banner
670
+ _ai_cli_scope_notice
671
+ """
672
+ )
673
+ return [
674
+ PackageFileEntry(remote_rel_path=".ai-cli/shell-common.sh", content=shell_common),
675
+ PackageFileEntry(remote_rel_path=".profile", content=profile),
676
+ PackageFileEntry(remote_rel_path=".bashrc", content=bash_rc),
677
+ PackageFileEntry(remote_rel_path=".bash_env", content=bash_env),
678
+ PackageFileEntry(remote_rel_path=".bash_profile", content=bash_profile),
679
+ PackageFileEntry(remote_rel_path=".bash_login", content=bash_profile),
680
+ PackageFileEntry(remote_rel_path=".zshenv", content=zshenv),
681
+ PackageFileEntry(remote_rel_path=".zshrc", content=zshrc),
682
+ PackageFileEntry(remote_rel_path=".zprofile", content=zprofile),
683
+ PackageFileEntry(remote_rel_path=".shrc", content=shrc),
684
+ PackageFileEntry(remote_rel_path=".kshrc", content=shrc),
685
+ PackageFileEntry(remote_rel_path=".tmux.conf", content=tmux_conf),
686
+ ]
687
+
688
+
689
+ def _ssh_base(remote_spec: RemoteSpec) -> list[str]:
690
+ return ["ssh"] + _SSH_OPTS.split()[1:] + [remote_spec.ssh_target]
691
+
692
+
693
+ def resolve_remote_home(remote_spec: RemoteSpec) -> str:
694
+ """Resolve the remote user's absolute home directory via SSH."""
695
+ proc = subprocess.run(
696
+ [*_ssh_base(remote_spec), "printf '%s\\n' \"$HOME\""],
697
+ capture_output=True,
698
+ text=True,
699
+ check=False,
700
+ )
701
+ if proc.returncode != 0:
702
+ raise RuntimeError(
703
+ f"Could not resolve remote home for {remote_spec.ssh_target}: "
704
+ f"{proc.stderr.strip() or 'ssh command failed'}"
705
+ )
706
+ remote_home = proc.stdout.strip()
707
+ if not remote_home.startswith("/"):
708
+ raise RuntimeError(
709
+ f"Could not resolve remote home for {remote_spec.ssh_target}: "
710
+ f"unexpected value {remote_home!r}"
711
+ )
712
+ return remote_home
713
+
714
+
715
+ def build_package_manifest(
716
+ tool_name: str,
717
+ remote_spec: RemoteSpec,
718
+ *,
719
+ ca_path: Optional[Path] = None,
720
+ ai_mux_binary: Optional[Path] = None,
721
+ ) -> RemotePackage:
722
+ """Build the file manifest for a remote tool session.
723
+
724
+ Only includes entries whose local source actually exists.
725
+ """
726
+ session_rel_dir = PurePosixPath(compute_session_dir(tool_name, remote_spec).removeprefix("~/"))
727
+ real_home = resolve_remote_home(remote_spec)
728
+ session_dir = str(PurePosixPath(real_home) / session_rel_dir)
729
+ tmux_socket = compute_tmux_socket(tool_name)
730
+ session_name = compute_session_name(tool_name, remote_spec)
731
+
732
+ entries: list[PackageFileEntry] = []
733
+
734
+ # ── Common (all tools) ────────────────────────────────────────────────
735
+ ai_cli_dir = Path("~/.ai-cli").expanduser()
736
+ project_prompt_file = Path(
737
+ ensure_project_instructions_file(
738
+ project_cwd=remote_spec.path,
739
+ remote_spec=remote_spec.display,
740
+ )
741
+ ).expanduser()
742
+ project_prompt_meta = project_prompt_file.parent / "meta.json"
743
+ project_prompt_rel = project_prompt_file.relative_to(ai_cli_dir).as_posix()
744
+ project_prompt_rel_path = f".ai-cli/{project_prompt_rel}"
745
+
746
+ _add_if_exists(entries, ai_cli_dir / "config.json", ".ai-cli/config.json")
747
+ _add_if_exists(
748
+ entries, ai_cli_dir / "system_instructions.txt", ".ai-cli/system_instructions.txt"
749
+ )
750
+ _add_if_exists(
751
+ entries,
752
+ Path("~/.config/ai-cli/tmux.conf").expanduser(),
753
+ ".config/ai-cli/tmux.conf",
754
+ )
755
+
756
+ # User's base instructions (prefer user copy; fall back to shipped template)
757
+ user_base = ai_cli_dir / "base_instructions.txt"
758
+ shipped_base = Path(__file__).resolve().parent.parent / "templates" / "base_instructions.txt"
759
+ if user_base.is_file():
760
+ entries.append(
761
+ PackageFileEntry(
762
+ remote_rel_path=".ai-cli/base_instructions.txt",
763
+ local_path=user_base,
764
+ )
765
+ )
766
+ elif shipped_base.is_file():
767
+ entries.append(
768
+ PackageFileEntry(
769
+ remote_rel_path=".ai-cli/base_instructions.txt",
770
+ local_path=shipped_base,
771
+ )
772
+ )
773
+
774
+ # Per-tool instructions — push ALL tool files so F7 edits see them all
775
+ instructions_dir = ai_cli_dir / "instructions"
776
+ if instructions_dir.is_dir():
777
+ for txt in sorted(instructions_dir.glob("*.txt")):
778
+ _add_if_exists(
779
+ entries,
780
+ txt,
781
+ f".ai-cli/instructions/{txt.name}",
782
+ )
783
+ _add_if_exists(entries, project_prompt_file, project_prompt_rel_path)
784
+ _add_if_exists(
785
+ entries,
786
+ project_prompt_meta,
787
+ f".ai-cli/{project_prompt_meta.relative_to(ai_cli_dir).as_posix()}",
788
+ )
789
+ _add_if_exists(
790
+ entries,
791
+ Path(__file__).resolve().parent / "prompt_editor_launcher.py",
792
+ ".ai-cli/bin/ai-prompt-editor",
793
+ )
794
+ if ai_mux_binary is not None:
795
+ _add_if_exists(
796
+ entries,
797
+ ai_mux_binary.expanduser(),
798
+ ".ai-cli/bin/ai-mux",
799
+ )
800
+
801
+ # CA certificate (proxy trust)
802
+ if ca_path is not None:
803
+ ca_resolved = Path(ca_path).expanduser()
804
+ if ca_resolved.is_file():
805
+ entries.append(
806
+ PackageFileEntry(
807
+ remote_rel_path=".ai-cli/remote-ca.pem",
808
+ local_path=ca_resolved,
809
+ )
810
+ )
811
+ entries.append(
812
+ PackageFileEntry(
813
+ remote_rel_path=".mitmproxy/mitmproxy-ca-cert.pem",
814
+ local_path=ca_resolved,
815
+ )
816
+ )
817
+
818
+ # ── Tool-specific startup state ───────────────────────────────────────
819
+ _add_pairs(entries, _TOOL_PACKAGE_FILES.get(tool_name, []))
820
+ for entry in _generated_shell_files(
821
+ tool_name,
822
+ remote_spec,
823
+ session_name,
824
+ f"$HOME/{project_prompt_rel_path}",
825
+ ):
826
+ _add_generated(entries, entry.remote_rel_path, entry.content or "")
827
+
828
+ return RemotePackage(
829
+ tool_name=tool_name,
830
+ real_home=real_home,
831
+ session_dir=session_dir,
832
+ tmux_socket=tmux_socket,
833
+ session_name=session_name,
834
+ project_prompt_rel_path=project_prompt_rel_path,
835
+ entries=entries,
836
+ )
837
+
838
+
839
+ # ---------------------------------------------------------------------------
840
+ # Push
841
+ # ---------------------------------------------------------------------------
842
+
843
+
844
+ def push_package(
845
+ package: RemotePackage,
846
+ remote_spec: RemoteSpec,
847
+ ) -> None:
848
+ """Push the package to the remote host via rsync from a staging tmpdir."""
849
+ if not package.entries:
850
+ return
851
+
852
+ rsync_bin = shutil.which("rsync")
853
+ if not rsync_bin:
854
+ raise FileNotFoundError(
855
+ "rsync is required for remote package push but was not found on PATH."
856
+ )
857
+
858
+ with tempfile.TemporaryDirectory(prefix="ai-cli-pkg-") as staging:
859
+ staging_path = Path(staging)
860
+ for entry in package.entries:
861
+ dest = staging_path / entry.remote_rel_path
862
+ dest.parent.mkdir(parents=True, exist_ok=True)
863
+ if entry.local_path is not None:
864
+ shutil.copy2(str(entry.local_path), str(dest))
865
+ elif entry.content is not None:
866
+ dest.write_text(entry.content, encoding="utf-8")
867
+ else:
868
+ raise RuntimeError(
869
+ f"Package entry {entry.remote_rel_path} is missing both local_path and content."
870
+ )
871
+
872
+ # Ensure the remote session dir exists
873
+ mkdir_proc = subprocess.run(
874
+ [*_ssh_base(remote_spec), f"mkdir -p {shlex.quote(package.session_dir)}"],
875
+ check=False,
876
+ capture_output=True,
877
+ text=True,
878
+ )
879
+ if mkdir_proc.returncode != 0:
880
+ raise RuntimeError(
881
+ f"Could not create remote session dir {package.session_dir} "
882
+ f"(rc={mkdir_proc.returncode}): {mkdir_proc.stderr.strip()}"
883
+ )
884
+
885
+ # Single rsync push — chmod ensures credentials aren't world-readable
886
+ proc = subprocess.run(
887
+ [
888
+ rsync_bin,
889
+ "-az",
890
+ f"--chmod={_RSYNC_CHMOD}",
891
+ "-e",
892
+ _SSH_OPTS,
893
+ str(staging_path) + "/",
894
+ f"{remote_spec.ssh_target}:{package.session_dir}/",
895
+ ],
896
+ capture_output=True,
897
+ text=True,
898
+ check=False,
899
+ )
900
+ if proc.returncode != 0:
901
+ raise RuntimeError(
902
+ f"rsync package push failed (rc={proc.returncode}): {proc.stderr.strip()}"
903
+ )
904
+ subprocess.run(
905
+ [
906
+ *_ssh_base(remote_spec),
907
+ f"chmod 700 {shlex.quote(package.session_dir + '/.ai-cli/bin/ai-prompt-editor')} 2>/dev/null || true",
908
+ ],
909
+ capture_output=True,
910
+ text=True,
911
+ check=False,
912
+ )
913
+
914
+ # Also push tool config/auth files to the REAL home on the remote.
915
+ # Some tools (e.g. claude) resolve $HOME via getpwuid/passwd and
916
+ # ignore our HOME override, so credentials must also live there.
917
+ tool_pairs = _TOOL_PACKAGE_FILES.get(package.tool_name, [])
918
+ if tool_pairs and package.real_home:
919
+ real_staging = Path(staging) / "__real_home__"
920
+ real_staging.mkdir(exist_ok=True)
921
+ has_files = False
922
+ for local_raw, remote_rel in tool_pairs:
923
+ src = Path(local_raw).expanduser()
924
+ if src.is_file():
925
+ dest = real_staging / remote_rel
926
+ dest.parent.mkdir(parents=True, exist_ok=True)
927
+ shutil.copy2(str(src), str(dest))
928
+ has_files = True
929
+ if has_files:
930
+ subprocess.run(
931
+ [
932
+ rsync_bin,
933
+ "-az",
934
+ f"--chmod={_RSYNC_CHMOD}",
935
+ "-e",
936
+ _SSH_OPTS,
937
+ str(real_staging) + "/",
938
+ f"{remote_spec.ssh_target}:{package.real_home}/",
939
+ ],
940
+ capture_output=True,
941
+ text=True,
942
+ check=False,
943
+ )
944
+
945
+
946
+ # ---------------------------------------------------------------------------
947
+ # Probe
948
+ # ---------------------------------------------------------------------------
949
+
950
+
951
+ def probe_remote_session(
952
+ package: RemotePackage,
953
+ remote_spec: RemoteSpec,
954
+ ) -> bool:
955
+ """Check if a tmux session for this package already exists on the remote."""
956
+ proc = subprocess.run(
957
+ [
958
+ *_ssh_base(remote_spec),
959
+ f"tmux -L {shlex.quote(package.tmux_socket)} "
960
+ f"has-session -t {shlex.quote(package.session_name)} 2>/dev/null",
961
+ ],
962
+ capture_output=True,
963
+ check=False,
964
+ )
965
+ return proc.returncode == 0
966
+
967
+
968
+ def reattach_remote_session(
969
+ package: RemotePackage,
970
+ remote_spec: RemoteSpec,
971
+ *,
972
+ proxy_port: int = 0,
973
+ ssh_opts: Optional[list[str]] = None,
974
+ ) -> int:
975
+ """Reattach to an existing remote tmux session.
976
+
977
+ Sets up the SSH reverse tunnel if *proxy_port* is non-zero.
978
+ """
979
+ sock_q = shlex.quote(package.tmux_socket)
980
+ sess_q = shlex.quote(package.session_name)
981
+ conf_q = shlex.quote(f"{package.session_dir}/.tmux.conf")
982
+ remote_cmd = (
983
+ f"for c in $(tmux -L {sock_q} list-clients -t {sess_q} -F '#{{client_tty}}' 2>/dev/null); do "
984
+ f"tmux -L {sock_q} detach-client -t \"$c\" 2>/dev/null; done; "
985
+ f"tmux -L {sock_q} source-file {conf_q} >/dev/null 2>&1; "
986
+ f"tmux -L {sock_q} attach -t {sess_q}"
987
+ )
988
+
989
+ ssh_cmd = [
990
+ "ssh",
991
+ "-o", "PermitLocalCommand=no",
992
+ "-o", "ServerAliveInterval=30",
993
+ "-o", "ServerAliveCountMax=3",
994
+ "-o", "RequestTTY=force",
995
+ ]
996
+ if proxy_port:
997
+ ssh_cmd += ["-R", f"127.0.0.1:{proxy_port}:127.0.0.1:{proxy_port}"]
998
+ for opt in (ssh_opts or []):
999
+ ssh_cmd.append(opt)
1000
+ ssh_cmd += [remote_spec.ssh_target, remote_cmd]
1001
+
1002
+ print_sync_status(f"Reattaching to existing session '{package.session_name}'")
1003
+ proc = subprocess.run(ssh_cmd, check=False)
1004
+ return proc.returncode
1005
+
1006
+
1007
+ def render_remote_ai_mux_config(
1008
+ *,
1009
+ tool_name: str,
1010
+ session_name: str,
1011
+ command: list[str],
1012
+ cwd: str,
1013
+ env: dict[str, str],
1014
+ ) -> str:
1015
+ payload = {
1016
+ "session_name": session_name,
1017
+ "tabs": [
1018
+ {
1019
+ "label": tool_name,
1020
+ "cmd": command,
1021
+ "env": env,
1022
+ "cwd": cwd,
1023
+ "primary": True,
1024
+ }
1025
+ ],
1026
+ }
1027
+ return json.dumps(payload)
1028
+
1029
+
1030
+ # ---------------------------------------------------------------------------
1031
+ # Pull artifacts
1032
+ # ---------------------------------------------------------------------------
1033
+
1034
+ _ARTIFACT_GLOBS = [
1035
+ ".ai-cli/logs/",
1036
+ ".ai-cli/system_instructions.txt",
1037
+ ".ai-cli/base_instructions.txt",
1038
+ ".ai-cli/instructions/",
1039
+ ".ai-cli/project-prompts/",
1040
+ ".claude/projects/",
1041
+ ".codex/sessions/",
1042
+ ".codex/projects/",
1043
+ ".copilot/sessions/",
1044
+ ".gemini/sessions/",
1045
+ ]
1046
+
1047
+
1048
+ def pull_session_artifacts(
1049
+ package: RemotePackage,
1050
+ remote_spec: RemoteSpec,
1051
+ local_dest: Path,
1052
+ ) -> None:
1053
+ """Pull logs and session data from the remote session dir back to local."""
1054
+ local_dest.mkdir(parents=True, exist_ok=True)
1055
+ rsync_bin = shutil.which("rsync")
1056
+ if not rsync_bin:
1057
+ print_sync_status("Warning: rsync not found; skipping artifact pull")
1058
+ return
1059
+
1060
+ safe_host = re.sub(r"[^A-Za-z0-9_-]", "-", remote_spec.host)
1061
+ for rel_glob in _ARTIFACT_GLOBS:
1062
+ src = f"{remote_spec.ssh_target}:{package.session_dir}/{rel_glob}"
1063
+ dest = local_dest / f"remote-{safe_host}" / rel_glob.rstrip("/").replace("/", "-")
1064
+ dest.mkdir(parents=True, exist_ok=True)
1065
+ proc = subprocess.run(
1066
+ [rsync_bin, "-az", "--ignore-errors", "-e", _SSH_OPTS, src, str(dest) + "/"],
1067
+ capture_output=True,
1068
+ text=True,
1069
+ check=False,
1070
+ )
1071
+ if proc.returncode == 0:
1072
+ print_sync_status(f"Pulled {rel_glob} → {dest}")
1073
+ # Non-fatal: remote dir may not exist
1074
+
1075
+ # Sync edited prompt layers back to local ~/.ai-cli/
1076
+ _pull_prompt_layers_back(package, remote_spec, rsync_bin)
1077
+
1078
+
1079
+ # Prompt layer files to sync back (relative to session dir → local ~/.ai-cli/)
1080
+ _PROMPT_PULL_BACK = [
1081
+ ".ai-cli/system_instructions.txt",
1082
+ ".ai-cli/base_instructions.txt",
1083
+ ".ai-cli/instructions/",
1084
+ ".ai-cli/project-prompts/",
1085
+ ]
1086
+
1087
+
1088
+ def _pull_prompt_layers_back(
1089
+ package: RemotePackage,
1090
+ remote_spec: RemoteSpec,
1091
+ rsync_bin: str,
1092
+ ) -> None:
1093
+ """Pull edited prompt files from the remote session dir back to local ~/.ai-cli/."""
1094
+ ai_cli_dir = Path("~/.ai-cli").expanduser()
1095
+ for rel in _PROMPT_PULL_BACK:
1096
+ src = f"{remote_spec.ssh_target}:{package.session_dir}/{rel}"
1097
+ local_target = ai_cli_dir / rel.removeprefix(".ai-cli/")
1098
+ if rel.endswith("/"):
1099
+ local_target.mkdir(parents=True, exist_ok=True)
1100
+ dest_str = str(local_target) + "/"
1101
+ else:
1102
+ local_target.parent.mkdir(parents=True, exist_ok=True)
1103
+ dest_str = str(local_target)
1104
+ proc = subprocess.run(
1105
+ [rsync_bin, "-az", "--update", "--ignore-errors", "-e", _SSH_OPTS, src, dest_str],
1106
+ capture_output=True,
1107
+ text=True,
1108
+ check=False,
1109
+ )
1110
+ if proc.returncode == 0 and proc.stdout.strip():
1111
+ print_sync_status(f"Synced back {rel} → {local_target}")