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.
- {pocketshell-0.3.25 → pocketshell-0.3.27}/PKG-INFO +1 -1
- {pocketshell-0.3.25 → pocketshell-0.3.27}/pyproject.toml +1 -1
- {pocketshell-0.3.25 → pocketshell-0.3.27}/src/pocketshell/agent_log.py +397 -15
- {pocketshell-0.3.25 → pocketshell-0.3.27}/tests/test_agent_log.py +201 -0
- {pocketshell-0.3.25 → pocketshell-0.3.27}/uv.lock +2 -2
- {pocketshell-0.3.25 → pocketshell-0.3.27}/.gitignore +0 -0
- {pocketshell-0.3.25 → pocketshell-0.3.27}/README.md +0 -0
- {pocketshell-0.3.25 → pocketshell-0.3.27}/src/pocketshell/__init__.py +0 -0
- {pocketshell-0.3.25 → pocketshell-0.3.27}/src/pocketshell/__main__.py +0 -0
- {pocketshell-0.3.25 → pocketshell-0.3.27}/src/pocketshell/cli.py +0 -0
- {pocketshell-0.3.25 → pocketshell-0.3.27}/src/pocketshell/daemon.py +0 -0
- {pocketshell-0.3.25 → pocketshell-0.3.27}/src/pocketshell/env.py +0 -0
- {pocketshell-0.3.25 → pocketshell-0.3.27}/src/pocketshell/hooks.py +0 -0
- {pocketshell-0.3.25 → pocketshell-0.3.27}/src/pocketshell/jobs.py +0 -0
- {pocketshell-0.3.25 → pocketshell-0.3.27}/src/pocketshell/logs.py +0 -0
- {pocketshell-0.3.25 → pocketshell-0.3.27}/src/pocketshell/qr_share.py +0 -0
- {pocketshell-0.3.25 → pocketshell-0.3.27}/src/pocketshell/repos.py +0 -0
- {pocketshell-0.3.25 → pocketshell-0.3.27}/src/pocketshell/sessions.py +0 -0
- {pocketshell-0.3.25 → pocketshell-0.3.27}/src/pocketshell/usage.py +0 -0
- {pocketshell-0.3.25 → pocketshell-0.3.27}/tests/__init__.py +0 -0
- {pocketshell-0.3.25 → pocketshell-0.3.27}/tests/test_cli.py +0 -0
- {pocketshell-0.3.25 → pocketshell-0.3.27}/tests/test_daemon.py +0 -0
- {pocketshell-0.3.25 → pocketshell-0.3.27}/tests/test_env.py +0 -0
- {pocketshell-0.3.25 → pocketshell-0.3.27}/tests/test_hooks.py +0 -0
- {pocketshell-0.3.25 → pocketshell-0.3.27}/tests/test_jobs.py +0 -0
- {pocketshell-0.3.25 → pocketshell-0.3.27}/tests/test_logs.py +0 -0
- {pocketshell-0.3.25 → pocketshell-0.3.27}/tests/test_qr_share.py +0 -0
- {pocketshell-0.3.25 → pocketshell-0.3.27}/tests/test_repos.py +0 -0
- {pocketshell-0.3.25 → pocketshell-0.3.27}/tests/test_sessions.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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.
|
|
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=
|
|
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=
|
|
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-
|
|
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.
|
|
146
|
+
version = "0.3.26"
|
|
147
147
|
source = { editable = "." }
|
|
148
148
|
dependencies = [
|
|
149
149
|
{ name = "click" },
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|