agent-postbox 0.2.0__tar.gz → 0.3.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-postbox
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A local, filesystem-backed message board that lets AI coding agents open private channels, exchange messages and artifacts, and tear them down when done.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -94,6 +94,7 @@ command is `agent-board`** and the **import name is `agent_board`** — only the
94
94
  | `list-agents [--json]` | the directory of registered agents |
95
95
  | `create --to <id\|name> [--topic T] [--json]` | open a channel and invite the target |
96
96
  | `check-messages [--json] [--no-advance]` | new invites + messages since last check |
97
+ | `wait [--channel C] [--timeout N] [--follow]` | block until new activity (run via `run_in_background` or `Monitor`) |
97
98
  | `list-channels [--json]` | the channels you belong to, with unread counts |
98
99
  | `accept <channel>` | join a channel you were invited to |
99
100
  | `post <channel> [--text T] [--artifact PATH] [--json]` | send a message and/or a file |
@@ -67,6 +67,7 @@ command is `agent-board`** and the **import name is `agent_board`** — only the
67
67
  | `list-agents [--json]` | the directory of registered agents |
68
68
  | `create --to <id\|name> [--topic T] [--json]` | open a channel and invite the target |
69
69
  | `check-messages [--json] [--no-advance]` | new invites + messages since last check |
70
+ | `wait [--channel C] [--timeout N] [--follow]` | block until new activity (run via `run_in_background` or `Monitor`) |
70
71
  | `list-channels [--json]` | the channels you belong to, with unread counts |
71
72
  | `accept <channel>` | join a channel you were invited to |
72
73
  | `post <channel> [--text T] [--artifact PATH] [--json]` | send a message and/or a file |
@@ -4,7 +4,7 @@
4
4
  # the existing PyPI project "agentboard".
5
5
  [project]
6
6
  name = "agent-postbox"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "A local, filesystem-backed message board that lets AI coding agents open private channels, exchange messages and artifacts, and tear them down when done."
9
9
  authors = [{ name = "Ben Ballintyn", email = "benballintyn@gmail.com" }]
10
10
  readme = "README.md"
@@ -18,6 +18,7 @@ import json
18
18
  import os
19
19
  import shutil
20
20
  import sys
21
+ import time
21
22
  from pathlib import Path
22
23
  from typing import Any, TextIO
23
24
 
@@ -163,6 +164,42 @@ def cmd_check_messages(args: argparse.Namespace, out: TextIO) -> int:
163
164
  return 0
164
165
 
165
166
 
167
+ def cmd_wait(args: argparse.Namespace, out: TextIO) -> int:
168
+ """Block until new board activity arrives (run via Bash run_in_background / Monitor).
169
+
170
+ Light internal poll inside a single process -- the model never sees the
171
+ polling, only the result. Bounded by ``--timeout``; prints nothing during
172
+ quiet stretches (so it can't flood context). One-shot by default (exits on the
173
+ first activity); ``--follow`` streams one line per new message for Monitor.
174
+ Read-only: it does not advance your real cursor, so a normal check-messages/
175
+ read still works after you're woken.
176
+ """
177
+ agent_id = _require_agent_id(args)
178
+ if store.get_agent(agent_id) is None:
179
+ out.write("not on the board yet -- create/accept/post first, then wait\n")
180
+ return 0
181
+
182
+ baseline = store.get_cursors(agent_id)
183
+ deadline = time.monotonic() + args.timeout
184
+ got = False
185
+ while True:
186
+ result, baseline = store.poll_updates(agent_id, baseline, channel=args.channel)
187
+ if store.has_updates(result):
188
+ out.write(_format_updates(result) + "\n")
189
+ out.flush()
190
+ got = True
191
+ if not args.follow:
192
+ return 0
193
+ if time.monotonic() >= deadline:
194
+ break
195
+ time.sleep(min(args.interval, max(0.0, deadline - time.monotonic())))
196
+
197
+ if not got and not args.follow:
198
+ out.write("(no new messages)\n")
199
+ return 3
200
+ return 0
201
+
202
+
166
203
  def cmd_accept(args: argparse.Namespace, out: TextIO) -> int:
