pocketshell 0.3.25__tar.gz → 0.3.27__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.25 → pocketshell-0.3.27}/PKG-INFO +1 -1
  2. {pocketshell-0.3.25 → pocketshell-0.3.27}/pyproject.toml +1 -1
  3. {pocketshell-0.3.25 → pocketshell-0.3.27}/src/pocketshell/agent_log.py +397 -15
  4. {pocketshell-0.3.25 → pocketshell-0.3.27}/tests/test_agent_log.py +201 -0
  5. {pocketshell-0.3.25 → pocketshell-0.3.27}/uv.lock +2 -2
  6. {pocketshell-0.3.25 → pocketshell-0.3.27}/.gitignore +0 -0
  7. {pocketshell-0.3.25 → pocketshell-0.3.27}/README.md +0 -0
  8. {pocketshell-0.3.25 → pocketshell-0.3.27}/src/pocketshell/__init__.py +0 -0
  9. {pocketshell-0.3.25 → pocketshell-0.3.27}/src/pocketshell/__main__.py +0 -0
  10. {pocketshell-0.3.25 → pocketshell-0.3.27}/src/pocketshell/cli.py +0 -0
  11. {pocketshell-0.3.25 → pocketshell-0.3.27}/src/pocketshell/daemon.py +0 -0
  12. {pocketshell-0.3.25 → pocketshell-0.3.27}/src/pocketshell/env.py +0 -0
  13. {pocketshell-0.3.25 → pocketshell-0.3.27}/src/pocketshell/hooks.py +0 -0
  14. {pocketshell-0.3.25 → pocketshell-0.3.27}/src/pocketshell/jobs.py +0 -0
  15. {pocketshell-0.3.25 → pocketshell-0.3.27}/src/pocketshell/logs.py +0 -0
  16. {pocketshell-0.3.25 → pocketshell-0.3.27}/src/pocketshell/qr_share.py +0 -0
  17. {pocketshell-0.3.25 → pocketshell-0.3.27}/src/pocketshell/repos.py +0 -0
  18. {pocketshell-0.3.25 → pocketshell-0.3.27}/src/pocketshell/sessions.py +0 -0
  19. {pocketshell-0.3.25 → pocketshell-0.3.27}/src/pocketshell/usage.py +0 -0
  20. {pocketshell-0.3.25 → pocketshell-0.3.27}/tests/__init__.py +0 -0
  21. {pocketshell-0.3.25 → pocketshell-0.3.27}/tests/test_cli.py +0 -0
  22. {pocketshell-0.3.25 → pocketshell-0.3.27}/tests/test_daemon.py +0 -0
  23. {pocketshell-0.3.25 → pocketshell-0.3.27}/tests/test_env.py +0 -0
  24. {pocketshell-0.3.25 → pocketshell-0.3.27}/tests/test_hooks.py +0 -0
  25. {pocketshell-0.3.25 → pocketshell-0.3.27}/tests/test_jobs.py +0 -0
  26. {pocketshell-0.3.25 → pocketshell-0.3.27}/tests/test_logs.py +0 -0
  27. {pocketshell-0.3.25 → pocketshell-0.3.27}/tests/test_qr_share.py +0 -0
  28. {pocketshell-0.3.25 → pocketshell-0.3.27}/tests/test_repos.py +0 -0
  29. {pocketshell-0.3.25 → pocketshell-0.3.27}/tests/test_sessions.py +0 -0
  30. {pocketshell-0.3.25 → pocketshell-0.3.27}/tests/test_usage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pocketshell
3
- Version: 0.3.25
3
+ Version: 0.3.27
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.25"
11
+ version = "0.3.27"
12
12
  description = "Unified server-side Python utility for the PocketShell Android client."
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.11"
@@ -2,11 +2,12 @@
2
2
 
3
3
  Mirrors the per-engine JSONL conversation-log reads the Android app
