pocketshell 0.3.12__tar.gz → 0.3.13__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 (30) hide show
  1. {pocketshell-0.3.12 → pocketshell-0.3.13}/PKG-INFO +1 -1
  2. {pocketshell-0.3.12 → pocketshell-0.3.13}/pyproject.toml +1 -1
  3. {pocketshell-0.3.12 → pocketshell-0.3.13}/src/pocketshell/daemon.py +79 -0
  4. {pocketshell-0.3.12 → pocketshell-0.3.13}/src/pocketshell/jobs.py +201 -26
  5. {pocketshell-0.3.12 → pocketshell-0.3.13}/src/pocketshell/sessions.py +86 -4
  6. {pocketshell-0.3.12 → pocketshell-0.3.13}/tests/test_daemon.py +66 -0
  7. {pocketshell-0.3.12 → pocketshell-0.3.13}/tests/test_jobs.py +71 -0
  8. {pocketshell-0.3.12 → pocketshell-0.3.13}/tests/test_sessions.py +33 -0
  9. {pocketshell-0.3.12 → pocketshell-0.3.13}/uv.lock +1 -1
  10. {pocketshell-0.3.12 → pocketshell-0.3.13}/.gitignore +0 -0
  11. {pocketshell-0.3.12 → pocketshell-0.3.13}/README.md +0 -0
  12. {pocketshell-0.3.12 → pocketshell-0.3.13}/src/pocketshell/__init__.py +0 -0
  13. {pocketshell-0.3.12 → pocketshell-0.3.13}/src/pocketshell/__main__.py +0 -0
  14. {pocketshell-0.3.12 → pocketshell-0.3.13}/src/pocketshell/agent_log.py +0 -0
  15. {pocketshell-0.3.12 → pocketshell-0.3.13}/src/pocketshell/cli.py +0 -0
  16. {pocketshell-0.3.12 → pocketshell-0.3.13}/src/pocketshell/env.py +0 -0
  17. {pocketshell-0.3.12 → pocketshell-0.3.13}/src/pocketshell/hooks.py +0 -0
  18. {pocketshell-0.3.12 → pocketshell-0.3.13}/src/pocketshell/logs.py +0 -0
  19. {pocketshell-0.3.12 → pocketshell-0.3.13}/src/pocketshell/qr_share.py +0 -0
  20. {pocketshell-0.3.12 → pocketshell-0.3.13}/src/pocketshell/repos.py +0 -0
  21. {pocketshell-0.3.12 → pocketshell-0.3.13}/src/pocketshell/usage.py +0 -0
  22. {pocketshell-0.3.12 → pocketshell-0.3.13}/tests/__init__.py +0 -0
  23. {pocketshell-0.3.12 → pocketshell-0.3.13}/tests/test_agent_log.py +0 -0
  24. {pocketshell-0.3.12 → pocketshell-0.3.13}/tests/test_cli.py +0 -0
  25. {pocketshell-0.3.12 → pocketshell-0.3.13}/tests/test_env.py +0 -0
  26. {pocketshell-0.3.12 → pocketshell-0.3.13}/tests/test_hooks.py +0 -0
  27. {pocketshell-0.3.12 → pocketshell-0.3.13}/tests/test_logs.py +0 -0
  28. {pocketshell-0.3.12 → pocketshell-0.3.13}/tests/test_qr_share.py +0 -0
  29. {pocketshell-0.3.12 → pocketshell-0.3.13}/tests/test_repos.py +0 -0
  30. {pocketshell-0.3.12 → pocketshell-0.3.13}/tests/test_usage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pocketshell
3
- Version: 0.3.12
3
+ Version: 0.3.13
4
4
  Summary: Unified server-side Python utility for the PocketShell Android client.
5
5
  Project-URL: Homepage, https://github.com/alexeygrigorev/pocketshell
6
6
  Project-URL: Issues, https://github.com/alexeygrigorev/pocketshell/issues
@@ -8,7 +8,7 @@ name = "pocketshell"
8
8
  # scripts/check-pypi-version.sh enforces this; .github/workflows/build.yml
9
9
  # runs that check before publishing to PyPI. See
10
10
  # tools/pocketshell/README.md ("Release flow") for the bump procedure.
11
- version = "0.3.12"
11
+ version = "0.3.13"
12
12
  description = "Unified server-side Python utility for the PocketShell Android client."
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.11"
@@ -85,6 +85,12 @@ METHOD_TTLS: Mapping[str, float] = {
85
85
  # rarely change minute-to-minute; the longer window keeps the
86
86
  # Android picker fast without burning rate-limit quota.
87
87
  "repos.list_remote": 300.0,
88
+ # tmuxctl-backed lists are relatively cheap, but Android may poll
89
+ # them from dashboards. Keep the window short so daemon-side
90
+ # mutations can invalidate immediately and external tmux changes are
91
+ # not hidden for long.
92
+ "sessions.list": 5.0,
93
+ "jobs.list": 5.0,
88
94
  }