167
204
  """Join a channel you were invited to."""
168
205
  agent_id = _require_agent_id(args)
@@ -404,6 +441,14 @@ def build_parser() -> argparse.ArgumentParser:
404
441
  p.add_argument("--json", action="store_true", help="emit JSON")
405
442
  p.set_defaults(func=cmd_list_channels)
406
443
 
444
+ p = sub.add_parser("wait", help="block until new activity (via run_in_background or Monitor)")
445
+ add_id(p)
446
+ p.add_argument("--channel", help="watch only this channel id")
447
+ p.add_argument("--timeout", type=float, default=300.0, help="max seconds to wait")
448
+ p.add_argument("--interval", type=float, default=1.0, help="poll interval seconds")
449
+ p.add_argument("--follow", action="store_true", help="stream for Monitor (vs exit on first)")
450
+ p.set_defaults(func=cmd_wait)
451
+
407
452
  p = sub.add_parser("accept", help="join a channel you were invited to")
408
453
  add_id(p)
409
454
  p.add_argument("channel", help="channel id")
@@ -724,6 +724,60 @@ def channel_overviews(agent_id: str) -> list[dict[str, Any]]:
724
724
  return overviews
725
725
 
726
726
 
727
+ def poll_updates(
728
+ agent_id: str, baseline: dict[str, int], channel: str | None = None
729
+ ) -> tuple[dict[str, Any], dict[str, int]]:
730
+ """Non-mutating check for activity newer than ``baseline``.
731
+
732
+ Unlike :func:`check_messages`, this never touches the agent's stored cursors --
733
+ it backs the blocking ``wait`` command, which only *notifies* of new activity
734
+ and leaves the authoritative read (and cursor advance) to the agent's own
735
+ check-messages/read once it's woken.
736
+
737
+ Args:
738
+ agent_id: The waiting agent.
739
+ baseline: Per-source last-seen seq (``"inbox"`` plus channel ids). Seed it
740
+ from :func:`get_cursors` to also surface anything already unread.
741
+ channel: If set, watch only this channel and ignore the inbox.
742
+
743
+ Returns:
744
+ ``(result, advanced_baseline)`` -- ``result`` has the same shape as
745
+ :func:`check_messages` (others' events only); ``advanced_baseline`` is
746
+ ``baseline`` moved past everything seen this poll (so a follow loop won't
747
+ re-report it).
748
+ """
749
+ result: dict[str, Any] = {"invites": [], "channels": {}}
750
+ advanced = dict(baseline)
751
+
752
+ if channel is None:
753
+ inbox = _agent_home(agent_id) / "inbox"
754
+ inbox_seqs = _seq_files(inbox)
755
+ last = baseline.get("inbox", 0)
756
+ invites = [_read_json(inbox / f"{s:0{_SEQ_WIDTH}d}.json") for s in inbox_seqs if s > last]
757
+ if invites:
758
+ result["invites"] = invites
759
+ if inbox_seqs:
760
+ advanced["inbox"] = inbox_seqs[-1]
761
+
762
+ channels = member_channels(agent_id)
763
+ if channel is not None:
764
+ channels = [c for c in channels if c.id == channel]
765
+ for ch in channels:
766
+ try:
767
+ events, head = read_events(ch.id, baseline.get(ch.id, 0))
768
+ except BoardError:
769
+ continue # channel closed/deleted between listing and read
770
+ foreign = [e for e in events if e.author != agent_id]
771
+ if foreign:
772
+ result["channels"][ch.id] = {
773
+ "topic": ch.topic,
774
+ "events": [e.to_dict() for e in foreign],
775
+ }
776
+ if events:
777
+ advanced[ch.id] = head
778
+ return result, advanced
779
+
780
+
727
781
  # --------------------------------------------------------------------------- #
728
782
  # Garbage collection
729
783
  # --------------------------------------------------------------------------- #
File without changes