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.
Files changed (31) hide show
  1. {pocketshell-0.3.26 → pocketshell-0.3.28}/PKG-INFO +1 -1
  2. {pocketshell-0.3.26 → pocketshell-0.3.28}/pyproject.toml +1 -1
  3. {pocketshell-0.3.26 → pocketshell-0.3.28}/src/pocketshell/agent_log.py +397 -15
  4. {pocketshell-0.3.26 → pocketshell-0.3.28}/src/pocketshell/daemon.py +7 -8
  5. pocketshell-0.3.28/src/pocketshell/usage.py +502 -0
  6. {pocketshell-0.3.26 → pocketshell-0.3.28}/tests/test_agent_log.py +201 -0
  7. {pocketshell-0.3.26 → pocketshell-0.3.28}/tests/test_usage.py +109 -3
  8. pocketshell-0.3.26/src/pocketshell/usage.py +0 -222
  9. {pocketshell-0.3.26 → pocketshell-0.3.28}/.gitignore +0 -0
  10. {pocketshell-0.3.26 → pocketshell-0.3.28}/README.md +0 -0
  11. {pocketshell-0.3.26 → pocketshell-0.3.28}/src/pocketshell/__init__.py +0 -0
  12. {pocketshell-0.3.26 → pocketshell-0.3.28}/src/pocketshell/__main__.py +0 -0
  13. {pocketshell-0.3.26 → pocketshell-0.3.28}/src/pocketshell/cli.py +0 -0
  14. {pocketshell-0.3.26 → pocketshell-0.3.28}/src/pocketshell/env.py +0 -0
  15. {pocketshell-0.3.26 → pocketshell-0.3.28}/src/pocketshell/hooks.py +0 -0
  16. {pocketshell-0.3.26 → pocketshell-0.3.28}/src/pocketshell/jobs.py +0 -0
  17. {pocketshell-0.3.26 → pocketshell-0.3.28}/src/pocketshell/logs.py +0 -0
  18. {pocketshell-0.3.26 → pocketshell-0.3.28}/src/pocketshell/qr_share.py +0 -0
  19. {pocketshell-0.3.26 → pocketshell-0.3.28}/src/pocketshell/repos.py +0 -0
  20. {pocketshell-0.3.26 → pocketshell-0.3.28}/src/pocketshell/sessions.py +0 -0
  21. {pocketshell-0.3.26 → pocketshell-0.3.28}/tests/__init__.py +0 -0
  22. {pocketshell-0.3.26 → pocketshell-0.3.28}/tests/test_cli.py +0 -0
  23. {pocketshell-0.3.26 → pocketshell-0.3.28}/tests/test_daemon.py +0 -0
  24. {pocketshell-0.3.26 → pocketshell-0.3.28}/tests/test_env.py +0 -0
  25. {pocketshell-0.3.26 → pocketshell-0.3.28}/tests/test_hooks.py +0 -0
  26. {pocketshell-0.3.26 → pocketshell-0.3.28}/tests/test_jobs.py +0 -0
  27. {pocketshell-0.3.26 → pocketshell-0.3.28}/tests/test_logs.py +0 -0
  28. {pocketshell-0.3.26 → pocketshell-0.3.28}/tests/test_qr_share.py +0 -0
  29. {pocketshell-0.3.26 → pocketshell-0.3.28}/tests/test_repos.py +0 -0
  30. {pocketshell-0.3.26 → pocketshell-0.3.28}/tests/test_sessions.py +0 -0
  31. {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.26
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.26"
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 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
 
@@ -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 raw stdout (rather than parsed JSON) preserves byte-for-
354
- byte parity with ``quse --json``; the existing Kotlin
355
- ``QuseUsageJsonParser`` keeps working without modification when the
356
- CLI re-emits the daemon's response on stdout. ``quse --json``
357
- actually emits **NDJSON** (one provider per line, not a single
358
- document), so we cannot parse-then-re-serialise without changing
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,