89
95
 
90
96
  # Length-prefix is a 4-byte unsigned big-endian integer. ``struct``
@@ -460,6 +466,67 @@ def _repos_open_handler(params: Mapping[str, Any]) -> dict[str, Any]:
460
466
  return _repos.daemon_handler_open(dict(params))
461
467
 
462
468
 
469
+ # ---------------------------------------------------------------------------
470
+ # Methods: sessions.* / jobs.*
471
+ # ---------------------------------------------------------------------------
472
+
473
+
474
+ def _sessions_list_handler(params: Mapping[str, Any]) -> dict[str, Any]:
475
+ """Delegate ``sessions.list`` to the existing tmuxctl-backed wrapper."""
476
+ from pocketshell import sessions as _sessions
477
+
478
+ return _sessions.daemon_handler_list(dict(params))
479
+
480
+
481
+ def _jobs_list_handler(params: Mapping[str, Any]) -> dict[str, Any]:
482
+ """Delegate ``jobs.list`` to the existing tmuxctl-backed wrapper."""
483
+ from pocketshell import jobs as _jobs
484
+
485
+ return _jobs.daemon_handler_list(dict(params))
486
+
487
+
488
+ def _jobs_show_handler(params: Mapping[str, Any]) -> dict[str, Any]:
489
+ """Delegate ``jobs.show`` to the existing tmuxctl-backed wrapper."""
490
+ from pocketshell import jobs as _jobs
491
+
492
+ return _jobs.daemon_handler_show(dict(params))
493
+
494
+
495
+ def _jobs_trigger_handler(params: Mapping[str, Any]) -> dict[str, Any]:
496
+ """Delegate ``jobs.trigger`` to the existing tmuxctl-backed wrapper."""
497
+ from pocketshell import jobs as _jobs
498
+
499
+ return _jobs.daemon_handler_trigger(dict(params))
500
+
501
+
502
+ def _jobs_add_handler(params: Mapping[str, Any]) -> dict[str, Any]:
503
+ """Delegate ``jobs.add`` to the existing tmuxctl-backed wrapper."""
504
+ from pocketshell import jobs as _jobs
505
+
506
+ return _jobs.daemon_handler_add(dict(params))
507
+
508
+
509
+ def _jobs_edit_handler(params: Mapping[str, Any]) -> dict[str, Any]:
510
+ """Delegate ``jobs.edit`` to the existing tmuxctl-backed wrapper."""
511
+ from pocketshell import jobs as _jobs
512
+
513
+ return _jobs.daemon_handler_edit(dict(params))
514
+
515
+
516
+ def _jobs_remove_handler(params: Mapping[str, Any]) -> dict[str, Any]:
517
+ """Delegate ``jobs.remove`` to the existing tmuxctl-backed wrapper."""
518
+ from pocketshell import jobs as _jobs
519
+
520
+ return _jobs.daemon_handler_remove(dict(params))
521
+
522
+
523
+ def _jobs_status_handler(params: Mapping[str, Any]) -> dict[str, Any]:
524
+ """Delegate ``jobs.status`` to the scheduler status helper."""
525
+ from pocketshell import jobs as _jobs
526
+
527
+ return _jobs.daemon_handler_status(dict(params))
528
+
529
+
463
530
  # Single shared registry; tests can register additional methods via
464
531
  # :meth:`Daemon.register_method` on a fresh instance without touching
465
532
  # this dict.
@@ -469,6 +536,14 @@ DEFAULT_METHODS: Mapping[str, RpcHandler] = {
469
536
  "repos.list_remote": _repos_list_remote_handler,
470
537
  "repos.clone": _repos_clone_handler,
471
538
  "repos.open": _repos_open_handler,
539
+ "sessions.list": _sessions_list_handler,
540
+ "jobs.list": _jobs_list_handler,
541
+ "jobs.show": _jobs_show_handler,
542
+ "jobs.trigger": _jobs_trigger_handler,
543
+ "jobs.add": _jobs_add_handler,
544
+ "jobs.edit": _jobs_edit_handler,
545
+ "jobs.remove": _jobs_remove_handler,
546
+ "jobs.status": _jobs_status_handler,
472
547
  }
473
548
 
474
549
 
