agentworks-cli 0.2.1__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.
Files changed (59) hide show
  1. agentworks/__init__.py +1 -0
  2. agentworks/agents/__init__.py +0 -0
  3. agentworks/agents/manager.py +1095 -0
  4. agentworks/agents/templates.py +145 -0
  5. agentworks/catalog.py +264 -0
  6. agentworks/catalog.toml +131 -0
  7. agentworks/cli.py +1462 -0
  8. agentworks/completions/__init__.py +33 -0
  9. agentworks/completions/bash.py +179 -0
  10. agentworks/completions/install.py +122 -0
  11. agentworks/completions/powershell.py +270 -0
  12. agentworks/completions/spec.py +216 -0
  13. agentworks/completions/zsh.py +256 -0
  14. agentworks/config.py +894 -0
  15. agentworks/db.py +1083 -0
  16. agentworks/doctor.py +430 -0
  17. agentworks/git_credentials/__init__.py +0 -0
  18. agentworks/git_credentials/azdo.py +29 -0
  19. agentworks/git_credentials/base.py +71 -0
  20. agentworks/git_credentials/github.py +22 -0
  21. agentworks/nerf-config.yaml +16 -0
  22. agentworks/output.py +296 -0
  23. agentworks/remote_exec.py +286 -0
  24. agentworks/sample-config.toml +289 -0
  25. agentworks/sessions/__init__.py +0 -0
  26. agentworks/sessions/console.py +164 -0
  27. agentworks/sessions/manager.py +1297 -0
  28. agentworks/sessions/templates.py +101 -0
  29. agentworks/sessions/tmux.py +503 -0
  30. agentworks/sources.py +303 -0
  31. agentworks/ssh.py +759 -0
  32. agentworks/ssh_config.py +255 -0
  33. agentworks/vm_hosts/__init__.py +0 -0
  34. agentworks/vm_hosts/manager.py +86 -0
  35. agentworks/vms/__init__.py +0 -0
  36. agentworks/vms/backup.py +409 -0
  37. agentworks/vms/base.py +56 -0
  38. agentworks/vms/bootstrap_script.py +185 -0
  39. agentworks/vms/cloud_init.py +55 -0
  40. agentworks/vms/initializer.py +1523 -0
  41. agentworks/vms/manager.py +1122 -0
  42. agentworks/vms/provisioners/__init__.py +0 -0
  43. agentworks/vms/provisioners/azure.py +602 -0
  44. agentworks/vms/provisioners/lima.py +295 -0
  45. agentworks/vms/provisioners/proxmox.py +279 -0
  46. agentworks/vms/provisioners/proxmox_api.py +261 -0
  47. agentworks/vms/provisioners/wsl2.py +340 -0
  48. agentworks/vms/templates.py +152 -0
  49. agentworks/workspaces/__init__.py +0 -0
  50. agentworks/workspaces/backends/__init__.py +0 -0
  51. agentworks/workspaces/backends/local.py +119 -0
  52. agentworks/workspaces/backends/vm.py +175 -0
  53. agentworks/workspaces/manager.py +1080 -0
  54. agentworks/workspaces/templates.py +76 -0
  55. agentworks/workspaces/tmuxinator.py +80 -0
  56. agentworks_cli-0.2.1.dist-info/METADATA +635 -0
  57. agentworks_cli-0.2.1.dist-info/RECORD +59 -0
  58. agentworks_cli-0.2.1.dist-info/WHEEL +4 -0
  59. agentworks_cli-0.2.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,101 @@
