pocketshell 0.3.26__tar.gz → 0.3.28__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.26 → pocketshell-0.3.28}/PKG-INFO +1 -1
- {pocketshell-0.3.26 → pocketshell-0.3.28}/pyproject.toml +1 -1
- {pocketshell-0.3.26 → pocketshell-0.3.28}/src/pocketshell/agent_log.py +397 -15
- {pocketshell-0.3.26 → pocketshell-0.3.28}/src/pocketshell/daemon.py +7 -8
- pocketshell-0.3.28/src/pocketshell/usage.py +502 -0
- {pocketshell-0.3.26 → pocketshell-0.3.28}/tests/test_agent_log.py +201 -0
- {pocketshell-0.3.26 → pocketshell-0.3.28}/tests/test_usage.py +109 -3
- pocketshell-0.3.26/src/pocketshell/usage.py +0 -222
- {pocketshell-0.3.26 → pocketshell-0.3.28}/.gitignore +0 -0
- {pocketshell-0.3.26 → pocketshell-0.3.28}/README.md +0 -0
- {pocketshell-0.3.26 → pocketshell-0.3.28}/src/pocketshell/__init__.py +0 -0
- {pocketshell-0.3.26 → pocketshell-0.3.28}/src/pocketshell/__main__.py +0 -0
- {pocketshell-0.3.26 → pocketshell-0.3.28}/src/pocketshell/cli.py +0 -0
- {pocketshell-0.3.26 → pocketshell-0.3.28}/src/pocketshell/env.py +0 -0
- {pocketshell-0.3.26 → pocketshell-0.3.28}/src/pocketshell/hooks.py +0 -0
- {pocketshell-0.3.26 → pocketshell-0.3.28}/src/pocketshell/jobs.py +0 -0
- {pocketshell-0.3.26 → pocketshell-0.3.28}/src/pocketshell/logs.py +0 -0
- {pocketshell-0.3.26 → pocketshell-0.3.28}/src/pocketshell/qr_share.py +0 -0
- {pocketshell-0.3.26 → pocketshell-0.3.28}/src/pocketshell/repos.py +0 -0
- {pocketshell-0.3.26 → pocketshell-0.3.28}/src/pocketshell/sessions.py +0 -0
- {pocketshell-0.3.26 → pocketshell-0.3.28}/tests/__init__.py +0 -0
- {pocketshell-0.3.26 → pocketshell-0.3.28}/tests/test_cli.py +0 -0
- {pocketshell-0.3.26 → pocketshell-0.3.28}/tests/test_daemon.py +0 -0
- {pocketshell-0.3.26 → pocketshell-0.3.28}/tests/test_env.py +0 -0
- {pocketshell-0.3.26 → pocketshell-0.3.28}/tests/test_hooks.py +0 -0
- {pocketshell-0.3.26 → pocketshell-0.3.28}/tests/test_jobs.py +0 -0
- {pocketshell-0.3.26 → pocketshell-0.3.28}/tests/test_logs.py +0 -0
- {pocketshell-0.3.26 → pocketshell-0.3.28}/tests/test_qr_share.py +0 -0
- {pocketshell-0.3.26 → pocketshell-0.3.28}/tests/test_repos.py +0 -0
- {pocketshell-0.3.26 → pocketshell-0.3.28}/tests/test_sessions.py +0 -0
- {pocketshell-0.3.26 → pocketshell-0.3.28}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pocketshell
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.28
|
|
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.28"
|
|
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
|
|
|
@@ -350,13 +350,12 @@ def _usage_fetch_handler(params: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
350
350
|
"provider": "codex" // or null
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
-
Returning
|
|
354
|
-
|
|
355
|
-
``
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
the wire format.
|
|
353
|
+
Returning stdout in the envelope preserves the CLI/daemon contract.
|
|
354
|
+
The stdout is normalized by :mod:`pocketshell.usage` before it is cached,
|
|
355
|
+
because ``quse --json`` can expose provider quirks that are not the
|
|
356
|
+
PocketShell app-facing schema. ``quse --json`` emits **NDJSON** (one
|
|
357
|
+
provider per line, not a single document), so normalization keeps that
|
|
358
|
+
line-oriented wire format.
|
|
360
359
|
"""
|
|
361
360
|
# Lazy import to avoid a circular module load at startup: the
|
|
362
361
|
# daemon module is imported from ``cli.py`` which also imports
|
|
@@ -397,7 +396,7 @@ def _usage_fetch_handler(params: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
397
396
|
text=True,
|
|
398
397
|
)
|
|
399
398
|
return {
|
|
400
|
-
"stdout": completed.stdout,
|
|
399
|
+
"stdout": _usage.normalize_usage_stdout(completed.stdout),
|
|
401
400
|
"stderr": completed.stderr,
|
|
402
401
|
"returncode": completed.returncode,
|
|
403
402
|
"provider": provider,
|