pocketshell 0.4.13__tar.gz → 0.4.17__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 (57) hide show
  1. {pocketshell-0.4.13 → pocketshell-0.4.17}/PKG-INFO +1 -1
  2. {pocketshell-0.4.13 → pocketshell-0.4.17}/pyproject.toml +1 -1
  3. {pocketshell-0.4.13 → pocketshell-0.4.17}/src/pocketshell/agents.py +25 -5
  4. {pocketshell-0.4.13 → pocketshell-0.4.17}/src/pocketshell/cards.py +124 -0
  5. {pocketshell-0.4.13 → pocketshell-0.4.17}/tests/test_agents.py +108 -17
  6. {pocketshell-0.4.13 → pocketshell-0.4.17}/tests/test_cards.py +136 -0
  7. {pocketshell-0.4.13 → pocketshell-0.4.17}/uv.lock +3 -2
  8. {pocketshell-0.4.13 → pocketshell-0.4.17}/.gitignore +0 -0
  9. {pocketshell-0.4.13 → pocketshell-0.4.17}/README.md +0 -0
  10. {pocketshell-0.4.13 → pocketshell-0.4.17}/scheduler/README.md +0 -0
  11. {pocketshell-0.4.13 → pocketshell-0.4.17}/scheduler/pocketshell-usage-capture.service +0 -0
  12. {pocketshell-0.4.13 → pocketshell-0.4.17}/scheduler/pocketshell-usage-capture.timer +0 -0
  13. {pocketshell-0.4.13 → pocketshell-0.4.17}/src/pocketshell/__init__.py +0 -0
  14. {pocketshell-0.4.13 → pocketshell-0.4.17}/src/pocketshell/__main__.py +0 -0
  15. {pocketshell-0.4.13 → pocketshell-0.4.17}/src/pocketshell/agent_log.py +0 -0
  16. {pocketshell-0.4.13 → pocketshell-0.4.17}/src/pocketshell/agents_kind.py +0 -0
  17. {pocketshell-0.4.13 → pocketshell-0.4.17}/src/pocketshell/cgroup_agents.py +0 -0
  18. {pocketshell-0.4.13 → pocketshell-0.4.17}/src/pocketshell/cli.py +0 -0
  19. {pocketshell-0.4.13 → pocketshell-0.4.17}/src/pocketshell/daemon.py +0 -0
  20. {pocketshell-0.4.13 → pocketshell-0.4.17}/src/pocketshell/env.py +0 -0
  21. {pocketshell-0.4.13 → pocketshell-0.4.17}/src/pocketshell/github.py +0 -0
  22. {pocketshell-0.4.13 → pocketshell-0.4.17}/src/pocketshell/hooks.py +0 -0
  23. {pocketshell-0.4.13 → pocketshell-0.4.17}/src/pocketshell/jobs.py +0 -0
  24. {pocketshell-0.4.13 → pocketshell-0.4.17}/src/pocketshell/logs.py +0 -0
  25. {pocketshell-0.4.13 → pocketshell-0.4.17}/src/pocketshell/profiles.py +0 -0
  26. {pocketshell-0.4.13 → pocketshell-0.4.17}/src/pocketshell/prune_attachments.py +0 -0
  27. {pocketshell-0.4.13 → pocketshell-0.4.17}/src/pocketshell/push.py +0 -0
  28. {pocketshell-0.4.13 → pocketshell-0.4.17}/src/pocketshell/qr_share.py +0 -0
  29. {pocketshell-0.4.13 → pocketshell-0.4.17}/src/pocketshell/repos.py +0 -0
  30. {pocketshell-0.4.13 → pocketshell-0.4.17}/src/pocketshell/resume.py +0 -0
  31. {pocketshell-0.4.13 → pocketshell-0.4.17}/src/pocketshell/sessions.py +0 -0
  32. {pocketshell-0.4.13 → pocketshell-0.4.17}/src/pocketshell/tree.py +0 -0
  33. {pocketshell-0.4.13 → pocketshell-0.4.17}/src/pocketshell/usage.py +0 -0
  34. {pocketshell-0.4.13 → pocketshell-0.4.17}/src/pocketshell/usage_capture.py +0 -0
  35. {pocketshell-0.4.13 → pocketshell-0.4.17}/src/pocketshell/usage_reset.py +0 -0
  36. {pocketshell-0.4.13 → pocketshell-0.4.17}/tests/__init__.py +0 -0
  37. {pocketshell-0.4.13 → pocketshell-0.4.17}/tests/test_agent_log.py +0 -0
  38. {pocketshell-0.4.13 → pocketshell-0.4.17}/tests/test_agents_kind.py +0 -0
  39. {pocketshell-0.4.13 → pocketshell-0.4.17}/tests/test_cgroup_agents.py +0 -0
  40. {pocketshell-0.4.13 → pocketshell-0.4.17}/tests/test_cli.py +0 -0
  41. {pocketshell-0.4.13 → pocketshell-0.4.17}/tests/test_daemon.py +0 -0
  42. {pocketshell-0.4.13 → pocketshell-0.4.17}/tests/test_env.py +0 -0
  43. {pocketshell-0.4.13 → pocketshell-0.4.17}/tests/test_github.py +0 -0
  44. {pocketshell-0.4.13 → pocketshell-0.4.17}/tests/test_hooks.py +0 -0
  45. {pocketshell-0.4.13 → pocketshell-0.4.17}/tests/test_jobs.py +0 -0
  46. {pocketshell-0.4.13 → pocketshell-0.4.17}/tests/test_logs.py +0 -0
  47. {pocketshell-0.4.13 → pocketshell-0.4.17}/tests/test_profiles.py +0 -0
  48. {pocketshell-0.4.13 → pocketshell-0.4.17}/tests/test_prune_attachments.py +0 -0
  49. {pocketshell-0.4.13 → pocketshell-0.4.17}/tests/test_push.py +0 -0
  50. {pocketshell-0.4.13 → pocketshell-0.4.17}/tests/test_qr_share.py +0 -0
  51. {pocketshell-0.4.13 → pocketshell-0.4.17}/tests/test_repos.py +0 -0
  52. {pocketshell-0.4.13 → pocketshell-0.4.17}/tests/test_resume.py +0 -0
  53. {pocketshell-0.4.13 → pocketshell-0.4.17}/tests/test_sessions.py +0 -0
  54. {pocketshell-0.4.13 → pocketshell-0.4.17}/tests/test_tree.py +0 -0
  55. {pocketshell-0.4.13 → pocketshell-0.4.17}/tests/test_usage.py +0 -0
  56. {pocketshell-0.4.13 → pocketshell-0.4.17}/tests/test_usage_capture.py +0 -0
  57. {pocketshell-0.4.13 → pocketshell-0.4.17}/tests/test_usage_reset.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pocketshell
