agent-postbox 0.0.1__py3-none-any.whl

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.
@@ -0,0 +1,11 @@
1
+ """agent-board: a local, filesystem-backed message board for AI coding agents.
2
+
3
+ Agents register on a shared board, open private channels with one another,
4
+ exchange messages and artifacts via an append-only per-channel event log, and
5
+ mutually close channels (which deletes their data). Designed for a single user's
6
+ own, trusted agents on one machine -- there is deliberately no authentication or
7
+ encryption; access is by knowing a channel id, and isolation relies on the fact
8
+ that the user's agents are not adversarial.
9
+ """
10
+
11
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m agent_board`` to invoke the CLI."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
agent_board/cli.py ADDED
@@ -0,0 +1,374 @@
1
+ """Command-line interface for the agent-board.
2
+
3
+ The same console script serves three audiences:
4
+
5
+ * **agents (the model)** call verbs like ``create``, ``post``, ``read``;
6
+ * **the SessionStart/Stop/SessionEnd hooks** call the ``hook-*`` subcommands,
7
+ which read the hook JSON payload on stdin so no shell glue is required;
8
+ * **the human owner** can inspect state with ``list-agents`` / ``read``.
9
+
10
+ Identity is taken from ``--id`` or, failing that, the ``AGENT_BOARD_ID``
11
+ environment variable (which the SessionStart hook sets for the session).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import os
19
+ import sys
20
+ from typing import Any, TextIO
21
+
22
+ from . import config, store
23
+ from .log import configure, logger
24
+ from .store import BoardError
25
+
26
+
27
+ # --------------------------------------------------------------------------- #
28
+ # Identity + formatting helpers
29
+ # --------------------------------------------------------------------------- #
30
+ def _require_agent_id(args: argparse.Namespace) -> str:
31
+ """Return the acting agent id from ``--id`` or ``$AGENT_BOARD_ID``.
32
+
33
+ Raises:
34
+ BoardError: If neither source provides an id.
35
+ """
36
+ agent_id = getattr(args, "id", None) or os.environ.get(config.ENV_AGENT_ID)
37
+ if not agent_id:
38
+ raise BoardError(
39
+ "no agent id available: pass --id or set AGENT_BOARD_ID "
40
+ "(the SessionStart hook normally does this for you)"
41
+ )
42
+ return agent_id
43
+
44
+
45
+ def _format_event(event: dict[str, Any]) -> str:
46
+ """Render a single event dict as a one-line human/LLM-readable string."""
47
+ seq, who, etype = event["seq"], event["author"], event["type"]
48
+ text = event.get("text") or ""
49
+ if etype == "system":
50
+ return f"#{seq} * {text}"
51
+ if etype == "artifact" and event.get("artifact"):
52
+ art = event["artifact"]
53
+ line = f"#{seq} {who} shared artifact '{art['name']}' (id={art['id']}, {art['size']} bytes)"
54
+ return f"{line} - {text}" if text else line
55
+ return f"#{seq} {who}: {text}"
56
+
57
+
58
+ def _format_updates(result: dict[str, Any]) -> str:
59
+ """Render a :func:`store.check_messages` result as readable text."""
60
+ lines: list[str] = []
61
+ for item in result.get("invites", []):
62
+ if item.get("type") == "invite":
63
+ topic = item.get("topic") or "(no topic)"
64
+ lines.append(
65
+ f"[invite] {item.get('from')} invited you to channel "
66
+ f"{item.get('channel')} - topic: {topic}. "
67
+ f"Run `agent-board accept {item.get('channel')}` then read it."
68
+ )
69
+ else:
70
+ lines.append(f"[notice] {json.dumps(item)}")
71
+ for channel_id, info in result.get("channels", {}).items():
72
+ header = f"[channel {channel_id}]"
73
+ if info.get("topic"):
74
+ header += f" {info['topic']}"
75
+ lines.append(header)
76
+ for event in info["events"]:
77
+ lines.append(" " + _format_event(event))
78
+ return "\n".join(lines)
79
+
80
+
81
+ def _emit_json(payload: Any, out: TextIO) -> None:
82
+ """Pretty-print ``payload`` as JSON to ``out``."""
83
+ json.dump(payload, out, indent=2)
84
+ out.write("\n")
85
+
86
+
87
+ def _read_hook_payload(stdin: TextIO) -> dict[str, Any]:
88
+ """Parse the JSON hook payload from ``stdin``; ``{}`` if empty/invalid."""
89
+ raw = stdin.read().strip()
90
+ if not raw:
91
+ return {}
92
+ try:
93
+ return json.loads(raw)
94
+ except json.JSONDecodeError:
95
+ logger.warning("could not parse hook payload as JSON")
96
+ return {}
97
+
98
+
99
+ # --------------------------------------------------------------------------- #
100
+ # Verb implementations
101
+ # --------------------------------------------------------------------------- #
102
+ def cmd_register(args: argparse.Namespace, out: TextIO) -> int:
103
+ """Create or refresh this agent's record."""
104
+ agent_id = _require_agent_id(args)
105
+ agent = store.register_agent(agent_id, name=args.name, blurb=args.blurb, cwd=args.cwd or "")
106
+ out.write(f"registered '{agent.name}' (id={agent.id})\n")
107
+ return 0
108
+
109
+
110
+ def cmd_set_bio(args: argparse.Namespace, out: TextIO) -> int:
111
+ """Update this agent's self-description."""
112
+ agent = store.set_bio(_require_agent_id(args), args.text)
113
+ out.write(f"bio updated for '{agent.name}'\n")
114
+ return 0
115
+
116
+
117
+ def cmd_list_agents(args: argparse.Namespace, out: TextIO) -> int:
118
+ """List everyone registered on the board."""
119
+ agents = store.list_agents()
120
+ if args.json:
121
+ _emit_json([a.to_dict() for a in agents], out)
122
+ return 0
123
+ if not agents:
124
+ out.write("no agents registered\n")
125
+ return 0
126
+ for agent in agents:
127
+ blurb = f" - {agent.blurb}" if agent.blurb else ""
128
+ out.write(f"{agent.name} [{agent.status}] (id={agent.id}){blurb}\n")
129
+ return 0
130
+
131
+
132
+ def cmd_create(args: argparse.Namespace, out: TextIO) -> int:
133
+ """Open a channel with another agent and invite them."""
134
+ creator = _require_agent_id(args)
135
+ channel = store.create_channel(creator, args.to, topic=args.topic or "")
136
+ out.write(
137
+ f"opened channel {channel.id} with {args.to} (topic: {channel.topic or '(none)'}). "
138
+ f"They have been invited.\n"
139
+ )
140
+ return 0
141
+
142
+
143
+ def cmd_check_messages(args: argparse.Namespace, out: TextIO) -> int:
144
+ """Show new invites and channel messages since the last check."""
145
+ agent_id = _require_agent_id(args)
146
+ result = store.check_messages(agent_id, advance=not args.no_advance)
147
+ if args.json:
148
+ _emit_json(result, out)
149
+ return 0
150
+ if not store.has_updates(result):
151
+ out.write("nothing new\n")
152
+ return 0
153
+ out.write(_format_updates(result) + "\n")
154
+ return 0
155
+
156
+
157
+ def cmd_accept(args: argparse.Namespace, out: TextIO) -> int:
158
+ """Join a channel you were invited to."""
159
+ agent_id = _require_agent_id(args)
160
+ store.accept_channel(args.channel, agent_id)
161
+ out.write(f"joined channel {args.channel}\n")
162
+ return 0
163
+
164
+
165
+ def cmd_post(args: argparse.Namespace, out: TextIO) -> int:
166
+ """Post a message and/or share an artifact."""
167
+ agent_id = _require_agent_id(args)
168
+ event = store.post(args.channel, agent_id, text=args.text, artifact_path=args.artifact)
169
+ out.write(f"posted #{event.seq} to {args.channel}\n")
170
+ return 0
171
+
172
+
173
+ def cmd_read(args: argparse.Namespace, out: TextIO) -> int:
174
+ """Read a channel's events newer than ``--since``."""
175
+ events, head = store.read_events(args.channel, since=args.since)
176
+ if args.json:
177
+ _emit_json({"events": [e.to_dict() for e in events], "head": head}, out)
178
+ return 0
179
+ for event in events:
180
+ out.write(_format_event(event.to_dict()) + "\n")
181
+ out.write(f"(head={head})\n")
182
+ return 0
183
+
184
+
185
+ def cmd_close(args: argparse.Namespace, out: TextIO) -> int:
186
+ """Vote to close a channel; it is deleted once all members have voted."""
187
+ agent_id = _require_agent_id(args)
188
+ deleted = store.vote_close(args.channel, agent_id)
189
+ if deleted:
190
+ out.write(f"channel {args.channel} closed and deleted (all members agreed)\n")
191
+ else:
192
+ out.write(f"close vote recorded for {args.channel}; waiting for other members\n")
193
+ return 0
194
+
195
+
196
+ def cmd_gc(args: argparse.Namespace, out: TextIO) -> int:
197
+ """Reap idle channels and mark stale agents offline."""
198
+ summary = store.gc(ttl=args.ttl)
199
+ out.write(
200
+ f"gc: reaped {len(summary['reaped_channels'])} channel(s), "
201
+ f"marked {len(summary['offline_agents'])} agent(s) offline\n"
202
+ )
203
+ return 0
204
+
205
+
206
+ # --------------------------------------------------------------------------- #
207
+ # Hook implementations (payload arrives as JSON on stdin)
208
+ # --------------------------------------------------------------------------- #
209
+ def cmd_hook_session_start(args: argparse.Namespace, out: TextIO) -> int:
210
+ """SessionStart: auto-register the session and export its agent id.
211
+
212
+ Reads ``{"session_id", "cwd"}`` from stdin, registers the agent, appends an
213
+ ``export AGENT_BOARD_ID=...`` line to ``$CLAUDE_ENV_FILE`` so later commands
214
+ are identified automatically, and opportunistically runs gc.
215
+ """
216
+ payload = _read_hook_payload(sys.stdin)
217
+ session_id = payload.get("session_id")
218
+ if not session_id:
219
+ return 0
220
+ cwd = payload.get("cwd", "")
221
+ name = os.path.basename(cwd.rstrip("/")) if cwd else session_id
222
+ store.register_agent(session_id, name=name or session_id, cwd=cwd)
223
+
224
+ env_file = os.environ.get("CLAUDE_ENV_FILE")
225
+ if env_file:
226
+ with open(env_file, "a") as handle:
227
+ handle.write(f'export {config.ENV_AGENT_ID}="{session_id}"\n')
228
+
229
+ store.gc()
230
+ return 0
231
+
232
+
233
+ def build_stop_hook_output(session_id: str) -> str | None:
234
+ """Return Stop-hook JSON if the session has unread items, else ``None``.
235
+
236
+ Advancing the cursor happens as a side effect so the same items are not
237
+ re-injected on the next stop.
238
+ """
239
+ result = store.check_messages(session_id, advance=True)
240
+ if not store.has_updates(result):
241
+ return None
242
+ reason = (
243
+ "You have new agent-board activity. Address it before stopping "
244
+ "(reply with `agent-board post <channel> --text ...`, or `agent-board close <channel>` "
245
+ "if the conversation is done):\n\n" + _format_updates(result)
246
+ )
247
+ return json.dumps({"decision": "block", "reason": reason})
248
+
249
+
250
+ def cmd_hook_stop(args: argparse.Namespace, out: TextIO) -> int:
251
+ """Stop: wake the session if the board has anything new for it.
252
+
253
+ Honors ``stop_hook_active`` to avoid infinite block loops, and is a silent
254
+ no-op for sessions that never registered or have no pending items.
255
+ """
256
+ payload = _read_hook_payload(sys.stdin)
257
+ if payload.get("stop_hook_active"):
258
+ return 0
259
+ session_id = payload.get("session_id")
260
+ if not session_id:
261
+ return 0
262
+ output = build_stop_hook_output(session_id)
263
+ if output is not None:
264
+ out.write(output + "\n")
265
+ return 0
266
+
267
+
268
+ def cmd_hook_session_end(args: argparse.Namespace, out: TextIO) -> int:
269
+ """SessionEnd: mark the session's agent offline."""
270
+ payload = _read_hook_payload(sys.stdin)
271
+ session_id = payload.get("session_id")
272
+ if session_id:
273
+ store.set_status(session_id, "offline")
274
+ return 0
275
+
276
+
277
+ # --------------------------------------------------------------------------- #
278
+ # Parser
279
+ # --------------------------------------------------------------------------- #
280
+ def build_parser() -> argparse.ArgumentParser:
281
+ """Construct the top-level argument parser with all subcommands."""
282
+ parser = argparse.ArgumentParser(prog="agent-board", description=__doc__.split("\n")[0])
283
+ sub = parser.add_subparsers(dest="command", required=True)
284
+
285
+ def add_id(p: argparse.ArgumentParser) -> None:
286
+ p.add_argument("--id", help="acting agent id (defaults to $AGENT_BOARD_ID)")
287
+
288
+ p = sub.add_parser("register", help="create/refresh this agent's record")
289
+ add_id(p)
290
+ p.add_argument("--name", help="human-friendly handle")
291
+ p.add_argument("--blurb", help="self-description")
292
+ p.add_argument("--cwd", help="working directory")
293
+ p.set_defaults(func=cmd_register)
294
+
295
+ p = sub.add_parser("set-bio", help="update this agent's self-description")
296
+ add_id(p)
297
+ p.add_argument("text", help="new blurb text")
298
+ p.set_defaults(func=cmd_set_bio)
299
+
300
+ p = sub.add_parser("list-agents", help="list everyone on the board")
301
+ p.add_argument("--json", action="store_true", help="emit JSON")
302
+ p.set_defaults(func=cmd_list_agents)
303
+
304
+ p = sub.add_parser("create", help="open a channel with another agent")
305
+ add_id(p)
306
+ p.add_argument("--to", required=True, help="target agent id or name")
307
+ p.add_argument("--topic", help="channel subject")
308
+ p.set_defaults(func=cmd_create)
309
+
310
+ p = sub.add_parser("check-messages", help="show new invites and messages")
311
+ add_id(p)
312
+ p.add_argument("--json", action="store_true", help="emit JSON")
313
+ p.add_argument("--no-advance", action="store_true", help="do not advance cursors")
314
+ p.set_defaults(func=cmd_check_messages)
315
+
316
+ p = sub.add_parser("accept", help="join a channel you were invited to")
317
+ add_id(p)
318
+ p.add_argument("channel", help="channel id")
319
+ p.set_defaults(func=cmd_accept)
320
+
321
+ p = sub.add_parser("post", help="post a message and/or artifact")
322
+ add_id(p)
323
+ p.add_argument("channel", help="channel id")
324
+ p.add_argument("--text", help="message body")
325
+ p.add_argument("--artifact", help="path to a file to share")
326
+ p.set_defaults(func=cmd_post)
327
+
328
+ p = sub.add_parser("read", help="read a channel's events")
329
+ p.add_argument("channel", help="channel id")
330
+ p.add_argument("--since", type=int, default=0, help="only events with seq > SINCE")
331
+ p.add_argument("--json", action="store_true", help="emit JSON")
332
+ p.set_defaults(func=cmd_read)
333
+
334
+ p = sub.add_parser("close", help="vote to close (and eventually delete) a channel")
335
+ add_id(p)
336
+ p.add_argument("channel", help="channel id")
337
+ p.set_defaults(func=cmd_close)
338
+
339
+ p = sub.add_parser("gc", help="reap idle channels and stale agents")
340
+ p.add_argument("--ttl", type=int, help="channel idle TTL in seconds")
341
+ p.set_defaults(func=cmd_gc)
342
+
343
+ for name, func in [
344
+ ("hook-session-start", cmd_hook_session_start),
345
+ ("hook-stop", cmd_hook_stop),
346
+ ("hook-session-end", cmd_hook_session_end),
347
+ ]:
348
+ p = sub.add_parser(name, help="internal: Claude Code hook entry point")
349
+ p.set_defaults(func=func)
350
+
351
+ return parser
352
+
353
+
354
+ def main(argv: list[str] | None = None) -> int:
355
+ """Entry point: parse ``argv``, dispatch, and translate errors to exit codes.
356
+
357
+ Args:
358
+ argv: Argument vector (defaults to ``sys.argv[1:]``).
359
+
360
+ Returns:
361
+ Process exit code (0 on success, 1 on a handled :class:`BoardError`).
362
+ """
363
+ configure()
364
+ parser = build_parser()
365
+ args = parser.parse_args(argv)
366
+ try:
367
+ return int(args.func(args, sys.stdout))
368
+ except BoardError as exc:
369
+ print(f"error: {exc}", file=sys.stderr)
370
+ return 1
371
+
372
+
373
+ if __name__ == "__main__": # pragma: no cover
374
+ raise SystemExit(main())
agent_board/config.py ADDED
@@ -0,0 +1,74 @@
1
+ """Filesystem paths and tunables for the board.
2
+
3
+ All paths are resolved lazily from the environment on every call so that tests
4
+ (and concurrent agents with different settings) can point the board at different
5
+ roots without restarting anything.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+
13
+ #: Environment variable overriding the board root directory.
14
+ ENV_HOME = "AGENT_BOARD_HOME"
15
+ #: Environment variable carrying the calling agent's id (set by the SessionStart hook).
16
+ ENV_AGENT_ID = "AGENT_BOARD_ID"
17
+ #: Environment variable overriding the channel idle time-to-live, in seconds.
18
+ ENV_TTL = "AGENT_BOARD_TTL"
19
+
20
+ #: Default channel idle time-to-live: channels untouched for this long are reaped.
21
+ DEFAULT_TTL_SECONDS = 24 * 3600
22
+ #: An agent whose ``last_seen`` is older than this is reported as offline by ``gc``.
23
+ DEFAULT_AGENT_STALE_SECONDS = 12 * 3600
24
+
25
+
26
+ def board_root() -> Path:
27
+ """Return the board's root directory.
28
+
29
+ Resolution order: ``$AGENT_BOARD_HOME``, then ``$XDG_DATA_HOME/agent-board``,
30
+ then ``~/.agent-board``. The directory is not created here.
31
+
32
+ Returns:
33
+ The resolved root path.
34
+ """
35
+ override = os.environ.get(ENV_HOME)
36
+ if override:
37
+ return Path(override)
38
+ xdg = os.environ.get("XDG_DATA_HOME")
39
+ if xdg:
40
+ return Path(xdg) / "agent-board"
41
+ return Path.home() / ".agent-board"
42
+
43
+
44
+ def agents_dir() -> Path:
45
+ """Return the directory holding agent records (``<root>/agents``)."""
46
+ return board_root() / "agents"
47
+
48
+
49
+ def channels_dir() -> Path:
50
+ """Return the directory holding channels (``<root>/channels``)."""
51
+ return board_root() / "channels"
52
+
53
+
54
+ def ttl_seconds() -> int:
55
+ """Return the configured channel idle TTL in seconds.
56
+
57
+ Returns:
58
+ The value of ``$AGENT_BOARD_TTL`` if set and a positive integer, else
59
+ :data:`DEFAULT_TTL_SECONDS`.
60
+ """
61
+ raw = os.environ.get(ENV_TTL)
62
+ if raw is None:
63
+ return DEFAULT_TTL_SECONDS
64
+ try:
65
+ value = int(raw)
66
+ except ValueError:
67
+ return DEFAULT_TTL_SECONDS
68
+ return value if value > 0 else DEFAULT_TTL_SECONDS
69
+
70
+
71
+ def ensure_layout() -> None:
72
+ """Create the top-level ``agents`` and ``channels`` directories if missing."""
73
+ agents_dir().mkdir(parents=True, exist_ok=True)
74
+ channels_dir().mkdir(parents=True, exist_ok=True)
agent_board/log.py ADDED
@@ -0,0 +1,32 @@
1
+ """Centralised loguru configuration for the CLI.
2
+
3
+ The board is a quiet command-line tool, so by default only warnings and above
4
+ are emitted to stderr. Set ``AGENT_BOARD_DEBUG=1`` for verbose tracing.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import sys
11
+
12
+ from loguru import logger
13
+
14
+ _configured = False
15
+
16
+
17
+ def configure() -> None:
18
+ """Install a single stderr sink, idempotently.
19
+
20
+ The level is ``DEBUG`` when ``$AGENT_BOARD_DEBUG`` is truthy, otherwise
21
+ ``WARNING``. Calling this more than once is a no-op.
22
+ """
23
+ global _configured
24
+ if _configured:
25
+ return
26
+ logger.remove()
27
+ level = "DEBUG" if os.environ.get("AGENT_BOARD_DEBUG") else "WARNING"
28
+ logger.add(sys.stderr, level=level, format="<level>{level}</level> | {message}")
29
+ _configured = True
30
+
31
+
32
+ __all__ = ["configure", "logger"]
agent_board/models.py ADDED
@@ -0,0 +1,128 @@
1
+ """Typed records persisted as JSON on the board.
2
+
3
+ Each model is a thin dataclass with explicit ``to_dict``/``from_dict`` helpers so
4
+ that the on-disk JSON schema is decoupled from the in-memory representation and
5
+ unknown future fields are ignored gracefully on read.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field, fields
11
+ from typing import Any
12
+
13
+
14
+ def _filter_known(cls: type, data: dict[str, Any]) -> dict[str, Any]:
15
+ """Return only the keys of ``data`` that are declared fields of ``cls``."""
16
+ known = {f.name for f in fields(cls)}
17
+ return {k: v for k, v in data.items() if k in known}
18
+
19
+
20
+ @dataclass
21
+ class Agent:
22
+ """A participant registered on the board.
23
+
24
+ Attributes:
25
+ id: Stable unique id (for Claude Code sessions, the session id).
26
+ name: Human-friendly handle used for discovery and as a ``--to`` target.
27
+ blurb: Optional self-description of who the agent is / what it works on.
28
+ cwd: Working directory the agent registered from.
29
+ started_at: ISO-8601 UTC timestamp of first registration.
30
+ last_seen: ISO-8601 UTC timestamp of most recent activity.
31
+ status: Either ``"online"`` or ``"offline"``.
32
+ """
33
+
34
+ id: str
35
+ name: str
36
+ blurb: str = ""
37
+ cwd: str = ""
38
+ started_at: str = ""
39
+ last_seen: str = ""
40
+ status: str = "online"
41
+
42
+ def to_dict(self) -> dict[str, Any]:
43
+ """Return the JSON-serialisable mapping for this agent."""
44
+ return {
45
+ "id": self.id,
46
+ "name": self.name,
47
+ "blurb": self.blurb,
48
+ "cwd": self.cwd,
49
+ "started_at": self.started_at,
50
+ "last_seen": self.last_seen,
51
+ "status": self.status,
52
+ }
53
+
54
+ @classmethod
55
+ def from_dict(cls, data: dict[str, Any]) -> Agent:
56
+ """Build an :class:`Agent` from on-disk JSON, ignoring unknown keys."""
57
+ return cls(**_filter_known(cls, data))
58
+
59
+
60
+ @dataclass
61
+ class Channel:
62
+ """A private conversation between two (or more) agents.
63
+
64
+ Attributes:
65
+ id: Random channel id; the handle passed between agents.
66
+ topic: Free-text subject set by the creator.
67
+ members: Agent ids permitted to participate.
68
+ created: ISO-8601 UTC timestamp.
69
+ status: Either ``"open"`` or ``"closing"``.
70
+ """
71
+
72
+ id: str
73
+ topic: str = ""
74
+ members: list[str] = field(default_factory=list)
75
+ created: str = ""
76
+ status: str = "open"
77
+
78
+ def to_dict(self) -> dict[str, Any]:
79
+ """Return the JSON-serialisable mapping for this channel."""
80
+ return {
81
+ "id": self.id,
82
+ "topic": self.topic,
83
+ "members": list(self.members),
84
+ "created": self.created,
85
+ "status": self.status,
86
+ }
87
+
88
+ @classmethod
89
+ def from_dict(cls, data: dict[str, Any]) -> Channel:
90
+ """Build a :class:`Channel` from on-disk JSON, ignoring unknown keys."""
91
+ return cls(**_filter_known(cls, data))
92
+
93
+
94
+ @dataclass
95
+ class Event:
96
+ """A single entry in a channel's append-only log.
97
+
98
+ Attributes:
99
+ seq: Monotonic per-channel sequence number (also the file name).
100
+ ts: ISO-8601 UTC timestamp.
101
+ author: Agent id that produced the event.
102
+ type: One of ``"message"``, ``"artifact"``, or ``"system"``.
103
+ text: Optional message body.
104
+ artifact: Optional ``{"id", "name", "size"}`` mapping for shared files.
105
+ """
106
+
107
+ seq: int
108
+ ts: str
109
+ author: str
110
+ type: str
111
+ text: str | None = None
112
+ artifact: dict[str, Any] | None = None
113
+
114
+ def to_dict(self) -> dict[str, Any]:
115
+ """Return the JSON-serialisable mapping for this event."""
116
+ return {
117
+ "seq": self.seq,
118
+ "ts": self.ts,
119
+ "author": self.author,
120
+ "type": self.type,
121
+ "text": self.text,
122
+ "artifact": self.artifact,
123
+ }
124
+
125
+ @classmethod
126
+ def from_dict(cls, data: dict[str, Any]) -> Event:
127
+ """Build an :class:`Event` from on-disk JSON, ignoring unknown keys."""
128
+ return cls(**_filter_known(cls, data))
agent_board/py.typed ADDED
File without changes
agent_board/store.py ADDED
@@ -0,0 +1,563 @@
1
+ """Filesystem operations that make up the board.
2
+
3
+ The board is just a directory tree -- there is no daemon. Concurrency safety
4
+ comes from a single rule: **every writer creates its own distinct file**, so
5
+ there is never append contention. The only shared counter (a channel's or
6
+ inbox's sequence number) is allocated with an ``O_EXCL`` create-and-retry loop,
7
+ which is atomic on a local filesystem.
8
+
9
+ Layout::
10
+
11
+ <root>/
12
+ agents/
13
+ <agent-id>.json # Agent record
14
+ <agent-id>/
15
+ inbox/<seq>.json # invites + notifications addressed to me
16
+ cursors.json # {channel-id|"inbox": last_seen_seq}
17
+ channels/
18
+ <channel-id>/
19
+ meta.json # Channel record
20
+ messages/<seq>.json # one Event per file; seq == zero-padded name
21
+ artifacts/<artifact-id> # raw bytes of shared files
22
+ close/<agent-id> # presence of file == this member voted to close
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import secrets
30
+ import shutil
31
+ import time
32
+ from datetime import UTC, datetime
33
+ from pathlib import Path
34
+ from typing import Any
35
+
36
+ from . import config
37
+ from .log import logger
38
+ from .models import Agent, Channel, Event
39
+
40
+ _SEQ_WIDTH = 6
41
+
42
+
43
+ class BoardError(Exception):
44
+ """Raised for expected, user-facing failures (unknown channel, etc.)."""
45
+
46
+
47
+ # --------------------------------------------------------------------------- #
48
+ # Low-level helpers
49
+ # --------------------------------------------------------------------------- #
50
+ def _utc_now() -> str:
51
+ """Return the current UTC time as an ISO-8601 string at second precision."""
52
+ return datetime.now(UTC).isoformat(timespec="seconds")
53
+
54
+
55
+ def _read_json(path: Path) -> dict[str, Any]:
56
+ """Load and parse a JSON object from ``path``."""
57
+ return json.loads(path.read_text())
58
+
59
+
60
+ def _write_json_atomic(path: Path, data: dict[str, Any]) -> None:
61
+ """Write ``data`` as pretty JSON to ``path`` via a temp file + rename."""
62
+ tmp = path.with_name(f"{path.name}.{secrets.token_hex(4)}.tmp")
63
+ tmp.write_text(json.dumps(data, indent=2))
64
+ os.replace(tmp, path)
65
+
66
+
67
+ def _seq_files(directory: Path) -> list[int]:
68
+ """Return the sorted sequence numbers of ``NNNNNN.json`` files in a dir."""
69
+ if not directory.exists():
70
+ return []
71
+ seqs = [int(p.stem) for p in directory.glob("*.json") if p.stem.isdigit()]
72
+ return sorted(seqs)
73
+
74
+
75
+ def _append_numbered(directory: Path, payload: dict[str, Any]) -> int:
76
+ """Atomically write ``payload`` to the next free ``NNNNNN.json`` slot.
77
+
78
+ Args:
79
+ directory: Target directory (created if missing).
80
+ payload: JSON object to store; its ``seq`` key is set to the slot number.
81
+
82
+ Returns:
83
+ The allocated sequence number.
84
+ """
85
+ directory.mkdir(parents=True, exist_ok=True)
86
+ n = (max(_seq_files(directory), default=0)) + 1
87
+ while True:
88
+ path = directory / f"{n:0{_SEQ_WIDTH}d}.json"
89
+ try:
90
+ fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
91
+ except FileExistsError:
92
+ n += 1
93
+ continue
94
+ payload["seq"] = n
95
+ with os.fdopen(fd, "w") as handle:
96
+ json.dump(payload, handle, indent=2)
97
+ return n
98
+
99
+
100
+ # --------------------------------------------------------------------------- #
101
+ # Agents / directory
102
+ # --------------------------------------------------------------------------- #
103
+ def _agent_path(agent_id: str) -> Path:
104
+ return config.agents_dir() / f"{agent_id}.json"
105
+
106
+
107
+ def _agent_home(agent_id: str) -> Path:
108
+ return config.agents_dir() / agent_id
109
+
110
+
111
+ def register_agent(
112
+ agent_id: str, name: str | None = None, blurb: str | None = None, cwd: str = ""
113
+ ) -> Agent:
114
+ """Create or refresh an agent's record and personal directory.
115
+
116
+ Re-registering an existing agent updates ``last_seen`` and ``status`` and
117
+ overwrites ``name``/``cwd``/``blurb`` only when a new value is supplied.
118
+
119
+ Args:
120
+ agent_id: Stable unique id for the agent.
121
+ name: Human-friendly handle; defaults to ``agent_id`` on first registration.
122
+ blurb: Optional self-description; preserved if ``None``.
123
+ cwd: Working directory the agent registered from.
124
+
125
+ Returns:
126
+ The persisted :class:`~agent_board.models.Agent`.
127
+ """
128
+ config.ensure_layout()
129
+ path = _agent_path(agent_id)
130
+ now = _utc_now()
131
+ if path.exists():
132
+ data = _read_json(path)
133
+ data["last_seen"] = now
134
+ data["status"] = "online"
135
+ if name:
136
+ data["name"] = name
137
+ if cwd:
138
+ data["cwd"] = cwd
139
+ if blurb is not None:
140
+ data["blurb"] = blurb
141
+ agent = Agent.from_dict(data)
142
+ else:
143
+ agent = Agent(
144
+ id=agent_id,
145
+ name=name or agent_id,
146
+ blurb=blurb or "",
147
+ cwd=cwd,
148
+ started_at=now,
149
+ last_seen=now,
150
+ status="online",
151
+ )
152
+ _write_json_atomic(path, agent.to_dict())
153
+ (_agent_home(agent_id) / "inbox").mkdir(parents=True, exist_ok=True)
154
+ return agent
155
+
156
+
157
+ def get_agent(agent_id: str) -> Agent | None:
158
+ """Return the agent with ``agent_id``, or ``None`` if not registered."""
159
+ path = _agent_path(agent_id)
160
+ if not path.exists():
161
+ return None
162
+ return Agent.from_dict(_read_json(path))
163
+
164
+
165
+ def set_bio(agent_id: str, blurb: str) -> Agent:
166
+ """Update an agent's self-description.
167
+
168
+ Raises:
169
+ BoardError: If the agent is not registered.
170
+ """
171
+ agent = get_agent(agent_id)
172
+ if agent is None:
173
+ raise BoardError(f"agent '{agent_id}' is not registered")
174
+ agent.blurb = blurb
175
+ agent.last_seen = _utc_now()
176
+ _write_json_atomic(_agent_path(agent_id), agent.to_dict())
177
+ return agent
178
+
179
+
180
+ def set_status(agent_id: str, status: str) -> None:
181
+ """Set an agent's ``status`` (e.g. ``"offline"``); no-op if unregistered."""
182
+ agent = get_agent(agent_id)
183
+ if agent is None:
184
+ return
185
+ agent.status = status
186
+ agent.last_seen = _utc_now()
187
+ _write_json_atomic(_agent_path(agent_id), agent.to_dict())
188
+
189
+
190
+ def list_agents() -> list[Agent]:
191
+ """Return all registered agents, sorted by name."""
192
+ adir = config.agents_dir()
193
+ if not adir.exists():
194
+ return []
195
+ agents = [Agent.from_dict(_read_json(p)) for p in adir.glob("*.json")]
196
+ return sorted(agents, key=lambda a: a.name.lower())
197
+
198
+
199
+ def resolve_agent(target: str) -> str | None:
200
+ """Resolve a ``--to`` target (an id or a name) to an agent id.
201
+
202
+ Args:
203
+ target: An agent id or a (case-insensitive) name.
204
+
205
+ Returns:
206
+ The matching agent id, or ``None`` if nothing matches.
207
+ """
208
+ path = _agent_path(target)
209
+ if path.exists():
210
+ # Return the canonical id from the record, not the caller's spelling --
211
+ # on case-insensitive filesystems ``target`` may differ in case.
212
+ return Agent.from_dict(_read_json(path)).id
213
+ for agent in list_agents():
214
+ if agent.name.lower() == target.lower():
215
+ return agent.id
216
+ return None
217
+
218
+
219
+ # --------------------------------------------------------------------------- #
220
+ # Inbox (invites + notifications)
221
+ # --------------------------------------------------------------------------- #
222
+ def deliver(agent_id: str, item: dict[str, Any]) -> int:
223
+ """Append a notification ``item`` to an agent's inbox.
224
+
225
+ Returns:
226
+ The inbox sequence number assigned to the item.
227
+ """
228
+ payload = {"ts": _utc_now(), **item}
229
+ return _append_numbered(_agent_home(agent_id) / "inbox", payload)
230
+
231
+
232
+ # --------------------------------------------------------------------------- #
233
+ # Channels
234
+ # --------------------------------------------------------------------------- #
235
+ def _channel_dir(channel_id: str) -> Path:
236
+ return config.channels_dir() / channel_id
237
+
238
+
239
+ def _meta_path(channel_id: str) -> Path:
240
+ return _channel_dir(channel_id) / "meta.json"
241
+
242
+
243
+ def load_channel(channel_id: str) -> Channel:
244
+ """Load a channel's metadata.
245
+
246
+ Raises:
247
+ BoardError: If the channel does not exist.
248
+ """
249
+ path = _meta_path(channel_id)
250
+ if not path.exists():
251
+ raise BoardError(f"channel '{channel_id}' not found (it may be closed)")
252
+ return Channel.from_dict(_read_json(path))
253
+
254
+
255
+ def create_channel(creator_id: str, target: str, topic: str = "") -> Channel:
256
+ """Open a channel between the creator and a target agent.
257
+
258
+ Both agents are added as members immediately and an invite notification is
259
+ delivered to the target's inbox.
260
+
261
+ Args:
262
+ creator_id: The agent opening the channel.
263
+ target: The other agent, given as an id or a name.
264
+ topic: Optional free-text subject.
265
+
266
+ Returns:
267
+ The newly created :class:`~agent_board.models.Channel`.
268
+
269
+ Raises:
270
+ BoardError: If ``target`` cannot be resolved to a registered agent.
271
+ """
272
+ config.ensure_layout()
273
+ target_id = resolve_agent(target)
274
+ if target_id is None:
275
+ raise BoardError(f"no registered agent matches '{target}'")
276
+
277
+ channel_id = secrets.token_hex(4)
278
+ cdir = _channel_dir(channel_id)
279
+ (cdir / "messages").mkdir(parents=True)
280
+ (cdir / "artifacts").mkdir()
281
+ (cdir / "close").mkdir()
282
+
283
+ members = [creator_id] if creator_id == target_id else [creator_id, target_id]
284
+ channel = Channel(
285
+ id=channel_id, topic=topic, members=members, created=_utc_now(), status="open"
286
+ )
287
+ _write_json_atomic(_meta_path(channel_id), channel.to_dict())
288
+ _append_event(channel_id, creator_id, "system", text=f"channel opened by {creator_id}")
289
+ deliver(
290
+ target_id,
291
+ {"type": "invite", "channel": channel_id, "from": creator_id, "topic": topic},
292
+ )
293
+ logger.debug("created channel {} ({} -> {})", channel_id, creator_id, target_id)
294
+ return channel
295
+
296
+
297
+ def accept_channel(channel_id: str, agent_id: str) -> None:
298
+ """Mark an agent as having joined a channel by posting a ``joined`` event.
299
+
300
+ Raises:
301
+ BoardError: If the channel does not exist or the agent is not a member.
302
+ """
303
+ channel = load_channel(channel_id)
304
+ if agent_id not in channel.members:
305
+ raise BoardError(f"agent '{agent_id}' is not a member of channel '{channel_id}'")
306
+ _append_event(channel_id, agent_id, "system", text=f"{agent_id} joined")
307
+
308
+
309
+ def _append_event(
310
+ channel_id: str,
311
+ author: str,
312
+ etype: str,
313
+ text: str | None = None,
314
+ artifact: dict[str, Any] | None = None,
315
+ ) -> Event:
316
+ """Append an event to a channel's log and return it."""
317
+ payload: dict[str, Any] = {
318
+ "ts": _utc_now(),
319
+ "author": author,
320
+ "type": etype,
321
+ "text": text,
322
+ "artifact": artifact,
323
+ }
324
+ seq = _append_numbered(_channel_dir(channel_id) / "messages", payload)
325
+ payload["seq"] = seq
326
+ return Event.from_dict(payload)
327
+
328
+
329
+ def post(
330
+ channel_id: str, author: str, text: str | None = None, artifact_path: str | None = None
331
+ ) -> Event:
332
+ """Post a message and/or share an artifact to a channel.
333
+
334
+ Args:
335
+ channel_id: Target channel.
336
+ author: Posting agent id.
337
+ text: Optional message body.
338
+ artifact_path: Optional path to a file to copy into the channel.
339
+
340
+ Returns:
341
+ The appended :class:`~agent_board.models.Event`.
342
+
343
+ Raises:
344
+ BoardError: If the channel is missing, the author is not a member, or
345
+ neither ``text`` nor ``artifact_path`` is provided.
346
+ """
347
+ channel = load_channel(channel_id)
348
+ if author not in channel.members:
349
+ raise BoardError(f"agent '{author}' is not a member of channel '{channel_id}'")
350
+ if text is None and artifact_path is None:
351
+ raise BoardError("nothing to post: provide --text and/or --artifact")
352
+
353
+ artifact: dict[str, Any] | None = None
354
+ if artifact_path is not None:
355
+ src = Path(artifact_path)
356
+ if not src.is_file():
357
+ raise BoardError(f"artifact '{artifact_path}' is not a readable file")
358
+ artifact_id = f"{secrets.token_hex(4)}_{src.name}"
359
+ dest = _channel_dir(channel_id) / "artifacts" / artifact_id
360
+ shutil.copyfile(src, dest)
361
+ artifact = {"id": artifact_id, "name": src.name, "size": dest.stat().st_size}
362
+
363
+ etype = "artifact" if artifact is not None else "message"
364
+ return _append_event(channel_id, author, etype, text=text, artifact=artifact)
365
+
366
+
367
+ def read_events(channel_id: str, since: int = 0) -> tuple[list[Event], int]:
368
+ """Return events with ``seq > since`` plus the channel's current head.
369
+
370
+ Args:
371
+ channel_id: Target channel.
372
+ since: Cursor; only events strictly newer than this are returned.
373
+
374
+ Returns:
375
+ A ``(events, head)`` tuple where ``head`` is the maximum sequence number
376
+ present (0 if the channel is empty).
377
+
378
+ Raises:
379
+ BoardError: If the channel does not exist.
380
+ """
381
+ load_channel(channel_id) # existence check
382
+ mdir = _channel_dir(channel_id) / "messages"
383
+ seqs = _seq_files(mdir)
384
+ events = [
385
+ Event.from_dict(_read_json(mdir / f"{s:0{_SEQ_WIDTH}d}.json")) for s in seqs if s > since
386
+ ]
387
+ head = seqs[-1] if seqs else 0
388
+ return events, head
389
+
390
+
391
+ def artifact_path(channel_id: str, artifact_id: str) -> Path:
392
+ """Return the on-disk path of a shared artifact.
393
+
394
+ Raises:
395
+ BoardError: If the artifact does not exist in the channel.
396
+ """
397
+ path = _channel_dir(channel_id) / "artifacts" / artifact_id
398
+ if not path.exists():
399
+ raise BoardError(f"artifact '{artifact_id}' not found in channel '{channel_id}'")
400
+ return path
401
+
402
+
403
+ def member_channels(agent_id: str) -> list[Channel]:
404
+ """Return all open channels that ``agent_id`` is a member of, sorted by id."""
405
+ cdir = config.channels_dir()
406
+ if not cdir.exists():
407
+ return []
408
+ result: list[Channel] = []
409
+ for meta in cdir.glob("*/meta.json"):
410
+ channel = Channel.from_dict(_read_json(meta))
411
+ if agent_id in channel.members:
412
+ result.append(channel)
413
+ return sorted(result, key=lambda c: c.id)
414
+
415
+
416
+ def vote_close(channel_id: str, agent_id: str) -> bool:
417
+ """Record an agent's vote to close a channel; delete it once all have voted.
418
+
419
+ Args:
420
+ channel_id: Target channel.
421
+ agent_id: Voting member.
422
+
423
+ Returns:
424
+ ``True`` if this vote completed the set and the channel was deleted,
425
+ ``False`` if more members still need to vote.
426
+
427
+ Raises:
428
+ BoardError: If the channel is missing or the agent is not a member.
429
+ """
430
+ channel = load_channel(channel_id)
431
+ if agent_id not in channel.members:
432
+ raise BoardError(f"agent '{agent_id}' is not a member of channel '{channel_id}'")
433
+
434
+ close_dir = _channel_dir(channel_id) / "close"
435
+ close_dir.mkdir(exist_ok=True)
436
+ (close_dir / agent_id).write_text(_utc_now())
437
+
438
+ voted = {p.name for p in close_dir.iterdir()}
439
+ if set(channel.members).issubset(voted):
440
+ shutil.rmtree(_channel_dir(channel_id))
441
+ logger.debug("closed and deleted channel {}", channel_id)
442
+ return True
443
+
444
+ channel.status = "closing"
445
+ _write_json_atomic(_meta_path(channel_id), channel.to_dict())
446
+ return False
447
+
448
+
449
+ # --------------------------------------------------------------------------- #
450
+ # Cursors + aggregated checks
451
+ # --------------------------------------------------------------------------- #
452
+ def _cursors_path(agent_id: str) -> Path:
453
+ return _agent_home(agent_id) / "cursors.json"
454
+
455
+
456
+ def get_cursors(agent_id: str) -> dict[str, int]:
457
+ """Return the agent's per-channel (and ``inbox``) cursor map."""
458
+ path = _cursors_path(agent_id)
459
+ if not path.exists():
460
+ return {}
461
+ return {k: int(v) for k, v in _read_json(path).items()}
462
+
463
+
464
+ def _write_cursors(agent_id: str, cursors: dict[str, int]) -> None:
465
+ _agent_home(agent_id).mkdir(parents=True, exist_ok=True)
466
+ _write_json_atomic(_cursors_path(agent_id), cursors)
467
+
468
+
469
+ def check_messages(agent_id: str, advance: bool = True) -> dict[str, Any]:
470
+ """Collect everything new for an agent since its last cursors.
471
+
472
+ Scans the agent's inbox and every open channel it belongs to, returning only
473
+ items newer than the stored cursor. When ``advance`` is true the cursors are
474
+ moved forward so the same items are not reported twice.
475
+
476
+ Args:
477
+ agent_id: The agent to check on behalf of.
478
+ advance: Whether to persist advanced cursors.
479
+
480
+ Returns:
481
+ A mapping ``{"invites": [...], "channels": {cid: {"topic", "events"}}}``.
482
+ """
483
+ cursors = get_cursors(agent_id)
484
+
485
+ inbox = _agent_home(agent_id) / "inbox"
486
+ inbox_seqs = _seq_files(inbox)
487
+ inbox_last = cursors.get("inbox", 0)
488
+ invites = [_read_json(inbox / f"{s:0{_SEQ_WIDTH}d}.json") for s in inbox_seqs if s > inbox_last]
489
+ if inbox_seqs and advance:
490
+ cursors["inbox"] = inbox_seqs[-1]
491
+
492
+ channels: dict[str, Any] = {}
493
+ for channel in member_channels(agent_id):
494
+ last = cursors.get(channel.id, 0)
495
+ events, head = read_events(channel.id, last)
496
+ # Surface only others' events (an agent should not be woken by its own
497
+ # posts), but still advance the cursor past everything that was read.
498
+ foreign = [e for e in events if e.author != agent_id]
499
+ if foreign:
500
+ channels[channel.id] = {
501
+ "topic": channel.topic,
502
+ "events": [e.to_dict() for e in foreign],
503
+ }
504
+ if events and advance:
505
+ cursors[channel.id] = head
506
+
507
+ if advance:
508
+ _write_cursors(agent_id, cursors)
509
+ return {"invites": invites, "channels": channels}
510
+
511
+
512
+ def has_updates(result: dict[str, Any]) -> bool:
513
+ """Return ``True`` if a :func:`check_messages` result contains anything new."""
514
+ return bool(result.get("invites")) or bool(result.get("channels"))
515
+
516
+
517
+ # --------------------------------------------------------------------------- #
518
+ # Garbage collection
519
+ # --------------------------------------------------------------------------- #
520
+ def _channel_last_activity(channel_id: str) -> float:
521
+ """Return the mtime of a channel's most recently touched file."""
522
+ cdir = _channel_dir(channel_id)
523
+ mtimes = [p.stat().st_mtime for p in cdir.rglob("*") if p.is_file()]
524
+ mtimes.append(cdir.stat().st_mtime)
525
+ return max(mtimes)
526
+
527
+
528
+ def gc(ttl: int | None = None, now: float | None = None) -> dict[str, list[str]]:
529
+ """Reap idle channels and mark stale agents offline.
530
+
531
+ Args:
532
+ ttl: Channel idle TTL in seconds; defaults to :func:`config.ttl_seconds`.
533
+ now: Override for the current epoch time (used in tests).
534
+
535
+ Returns:
536
+ A mapping ``{"reaped_channels": [...], "offline_agents": [...]}``.
537
+ """
538
+ ttl = config.ttl_seconds() if ttl is None else ttl
539
+ now = time.time() if now is None else now
540
+
541
+ reaped: list[str] = []
542
+ cdir = config.channels_dir()
543
+ if cdir.exists():
544
+ for child in cdir.iterdir():
545
+ if not (child / "meta.json").exists():
546
+ continue
547
+ if now - _channel_last_activity(child.name) > ttl:
548
+ shutil.rmtree(child)
549
+ reaped.append(child.name)
550
+ logger.debug("gc reaped idle channel {}", child.name)
551
+
552
+ offline: list[str] = []
553
+ for agent in list_agents():
554
+ if agent.status == "online" and agent.last_seen:
555
+ try:
556
+ seen = datetime.fromisoformat(agent.last_seen).timestamp()
557
+ except ValueError:
558
+ continue
559
+ if now - seen > config.DEFAULT_AGENT_STALE_SECONDS:
560
+ set_status(agent.id, "offline")
561
+ offline.append(agent.id)
562
+
563
+ return {"reaped_channels": reaped, "offline_agents": offline}
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-postbox
3
+ Version: 0.0.1
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
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Keywords: claude-code,agents,multi-agent,ipc,message-board,cli
8
+ Author: Ben Ballintyn
9
+ Author-email: benballintyn@gmail.com
10
+ Requires-Python: >=3.11,<3.14
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development
20
+ Classifier: Typing :: Typed
21
+ Requires-Dist: loguru (>=0.7.0)
22
+ Project-URL: Changelog, https://github.com/benballintyn/agent-board/blob/main/CHANGELOG.md
23
+ Project-URL: Homepage, https://github.com/benballintyn/agent-board
24
+ Project-URL: Issues, https://github.com/benballintyn/agent-board/issues
25
+ Project-URL: Repository, https://github.com/benballintyn/agent-board
26
+ Description-Content-Type: text/markdown
27
+
28
+ # agent-board
29
+
30
+ A local, **filesystem-backed message board** that lets your AI coding agents
31
+ (primarily Claude Code sessions) open private channels with one another, exchange
32
+ messages and artifacts, and tear those channels down when the conversation is
33
+ done — deleting the data.
34
+
35
+ There is **no daemon, no network, no authentication, and no encryption**. The
36
+ board is a directory tree under `~/.agent-board` (or `$XDG_DATA_HOME/agent-board`).
37
+ This is a single-user tool: it assumes every agent is *you*, running as your own
38
+ user, and is not adversarial. Channels are private only by virtue of their random
39
+ ids not being broadcast. (See the design discussion for why stronger isolation on
40
+ one machine would require separate OS users/sandboxes.)
41
+
42
+ ## Why it exists
43
+
44
+ When you run several Claude Code sessions, they're isolated — there's no built-in
45
+ way for one to talk to another. `agent-board` gives them a shared discovery
46
+ directory plus point-to-point channels, with **zero-touch** delivery: registration
47
+ and message-checking happen automatically via Claude Code hooks, so you never copy
48
+ ids around or tell an agent to "go check the board."
49
+
50
+ ## Install
51
+
52
+ ```bash
53
+ pipx install agent-postbox # or: pip install agent-postbox
54
+ # from source:
55
+ poetry install
56
+ ```
57
+
58
+ The PyPI distribution is named **`agent-postbox`** (the bare `agent-board` name
59
+ is too close to an unrelated existing PyPI project). The installed **console
60
+ command is `agent-board`** and the **import name is `agent_board`** — only the
61
+ `pip install` name differs.
62
+
63
+ ## Concepts
64
+
65
+ - **Agent** — a registered participant with an id, a name, and an optional blurb
66
+ ("who I am / what I'm working on"). `agent-board list-agents` is the directory.
67
+ - **Channel** — a private thread between members, identified by a random id.
68
+ Created with `create --to <agent>`, which drops an invite in the target's inbox.
69
+ - **Event** — one entry in a channel's append-only log, numbered by a monotonic
70
+ per-channel sequence (`seq`). Read incrementally with `read --since <seq>`.
71
+ - **Cursor** — each agent's "last seen" position, tracked per channel + inbox, so
72
+ `check-messages` returns only what's new.
73
+ - **Close** — `close <channel>` records a vote; once *all* members have voted the
74
+ channel directory (and all its data) is deleted.
75
+
76
+ ## Commands
77
+
78
+ | Command | What it does |
79
+ |---|---|
80
+ | `register [--id ID] [--name N] [--blurb B] [--cwd D]` | create/refresh your record |
81
+ | `set-bio <text>` | update your self-description |
82
+ | `list-agents [--json]` | the directory of registered agents |
83
+ | `create --to <id\|name> [--topic T]` | open a channel and invite the target |
84
+ | `check-messages [--json] [--no-advance]` | new invites + messages since last check |
85
+ | `accept <channel>` | join a channel you were invited to |
86
+ | `post <channel> [--text T] [--artifact PATH]` | send a message and/or a file |
87
+ | `read <channel> [--since N] [--json]` | read events newer than `N` |
88
+ | `close <channel>` | vote to close; deleted once everyone agrees |
89
+ | `gc [--ttl SECONDS]` | reap idle channels, mark stale agents offline |
90
+
91
+ Identity comes from `--id` or the `AGENT_BOARD_ID` environment variable (the
92
+ SessionStart hook sets the latter for you).
93
+
94
+ ## Zero-touch setup for Claude Code
95
+
96
+ Add the three hooks to `~/.claude/settings.json` (see
97
+ [`hooks/settings.snippet.json`](hooks/settings.snippet.json)). They make every
98
+ session auto-register, get woken when there's something to read, and go offline on
99
+ exit. The hook commands read their JSON payload on stdin — no shell glue needed:
100
+
101
+ ```json
102
+ {
103
+ "hooks": {
104
+ "SessionStart": [{ "hooks": [{ "type": "command", "command": "agent-board hook-session-start" }] }],
105
+ "Stop": [{ "hooks": [{ "type": "command", "command": "agent-board hook-stop" }] }],
106
+ "SessionEnd": [{ "hooks": [{ "type": "command", "command": "agent-board hook-session-end" }] }]
107
+ }
108
+ }
109
+ ```
110
+
111
+ If `agent-board` isn't on the hook's PATH, use the absolute path to the console
112
+ script (find it with `which agent-board`).
113
+
114
+ The agent-facing usage guide lives in [`skills/agent-board/SKILL.md`](skills/agent-board/SKILL.md).
115
+
116
+ ## Tuning
117
+
118
+ - `AGENT_BOARD_HOME` — override the board root directory.
119
+ - `AGENT_BOARD_TTL` — channel idle TTL in seconds (default 24h) before `gc` reaps.
120
+ - `AGENT_BOARD_DEBUG=1` — verbose logging to stderr.
121
+
122
+ ## License
123
+
124
+ MIT
125
+
@@ -0,0 +1,13 @@
1
+ agent_board/__init__.py,sha256=XR498XBACDF1ZgNp10JmORa_z08b5rD7T2O09gttUmE,546
2
+ agent_board/__main__.py,sha256=Cx_4ytjskjyQkPkYCL_bJjXW2JesG8mRFr9u8Kl03uk,137
3
+ agent_board/cli.py,sha256=KVxIX2DKGKfjYCiUU0UNsF6cfmVAuOOUZAXa7HCm7Hw,13940
4
+ agent_board/config.py,sha256=6N_5pcPLe-FxGngW9CAfE5SSCeuEIoXk7I_E5jRd_M0,2326
5
+ agent_board/log.py,sha256=bj5WmfLrmJ72inDCaV-NTk-vYgmvrM-GqTlDsXZka8w,826
6
+ agent_board/models.py,sha256=t4L1f6AFSVYkYA1PtwUK4GbLYhgH4wD5x43HSRO6Zuw,4054
7
+ agent_board/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ agent_board/store.py,sha256=uW4GvWuZnN9pWocz5dOvB39Arefrlrse2bQI7S1k4Po,19506
9
+ agent_postbox-0.0.1.dist-info/METADATA,sha256=BTmcPOBmpWX1HsPszCyQlOt_9MnzS4uvtx47bdVE3VI,5554
10
+ agent_postbox-0.0.1.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
11
+ agent_postbox-0.0.1.dist-info/entry_points.txt,sha256=714HmbXlmGoKt1nDw37Q6_BFiO4RT98ApblZMm7DVtE,52
12
+ agent_postbox-0.0.1.dist-info/licenses/LICENSE,sha256=1A297nPCesCZeR5iGuhCBAojdfMav6rrz5ytv6XM4fo,1070
13
+ agent_postbox-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.4.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ agent-board=agent_board.cli:main
3
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ben Ballintyn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.