4
4
  currently runs over SSH (see ``AgentConversationRepository`` in
5
- ``app/src/main/java/com/pocketshell/app/session/``). The maintainer's
6
- 2026-05-27 note on issue #170 confirmed the strategy: no reimplementation
7
- of the parsers themselves this subcommand just reads the raw JSONL so
8
- the Android client (and the planned IPC daemon, #219) can cache + serve
9
- the same bytes the agent CLI wrote to disk.
5
+ ``app/src/main/java/com/pocketshell/app/session/``). The default command
6
+ still reads the raw JSONL so the Android client (and the planned IPC
7
+ daemon, #219) can cache + serve the same bytes the agent CLI wrote to
8
+ disk. The ``handoff`` subcommand adds a deliberately smaller export path:
9
+ it parses only user/assistant prose needed for cross-agent continuation
10
+ and skips tool calls/results by default.
10
11
 
11
12
  Per-engine canonical paths (matching the Kotlin
12
13
  ``AgentConversationRepository.detectionCommand`` enumeration):
@@ -32,10 +33,10 @@ Why direct file read instead of a subprocess delegation:
32
33
  itself via ``ssh exec 'tail -n N <path>'``. There is no ``quse``- or
33
34
  ``tmuxctl``-shaped binary on the host to wrap; reimplementing the
34
35
  ``tail -n N`` step in Python is the smallest reasonable parity layer.
35
- - The JSONL files are append-only, plain text, one event per line. No
36
- parsing logic from ``ClaudeCodeParser`` / ``CodexParser`` /
37
- ``OpenCodeReader`` is duplicated here we emit raw lines verbatim.
38
- The Kotlin parsers keep owning the structural contract.
36
+ - The JSONL files are append-only, plain text, one event per line. The
37
+ default read path emits raw lines verbatim. The ``handoff`` path only
38
+ extracts the portable message subset (human/user + assistant text) so
39
+ it can produce a compact Markdown artifact without raw tool JSON.
39
40
  - ``--json`` wraps the same raw lines in a small envelope (``engine``,
40
41
  ``session``, ``path``, ``lines``, ``count``) so machine consumers
41
42
  (the planned daemon, integration tests) can pin to a stable shape
@@ -46,9 +47,11 @@ from __future__ import annotations
46
47
 
47
48
  import json
48
49
  import os
50
+ import re
49
51
  import sys
52
+ from dataclasses import dataclass
50
53
  from pathlib import Path
51
- from typing import Iterable, List, Optional
54
+ from typing import Any, Iterable, List, Optional
52
55
 
53
56
  import click
54
57
 
@@ -63,6 +66,30 @@ import click
63
66
  # daemon consumer can tell "session id is wrong" apart from
64
67
  # "binary is missing".
65
68
  _EXIT_LOG_NOT_FOUND = 66
69
+ _DEFAULT_HANDOFF_MAX_TURNS = 30
70
+ _DEFAULT_HANDOFF_MAX_CHARS = 20_000
71
+ _HANDOFF_PROMPT = (
72
+ "Read this previous agent conversation and continue from the current state. "
73
+ "Use only the user and assistant messages below as context; tool calls and "
74
+ "tool results were omitted. Ask for clarification only if critical context "
75
+ "is missing."
76
+ )
77
+ _CLAUDE_SYSTEM_NOTE_TAGS = [
78
+ "system-reminder",
79
+ "command-name",
80
+ "command-args",
81
+ "command-message",
82
+ "command-stdout",
83
+ "local-command-stdout",
84
+ ]
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class HandoffMessage:
89
+ """A compact transcript message for cross-agent handoff output."""
90
+
91
+ role: str
92
+ text: str
66
93
 
67
94
 
68
95
  def _claude_projects_root() -> Path:
@@ -225,6 +252,251 @@ def _tail(lines: Iterable[str], n: Optional[int]) -> List[str]:
225
252
  return materialised[-n:]
226
253
 
227
254
 
255
+ def _parse_json_line(line: str) -> Optional[dict[str, Any]]:
256
+ """Parse one JSONL row, returning ``None`` for malformed/partial rows."""
257
+ try:
258
+ value = json.loads(line)
259
+ except json.JSONDecodeError:
260
+ return None
261
+ return value if isinstance(value, dict) else None
262
+
263
+
264
+ def _normalise_text(text: str) -> str:
265
+ """Trim surrounding whitespace and drop blank transcript fragments."""
266
+ return text.strip()
267
+
268
+
269
+ def _text_from_scalar(value: Any) -> Optional[str]:
270
+ if isinstance(value, str):
271
+ text = _normalise_text(value)
272
+ return text if text else None
273
+ return None
274
+
275
+
276
+ def _text_parts_from_content(value: Any, allowed_types: set[str]) -> List[str]:
277
+ """Extract plain text content blocks without tool-call/tool-result blocks."""
278
+ if isinstance(value, str):
279
+ text = _normalise_text(value)
280
+ return [text] if text else []
281
+ if isinstance(value, dict):
282
+ block_type = value.get("type")
283
+ if isinstance(block_type, str) and block_type not in allowed_types:
284
+ return []
285
+ text = _text_from_scalar(value.get("text"))
286
+ if text is not None:
287
+ return [text]
288
+ return _text_parts_from_content(value.get("content"), allowed_types)
289
+ if isinstance(value, list):
290
+ parts: List[str] = []
291
+ for item in value:
292
+ parts.extend(_text_parts_from_content(item, allowed_types))
293
+ return parts
294
+ return []
295
+
296
+
297
+ def _strip_claude_system_notes(text: str) -> str:
298
+ """Remove Claude XML-style system-note blocks from otherwise-human text."""
299
+ cleaned = text
300
+ for tag in _CLAUDE_SYSTEM_NOTE_TAGS:
301
+ cleaned = re.sub(
302
+ rf"(?is)<{re.escape(tag)}(?:\s[^>]*)?>.*?</{re.escape(tag)}>",
303
+ "",
304
+ cleaned,
305
+ )
306
+ return _normalise_text(cleaned)
307
+
308
+
309
+ def _claude_messages_from_row(row: dict[str, Any]) -> List[HandoffMessage]:
310
+ message = row.get("message") if isinstance(row.get("message"), dict) else None
311
+ role = row.get("role") or (message or {}).get("role") or row.get("type")
312
+ if role not in {"user", "assistant"}:
313
+ return []
314
+
315
+ content = (message or {}).get("content") if message is not None else row.get("content")
316
+ fragments = _text_parts_from_content(content, {"text"})
317
+ messages: List[HandoffMessage] = []
318
+ for fragment in fragments:
319
+ text = _strip_claude_system_notes(fragment)
320
+ if text:
321
+ messages.append(HandoffMessage(role=role, text=text))
322
+ return messages
323
+
324
+
325
+ def _codex_message_text(item: dict[str, Any]) -> Optional[str]:
326
+ for key in ("message", "text"):
327
+ text = _text_from_scalar(item.get(key))
328
+ if text is not None:
329
+ return text
330
+ parts = _text_parts_from_content(item.get("content"), {"input_text", "output_text", "text"})
331
+ return "\n\n".join(parts).strip() if parts else None
332
+
333
+
334
+ def _codex_messages_from_row(row: dict[str, Any]) -> List[HandoffMessage]:
335
+ item = row.get("payload") if isinstance(row.get("payload"), dict) else None
336
+ if item is None:
337
+ item = row.get("item") if isinstance(row.get("item"), dict) else row
338
+
339
+ item_type = item.get("type") or row.get("type")
340
+ if item_type == "message":
341
+ role = item.get("role")
342
+ if role not in {"user", "assistant"}:
343
+ return []
344
+ elif item_type == "user_message":
345
+ role = "user"
346
+ elif item_type in {"assistant_message", "agent_message"}:
347
+ role = "assistant"
348
+ else:
349
+ return []
350
+
351
+ text = _codex_message_text(item)
352
+ return [HandoffMessage(role=role, text=text)] if text else []
353
+
354
+
355
+ def _json_object_from_string(value: Any) -> Optional[dict[str, Any]]:
356
+ if not isinstance(value, str) or not value.strip():
357
+ return None
358
+ try:
359
+ parsed = json.loads(value)
360
+ except json.JSONDecodeError:
361
+ return None
362
+ return parsed if isinstance(parsed, dict) else None
363
+
364
+
365
+ def _opencode_role(row: dict[str, Any], message_data: Optional[dict[str, Any]]) -> Optional[str]:
366
+ role = row.get("message_role") or row.get("messageRole") or row.get("role")
367
+ if role is None and message_data is not None:
368
+ role = message_data.get("role")
369
+ return role if role in {"user", "assistant"} else None
370
+
371
+
372
+ def _opencode_part_text(part: Optional[dict[str, Any]]) -> Optional[str]:
373
+ if part is None:
374
+ return None
375
+ part_type = part.get("type")
376
+ if part_type in {"tool_use", "tool", "tool_result", "function_call_output", "reasoning"}:
377
+ return None
378
+ if isinstance(part_type, str) and part_type not in {"input_text", "output_text", "text"}:
379
+ return None
380
+ parts = _text_parts_from_content(part, {"input_text", "output_text", "text"})
381
+ return "\n\n".join(parts).strip() if parts else None
382
+
383
+
384
+ def _opencode_messages_from_row(row: dict[str, Any]) -> List[HandoffMessage]:
385
+ message_data = _json_object_from_string(
386
+ row.get("message_data") or row.get("messageData") or row.get("msg_data")
387
+ )
388
+ role = _opencode_role(row, message_data)
389
+ if role is None:
390
+ return []
391
+
392
+ part_data = _json_object_from_string(row.get("part_data") or row.get("partData"))
393
+ part_text = _opencode_part_text(part_data)
394
+ if part_text:
395
+ return [HandoffMessage(role=role, text=part_text)]
396
+
397
+ fallback = (
398
+ _text_from_scalar(row.get("message_content"))
399
+ or _text_from_scalar(row.get("messageContent"))
400
+ or _text_from_scalar(row.get("content"))
401
+ or _text_from_scalar(row.get("text"))
402
+ or _opencode_part_text(message_data)
403
+ )
404
+ return [HandoffMessage(role=role, text=fallback)] if fallback else []
405
+
406
+
407
+ def _handoff_messages_from_lines(engine: str, lines: Iterable[str]) -> List[HandoffMessage]:
408
+ """Extract user/assistant prose from raw agent JSONL rows."""
409
+ messages: List[HandoffMessage] = []
410
+ for line in lines:
411
+ row = _parse_json_line(line)
412
+ if row is None:
413
+ continue
414
+ if engine == "claude":
415
+ messages.extend(_claude_messages_from_row(row))
416
+ elif engine == "codex":
417
+ messages.extend(_codex_messages_from_row(row))
418
+ elif engine == "opencode":
419
+ messages.extend(_opencode_messages_from_row(row))
420
+ return messages
421
+
422
+
423
+ def _bound_handoff_messages(
424
+ messages: List[HandoffMessage],
425
+ max_turns: int,
426
+ ) -> tuple[List[HandoffMessage], int]:
427
+ if max_turns <= 0 or max_turns >= len(messages):
428
+ return list(messages), 0
429
+ omitted = len(messages) - max_turns
430
+ return messages[-max_turns:], omitted
431
+
432
+
433
+ def _render_message(message: HandoffMessage) -> str:
434
+ title = "User" if message.role == "user" else "Assistant"
435
+ return f"### {title}\n\n{message.text}\n"
436
+
437
+
438
+ def _render_handoff_markdown(
439
+ *,
440
+ engine: str,
441
+ session: str,
442
+ messages: List[HandoffMessage],
443
+ omitted_for_turns: int,
444
+ max_turns: int,
445
+ max_chars: int,
446
+ ) -> str:
447
+ session_name = session.removesuffix(".jsonl")
448
+ header_parts = [
449
+ "# Agent Handoff",
450
+ "",
451
+ "## Ready Prompt",
452
+ "",
453
+ _HANDOFF_PROMPT,
454
+ "",
455
+ "## Source",
456
+ "",
457
+ f"- Engine: {engine}",
458
+ f"- Session: {session_name}",
459
+ f"- Messages included: {len(messages)}",
460
+ f"- Max turns: {max_turns}",
461
+ f"- Max chars: {max_chars}",
462
+ "- Omitted by default: tool calls, tool results, command output, reasoning, and system notes",
463
+ "",
464
+ "## Conversation",
465
+ "",
466
+ ]
467
+ if omitted_for_turns:
468
+ header_parts.extend([f"[{omitted_for_turns} earlier message(s) omitted by --max-turns.]", ""])
469
+
470
+ header = "\n".join(header_parts)
471
+ body = "\n".join(_render_message(message) for message in messages)
472
+ output = header + body
473
+ if len(output) <= max_chars:
474
+ return output
475
+
476
+ marker = "[Earlier conversation text omitted to fit --max-chars.]\n\n"
477
+ budget = max_chars - len(header) - len(marker)
478
+ if budget > 0:
479
+ trimmed_body = body[-budget:].lstrip()
480
+ output = header + marker + trimmed_body
481
+ if len(output) <= max_chars:
482
+ return output
483
+
484
+ # Extremely small limits cannot fit the complete header. Keep the ready
485
+ # prompt at the front and hard-bound the artifact.
486
+ truncated = output[:max_chars].rstrip()
487
+ return truncated + "\n" if len(truncated) < max_chars else truncated
488
+
489
+
490
+ def _write_handoff_output(text: str, out: Optional[Path]) -> None:
491
+ if out is None:
492
+ sys.stdout.write(text)
493
+ if not text.endswith("\n"):
494
+ sys.stdout.write("\n")
495
+ return
496
+ out.parent.mkdir(parents=True, exist_ok=True)
497
+ out.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
498
+
499
+
228
500
  def _emit_text(lines: List[str]) -> None:
229
501
  """Write each line followed by a newline to stdout.
230
502
 
@@ -277,15 +549,16 @@ def _emit_json(
277
549
  sys.stdout.write("\n")
278
550
 
279
551
 
280
- @click.command(
552
+ @click.group(
281
553
  "agent-log",
554
+ invoke_without_command=True,
282
555
  context_settings={"help_option_names": ["-h", "--help"]},
283
556
  )
284
557
  @click.option(
285
558
  "--session",
286
559
  "-s",
287
560
  "session",
288
- required=True,
561
+ required=False,
289
562
  type=str,
290
563
  help=(
291
564
  "Session id — usually the JSONL file's basename. Accepts both "
@@ -296,7 +569,7 @@ def _emit_json(
296
569
  "--engine",
297
570
  "-e",
298
571
  "engine",
299
- required=True,
572
+ required=False,
300
573
  type=click.Choice(["claude", "codex", "opencode"], case_sensitive=False),
301
574
  help="Which agent CLI's log to read.",
302
575
  )
@@ -328,8 +601,8 @@ def _emit_json(
328
601
  @click.pass_context
329
602
  def agent_log_command(
330
603
  ctx: click.Context,
331
- session: str,
332
- engine: str,
604
+ session: Optional[str],
605
+ engine: Optional[str],
333
606
  cwd: Optional[str],
334
607
  tail_count: Optional[int],
335
608
  json_output: bool,
@@ -348,6 +621,13 @@ def agent_log_command(
348
621
  wrong engine / agent has not written anything yet).
349
622
  - 2 -> bad invocation (handled by Click).
350
623
  """
624
+ if ctx.invoked_subcommand is not None:
625
+ return
626
+ if session is None:
627
+ raise click.UsageError("Missing option '--session' / '-s'.")
628
+ if engine is None:
629
+ raise click.UsageError("Missing option '--engine' / '-e'.")
630
+
351
631
  engine_normalised = engine.lower()
352
632
  path = _resolve_log_path(engine_normalised, session, cwd)
353
633
  if path is None:
@@ -373,6 +653,108 @@ def agent_log_command(
373
653
  _emit_text(lines)
374
654
 
375
655
 
656
+ @agent_log_command.command(
657
+ "handoff",
658
+ context_settings={"help_option_names": ["-h", "--help"]},
659
+ )
660
+ @click.option(
661
+ "--session",
662
+ "-s",
663
+ "session",
664
+ required=True,
665
+ type=str,
666
+ help=(
667
+ "Session id — usually the JSONL file's basename. Accepts both "
668
+ "`abc123` and `abc123.jsonl`."
669
+ ),
670
+ )
671
+ @click.option(
672
+ "--engine",
673
+ "-e",
674
+ "engine",
675
+ required=True,
676
+ type=click.Choice(["claude", "codex", "opencode"], case_sensitive=False),
677
+ help="Which agent CLI's log to export.",
678
+ )
679
+ @click.option(
680
+ "--cwd",
681
+ "cwd",
682
+ type=str,
683
+ default=None,
684
+ help=(
685
+ "Working directory the agent was launched from. Only used for "
686
+ "Claude Code; ignored for codex and opencode."
687
+ ),
688
+ )
689
+ @click.option(
690
+ "--max-turns",
691
+ "max_turns",
692
+ type=click.IntRange(min=0),
693
+ default=_DEFAULT_HANDOFF_MAX_TURNS,
694
+ show_default=True,
695
+ help=(
696
+ "Include at most the last N user/assistant messages after filtering. "
697
+ "Use 0 for all filtered messages."
698
+ ),
699
+ )
700
+ @click.option(
701
+ "--max-chars",
702
+ "max_chars",
703
+ type=click.IntRange(min=500),
704
+ default=_DEFAULT_HANDOFF_MAX_CHARS,
705
+ show_default=True,
706
+ help="Hard cap for the rendered Markdown artifact.",
707
+ )
708
+ @click.option(
709
+ "--out",
710
+ "out",
711
+ type=click.Path(dir_okay=False, path_type=Path),
712
+ default=None,
713
+ help="Write the Markdown artifact to this file. Default: stdout.",
714
+ )
715
+ def handoff_command(
716
+ session: str,
717
+ engine: str,
718
+ cwd: Optional[str],
719
+ max_turns: int,
720
+ max_chars: int,
721
+ out: Optional[Path],
722
+ ) -> None:
723
+ """Export a compact user/assistant transcript for another agent.
724
+
725
+ The handoff artifact is Markdown/plain text with a ready-to-paste
726
+ continuation prompt. Tool calls, tool results, reasoning, command
727
+ output, and system-note records are excluded by default.
728
+ """
729
+ engine_normalised = engine.lower()
730
+ path = _resolve_log_path(engine_normalised, session, cwd)
731
+ if path is None:
732
+ click.echo(
733
+ (
734
+ f"pocketshell: no {engine_normalised} session log found for "
735
+ f"`{session}`. Looked under "
736
+ f"{_search_root_for(engine_normalised)} "
737
+ f"(use --cwd for Claude if the session was launched from a "
738
+ f"specific project directory)."
739
+ ),
740
+ err=True,
741
+ )
742
+ raise click.exceptions.Exit(_EXIT_LOG_NOT_FOUND)
743
+
744
+ raw_lines = _read_lines(path)
745
+ messages = _handoff_messages_from_lines(engine_normalised, raw_lines)
746
+ bounded_messages, omitted_for_turns = _bound_handoff_messages(messages, max_turns)
747
+ output = _render_handoff_markdown(
748
+ engine=engine_normalised,
749
+ session=session,
750
+ messages=bounded_messages,
751
+ omitted_for_turns=omitted_for_turns,
752
+ max_turns=max_turns,
753
+ max_chars=max_chars,
754
+ )
755
+ _write_handoff_output(output, out)
756
+
757
+
376
758
  def _search_root_for(engine: str) -> str:
377
759
  """Human-friendly description of where ``engine`` logs live.
378
760
 
@@ -17,6 +17,10 @@ The third-PR scope exercises:
17
17
  - `--tail N` returns only the last N rows.
18
18
  - `--json` wraps the raw lines in a `{engine, session, path, count,
19
19
  lines}` envelope.
20
+ - `pocketshell agent-log handoff ...` emits a compact Markdown handoff
21
+ prompt containing only user/assistant prose.
22
+ - The handoff path excludes tool calls/results and is bounded by
23
+ `--max-turns` / `--max-chars`.
20
24
  - A bare session id (no `.jsonl` suffix) and an explicit `<id>.jsonl`
21
25
  both resolve to the same file.
22
26
  - Missing log file exits 66 (`EX_NOINPUT`) with a friendly stderr hint.
@@ -109,6 +113,18 @@ def test_agent_log_help_lists_engine_session_tail_json_flags() -> None:
109
113
  assert "opencode" in lowered
110
114
 
111
115
 
116
+ def test_agent_log_handoff_help_lists_export_bounds() -> None:
117
+ runner = CliRunner()
118
+ result = runner.invoke(agent_log_command, ["handoff", "--help"])
119
+ assert result.exit_code == 0, result.output
120
+ lowered = result.output.lower()
121
+ assert "--engine" in lowered
122
+ assert "--session" in lowered
123
+ assert "--max-turns" in lowered
124
+ assert "--max-chars" in lowered
125
+ assert "--out" in lowered
126
+
127
+
112
128
  # ----- Claude resolution ---------------------------------------------
113
129
 
114
130
 
@@ -359,6 +375,191 @@ def test_json_envelope_with_tail(fake_home: Path) -> None:
359
375
  assert envelope["lines"] == [json.dumps(e, sort_keys=True) for e in events[-2:]]
360
376
 
361
377
 
378
+ # ----- handoff export ------------------------------------------------
379
+
380
+
381
+ def test_handoff_claude_filters_tool_calls_results_and_system_notes(fake_home: Path) -> None:
382
+ session = "handoff-claude"
383
+ events = [
384
+ {
385
+ "type": "user",
386
+ "uuid": "u1",
387
+ "message": {
388
+ "role": "user",
389
+ "content": (
390
+ "<system-reminder>The date has changed.</system-reminder>\n"
391
+ "Please inspect the failing test."
392
+ ),
393
+ },
394
+ },
395
+ {
396
+ "type": "assistant",
397
+ "uuid": "a1",
398
+ "message": {
399
+ "role": "assistant",
400
+ "content": [
401
+ {"type": "text", "text": "I'll inspect the focused test."},
402
+ {
403
+ "type": "tool_use",
404
+ "id": "toolu_1",
405
+ "name": "Bash",
406
+ "input": {"command": "pytest -q"},
407
+ },
408
+ {
409
+ "type": "tool_result",
410
+ "tool_use_id": "toolu_1",
411
+ "content": "FAILED test output",
412
+ "is_error": True,
413
+ },
414
+ {"type": "text", "text": "The likely fix is in the parser."},
415
+ ],
416
+ },
417
+ },
418
+ ]
419
+ log_path = fake_home / ".claude" / "projects" / "-home-handoff" / f"{session}.jsonl"
420
+ _write_jsonl(log_path, events)
421
+
422
+ runner = CliRunner()
423
+ result = runner.invoke(
424
+ agent_log_command,
425
+ ["handoff", "--engine", "claude", "--session", session, "--cwd", "/home/handoff"],
426
+ )
427
+
428
+ assert result.exit_code == 0, result.output
429
+ assert "Read this previous agent conversation" in result.output
430
+ assert "### User" in result.output
431
+ assert "Please inspect the failing test." in result.output
432
+ assert "### Assistant" in result.output
433
+ assert "I'll inspect the focused test." in result.output
434
+ assert "The likely fix is in the parser." in result.output
435
+ assert "toolu_1" not in result.output
436
+ assert "pytest -q" not in result.output
437
+ assert "FAILED test output" not in result.output
438
+ assert "system-reminder" not in result.output
439
+ assert "The date has changed" not in result.output
440
+
441
+
442
+ def test_handoff_codex_filters_function_calls_and_bounds_turns(fake_home: Path) -> None:
443
+ session = "handoff-codex"
444
+ events = [
445
+ {"type": "event_msg", "payload": {"type": "user_message", "message": "first user"}},
446
+ {"type": "event_msg", "payload": {"type": "agent_message", "message": "first answer"}},
447
+ {
448
+ "type": "response_item",
449
+ "payload": {
450
+ "type": "function_call",
451
+ "call_id": "call_1",
452
+ "name": "shell",
453
+ "arguments": "{\"cmd\":\"cat secrets.txt\"}",
454
+ },
455
+ },
456
+ {
457
+ "type": "response_item",
458
+ "payload": {
459
+ "type": "function_call_output",
460
+ "call_id": "call_1",
461
+ "output": "raw command output",
462
+ },
463
+ },
464
+ {
465
+ "type": "response_item",
466
+ "payload": {
467
+ "type": "message",
468
+ "id": "m2",
469
+ "role": "assistant",
470
+ "content": [{"type": "output_text", "text": "second answer"}],
471
+ },
472
+ },
473
+ {"type": "event_msg", "payload": {"type": "user_message", "message": "second user"}},
474
+ ]
475
+ log_path = fake_home / ".codex" / "sessions" / "2026" / "06" / "06" / f"{session}.jsonl"
476
+ _write_jsonl(log_path, events)
477
+
478
+ runner = CliRunner()
479
+ result = runner.invoke(
480
+ agent_log_command,
481
+ ["handoff", "--engine", "codex", "--session", session, "--max-turns", "2"],
482
+ )
483
+
484
+ assert result.exit_code == 0, result.output
485
+ assert "[2 earlier message(s) omitted by --max-turns.]" in result.output
486
+ assert "second answer" in result.output
487
+ assert "second user" in result.output
488
+ assert "first user" not in result.output
489
+ assert "first answer" not in result.output
490
+ assert "cat secrets.txt" not in result.output
491
+ assert "raw command output" not in result.output
492
+
493
+
494
+ def test_handoff_respects_max_chars(fake_home: Path) -> None:
495
+ session = "handoff-chars"
496
+ events = [
497
+ {
498
+ "type": "event_msg",
499
+ "payload": {"type": "user_message", "message": "start " + "x" * 1200},
500
+ },
501
+ {
502
+ "type": "event_msg",
503
+ "payload": {"type": "agent_message", "message": "answer " + "y" * 1200},
504
+ },
505
+ ]
506
+ log_path = fake_home / ".codex" / "sessions" / "2026" / "06" / "06" / f"{session}.jsonl"
507
+ _write_jsonl(log_path, events)
508
+
509
+ runner = CliRunner()
510
+ result = runner.invoke(
511
+ agent_log_command,
512
+ ["handoff", "--engine", "codex", "--session", session, "--max-chars", "900"],
513
+ )
514
+
515
+ assert result.exit_code == 0, result.output
516
+ assert len(result.output) <= 900
517
+ assert "omitted to fit --max-chars" in result.output
518
+
519
+
520
+ def test_handoff_opencode_writes_out_file(fake_home: Path, tmp_path: Path) -> None:
521
+ session = "handoff-opencode"
522
+ events = [
523
+ {
524
+ "message_id": "m1",
525
+ "message_role": "user",
526
+ "part_id": "p1",
527
+ "part_data": json.dumps({"type": "input_text", "text": "open the issue"}),
528
+ },
529
+ {
530
+ "message_id": "m2",
531
+ "message_role": "assistant",
532
+ "part_id": "p2",
533
+ "part_data": json.dumps({"type": "output_text", "text": "I read the issue."}),
534
+ },
535
+ {
536
+ "message_id": "m3",
537
+ "message_role": "assistant",
538
+ "part_id": "p3",
539
+ "part_data": json.dumps(
540
+ {"type": "tool", "tool": "shell", "state": "UNIQUE_OPCODE_PAYLOAD"}
541
+ ),
542
+ },
543
+ ]
544
+ log_path = fake_home / ".local" / "share" / "opencode" / f"{session}.jsonl"
545
+ _write_jsonl(log_path, events)
546
+ out = tmp_path / "handoff.md"
547
+
548
+ runner = CliRunner()
549
+ result = runner.invoke(
550
+ agent_log_command,
551
+ ["handoff", "--engine", "opencode", "--session", session, "--out", str(out)],
552
+ )
553
+
554
+ assert result.exit_code == 0, result.output
555
+ assert result.output == ""
556
+ text = out.read_text(encoding="utf-8")
557
+ assert "open the issue" in text
558
+ assert "I read the issue." in text
559
+ assert "shell" not in text
560
+ assert "UNIQUE_OPCODE_PAYLOAD" not in text
561
+
562
+
362
563
  # ----- missing-log handling ------------------------------------------
363
564
 
364
565
 
@@ -3,7 +3,7 @@ revision = 3
3
3
  requires-python = ">=3.11"
4
4
 
5
5
  [options]
6
- exclude-newer = "2026-05-30T07:24:14.324815622Z"
6
+ exclude-newer = "2026-05-30T11:30:48.503303377Z"
7
7
  exclude-newer-span = "P7D"
8
8
 
9
9
  [[package]]
@@ -143,7 +143,7 @@ wheels = [
143
143
 
144
144
  [[package]]
145
145
  name = "pocketshell"
146
- version = "0.3.25"
146
+ version = "0.3.26"
147
147
  source = { editable = "." }
148
148
  dependencies = [
149
149
  { name = "click" },
File without changes
File without changes