1
+ """Session template resolution and processing.
2
+
3
+ Handles inheritance (depth-first, left-to-right), merge rules, and the
4
+ built-in default template fallback. Follows the same pattern as VM,
5
+ workspace, and agent templates.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from agentworks.config import Config, SessionTemplate
15
+
16
+
17
+ @dataclass
18
+ class ResolvedSessionTemplate:
19
+ """A fully resolved session template with all inheritance applied."""
20
+
21
+ name: str
22
+ command: str = ""
23
+ description: str = "Login shell"
24
+ restart_command: str = ""
25
+ env: dict[str, str] = field(default_factory=dict)
26
+
27
+
28
+ def _append_dedupe(target: list[str], source: list[str]) -> list[str]:
29
+ """Append source items to target, skipping dupes. Preserves order."""
30
+ seen = set(target)
31
+ result = list(target)
32
+ for item in source:
33
+ if item not in seen:
34
+ seen.add(item)
35
+ result.append(item)
36
+ return result
37
+
38
+
39
+ def _merge_map(target: dict[str, str], source: dict[str, str]) -> dict[str, str]:
40
+ """Merge source map into target. Source wins on key collision."""
41
+ return {**target, **source}
42
+
43
+
44
+ def resolve_from_dict(
45
+ templates: dict[str, SessionTemplate],
46
+ template_name: str | None = None,
47
+ ) -> ResolvedSessionTemplate:
48
+ """Resolve a session template from a templates dict (no Config required)."""
49
+ if template_name is not None and template_name != "default":
50
+ if template_name not in templates:
51
+ msg = f"Unknown session template: {template_name}"
52
+ raise ValueError(msg)
53
+ return _resolve(templates, template_name)
54
+
55
+ if "default" in templates:
56
+ return _resolve(templates, "default")
57
+
58
+ return ResolvedSessionTemplate(name="default")
59
+
60
+
61
+ def resolve_template(config: Config, template_name: str | None = None) -> ResolvedSessionTemplate:
62
+ """Resolve a session template by name, applying inheritance."""
63
+ return resolve_from_dict(config.session_templates, template_name)
64
+
65
+
66
+ def _resolve(templates: dict[str, SessionTemplate], name: str) -> ResolvedSessionTemplate:
67
+ """Depth-first, left-to-right resolution."""
68
+ if name not in templates:
69
+ return ResolvedSessionTemplate(name=name)
70
+
71
+ tmpl = templates[name]
72
+ result = ResolvedSessionTemplate(name=name)
73
+
74
+ for parent_name in tmpl.inherits:
75
+ parent = _resolve(templates, parent_name)
76
+ _merge(result, parent)
77
+
78
+ _merge_template(result, tmpl)
79
+ result.name = name
80
+ return result
81
+
82
+
83
+ def _merge(target: ResolvedSessionTemplate, source: ResolvedSessionTemplate) -> None:
84
+ """Merge source into target. Scalars: source wins. Maps: merge with source wins."""
85
+ target.command = source.command
86
+ target.description = source.description
87
+ target.restart_command = source.restart_command
88
+ target.env = _merge_map(target.env, source.env)
89
+
90
+
91
+ def _merge_template(target: ResolvedSessionTemplate, tmpl: SessionTemplate) -> None:
92
+ """Merge a raw SessionTemplate into a ResolvedSessionTemplate. None = not set, skip.
93
+ Scalars: child overrides. Maps: merge with child wins."""
94
+ if tmpl.command is not None:
95
+ target.command = tmpl.command
96
+ if tmpl.description is not None:
97
+ target.description = tmpl.description
98
+ if tmpl.restart_command is not None:
99
+ target.restart_command = tmpl.restart_command
100
+ if tmpl.env is not None:
101
+ target.env = _merge_map(target.env, tmpl.env)
@@ -0,0 +1,503 @@
1
+ """tmux session management for agentworks sessions.
2
+
3
+ Each session gets a locked-down tmux session. Session names are globally
4
+ unique and used directly as the tmux session name. A restricted tmux config
5
+ disables all interactive session management (no splits, no new windows, no
6
+ prefix key) while keeping a large scrollback buffer.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import shlex
12
+ from typing import TYPE_CHECKING, Protocol
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Callable
16
+
17
+ from agentworks.ssh import ExecTarget
18
+
19
+ RESTRICTED_CONFIG_PATH = "/opt/agentworks/tmux-session.conf"
20
+ DEFAULT_HISTORY_LIMIT = 50_000
21
+
22
+ # Agent tmux socket infrastructure
23
+ AGENT_SOCKET_ROOT = "/run/agentworks/agent-tmux-sockets"
24
+ AGENT_SOCKET_GROUP = "tmux-agent-access"
25
+
26
+
27
+ class RunCommand(Protocol):
28
+ """Callable that runs a shell command on a target (e.g. partial of ssh.run)."""
29
+
30
+ def __call__(self, command: str, *, check: bool = True) -> object: ...
31
+
32
+
33
+ def agent_socket_path(linux_user: str, session_name: str) -> str:
34
+ """Return the tmux socket path for an agent-mode session."""
35
+ return f"{AGENT_SOCKET_ROOT}/{linux_user}/{session_name}.sock"
36
+
37
+
38
+ def ensure_agent_socket_root(
39
+ target: ExecTarget,
40
+ admin_username: str,
41
+ *,
42
+ warn_if_missing: bool = True,
43
+ ) -> None:
44
+ """Create the agent tmux socket root directory and group (idempotent).
45
+
46
+ Fast-paths when the directory already exists with the correct group and
47
+ permissions (probe + group membership check).
48
+
49
+ Pass ``warn_if_missing=False`` when the caller already knows the directory
50
+ won't exist (e.g. first-time VM init), to avoid a misleading warning.
51
+ """
52
+ grp = shlex.quote(AGENT_SOCKET_GROUP)
53
+ q_root = shlex.quote(AGENT_SOCKET_ROOT)
54
+
55
+ probe = target.run(
56
+ f'if test -d {q_root}; then stat -c "%G %a" {q_root} 2>/dev/null || echo PROBE_FAILED; '
57
+ f"else echo MISSING; fi",
58
+ sudo=True,
59
+ check=False,
60
+ )
61
+ stdout = probe.stdout.strip()
62
+ if stdout == f"{AGENT_SOCKET_GROUP} 2771":
63
+ # Directory is correct, but still ensure admin is in the group.
64
+ admin = shlex.quote(admin_username)
65
+ result = target.run(f"usermod -aG {grp} {admin}", sudo=True, check=False)
66
+ if not result.ok:
67
+ from agentworks import output
68
+
69
+ output.warn(f"Failed to add {admin_username} to {AGENT_SOCKET_GROUP}, tmux socket access may fail")
70
+ return
71
+
72
+ if stdout == "MISSING":
73
+ should_warn, state = warn_if_missing, "missing"
74
+ elif stdout == "PROBE_FAILED":
75
+ should_warn, state = True, "probe failed"
76
+ else:
77
+ should_warn, state = True, "misconfigured"
78
+
79
+ if should_warn:
80
+ from agentworks import output
81
+
82
+ output.warn(f"Socket root {AGENT_SOCKET_ROOT} {state}, recreating")
83
+
84
+ admin = shlex.quote(admin_username)
85
+ result = target.run(f"getent group {grp} >/dev/null 2>&1", check=False)
86
+ if not result.ok:
87
+ target.run(f"/usr/sbin/groupadd {grp}", sudo=True)
88
+ target.run(f"usermod -aG {grp} {admin}", sudo=True)
89
+ target.run(f"mkdir -p {AGENT_SOCKET_ROOT}", sudo=True)
90
+ target.run(f"chown root:{grp} {AGENT_SOCKET_ROOT}", sudo=True)
91
+ target.run(f"chmod 2771 {AGENT_SOCKET_ROOT}", sudo=True)
92
+
93
+
94
+ def ensure_agent_socket_dir(
95
+ target: ExecTarget,
96
+ linux_user: str,
97
+ *,
98
+ warn_if_missing: bool = True,
99
+ ) -> None:
100
+ """Create a per-agent tmux socket directory (idempotent).
101
+
102
+ Fast-paths when the directory already exists with the correct owner/group
103
+ and permissions (single SSH round-trip).
104
+ """
105
+ q_user = shlex.quote(linux_user)
106
+ grp = shlex.quote(AGENT_SOCKET_GROUP)
107
+ q_path = shlex.quote(f"{AGENT_SOCKET_ROOT}/{linux_user}")
108
+
109
+ probe = target.run(
110
+ f'if test -d {q_path}; then stat -c "%U %G %a" {q_path} 2>/dev/null || echo PROBE_FAILED; '
111
+ f"else echo MISSING; fi",
112
+ sudo=True,
113
+ check=False,
114
+ )
115
+ stdout = probe.stdout.strip()
116
+ if stdout == f"{linux_user} {AGENT_SOCKET_GROUP} 2770":
117
+ return
118
+
119
+ if stdout == "MISSING":
120
+ should_warn, state = warn_if_missing, "missing"
121
+ elif stdout == "PROBE_FAILED":
122
+ should_warn, state = True, "probe failed"
123
+ else:
124
+ should_warn, state = True, "misconfigured"
125
+
126
+ if should_warn:
127
+ from agentworks import output
128
+
129
+ output.warn(f"Socket directory for {linux_user} {state}, recreating")
130
+
131
+ target.run(f"mkdir -p {q_path}", sudo=True)
132
+ target.run(f"chown {q_user}:{grp} {q_path}", sudo=True)
133
+ target.run(f"chmod 2770 {q_path}", sudo=True)
134
+
135
+
136
+ def cleanup_stale_sockets(target: ExecTarget, linux_user: str) -> int:
137
+ """Remove socket files whose tmux server is no longer running.
138
+
139
+ Uses sudo for both the tmux check and file removal -- this is an
140
+ infrastructure maintenance context (vm reinit / agent create).
141
+
142
+ Returns the number of stale sockets removed.
143
+ """
144
+ q_dir = shlex.quote(f"{AGENT_SOCKET_ROOT}/{linux_user}")
145
+ result = target.run(f"find {q_dir} -name '*.sock' -type s 2>/dev/null", sudo=True, check=False)
146
+ if not result.stdout.strip():
147
+ return 0
148
+
149
+ removed = 0
150
+ for sock_path in result.stdout.strip().splitlines():
151
+ sock_path = sock_path.strip()
152
+ if not sock_path:
153
+ continue
154
+ q_sock = shlex.quote(sock_path)
155
+ check = target.run(f"tmux -S {q_sock} list-sessions 2>/dev/null", sudo=True, check=False)
156
+ if not check.ok:
157
+ target.run(f"rm -f {q_sock}", sudo=True, check=False)
158
+ removed += 1
159
+ return removed
160
+
161
+
162
+ def generate_restricted_config(history_limit: int = DEFAULT_HISTORY_LIMIT) -> str:
163
+ """Generate the locked-down tmux config for sessions.
164
+
165
+ Loads the user's tmux.conf first so that familiar keybindings (prefix key,
166
+ detach, copy mode, etc.) work for direct session attach. Then disables
167
+ window/pane/session management on top to enforce one session per tmux server.
168
+ When inside the console, the console's prefix eclipses the session's, so the
169
+ session-level bindings are effectively invisible.
170
+ """
171
+ return f"""\
172
+ # Generated by agentworks. Do not edit.
173
+ # Locked-down config for agentworks sessions.
174
+ #
175
+ # Loads user tmux.conf for familiar keybindings (prefix, detach, copy mode),
176
+ # then disables window/pane/session creation to enforce one session per server.
177
+
178
+ # Load user config first
179
+ if-shell "test -f ~/.tmux.conf" "source-file ~/.tmux.conf"
180
+
181
+ # Large scrollback buffer (override user config)
182
+ set -g history-limit {history_limit}
183
+
184
+ # Size windows based on the most recently active client, not the smallest.
185
+ # Sessions are created detached (default geometry) then attached from
186
+ # within the console session. Without this, the inner session stays stuck
187
+ # at the small detached size.
188
+ set -g window-size latest
189
+ set -g aggressive-resize on
190
+
191
+ # Disable status bar -- the console provides this when nested;
192
+ # for direct attach, the session is the only thing on screen.
193
+ set -g status off
194
+
195
+ # Disable window/pane/session creation and management.
196
+ # The user's prefix key, detach, copy mode, and scroll bindings are preserved.
197
+ unbind c # new-window
198
+ unbind % # split-window -h
199
+ unbind '"' # split-window -v
200
+ unbind & # kill-window
201
+ unbind x # kill-pane
202
+ unbind n # next-window
203
+ unbind p # previous-window
204
+ unbind w # choose-window
205
+ unbind s # choose-session
206
+ unbind $ # rename-session
207
+ unbind , # rename-window
208
+ unbind . # move-window
209
+ unbind ! # break-pane
210
+ unbind : # command-prompt (prevents arbitrary tmux commands)
211
+ """
212
+
213
+
214
+ def deploy_restricted_config(
215
+ run_command: RunCommand,
216
+ history_limit: int = DEFAULT_HISTORY_LIMIT,
217
+ ) -> None:
218
+ """Write the restricted tmux config to the VM."""
219
+ config = generate_restricted_config(history_limit)
220
+ # Ensure directory exists and write config
221
+ run_command(f"sudo mkdir -p $(dirname {RESTRICTED_CONFIG_PATH})")
222
+ run_command(f"sudo tee {RESTRICTED_CONFIG_PATH} > /dev/null << 'TMUX_CONF'\n{config}TMUX_CONF")
223
+
224
+
225
+ def tmux_cmd(base: str, socket_path: str | None = None, *, sudo: bool = False) -> str:
226
+ """Build a tmux command string, optionally with ``-S`` and ``sudo``.
227
+
228
+ Session commands (has-session, kill-session, send-keys, capture-pane) do
229
+ NOT use sudo -- socket access goes through group permissions, and failures
230
+ surface as BROKEN status. ``sudo=True`` is only for infrastructure
231
+ operations (e.g. cleanup_stale_sockets probing sockets during setup).
232
+ """
233
+ cmd = f"tmux -S {shlex.quote(socket_path)} {base}" if socket_path else f"tmux {base}"
234
+ return f"sudo -n {cmd}" if sudo else cmd
235
+
236
+
237
+ def _grant_server_access(
238
+ run_command: RunCommand,
239
+ linux_user: str,
240
+ socket_path: str,
241
+ ) -> None:
242
+ """Grant tmux server-access to every member of the socket group."""
243
+ q_user = shlex.quote(linux_user)
244
+ q_sock = shlex.quote(socket_path)
245
+ grp = shlex.quote(AGENT_SOCKET_GROUP)
246
+ run_command(
247
+ f"for u in $(getent group {grp} | cut -d: -f4 | tr ',' ' '); do "
248
+ f"sudo -u {q_user} tmux -S {q_sock} server-access -a \"$u\"; "
249
+ f"done",
250
+ )
251
+
252
+
253
+ def create_session(
254
+ session_name: str,
255
+ workspace_path: str,
256
+ command: str,
257
+ linux_user: str | None,
258
+ *,
259
+ run_command: RunCommand,
260
+ target: ExecTarget | None = None,
261
+ run_as_root: RunCommand | None = None,
262
+ admin_username: str | None = None,
263
+ is_admin: bool = True,
264
+ ) -> tuple[str | None, int | None]:
265
+ """Create a locked-down tmux session.
266
+
267
+ For admin mode, the command runs directly on the admin's default tmux
268
+ server. For agent mode, the session is created as the agent Linux user
269
+ with a per-session socket so the agent's tmux server (and shell) run under
270
+ the agent's uid. The admin gains access via group permissions on the
271
+ socket and the tmux ``server-access`` ACL.
272
+
273
+ Returns (socket_path, tmux_server_pid). socket_path is None for admin-mode.
274
+ """
275
+ q_session = shlex.quote(session_name)
276
+ q_path = shlex.quote(workspace_path)
277
+
278
+ if is_admin:
279
+ if command:
280
+ inner = shlex.quote(f"cd {q_path} && {command}")
281
+ shell_cmd = f"$SHELL -lic {inner}"
282
+ else:
283
+ shell_cmd = ""
284
+
285
+ cmd = f"tmux new-session -d -s {q_session} -c {q_path} -f {RESTRICTED_CONFIG_PATH}"
286
+ if shell_cmd:
287
+ cmd += f" {shlex.quote(shell_cmd)}"
288
+ run_command(cmd)
289
+ try:
290
+ pid_out = run_command("tmux display-message -p '#{pid}'", check=False)
291
+ pid: int | None = _parse_pid(getattr(pid_out, "stdout", ""), context="after session create")
292
+ except (RuntimeError, ValueError):
293
+ pid = None # best-effort; auto-repair will recover on next access
294
+ return (None, pid)
295
+ else:
296
+ assert linux_user is not None
297
+ assert run_as_root is not None, "run_as_root required for agent sessions"
298
+ assert admin_username is not None, "admin_username required for agent sessions"
299
+ q_user = shlex.quote(linux_user)
300
+ sock = agent_socket_path(linux_user, session_name)
301
+ q_sock = shlex.quote(sock)
302
+
303
+ # Ensure the tmpfs socket directories exist (wiped on VM reboot).
304
+ assert target is not None, "target required for agent sessions"
305
+ ensure_agent_socket_root(target, admin_username)
306
+ ensure_agent_socket_dir(target, linux_user)
307
+
308
+ # Check for an existing socket file before creating the session.
309
+ # A stale socket (no server) is removed to start clean. An active
310
+ # socket (server running) is an error -- something else is using it.
311
+ sock_exists = run_command(f"test -e {q_sock}", check=False)
312
+ if getattr(sock_exists, "ok", False):
313
+ server_alive = run_as_root(
314
+ f"tmux -S {q_sock} list-sessions 2>/dev/null",
315
+ check=False,
316
+ )
317
+ if getattr(server_alive, "ok", False):
318
+ raise RuntimeError(
319
+ f"Socket {sock} already has an active tmux server. "
320
+ f"Kill it first or choose a different session name."
321
+ )
322
+ # Stale socket -- remove it
323
+ from agentworks import output as _output
324
+
325
+ _output.detail(f"Removing stale socket: {sock}")
326
+ run_as_root(f"rm -f {q_sock}", check=False)
327
+
328
+ # Build the pane command. sudo --login gives the agent a proper
329
+ # login environment; tmux then starts the pane shell as that user.
330
+ if command:
331
+ inner = shlex.quote(f"cd {q_path} && {command}")
332
+ shell_cmd = f"$SHELL -lic {inner}"
333
+ else:
334
+ shell_cmd = ""
335
+
336
+ cmd = (
337
+ f"sudo --login -u {q_user} "
338
+ f"tmux -S {q_sock} new-session -d -s {q_session} "
339
+ f"-c {q_path} -f {RESTRICTED_CONFIG_PATH}"
340
+ )
341
+ if shell_cmd:
342
+ cmd += f" {shlex.quote(shell_cmd)}"
343
+ run_command(cmd)
344
+
345
+ # Fix socket permissions (tmux creates sockets mode 0700).
346
+ # Socket is owned by the agent user, so sudo is needed.
347
+ run_as_root(f"chmod g+rwx {q_sock}")
348
+
349
+ # Grant tmux server-access to all socket-group members
350
+ _grant_server_access(run_command, linux_user, sock)
351
+
352
+ try:
353
+ pid_out = run_command(tmux_cmd("display-message -p '#{pid}'", sock), check=False)
354
+ pid = _parse_pid(getattr(pid_out, "stdout", ""), context="after session create")
355
+ except (RuntimeError, ValueError):
356
+ pid = None # best-effort; auto-repair will recover on next access
357
+ return (sock, pid)
358
+
359
+
360
+ def kill_session(
361
+ session_name: str,
362
+ *,
363
+ run_command: RunCommand,
364
+ socket_path: str | None = None,
365
+ ) -> bool:
366
+ """Kill a session's tmux session. Returns True if the session existed."""
367
+ q_session = shlex.quote(session_name)
368
+ result = run_command(
369
+ tmux_cmd(f"kill-session -t {q_session}", socket_path),
370
+ check=False,
371
+ )
372
+ return getattr(result, "ok", True)
373
+
374
+
375
+ def session_exists(
376
+ session_name: str,
377
+ *,
378
+ run_command: RunCommand,
379
+ socket_path: str | None = None,
380
+ ) -> bool:
381
+ """Check if a session's tmux session is alive."""
382
+ q_session = shlex.quote(session_name)
383
+ result = run_command(
384
+ tmux_cmd(f"has-session -t {q_session}", socket_path) + " 2>/dev/null",
385
+ check=False,
386
+ )
387
+ return getattr(result, "ok", False)
388
+
389
+
390
+ def send_keys(
391
+ session_name: str,
392
+ keys: str,
393
+ *,
394
+ run_command: RunCommand,
395
+ socket_path: str | None = None,
396
+ ) -> None:
397
+ """Send keys to a session's tmux session."""
398
+ q_session = shlex.quote(session_name)
399
+ run_command(
400
+ tmux_cmd(f"send-keys -t {q_session} {keys}", socket_path),
401
+ check=False,
402
+ )
403
+
404
+
405
+ def capture_output(
406
+ session_name: str,
407
+ *,
408
+ run_command: RunCommand,
409
+ lines: int = DEFAULT_HISTORY_LIMIT,
410
+ socket_path: str | None = None,
411
+ ) -> str:
412
+ """Capture the scrollback buffer from a session."""
413
+ q_session = shlex.quote(session_name)
414
+ result = run_command(
415
+ tmux_cmd(f"capture-pane -t {q_session} -p -S -{lines}", socket_path),
416
+ check=False,
417
+ )
418
+ return getattr(result, "stdout", "") or ""
419
+
420
+
421
+ def _parse_pid(raw: str, context: str) -> int:
422
+ """Parse a PID from tmux display-message output. Raises RuntimeError on failure."""
423
+ pid_str = raw.strip()
424
+ if not pid_str:
425
+ raise RuntimeError(f"tmux returned empty PID output ({context})")
426
+ try:
427
+ pid = int(pid_str)
428
+ except ValueError:
429
+ raise RuntimeError(f"tmux returned non-numeric PID: {pid_str!r} ({context})") from None
430
+ if pid <= 0:
431
+ raise RuntimeError(f"tmux returned invalid PID: {pid} ({context})")
432
+ return pid
433
+
434
+
435
+ # -- PID-based liveness helpers --------------------------------------------
436
+
437
+
438
+ def get_tmux_server_pid(
439
+ *,
440
+ target: ExecTarget,
441
+ socket_path: str | None = None,
442
+ ) -> int | None:
443
+ """Retrieve the PID of a running tmux server.
444
+
445
+ Returns None if the server is not running or unreachable.
446
+ """
447
+ cmd = tmux_cmd("display-message -p '#{pid}'", socket_path) + " 2>/dev/null"
448
+ result = target.run(cmd, check=False)
449
+ if not result.ok:
450
+ return None
451
+ pid_str = result.stdout.strip()
452
+ if not pid_str:
453
+ return None
454
+ try:
455
+ pid = int(pid_str)
456
+ except ValueError:
457
+ return None
458
+ return pid if pid > 0 else None
459
+
460
+
461
+ def force_kill_tmux_server(
462
+ pid: int,
463
+ *,
464
+ target: ExecTarget,
465
+ socket_path: str | None = None,
466
+ log: Callable[[str], None] | None = None,
467
+ ) -> bool:
468
+ """Kill a tmux server by PID with SIGTERM -> SIGKILL escalation.
469
+
470
+ Cleans up socket file if present. Returns True if the process is dead.
471
+ """
472
+ if pid <= 1:
473
+ raise ValueError(f"refusing to kill PID {pid} (dangerous special value)")
474
+ import time
475
+
476
+ def _log(msg: str) -> None:
477
+ if log:
478
+ log(msg)
479
+
480
+ # SIGTERM
481
+ _log(f"Sending SIGTERM to PID {pid}")
482
+ target.run(f"kill {pid}", sudo=True, check=False)
483
+ time.sleep(2)
484
+
485
+ # Check if still alive
486
+ if target.run(f"test -d /proc/{pid}", check=False).ok:
487
+ _log(f"PID {pid} survived SIGTERM, escalating to SIGKILL")
488
+ target.run(f"kill -9 {pid}", sudo=True, check=False)
489
+ time.sleep(1)
490
+
491
+ # Final check
492
+ if target.run(f"test -d /proc/{pid}", check=False).ok:
493
+ _log(f"PID {pid} survived SIGKILL")
494
+ return False # process survived
495
+
496
+ _log(f"PID {pid} is dead")
497
+
498
+ # Clean up stale socket (validate path is under expected root)
499
+ if socket_path and socket_path.startswith(AGENT_SOCKET_ROOT + "/"):
500
+ _log(f"Removing stale socket {socket_path}")
501
+ target.run(f"rm -f {shlex.quote(socket_path)}", sudo=True, check=False)
502
+
503
+ return True