3
- Version: 0.4.13
3
+ Version: 0.4.17
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.4.13"
11
+ version = "0.4.17"
12
12
  description = "Unified server-side Python utility for the PocketShell Android client."
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.11"
@@ -379,8 +379,13 @@ def record_agent_kind(
379
379
  profile name is known here, before ``os.execvpe``; #826 record-at-start
380
380
  hard-cut — no detection/parse path). When set, it is written as the
381
381
  per-session ``@ps_agent_profile`` user option alongside ``@ps_agent_kind``.
382
- A default / no-profile launch passes ``None`` and writes NO profile
383
- option, so a default session carries no spurious profile label.
382
+ A default / no-profile launch passes ``None`` and the option is
383
+ RECONCILED to the current launch by UNSETTING it
384
+ (``tmux set-option -uq @ps_agent_profile``), so a session previously
385
+ launched with a non-default profile and then relaunched as a default
386
+ agent in the SAME tmux session does not keep the stale profile label
387
+ (issue #889). ``@ps_agent_kind`` is always overwritten on every launch
388
+ so it has no equivalent stale hazard.
384
389
 
385
390
  The options are session-scoped (not global): ``tmux set-option`` without
386
391
  ``-g`` sets it on the current session, which is the session the agent
@@ -411,12 +416,26 @@ def record_agent_kind(
411
416
  check=False,
412
417
  )
413
418
  if profile:
414
- # Only a non-default profile is recorded; a default/no-profile
415
- # launch writes no option so the tree shows the plain kind.
419
+ # A non-default profile is recorded so the tree shows its label.
416
420
  runner(
417
421
  ["tmux", "set-option", "@ps_agent_profile", profile],
418
422
  check=False,
419
423
  )
424
+ else:
425
+ # A default / no-profile launch must RECONCILE the option to the
426
+ # current launch by UNSETTING it (issue #889). tmux session
427
+ # options persist for the life of the session, so a session
428
+ # launched once with a non-default profile (e.g. z.ai) and then
429
+ # relaunched as a default agent in the SAME session would keep the
430
+ # stale @ps_agent_profile and be mislabelled in the tree. The
431
+ # ``-u`` unsets the session option; ``-q`` makes unsetting an
432
+ # already-absent option a no-op (a fresh default session stays
433
+ # clean). The kind itself (set above) is always overwritten, so it
434
+ # has no equivalent stale hazard.
435
+ runner(
436
+ ["tmux", "set-option", "-uq", "@ps_agent_profile"],
437
+ check=False,
438
+ )
420
439
  except Exception:
421
440
  # Recording the kind is best-effort; never block the launch on it.
422
441
  return False
@@ -522,7 +541,8 @@ def _resolve_config_dir(
522
541
  tree can tell a z.ai Claude apart from a default Claude. The engine's
523
542
  default profile, ``--config-dir`` (which carries no named profile), and
524
543
  omitting both flags all resolve ``profile_label`` to ``None`` — so a
525
- default session records no ``@ps_agent_profile`` option.
544
+ default launch clears any stale ``@ps_agent_profile`` option (issue
545
+ #889) rather than leaving a profile label behind.
526
546
  """
527
547
  if config_dir is not None and profile is not None:
528
548
  click.echo(
@@ -360,6 +360,62 @@ register_card_type(
360
360
  )
361
361
 
362
362
 
363
+ # ---------------------------------------------------------------------------
364
+ # note card type (#859 Slice B — proves the registry is genuinely generic)
365
+ # ---------------------------------------------------------------------------
366
+ #
367
+ # A ``note`` is a non-interactive message the agent hands the human ("deploy
368
+ # finished", "found a flaky test"). Its only interaction is mark-as-read, so the
369
+ # agent can tell from ``push status`` whether the human has seen it. Registering
370
+ # it is exactly one ``register_card_type`` call + a ``push note`` verb — no new
371
+ # transport, no change to the store/read/write/upsert path. That is the whole
372
+ # point of the registry: a second real type is additive.
373
+
374
+ # Default note card id used when ``--id`` is omitted (one default note per
375
+ # session unless the agent names them — mirrors DEFAULT_CHECKLIST_ID).
376
+ DEFAULT_NOTE_ID = "note"
377
+
378
+
379
+ def _note_build_body(*, text: str, **_: Any) -> dict[str, Any]:
380
+ """Build the note ``body`` — ``{"text": <message>}``."""
381
+ return {"text": text}
382
+
383
+
384
+ def _note_initial_state(_body: dict[str, Any]) -> dict[str, Any]:
385
+ """A fresh note is unread."""
386
+ return {"read": False, "read_at": None}
387
+
388
+
389
+ def _note_apply_interaction(
390
+ body: dict[str, Any],
391
+ state: dict[str, Any],
392
+ interaction: Mapping[str, Any],
393
+ ) -> dict[str, Any]:
394
+ """Mark a note read/unread.
395
+
396
+ ``interaction = {"read": bool}``. ``read`` defaults to True (mark read).
397
+ ``read_at`` records when it was first read (cleared on unread) so the agent
398
+ can see acknowledgement timing via ``push status``.
399
+ """
400
+ read = bool(interaction.get("read", True))
401
+ return {"read": read, "read_at": _now_iso() if read else None}
402
+
403
+
404
+ def _note_summarise(body: dict[str, Any], state: dict[str, Any]) -> str:
405
+ return "note: read" if state.get("read") else "note: unread"
406
+
407
+
408
+ register_card_type(
409
+ CardType(
410
+ name="note",
411
+ build_body=_note_build_body,
412
+ initial_state=_note_initial_state,
413
+ apply_interaction=_note_apply_interaction,
414
+ summarise=_note_summarise,
415
+ )
416
+ )
417
+
418
+
363
419
  # ---------------------------------------------------------------------------
364
420
  # Store read/write
365
421
  # ---------------------------------------------------------------------------
@@ -661,3 +717,71 @@ def register_push_card_commands(push_group: click.Group) -> None:
661
717
  state = card.get("state", {}) if isinstance(card.get("state"), Mapping) else {}
662
718
  summary = handler.summarise(dict(body), dict(state)) if handler else ""
663
719
  click.echo(f"{card_id}: {summary}")
720
+
721
+ @push_group.command("note")
722
+ @click.option("--title", "title", default=None, help="Human title for the note card.")
723
+ @click.option(
724
+ "--id",
725
+ "card_id",
726
+ default=DEFAULT_NOTE_ID,
727
+ help=f"Card id (default {DEFAULT_NOTE_ID!r}; one default note per session).",
728
+ )
729
+ @click.option(
730
+ "--text",
731
+ "text",
732
+ default=None,
733
+ help="The note body. Alternative to piping the message on stdin.",
734
+ )
735
+ @click.option("--session", "session", default=None, help="Override the auto-detected tmux session.")
736
+ def push_note(
737
+ title: Optional[str],
738
+ card_id: str,
739
+ text: Optional[str],
740
+ session: Optional[str],
741
+ ) -> None:
742
+ """Create/replace the session's note card from --text or piped stdin.
743
+
744
+ A note is a non-interactive message the human marks read (``push read``).
745
+ The session is auto-detected from ``$TMUX`` (override with ``--session``).
746
+ A re-push fully replaces the card of that id (hard-cut, D22).
747
+ """
748
+ target = _require_session(session)
749
+ if text is not None and text.strip():
750
+ body_text = text.strip()
751
+ else:
752
+ stdin_text = "" if sys.stdin is None or sys.stdin.isatty() else sys.stdin.read()
753
+ body_text = stdin_text.strip()
754
+ if not body_text:
755
+ raise click.ClickException(
756
+ "empty note: pass --text or pipe the message on stdin."
757
+ )
758
+ card = build_card(
759
+ card_type="note",
760
+ card_id=card_id,
761
+ title=title,
762
+ build_kwargs={"text": body_text},
763
+ )
764
+ path = upsert_card(target, card, paths=resolve_paths())
765
+ click.echo(f"note {card_id!r} -> session {target!r} ({path})")
766
+
767
+ @push_group.command("read")
768
+ @click.option("--id", "card_id", required=True, help="The note card id.")
769
+ @click.option("--read/--unread", "read", default=True, help="Mark read (default) or unread.")
770
+ @click.option("--session", "session", default=None, help="Override the auto-detected tmux session.")
771
+ def push_read(card_id: str, read: bool, session: Optional[str]) -> None:
772
+ """Set a note's read state (this is what the app's "mark read" calls)."""
773
+ target = _require_session(session)
774
+ try:
775
+ card = apply_interaction(
776
+ target,
777
+ card_id,
778
+ {"read": read},
779
+ paths=resolve_paths(),
780
+ )
781
+ except ValueError as exc:
782
+ raise click.ClickException(str(exc))
783
+ handler = get_card_type(card.get("type", ""))
784
+ body = card.get("body", {}) if isinstance(card.get("body"), Mapping) else {}
785
+ state = card.get("state", {}) if isinstance(card.get("state"), Mapping) else {}
786
+ summary = handler.summarise(dict(body), dict(state)) if handler else ""
787
+ click.echo(f"{card_id}: {summary}")
@@ -470,11 +470,11 @@ def test_record_agent_kind_sets_session_option_inside_tmux(kind):
470
470
  runner=lambda argv, **kw: calls.append((argv, kw)),
471
471
  )
472
472
  assert ok is True
473
- assert len(calls) == 1
474
- argv, _kw = calls[0]
475
- # Session-scoped (no -g): tmux applies it to the current session, which
476
- # is the one the agent was launched into.
477
- assert argv == ["tmux", "set-option", "@ps_agent_kind", kind]
473
+ # No profile passed -> the kind is set (session-scoped, no -g), and the
474
+ # @ps_agent_profile option is reconciled away by an unset (issue #889).
475
+ argvs = [argv for argv, _kw in calls]
476
+ assert ["tmux", "set-option", "@ps_agent_kind", kind] in argvs
477
+ assert ["tmux", "set-option", "-uq", "@ps_agent_profile"] in argvs
478
478
 
479
479
 
480
480
  def test_record_agent_kind_noop_when_not_in_tmux():
@@ -594,9 +594,12 @@ def test_record_agent_kind_writes_profile_option_when_profile_set():
594
594
  ]
595
595
 
596
596
 
597
- def test_record_agent_kind_no_profile_writes_only_kind():
598
- # A default / no-profile launch records the kind but NO profile option,
599
- # so a default session shows the plain kind with no spurious chip.
597
+ def test_record_agent_kind_no_profile_clears_profile_option():
598
+ # A default / no-profile launch records the kind AND reconciles the
599
+ # @ps_agent_profile option by UNSETTING it (issue #889), so a session
600
+ # previously launched with a non-default profile cannot keep a stale
601
+ # label. ``-uq`` makes unsetting an already-absent option a no-op, so a
602
+ # fresh default session stays clean.
600
603
  calls = []
601
604
  ok = agents.record_agent_kind(
602
605
  "claude",
@@ -605,11 +608,15 @@ def test_record_agent_kind_no_profile_writes_only_kind():
605
608
  profile=None,
606
609
  )
607
610
  assert ok is True
608
- assert calls == [["tmux", "set-option", "@ps_agent_kind", "claude"]]
611
+ assert calls == [
612
+ ["tmux", "set-option", "@ps_agent_kind", "claude"],
613
+ ["tmux", "set-option", "-uq", "@ps_agent_profile"],
614
+ ]
609
615
 
610
616
 
611
- def test_record_agent_kind_empty_profile_writes_only_kind():
612
- # An empty-string profile is treated like no profile (no option written).
617
+ def test_record_agent_kind_empty_profile_clears_profile_option():
618
+ # An empty-string profile is treated like no profile: kind recorded, the
619
+ # @ps_agent_profile option unset (issue #889).
613
620
  calls = []
614
621
  agents.record_agent_kind(
615
622
  "codex",
@@ -617,7 +624,45 @@ def test_record_agent_kind_empty_profile_writes_only_kind():
617
624
  runner=lambda argv, **kw: calls.append(argv),
618
625
  profile="",
619
626
  )
620
- assert calls == [["tmux", "set-option", "@ps_agent_kind", "codex"]]
627
+ assert calls == [
628
+ ["tmux", "set-option", "@ps_agent_kind", "codex"],
629
+ ["tmux", "set-option", "-uq", "@ps_agent_profile"],
630
+ ]
631
+
632
+
633
+ def test_record_agent_kind_default_relaunch_clears_stale_profile():
634
+ # REPRODUCE-FIRST (issue #889): the reported false-z.ai-label bug. A tmux
635
+ # session is launched once with the z.ai profile (sets @ps_agent_profile),
636
+ # then the agent is killed and RELAUNCHED as a regular default Claude in
637
+ # the SAME session. tmux session options persist, so without the #889 fix
638
+ # the stale "Claude (Z.AI)" lingers and the tree mislabels the session.
639
+ # The default relaunch must emit the unset that clears it.
640
+ #
641
+ # Launch 1: z.ai profile.
642
+ zai_calls = []
643
+ agents.record_agent_kind(
644
+ "claude",
645
+ env={"TMUX": "/tmp/tmux-1000/default,1234,0"},
646
+ runner=lambda argv, **kw: zai_calls.append(argv),
647
+ profile="Claude (Z.AI)",
648
+ )
649
+ assert ["tmux", "set-option", "@ps_agent_profile", "Claude (Z.AI)"] in zai_calls
650
+
651
+ # Launch 2: default relaunch in the same session. It MUST issue the unset
652
+ # so the previously-set @ps_agent_profile is reconciled away.
653
+ default_calls = []
654
+ agents.record_agent_kind(
655
+ "claude",
656
+ env={"TMUX": "/tmp/tmux-1000/default,1234,0"},
657
+ runner=lambda argv, **kw: default_calls.append(argv),
658
+ profile=None,
659
+ )
660
+ assert ["tmux", "set-option", "-uq", "@ps_agent_profile"] in default_calls
661
+ # And it must NOT re-set a profile value.
662
+ assert not any(
663
+ argv[:3] == ["tmux", "set-option", "@ps_agent_profile"]
664
+ for argv in default_calls
665
+ )
621
666
 
622
667
 
623
668
  def test_launch_agent_records_profile_before_exec(tmp_path, monkeypatch):
@@ -669,9 +714,10 @@ def test_cli_agent_named_profile_records_profile_option(tmp_path, monkeypatch):
669
714
  assert ["tmux", "set-option", "@ps_agent_profile", "Claude (Z.AI)"] in calls
670
715
 
671
716
 
672
- def test_cli_agent_default_profile_records_no_profile_option(tmp_path, monkeypatch):
673
- """A default Claude profile records the kind but no profile option, so a
674
- default session is the plain kind with no spurious label."""
717
+ def test_cli_agent_default_profile_clears_profile_option(tmp_path, monkeypatch):
718
+ """A default Claude profile records the kind and reconciles the profile
719
+ option by UNSETTING it (issue #889), so a default launch never carries a
720
+ stale label and never sets a spurious one."""
675
721
  _seed_zlaude_home(tmp_path, monkeypatch)
676
722
  workdir = tmp_path / "work"
677
723
  workdir.mkdir()
@@ -684,13 +730,15 @@ def test_cli_agent_default_profile_records_no_profile_option(tmp_path, monkeypat
684
730
  )
685
731
  assert rc == 0
686
732
  assert ["tmux", "set-option", "@ps_agent_kind", "claude"] in calls
733
+ assert ["tmux", "set-option", "-uq", "@ps_agent_profile"] in calls
687
734
  assert not any(
688
735
  argv[:3] == ["tmux", "set-option", "@ps_agent_profile"] for argv in calls
689
736
  )
690
737
 
691
738
 
692
- def test_cli_agent_no_profile_records_no_profile_option(tmp_path, monkeypatch):
693
- """A bare launch (no --profile/--config-dir) records the kind only."""
739
+ def test_cli_agent_no_profile_clears_profile_option(tmp_path, monkeypatch):
740
+ """A bare launch (no --profile/--config-dir) records the kind and unsets
741
+ @ps_agent_profile (issue #889)."""
694
742
  workdir = tmp_path / "work"
695
743
  workdir.mkdir()
696
744
  monkeypatch.setenv("HOME", str(tmp_path))
@@ -701,11 +749,54 @@ def test_cli_agent_no_profile_records_no_profile_option(tmp_path, monkeypatch):
701
749
  rc = main(["agent", "codex", "--dir", str(workdir)])
702
750
  assert rc == 0
703
751
  assert ["tmux", "set-option", "@ps_agent_kind", "codex"] in calls
752
+ assert ["tmux", "set-option", "-uq", "@ps_agent_profile"] in calls
704
753
  assert not any(
705
754
  argv[:3] == ["tmux", "set-option", "@ps_agent_profile"] for argv in calls
706
755
  )
707
756
 
708
757
 
758
+ def test_cli_agent_default_then_zai_relaunch_sets_profile(tmp_path, monkeypatch):
759
+ """Class coverage (issue #889): default-then-relaunched-z.ai. A default
760
+ launch in the session unsets the profile; relaunching the SAME session as
761
+ z.ai sets the correct label (the chip appears)."""
762
+ _seed_zlaude_home(tmp_path, monkeypatch)
763
+ workdir = tmp_path / "work"
764
+ workdir.mkdir()
765
+ monkeypatch.setenv("TMUX", "/tmp/tmux-1000/default,1234,0")
766
+ monkeypatch.setattr(agents.os, "execvpe", lambda f, a, e: None)
767
+
768
+ # Launch 1: default -> unset.
769
+ default_calls = []
770
+ monkeypatch.setattr(
771
+ agents.subprocess, "run", lambda argv, **kw: default_calls.append(argv)
772
+ )
773
+ assert main(["agent", "claude", "--dir", str(workdir), "--profile", "Claude"]) == 0
774
+ assert ["tmux", "set-option", "-uq", "@ps_agent_profile"] in default_calls
775
+
776
+ # Launch 2: z.ai relaunch in the same session -> sets the label.
777
+ zai_calls = []
778
+ monkeypatch.setattr(
779
+ agents.subprocess, "run", lambda argv, **kw: zai_calls.append(argv)
780
+ )
781
+ assert (
782
+ main(
783
+ [
784
+ "agent",
785
+ "claude",
786
+ "--dir",
787
+ str(workdir),
788
+ "--profile",
789
+ "Claude (Z.AI)",
790
+ ]
791
+ )
792
+ == 0
793
+ )
794
+ assert ["tmux", "set-option", "@ps_agent_profile", "Claude (Z.AI)"] in zai_calls
795
+ assert not any(
796
+ argv[:3] == ["tmux", "set-option", "-uq"] for argv in zai_calls
797
+ )
798
+
799
+
709
800
  def test_resolve_config_dir_default_profile_label_is_none(tmp_path, monkeypatch):
710
801
  """The default Claude profile resolves to a None profile label."""
711
802
  _seed_zlaude_home(tmp_path, monkeypatch)
@@ -389,3 +389,139 @@ def test_cli_check_unknown_item_errors(tmp_path: Path) -> None:
389
389
  )
390
390
  assert res.exit_code != 0
391
391
  assert "unknown item" in res.output
392
+
393
+
394
+ # ----- note card type (#859 Slice B — registry is genuinely generic) ----
395
+
396
+
397
+ def test_note_card_type_is_registered() -> None:
398
+ """AC: the host registers a REAL `note` CardType (not just the stub test)."""
399
+ handler = cards_mod.get_card_type("note")
400
+ assert handler is not None
401
+ assert handler.name == "note"
402
+
403
+
404
+ def test_build_upsert_read_note(tmp_path: Path) -> None:
405
+ """AC: note `body={text}`, `state={read:false, read_at:None}` round-trips."""
406
+ paths = _paths(tmp_path)
407
+ card = cards_mod.build_card(
408
+ card_type="note",
409
+ card_id="note",
410
+ title="Heads up",
411
+ build_kwargs={"text": "deploy finished"},
412
+ )
413
+ cards_mod.upsert_card("demo", card, paths=paths)
414
+ got = cards_mod.read_cards("demo", paths=paths)
415
+ assert len(got) == 1
416
+ assert got[0]["type"] == "note"
417
+ assert got[0]["title"] == "Heads up"
418
+ assert got[0]["body"] == {"text": "deploy finished"}
419
+ assert got[0]["state"] == {"read": False, "read_at": None}
420
+
421
+
422
+ def test_note_apply_interaction_marks_read_and_unread(tmp_path: Path) -> None:
423
+ """AC: mark-as-read interaction sets read + read_at; unread clears read_at."""
424
+ paths = _paths(tmp_path)
425
+ card = cards_mod.build_card(
426
+ card_type="note", card_id="note", title=None,
427
+ build_kwargs={"text": "hi"},
428
+ )
429
+ cards_mod.upsert_card("demo", card, paths=paths)
430
+
431
+ read = cards_mod.apply_interaction("demo", "note", {"read": True}, paths=paths)
432
+ assert read["state"]["read"] is True
433
+ assert read["state"]["read_at"] # a timestamp, not None
434
+
435
+ unread = cards_mod.apply_interaction("demo", "note", {"read": False}, paths=paths)
436
+ assert unread["state"]["read"] is False
437
+ assert unread["state"]["read_at"] is None
438
+
439
+
440
+ def test_note_and_checklist_coexist_in_one_session(tmp_path: Path) -> None:
441
+ """The store is type-agnostic: a note + a checklist live side by side."""
442
+ paths = _paths(tmp_path)
443
+ checklist = cards_mod.build_card(
444
+ card_type="checklist", card_id="checklist", title="Deploy",
445
+ build_kwargs={"items": [{"id": "build-0", "text": "build"}]},
446
+ )
447
+ note = cards_mod.build_card(
448
+ card_type="note", card_id="note", title=None,
449
+ build_kwargs={"text": "fyi"},
450
+ )
451
+ cards_mod.upsert_card("demo", checklist, paths=paths)
452
+ cards_mod.upsert_card("demo", note, paths=paths)
453
+ got = cards_mod.read_cards("demo", paths=paths)
454
+ assert {c["type"] for c in got} == {"checklist", "note"}
455
+
456
+
457
+ def test_cli_note_set_get_read_status_flow(tmp_path: Path) -> None:
458
+ """AC: push note (stdin) → get --json → read → status reflects read."""
459
+ runner = CliRunner()
460
+ env = _env(tmp_path)
461
+
462
+ res = runner.invoke(
463
+ cli,
464
+ ["push", "note", "--title", "Heads up", "--session", "demo"],
465
+ input="deploy finished\n",
466
+ env=env,
467
+ )
468
+ assert res.exit_code == 0, res.output
469
+ assert "note 'note'" in res.output
470
+
471
+ res = runner.invoke(cli, ["push", "get", "--json", "--session", "demo"], env=env)
472
+ assert res.exit_code == 0, res.output
473
+ payload = json.loads(res.output)
474
+ card = payload["cards"][0]
475
+ assert card["type"] == "note"
476
+ assert card["title"] == "Heads up"
477
+ assert card["body"]["text"] == "deploy finished"
478
+ assert card["state"]["read"] is False
479
+
480
+ # Human marks it read (this is what the app's "mark read" calls).
481
+ res = runner.invoke(
482
+ cli, ["push", "read", "--id", "note", "--session", "demo"], env=env
483
+ )
484
+ assert res.exit_code == 0, res.output
485
+ assert "note: read" in res.output
486
+
487
+ # Agent reads the acknowledgement via status.
488
+ res = runner.invoke(cli, ["push", "status", "--json", "--session", "demo"], env=env)
489
+ assert res.exit_code == 0, res.output
490
+ status = json.loads(res.output)
491
+ assert status["status"][0]["state"]["read"] is True
492
+
493
+ res = runner.invoke(cli, ["push", "status", "--session", "demo"], env=env)
494
+ assert res.exit_code == 0, res.output
495
+ assert "note: read" in res.output
496
+
497
+
498
+ def test_cli_note_text_via_flag(tmp_path: Path) -> None:
499
+ runner = CliRunner()
500
+ env = _env(tmp_path)
501
+ res = runner.invoke(
502
+ cli, ["push", "note", "--text", "found a flaky test", "--session", "demo"],
503
+ env=env,
504
+ )
505
+ assert res.exit_code == 0, res.output
506
+ got = cards_mod.read_cards("demo", paths=_paths(tmp_path))
507
+ assert got[0]["body"]["text"] == "found a flaky test"
508
+
509
+
510
+ def test_cli_note_empty_errors(tmp_path: Path) -> None:
511
+ runner = CliRunner()
512
+ env = _env(tmp_path)
513
+ res = runner.invoke(
514
+ cli, ["push", "note", "--session", "demo"], input="", env=env
515
+ )
516
+ assert res.exit_code != 0
517
+ assert "empty note" in res.output
518
+
519
+
520
+ def test_cli_read_unknown_card_errors(tmp_path: Path) -> None:
521
+ runner = CliRunner()
522
+ env = _env(tmp_path)
523
+ res = runner.invoke(
524
+ cli, ["push", "read", "--id", "ghost", "--session", "demo"], env=env
525
+ )
526
+ assert res.exit_code != 0
527
+ assert "no card" in res.output
@@ -3,7 +3,8 @@ revision = 3
3
3
  requires-python = ">=3.11"
4
4
 
5
5
  [options]
6
- exclude-newer = "2026-12-31T23:00:00Z"
6
+ exclude-newer = "2026-06-18T14:08:51.166593044Z"
7
+ exclude-newer-span = "P7D"
7
8
 
8
9
  [[package]]
9
10
  name = "annotated-doc"
@@ -314,7 +315,7 @@ wheels = [
314
315
 
315
316
  [[package]]
316
317
  name = "pocketshell"
317
- version = "0.4.13"
318
+ version = "0.4.16"
318
319
  source = { editable = "." }
319
320
  dependencies = [
320
321
  { name = "click" },
File without changes
File without changes