@@ -485,6 +560,10 @@ DEFAULT_METHODS: Mapping[str, RpcHandler] = {
485
560
  # change as soon as a clone lands.
486
561
  METHOD_CACHE_INVALIDATIONS: Mapping[str, tuple[str, ...]] = {
487
562
  "repos.clone": ("repos.list_local",),
563
+ "jobs.add": ("jobs.list",),
564
+ "jobs.edit": ("jobs.list",),
565
+ "jobs.remove": ("jobs.list",),
566
+ "jobs.trigger": ("jobs.list",),
488
567
  }
489
568
 
490
569
 
@@ -79,7 +79,7 @@ from __future__ import annotations
79
79
  import shutil
80
80
  import subprocess
81
81
  import sys
82
- from typing import Optional, Sequence
82
+ from typing import Any, Optional, Sequence
83
83
 
84
84
  import click
85
85
 
@@ -146,6 +146,150 @@ def _run_tmuxctl(args: Sequence[str]) -> int:
146
146
  return completed.returncode
147
147
 
148
148
 
149
+ def _run_tmuxctl_capture(args: Sequence[str]) -> dict[str, Any]:
150
+ """Invoke ``tmuxctl`` and return a daemon-friendly raw envelope."""
151
+ tmuxctl_path = _resolve_tmuxctl_binary()
152
+ if tmuxctl_path is None:
153
+ return {
154
+ "stdout": "",
155
+ "stderr": _tmuxctl_missing_message() + "\n",
156
+ "returncode": 127,
157
+ }
158
+
159
+ completed = subprocess.run(
160
+ [tmuxctl_path, *args],
161
+ check=False,
162
+ capture_output=True,
163
+ text=True,
164
+ )
165
+ return {
166
+ "stdout": completed.stdout,
167
+ "stderr": completed.stderr,
168
+ "returncode": completed.returncode,
169
+ }
170
+
171
+
172
+ def _emit_envelope(ctx: click.Context, envelope: dict[str, Any]) -> None:
173
+ """Proxy a daemon/subprocess envelope to stdout/stderr and exit code."""
174
+ if envelope.get("stdout"):
175
+ sys.stdout.write(str(envelope["stdout"]))
176
+ if envelope.get("stderr"):
177
+ sys.stderr.write(str(envelope["stderr"]))
178
+ exit_code = int(envelope.get("returncode", 0))
179
+ if exit_code != 0:
180
+ ctx.exit(exit_code)
181
+
182
+
183
+ def _try_daemon_jobs_call(
184
+ method: str,
185
+ params: dict[str, Any],
186
+ *,
187
+ timeout: float = 5.0,
188
+ ) -> Optional[dict[str, Any]]:
189
+ """Dispatch a jobs RPC to the daemon; return ``None`` on miss/error."""
190
+ from pocketshell import daemon as _daemon
191
+
192
+ socket_path = _daemon.resolve_socket_path()
193
+ if not socket_path.exists():
194
+ return None
195
+
196
+ try:
197
+ result = _daemon.call(
198
+ method,
199
+ params=params,
200
+ socket_path=socket_path,
201
+ timeout=timeout,
202
+ )
203
+ except (_daemon.DaemonClientError, RuntimeError, OSError):
204
+ return None
205
+ if not isinstance(result, dict):
206
+ return None
207
+ return result
208
+
209
+
210
+ def _extra_args(params: dict[str, Any]) -> list[str]:
211
+ raw = params.get("extra_args")
212
+ if not isinstance(raw, list):
213
+ return []
214
+ return [item for item in raw if isinstance(item, str)]
215
+
216
+
217
+ def daemon_handler_list(params: dict[str, Any]) -> dict[str, Any]:
218
+ """JSON-RPC handler for ``jobs.list``."""
219
+ args: list[str] = ["jobs", "list"]
220
+ session = params.get("session")
221
+ if isinstance(session, str) and session:
222
+ args.extend(["--session", session])
223
+ args.extend(_extra_args(params))
224
+ return _run_tmuxctl_capture(args)
225
+
226
+
227
+ def daemon_handler_show(params: dict[str, Any]) -> dict[str, Any]:
228
+ """JSON-RPC handler for ``jobs.show``."""
229
+ job_id = params.get("job_id")
230
+ args: list[str] = ["jobs", "show", str(job_id)]
231
+ args.extend(_extra_args(params))
232
+ return _run_tmuxctl_capture(args)
233
+
234
+
235
+ def daemon_handler_trigger(params: dict[str, Any]) -> dict[str, Any]:
236
+ """JSON-RPC handler for ``jobs.trigger``."""
237
+ job_id = params.get("job_id")
238
+ args: list[str] = ["jobs", "trigger", str(job_id)]
239
+ args.extend(_extra_args(params))
240
+ return _run_tmuxctl_capture(args)
241
+
242
+
243
+ def daemon_handler_add(params: dict[str, Any]) -> dict[str, Any]:
244
+ """JSON-RPC handler for ``jobs.add``."""
245
+ session_name = params.get("session_name")
246
+ every = params.get("every")
247
+ args: list[str] = ["jobs", "add", str(session_name), "--every", str(every)]
248
+ message = params.get("message")
249
+ if isinstance(message, str):
250
+ args.extend(["--message", message])
251
+ if bool(params.get("start_now")):
252
+ args.append("--start-now")
253
+ args.extend(_extra_args(params))
254
+ return _run_tmuxctl_capture(args)
255
+
256
+
257
+ def daemon_handler_edit(params: dict[str, Any]) -> dict[str, Any]:
258
+ """JSON-RPC handler for ``jobs.edit``."""
259
+ job_id = params.get("job_id")
260
+ args: list[str] = ["jobs", "edit", str(job_id)]
261
+ session = params.get("session")
262
+ if isinstance(session, str):
263
+ args.extend(["--session", session])
264
+ every = params.get("every")
265
+ if isinstance(every, str):
266
+ args.extend(["--every", every])
267
+ message = params.get("message")
268
+ if isinstance(message, str):
269
+ args.extend(["--message", message])
270
+ if bool(params.get("enable")):
271
+ args.append("--enable")
272
+ if bool(params.get("disable")):
273
+ args.append("--disable")
274
+ args.extend(_extra_args(params))
275
+ return _run_tmuxctl_capture(args)
276
+
277
+
278
+ def daemon_handler_remove(params: dict[str, Any]) -> dict[str, Any]:
279
+ """JSON-RPC handler for ``jobs.remove``."""
280
+ job_id = params.get("job_id")
281
+ args: list[str] = ["jobs", "remove", str(job_id)]
282
+ args.extend(_extra_args(params))
283
+ return _run_tmuxctl_capture(args)
284
+
285
+
286
+ def daemon_handler_status(_params: dict[str, Any]) -> dict[str, Any]:
287
+ """JSON-RPC handler for ``jobs.status``."""
288
+ if _is_daemon_running():
289
+ return {"stdout": "running\n", "stderr": "", "returncode": 0}
290
+ return {"stdout": "not running\n", "stderr": "", "returncode": 3}
291
+
292
+
149
293
  @click.group(
150
294
  name="jobs",
151
295
  context_settings={"help_option_names": ["-h", "--help"]},
@@ -191,9 +335,13 @@ def jobs_list(ctx: click.Context, session: Optional[str]) -> None:
191
335
  # `ctx.args` holds any extras we ignored (e.g. `--json` once
192
336
  # tmuxctl supports it). Forward verbatim, position preserved.
193
337
  args.extend(ctx.args)
194
- exit_code = _run_tmuxctl(args)
195
- if exit_code != 0:
196
- ctx.exit(exit_code)
338
+ params: dict[str, Any] = {"extra_args": list(ctx.args)}
339
+ if session:
340
+ params["session"] = session
341
+ envelope = _try_daemon_jobs_call("jobs.list", params)
342
+ if envelope is None:
343
+ envelope = _run_tmuxctl_capture(args)
344
+ _emit_envelope(ctx, envelope)
197
345
 
198
346
 
199
347
  @jobs_group.command(
@@ -209,9 +357,13 @@ def jobs_list(ctx: click.Context, session: Optional[str]) -> None:
209
357
  def jobs_show(ctx: click.Context, job_id: int) -> None:
210
358
  """Show details for one job (delegates to `tmuxctl jobs show`)."""
211
359
  args: list[str] = ["jobs", "show", str(job_id), *ctx.args]
212
- exit_code = _run_tmuxctl(args)
213
- if exit_code != 0:
214
- ctx.exit(exit_code)
360
+ envelope = _try_daemon_jobs_call(
361
+ "jobs.show",
362
+ {"job_id": job_id, "extra_args": list(ctx.args)},
363
+ )
364
+ if envelope is None:
365
+ envelope = _run_tmuxctl_capture(args)
366
+ _emit_envelope(ctx, envelope)
215
367
 
216
368
 
217
369
  @jobs_group.command(
@@ -234,9 +386,13 @@ def jobs_trigger(ctx: click.Context, job_id: int) -> None:
234
386
  brief calls for parity, not a reimplementation.
235
387
  """
236
388
  args: list[str] = ["jobs", "trigger", str(job_id), *ctx.args]
237
- exit_code = _run_tmuxctl(args)
238
- if exit_code != 0:
239
- ctx.exit(exit_code)
389
+ envelope = _try_daemon_jobs_call(
390
+ "jobs.trigger",
391
+ {"job_id": job_id, "extra_args": list(ctx.args)},
392
+ )
393
+ if envelope is None:
394
+ envelope = _run_tmuxctl_capture(args)
395
+ _emit_envelope(ctx, envelope)
240
396
 
241
397
 
242
398
  # ----- jobs add / edit / remove --------------------------------------
@@ -294,9 +450,17 @@ def jobs_add(
294
450
  if start_now:
295
451
  args.append("--start-now")
296
452
  args.extend(ctx.args)
297
- exit_code = _run_tmuxctl(args)
298
- if exit_code != 0:
299
- ctx.exit(exit_code)
453
+ params: dict[str, Any] = {
454
+ "session_name": session_name,
455
+ "every": every,
456
+ "message": message,
457
+ "start_now": start_now,
458
+ "extra_args": list(ctx.args),
459
+ }
460
+ envelope = _try_daemon_jobs_call("jobs.add", params)
461
+ if envelope is None:
462
+ envelope = _run_tmuxctl_capture(args)
463
+ _emit_envelope(ctx, envelope)
300
464
 
301
465
 
302
466
  @jobs_group.command(
@@ -374,9 +538,19 @@ def jobs_edit(
374
538
  if disable:
375
539
  args.append("--disable")
376
540
  args.extend(ctx.args)
377
- exit_code = _run_tmuxctl(args)
378
- if exit_code != 0:
379
- ctx.exit(exit_code)
541
+ params = {
542
+ "job_id": job_id,
543
+ "session": session,
544
+ "every": every,
545
+ "message": message,
546
+ "enable": enable,
547
+ "disable": disable,
548
+ "extra_args": list(ctx.args),
549
+ }
550
+ envelope = _try_daemon_jobs_call("jobs.edit", params)
551
+ if envelope is None:
552
+ envelope = _run_tmuxctl_capture(args)
553
+ _emit_envelope(ctx, envelope)
380
554
 
381
555
 
382
556
  @jobs_group.command(
@@ -397,9 +571,13 @@ def jobs_remove(ctx: click.Context, job_id: int) -> None:
397
571
  `tmuxctl jobs remove JOB_ID`.
398
572
  """
399
573
  args: list[str] = ["jobs", "remove", str(job_id), *ctx.args]
400
- exit_code = _run_tmuxctl(args)
401
- if exit_code != 0:
402
- ctx.exit(exit_code)
574
+ envelope = _try_daemon_jobs_call(
575
+ "jobs.remove",
576
+ {"job_id": job_id, "extra_args": list(ctx.args)},
577
+ )
578
+ if envelope is None:
579
+ envelope = _run_tmuxctl_capture(args)
580
+ _emit_envelope(ctx, envelope)
403
581
 
404
582
 
405
583
  # ----- daemon subgroup ------------------------------------------------
@@ -493,13 +671,10 @@ def daemon_status(ctx: click.Context) -> None:
493
671
  - 0 -> daemon process is alive
494
672
  - 3 -> daemon process is NOT running
495
673
  """
496
- if _is_daemon_running():
497
- click.echo("running")
498
- return
499
- click.echo("not running")
500
- # `systemctl is-active` returns 3 for `inactive`; matching that lets
501
- # shell-script consumers `&& echo up || echo down` consistently.
502
- ctx.exit(3)
674
+ envelope = _try_daemon_jobs_call("jobs.status", {})
675
+ if envelope is None:
676
+ envelope = daemon_handler_status({})
677
+ _emit_envelope(ctx, envelope)
503
678
 
504
679
 
505
680
  @daemon_group.command("stop")
@@ -41,7 +41,7 @@ from __future__ import annotations
41
41
  import shutil
42
42
  import subprocess
43
43
  import sys
44
- from typing import Optional, Sequence
44
+ from typing import Any, Optional, Sequence
45
45
 
46
46
  import click
47
47
 
@@ -98,6 +98,87 @@ def _run_tmuxctl(args: Sequence[str]) -> int:
98
98
  return completed.returncode
99
99
 
100
100
 
101
+ def _run_tmuxctl_capture(args: Sequence[str]) -> dict[str, Any]:
102
+ """Invoke ``tmuxctl`` and return a daemon-friendly raw envelope."""
103
+ tmuxctl_path = _resolve_tmuxctl_binary()
104
+ if tmuxctl_path is None:
105
+ return {
106
+ "stdout": "",
107
+ "stderr": _tmuxctl_missing_message() + "\n",
108
+ "returncode": 127,
109
+ }
110
+
111
+ completed = subprocess.run(
112
+ [tmuxctl_path, *args],
113
+ check=False,
114
+ capture_output=True,
115
+ text=True,
116
+ )
117
+ return {
118
+ "stdout": completed.stdout,
119
+ "stderr": completed.stderr,
120
+ "returncode": completed.returncode,
121
+ }
122
+
123
+
124
+ def _emit_envelope(ctx: click.Context, envelope: dict[str, Any]) -> None:
125
+ """Proxy a daemon/subprocess envelope to stdout/stderr and exit code."""
126
+ if envelope.get("stdout"):
127
+ sys.stdout.write(str(envelope["stdout"]))
128
+ if envelope.get("stderr"):
129
+ sys.stderr.write(str(envelope["stderr"]))
130
+ exit_code = int(envelope.get("returncode", 0))
131
+ if exit_code != 0:
132
+ ctx.exit(exit_code)
133
+
134
+
135
+ def _try_daemon_sessions_list(
136
+ *,
137
+ sort_by: Optional[str],
138
+ extra_args: Sequence[str],
139
+ ) -> Optional[dict[str, Any]]:
140
+ """Dispatch ``sessions.list`` to the daemon; return ``None`` on miss."""
141
+ from pocketshell import daemon as _daemon
142
+
143
+ socket_path = _daemon.resolve_socket_path()
144
+ if not socket_path.exists():
145
+ return None
146
+
147
+ params: dict[str, Any] = {"extra_args": list(extra_args)}
148
+ if sort_by:
149
+ params["sort_by"] = sort_by
150
+
151
+ try:
152
+ result = _daemon.call(
153
+ "sessions.list",
154
+ params=params,
155
+ socket_path=socket_path,
156
+ timeout=5.0,
157
+ )
158
+ except (_daemon.DaemonClientError, RuntimeError, OSError):
159
+ return None
160
+ if not isinstance(result, dict):
161
+ return None
162
+ return result
163
+
164
+
165
+ def daemon_handler_list(params: dict[str, Any]) -> dict[str, Any]:
166
+ """JSON-RPC handler for ``sessions.list``.
167
+
168
+ Returns the same raw stdout/stderr/returncode envelope as the
169
+ one-shot subprocess path so the CLI can preserve byte-identical
170
+ output while moving the process spawn into the daemon.
171
+ """
172
+ args: list[str] = ["list"]
173
+ sort_by = params.get("sort_by")
174
+ if isinstance(sort_by, str) and sort_by:
175
+ args.extend(["--by", sort_by])
176
+ extra_args = params.get("extra_args")
177
+ if isinstance(extra_args, list):
178
+ args.extend(str(item) for item in extra_args if isinstance(item, str))
179
+ return _run_tmuxctl_capture(args)
180
+
181
+
101
182
  @click.group(
102
183
  name="sessions",
103
184
  context_settings={"help_option_names": ["-h", "--help"]},
@@ -149,6 +230,7 @@ def sessions_list(ctx: click.Context, sort_by: Optional[str]) -> None:
149
230
  # `ctx.args` holds any extras we ignored (e.g. `--json` once
150
231
  # tmuxctl supports it). Forward verbatim, position preserved.
151
232
  args.extend(ctx.args)
152
- exit_code = _run_tmuxctl(args)
153
- if exit_code != 0:
154
- ctx.exit(exit_code)
233
+ envelope = _try_daemon_sessions_list(sort_by=sort_by, extra_args=ctx.args)
234
+ if envelope is None:
235
+ envelope = _run_tmuxctl_capture(args)
236
+ _emit_envelope(ctx, envelope)
@@ -728,6 +728,72 @@ def test_cache_invalidate_method_drops_only_that_method() -> None:
728
728
  assert cache.invalidate_method("repos.list_local") == 0
729
729
 
730
730
 
731
+ def _dispatch_in_memory(daemon: daemon_mod.Daemon, method: str, params: dict) -> dict:
732
+ """Send one request through ``Daemon._handle_one`` using a socketpair."""
733
+ client, server = socket.socketpair()
734
+ try:
735
+ daemon_mod.send_json(
736
+ client,
737
+ {"jsonrpc": "2.0", "id": 1, "method": method, "params": params},
738
+ )
739
+ daemon._handle_one(server)
740
+ response = daemon_mod.recv_json(client)
741
+ assert isinstance(response, dict)
742
+ return response
743
+ finally:
744
+ client.close()
745
+ server.close()
746
+
747
+
748
+ def test_jobs_mutation_invalidates_jobs_list_cache(tmp_path: Path) -> None:
749
+ """Successful job mutations evict the cached ``jobs.list`` envelope."""
750
+ calls = {"list": 0}
751
+
752
+ def list_handler(_params: dict) -> dict:
753
+ calls["list"] += 1
754
+ return {"stdout": f"list-{calls['list']}\n", "stderr": "", "returncode": 0}
755
+
756
+ def add_handler(_params: dict) -> dict:
757
+ return {"stdout": "created\n", "stderr": "", "returncode": 0}
758
+
759
+ daemon = daemon_mod.Daemon(
760
+ socket_path=tmp_path / "daemon.sock",
761
+ methods={"jobs.list": list_handler, "jobs.add": add_handler},
762
+ )
763
+
764
+ first = _dispatch_in_memory(daemon, "jobs.list", {})
765
+ assert first["result"]["stdout"] == "list-1\n"
766
+ assert first["cached"] is False
767
+
768
+ cached = _dispatch_in_memory(daemon, "jobs.list", {})
769
+ assert cached["result"]["stdout"] == "list-1\n"
770
+ assert cached["cached"] is True
771
+ assert calls["list"] == 1
772
+
773
+ mutation = _dispatch_in_memory(daemon, "jobs.add", {"session_name": "work"})
774
+ assert mutation["result"]["returncode"] == 0
775
+
776
+ after = _dispatch_in_memory(daemon, "jobs.list", {})
777
+ assert after["result"]["stdout"] == "list-2\n"
778
+ assert after["cached"] is False
779
+ assert calls["list"] == 2
780
+
781
+
782
+ def test_daemon_registry_includes_sessions_and_jobs_methods() -> None:
783
+ assert "sessions.list" in daemon_mod.DEFAULT_METHODS
784
+ assert "jobs.list" in daemon_mod.DEFAULT_METHODS
785
+ assert "jobs.show" in daemon_mod.DEFAULT_METHODS
786
+ assert "jobs.trigger" in daemon_mod.DEFAULT_METHODS
787
+ assert "jobs.add" in daemon_mod.DEFAULT_METHODS
788
+ assert "jobs.edit" in daemon_mod.DEFAULT_METHODS
789
+ assert "jobs.remove" in daemon_mod.DEFAULT_METHODS
790
+ assert "jobs.status" in daemon_mod.DEFAULT_METHODS
791
+ assert daemon_mod.METHOD_CACHE_INVALIDATIONS["jobs.add"] == ("jobs.list",)
792
+ assert daemon_mod.METHOD_CACHE_INVALIDATIONS["jobs.edit"] == ("jobs.list",)
793
+ assert daemon_mod.METHOD_CACHE_INVALIDATIONS["jobs.remove"] == ("jobs.list",)
794
+ assert daemon_mod.METHOD_CACHE_INVALIDATIONS["jobs.trigger"] == ("jobs.list",)
795
+
796
+
731
797
  def test_failed_usage_fetch_is_not_cached(
732
798
  sandbox_socket: Path,
733
799
  tmp_path: Path,
@@ -130,6 +130,39 @@ def test_jobs_list_forwards_to_tmuxctl_and_proxies_stdout() -> None:
130
130
  assert invoked == ["/fake/tmuxctl", "jobs", "list"]
131
131
 
132
132
 
133
+ def test_jobs_list_uses_daemon_when_available() -> None:
134
+ payload = _fake_jobs_table()
135
+ runner = CliRunner()
136
+ with patch(
137
+ "pocketshell.jobs._try_daemon_jobs_call",
138
+ return_value={"stdout": payload, "stderr": "", "returncode": 0},
139
+ ) as daemon_call, patch("pocketshell.jobs.subprocess.run") as run:
140
+ result = runner.invoke(jobs_group, ["list", "--session", "work"])
141
+ assert result.exit_code == 0, result.output
142
+ assert result.output == payload
143
+ daemon_call.assert_called_once_with(
144
+ "jobs.list",
145
+ {"extra_args": [], "session": "work"},
146
+ )
147
+ run.assert_not_called()
148
+
149
+
150
+ def test_jobs_list_falls_back_when_daemon_misses() -> None:
151
+ payload = _fake_jobs_table()
152
+ runner = CliRunner()
153
+ with patch("pocketshell.jobs._try_daemon_jobs_call", return_value=None), patch(
154
+ "pocketshell.jobs._resolve_tmuxctl_binary", return_value="/fake/tmuxctl"
155
+ ), patch(
156
+ "pocketshell.jobs.subprocess.run",
157
+ return_value=_fake_completed(stdout=payload),
158
+ ) as run:
159
+ result = runner.invoke(jobs_group, ["list"])
160
+ assert result.exit_code == 0, result.output
161
+ assert result.output == payload
162
+ invoked: Sequence[str] = run.call_args.args[0]
163
+ assert invoked == ["/fake/tmuxctl", "jobs", "list"]
164
+
165
+
133
166
  def test_jobs_list_forwards_unknown_options_verbatim() -> None:
134
167
  """The wrapper does NOT enumerate every flag `tmuxctl jobs list`
135
168
  grows. The brief calls out a future `--json` flag in particular —
@@ -196,6 +229,31 @@ def test_jobs_trigger_forwards_id() -> None:
196
229
  assert invoked == ["/fake/tmuxctl", "jobs", "trigger", "7"]
197
230
 
198
231
 
232
+ def test_jobs_mutation_uses_daemon_when_available() -> None:
233
+ runner = CliRunner()
234
+ with patch(
235
+ "pocketshell.jobs._try_daemon_jobs_call",
236
+ return_value={"stdout": "Created job 7\n", "stderr": "", "returncode": 0},
237
+ ) as daemon_call, patch("pocketshell.jobs.subprocess.run") as run:
238
+ result = runner.invoke(
239
+ jobs_group,
240
+ ["add", "work", "--every", "15m", "--message", "poke claude"],
241
+ )
242
+ assert result.exit_code == 0, result.output
243
+ assert result.output == "Created job 7\n"
244
+ daemon_call.assert_called_once_with(
245
+ "jobs.add",
246
+ {
247
+ "session_name": "work",
248
+ "every": "15m",
249
+ "message": "poke claude",
250
+ "start_now": False,
251
+ "extra_args": [],
252
+ },
253
+ )
254
+ run.assert_not_called()
255
+
256
+
199
257
  def test_jobs_trigger_proxies_unknown_subcommand_failure() -> None:
200
258
  """If `tmuxctl` has not yet implemented `jobs trigger`, the wrapper
201
259
  must surface the failure unchanged rather than silently swallowing
@@ -600,6 +658,19 @@ def test_jobs_daemon_status_running_reports_zero() -> None:
600
658
  assert invoked[2].startswith("(^|/)") or invoked[2].startswith("(")
601
659
 
602
660
 
661
+ def test_jobs_daemon_status_uses_daemon_when_available() -> None:
662
+ runner = CliRunner()
663
+ with patch(
664
+ "pocketshell.jobs._try_daemon_jobs_call",
665
+ return_value={"stdout": "running\n", "stderr": "", "returncode": 0},
666
+ ) as daemon_call, patch("pocketshell.jobs.subprocess.run") as run:
667
+ result = runner.invoke(jobs_group, ["daemon", "status"])
668
+ assert result.exit_code == 0, result.output
669
+ assert result.output == "running\n"
670
+ daemon_call.assert_called_once_with("jobs.status", {})
671
+ run.assert_not_called()
672
+
673
+
603
674
  def test_jobs_daemon_status_not_running_reports_three() -> None:
604
675
  runner = CliRunner()
605
676
  with patch("pocketshell.jobs.shutil.which", return_value="/fake/pgrep"), patch(
@@ -106,6 +106,39 @@ def test_sessions_list_forwards_to_tmuxctl_and_proxies_stdout() -> None:
106
106
  assert invoked == ["/fake/tmuxctl", "list"]
107
107
 
108
108
 
109
+ def test_sessions_list_uses_daemon_when_available() -> None:
110
+ payload = _fake_sessions_table()
111
+ runner = CliRunner()
112
+ with patch(
113
+ "pocketshell.sessions._try_daemon_sessions_list",
114
+ return_value={"stdout": payload, "stderr": "", "returncode": 0},
115
+ ) as daemon_call, patch("pocketshell.sessions.subprocess.run") as run:
116
+ result = runner.invoke(sessions_group, ["list", "--by", "activity"])
117
+ assert result.exit_code == 0, result.output
118
+ assert result.output == payload
119
+ daemon_call.assert_called_once_with(sort_by="activity", extra_args=[])
120
+ run.assert_not_called()
121
+
122
+
123
+ def test_sessions_list_falls_back_when_daemon_misses() -> None:
124
+ payload = _fake_sessions_table()
125
+ runner = CliRunner()
126
+ with patch(
127
+ "pocketshell.sessions._try_daemon_sessions_list",
128
+ return_value=None,
129
+ ), patch(
130
+ "pocketshell.sessions._resolve_tmuxctl_binary", return_value="/fake/tmuxctl"
131
+ ), patch(
132
+ "pocketshell.sessions.subprocess.run",
133
+ return_value=_fake_completed(stdout=payload),
134
+ ) as run:
135
+ result = runner.invoke(sessions_group, ["list"])
136
+ assert result.exit_code == 0, result.output
137
+ assert result.output == payload
138
+ invoked: Sequence[str] = run.call_args.args[0]
139
+ assert invoked == ["/fake/tmuxctl", "list"]
140
+
141
+
109
142
  def test_sessions_list_forwards_by_activity() -> None:
110
143
  runner = CliRunner()
111
144
  with patch(
@@ -143,7 +143,7 @@ wheels = [
143
143
 
144
144
  [[package]]
145
145
  name = "pocketshell"
146
- version = "0.3.12"
146
+ version = "0.3.13"
147
147
  source = { editable = "." }
148
148
  dependencies = [
149
149
  { name = "click" },
File without changes
File without changes