agent-chat-plugin 0.1.0__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,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-chat-plugin
3
+ Version: 0.1.0
4
+ Summary: Peer AI agents coordinate through markdown messages in shared channel folders — no orchestrator, autonomous zero-token waiting, cross-platform.
5
+ Author: n24q02m
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/n24q02m/agent-chat-plugin
8
+ Project-URL: Specification, https://github.com/n24q02m/agent-chat-plugin/blob/main/SPEC.md
9
+ Keywords: multi-agent,coordination,markdown,blackboard,claude-code,agent-skills,peer-to-peer
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Topic :: Software Development :: Libraries
14
+ Requires-Python: >=3.8
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Dynamic: license-file
18
+
19
+ # agent-chat
20
+
21
+ [![PyPI](https://img.shields.io/pypi/v/agent-chat-plugin.svg)](https://pypi.org/project/agent-chat-plugin/)
22
+ [![Python](https://img.shields.io/pypi/pyversions/agent-chat-plugin.svg)](https://pypi.org/project/agent-chat-plugin/)
23
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
24
+ [![CI](https://github.com/n24q02m/agent-chat-plugin/actions/workflows/ci.yml/badge.svg)](https://github.com/n24q02m/agent-chat-plugin/actions/workflows/ci.yml)
25
+
26
+ **Peer AI agents chat in a shared folder — no human relay, no orchestrator, works on
27
+ Windows, waits at zero tokens.**
28
+
29
+ Multiple agent sessions (Claude Code, Codex, Cursor, OpenCode — same tool or mixed)
30
+ coordinate as equals by exchanging markdown messages in shared **channel folders**.
31
+ The folder is the whole state: git-committable, human-readable, replayable. A crashed
32
+ session loses nothing.
33
+
34
+ One dependency-free file (`chat.py`, Python stdlib) runs identically on Windows, WSL,
35
+ and Linux. Waiting for a reply blocks in-process — **an agent that is waiting spends
36
+ zero model tokens.**
37
+
38
+ > Distributed as **`agent-chat-plugin`** on PyPI and as a Claude Code plugin (the short
39
+ > name `agent-chat` was taken on PyPI). The command and skill are still `agent-chat`.
40
+
41
+ ## Why this exists
42
+
43
+ Every strong multi-agent coding tool today is either **hierarchical** (a supervisor or
44
+ human drives subagents) or **single-agent**. The peer case — N equal sessions
45
+ coordinating through a folder of messages, autonomously waiting on each other — has no
46
+ lean, cross-platform, tool-agnostic answer. This is that answer. (Honest scope: this is
47
+ a young space; see `COMPARISON.md` for exactly what already exists and where this differs.)
48
+
49
+ ## Quickstart
50
+
51
+ ```bash
52
+ # a channel = a group chat
53
+ python chat.py init review --members alice,bob --topic "code review"
54
+
55
+ # alice posts to bob
56
+ python chat.py post review --from alice --to bob --title "Schema v0.2" --body-file msg.md
57
+
58
+ # bob reads what's new for him (only messages addressed to him or the group)
59
+ python chat.py read review --as bob
60
+
61
+ # alice waits for bob's reply — burns 0 tokens while blocked
62
+ python chat.py wait review --as alice --timeout 900
63
+ ```
64
+
65
+ Root defaults to `~/agent-chat`; override with `$AGENT_CHAT_ROOT` or `--root`. Run
66
+ `python chat.py <cmd> --help` for all flags.
67
+
68
+ ## How it works
69
+
70
+ - **Channels** — one folder per group chat; make as many as you need (`init`).
71
+ - **Messages** — `NNNN-<from>-<slug>.md` with frontmatter (`from`, `to`, `reply_to`,
72
+ `status`, `title`). `to: all` broadcasts.
73
+ - **Cursors** — `read`/`wait` show only what's new for you and never re-scan the thread.
74
+ - **Atomic** — sequence numbers are allocated under a lock (no duplicate `-11`); task
75
+ claiming uses atomic rename (lose the race -> move on).
76
+
77
+ Six rules keep peers from stepping on each other: one channel per topic; claim before
78
+ you act; message don't chatter; `wait` don't poll-with-the-model; read since your
79
+ cursor; reply in a new file. Full protocol in `SPEC.md`.
80
+
81
+ ## Two modes, two budgets
82
+
83
+ - **Live swarm** — N sessions running concurrently, `wait`-ing on each other. Buys
84
+ wall-clock parallelism + fault tolerance; costs more tokens. For abundant budgets.
85
+ - **Async handoff / audit** — post a summary when a session ends; the next session
86
+ reads it. Nearly free — usable on a tight budget.
87
+
88
+ ## Install & distribution
89
+
90
+ - **As a CLI** — `pipx install agent-chat-plugin` then run `agent-chat`
91
+ (or `uvx --from agent-chat-plugin agent-chat`).
92
+ - **As a skill** — drop `SKILL.md` + `chat.py` into your agent's skills directory;
93
+ works across tools that read the Agent Skills / `SKILL.md` standard.
94
+ - **As a Claude Code plugin** — via a plugin marketplace.
95
+
96
+ ## Status
97
+
98
+ Early (v0). The reference implementation is tested end-to-end; the protocol may still
99
+ change. Feedback and interop with `tap` / `TICK.md` welcome.
100
+
101
+ ## License
102
+
103
+ MIT.
@@ -0,0 +1,7 @@
1
+ chat.py,sha256=oNt5AG-oc6ClnqJfN9SVNKSQvW8VWwPrTwuUkBb3RpM,14407
2
+ agent_chat_plugin-0.1.0.dist-info/licenses/LICENSE,sha256=d7xQ6sRyeGus6gnvwgqiQtSY7XdFw0Jd0w5-Co_xHnk,1064
3
+ agent_chat_plugin-0.1.0.dist-info/METADATA,sha256=mAGYGFqGI_maajbh6jNCRqpw1HEp-ROttVghLKWVC7Q,4685
4
+ agent_chat_plugin-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
5
+ agent_chat_plugin-0.1.0.dist-info/entry_points.txt,sha256=oJ1NpUwJKKFlxyxamHc_wzeHgq8s48S0cK0swZOI1EM,41
6
+ agent_chat_plugin-0.1.0.dist-info/top_level.txt,sha256=0Dp7qDRFfIFYBhe5CqxvZQUjL1VpUvy3-rs6dAscIXA,5
7
+ agent_chat_plugin-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ agent-chat = chat:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 n24q02m
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.
@@ -0,0 +1 @@
1
+ chat
chat.py ADDED
@@ -0,0 +1,430 @@
1
+ #!/usr/bin/env python3
2
+ """agent-chat: peer-to-peer coordination for multiple agent sessions via markdown files.
3
+
4
+ Zero-dependency (Python stdlib only) so it runs identically on Windows, WSL and
5
+ Linux -- the same `python3` every session on the home + company machines already
6
+ has. No inotify/fswatch split: `wait` blocks with a sleep-poll loop, so while an
7
+ agent waits for a reply the Python process is idle and burns ZERO model tokens.
8
+
9
+ Model
10
+ -----
11
+ A ROOT dir holds CHANNELS (one folder each = one "group chat"). Each channel holds
12
+ numbered message files `NNNN-<from>-<slug>.md` with YAML frontmatter, a `_meta.json`
13
+ (members/topic) and per-agent read cursors under `.cursors/`. Sequence numbers are
14
+ allocated under a filesystem lock (atomic `mkdir`) so two sessions can never claim
15
+ the same number -- the exact race that produced duplicate "seq 11" files in the
16
+ hand-rolled prototype.
17
+
18
+ Commands: init | channels | roster | post | read | wait | peek | claim
19
+ Run `python chat.py <command> --help` for flags.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import datetime as _dt
25
+ import json
26
+ import os
27
+ import re
28
+ import sys
29
+ import time
30
+ from pathlib import Path
31
+
32
+ # --- root + small helpers ----------------------------------------------------
33
+
34
+ def root_dir(explicit: str | None) -> Path:
35
+ # Precedence: --root flag > AGENT_CHAT_ROOT env > ~/agent-chat default.
36
+ base = explicit or os.environ.get("AGENT_CHAT_ROOT") or str(Path.home() / "agent-chat")
37
+ return Path(base)
38
+
39
+
40
+ def now_iso() -> str:
41
+ # Local time WITH offset so a git-committed thread is unambiguous across machines.
42
+ return _dt.datetime.now().astimezone().isoformat(timespec="seconds")
43
+
44
+
45
+ def slugify(text: str, maxlen: int = 40) -> str:
46
+ s = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
47
+ return (s[:maxlen].rstrip("-")) or "msg"
48
+
49
+
50
+ def die(msg: str, code: int = 1):
51
+ print(f"agent-chat: {msg}", file=sys.stderr)
52
+ raise SystemExit(code)
53
+
54
+
55
+ # --- channel + message primitives -------------------------------------------
56
+
57
+ def channel_dir(root: Path, channel: str) -> Path:
58
+ return root / channel
59
+
60
+
61
+ def require_channel(root: Path, channel: str) -> Path:
62
+ d = channel_dir(root, channel)
63
+ if not (d / "_meta.json").exists():
64
+ die(f"channel '{channel}' not found under {root} (run: init {channel})")
65
+ return d
66
+
67
+
68
+ def _seq_from_name(name: str) -> int | None:
69
+ m = re.match(r"(\d+)-", name)
70
+ return int(m.group(1)) if m else None
71
+
72
+
73
+ def message_files(chan: Path):
74
+ files = [p for p in chan.glob("*.md") if _seq_from_name(p.name) is not None]
75
+ return sorted(files, key=lambda p: _seq_from_name(p.name))
76
+
77
+
78
+ def parse_frontmatter(path: Path) -> dict:
79
+ """Minimal front-matter reader: the block between the first two '---' lines.
80
+
81
+ Values are strings except `to`, normalized to a list ([] == broadcast/all).
82
+ """
83
+ meta: dict = {}
84
+ try:
85
+ text = path.read_text(encoding="utf-8")
86
+ except OSError:
87
+ return meta
88
+ if not text.startswith("---"):
89
+ return meta
90
+ lines = text.splitlines()
91
+ body_start = None
92
+ for i in range(1, len(lines)):
93
+ if lines[i].strip() == "---":
94
+ body_start = i
95
+ break
96
+ if body_start is None:
97
+ return meta
98
+ for ln in lines[1:body_start]:
99
+ if ":" not in ln:
100
+ continue
101
+ k, v = ln.split(":", 1)
102
+ meta[k.strip()] = v.strip()
103
+ # Normalize `to` -> list of recipients (empty == everyone).
104
+ raw = meta.get("to", "").strip()
105
+ if raw in ("", "all", "[]", "*"):
106
+ meta["to_list"] = []
107
+ else:
108
+ meta["to_list"] = [x.strip() for x in raw.strip("[]").split(",") if x.strip()]
109
+ return meta
110
+
111
+
112
+ def is_relevant(meta: dict, agent: str) -> bool:
113
+ # A message concerns `agent` if it's a broadcast or explicitly addressed to
114
+ # them, and it isn't their own message (don't wake an agent on its own post).
115
+ if meta.get("from") == agent:
116
+ return False
117
+ to = meta.get("to_list", [])
118
+ return (not to) or (agent in to)
119
+
120
+
121
+ # --- atomic sequence lock ----------------------------------------------------
122
+
123
+ def _acquire_lock(chan: Path, timeout: float = 10.0, stale: float = 30.0) -> Path:
124
+ """Atomic cross-platform lock via mkdir (fails if the dir already exists).
125
+
126
+ Steals a lock older than `stale` seconds so a crashed poster can't wedge the
127
+ channel forever.
128
+ """
129
+ lock = chan / "_seq.lock"
130
+ start = time.time()
131
+ while True:
132
+ try:
133
+ os.mkdir(lock)
134
+ return lock
135
+ except FileExistsError:
136
+ try:
137
+ if time.time() - lock.stat().st_mtime > stale:
138
+ try:
139
+ os.rmdir(lock)
140
+ except OSError:
141
+ pass
142
+ continue
143
+ except FileNotFoundError:
144
+ continue
145
+ if time.time() - start > timeout:
146
+ die("could not acquire channel seq lock (another poster is stuck?)")
147
+ time.sleep(0.05)
148
+
149
+
150
+ def _release_lock(lock: Path):
151
+ try:
152
+ os.rmdir(lock)
153
+ except OSError:
154
+ pass
155
+
156
+
157
+ def _next_seq(chan: Path) -> int:
158
+ mx = 0
159
+ for p in chan.glob("*.md"):
160
+ s = _seq_from_name(p.name)
161
+ if s is not None:
162
+ mx = max(mx, s)
163
+ return mx + 1
164
+
165
+
166
+ # --- cursors -----------------------------------------------------------------
167
+
168
+ def cursor_path(chan: Path, agent: str) -> Path:
169
+ return chan / ".cursors" / f"{slugify(agent)}.txt"
170
+
171
+
172
+ def read_cursor(chan: Path, agent: str) -> int:
173
+ p = cursor_path(chan, agent)
174
+ try:
175
+ return int(p.read_text(encoding="utf-8").strip() or "0")
176
+ except (OSError, ValueError):
177
+ return 0
178
+
179
+
180
+ def write_cursor(chan: Path, agent: str, seq: int):
181
+ p = cursor_path(chan, agent)
182
+ p.parent.mkdir(parents=True, exist_ok=True)
183
+ p.write_text(str(seq), encoding="utf-8")
184
+
185
+
186
+ def max_seq(chan: Path) -> int:
187
+ files = message_files(chan)
188
+ return _seq_from_name(files[-1].name) if files else 0
189
+
190
+
191
+ # --- commands ----------------------------------------------------------------
192
+
193
+ def cmd_init(root: Path, a):
194
+ d = channel_dir(root, a.channel)
195
+ d.mkdir(parents=True, exist_ok=True)
196
+ (d / ".cursors").mkdir(exist_ok=True)
197
+ meta_path = d / "_meta.json"
198
+ if meta_path.exists():
199
+ die(f"channel '{a.channel}' already exists")
200
+ members = [m.strip() for m in (a.members or "").split(",") if m.strip()]
201
+ meta_path.write_text(json.dumps({
202
+ "channel": a.channel,
203
+ "members": members,
204
+ "topic": a.topic or "",
205
+ "created": now_iso(),
206
+ }, indent=2), encoding="utf-8")
207
+ print(f"created channel '{a.channel}' at {d} members={members or '(open)'}")
208
+
209
+
210
+ def cmd_channels(root: Path, a):
211
+ if not root.exists():
212
+ print(f"(no channels yet under {root})")
213
+ return
214
+ rows = []
215
+ for meta_path in sorted(root.glob("*/_meta.json")):
216
+ chan = meta_path.parent
217
+ try:
218
+ meta = json.loads(meta_path.read_text(encoding="utf-8"))
219
+ except (OSError, ValueError):
220
+ meta = {}
221
+ files = message_files(chan)
222
+ last = "-"
223
+ if files:
224
+ lm = parse_frontmatter(files[-1])
225
+ last = f"#{_seq_from_name(files[-1].name)} {lm.get('from','?')}: {lm.get('title','')[:40]}"
226
+ rows.append((chan.name, ",".join(meta.get("members", [])) or "(open)", len(files), last))
227
+ if not rows:
228
+ print(f"(no channels yet under {root})")
229
+ return
230
+ w = max(len(r[0]) for r in rows)
231
+ print(f"{'CHANNEL'.ljust(w)} MSGS MEMBERS / LAST")
232
+ for name, members, n, last in rows:
233
+ print(f"{name.ljust(w)} {str(n).rjust(4)} {members}")
234
+ print(f"{' '.ljust(w)} last: {last}")
235
+
236
+
237
+ def cmd_roster(root: Path, a):
238
+ d = require_channel(root, a.channel)
239
+ meta = json.loads((d / "_meta.json").read_text(encoding="utf-8"))
240
+ print(f"channel : {meta.get('channel')}")
241
+ print(f"topic : {meta.get('topic') or '(none)'}")
242
+ print(f"members : {', '.join(meta.get('members', [])) or '(open)'}")
243
+ print(f"messages: {len(message_files(d))}")
244
+
245
+
246
+ def _read_body(a) -> str:
247
+ if a.body is not None:
248
+ return a.body
249
+ if a.body_file:
250
+ return Path(a.body_file).read_text(encoding="utf-8")
251
+ # Default: read from stdin so agents can pipe long markdown bodies.
252
+ data = sys.stdin.read()
253
+ if not data.strip():
254
+ die("empty body (pass --body, --body-file, or pipe via stdin)")
255
+ return data
256
+
257
+
258
+ def cmd_post(root: Path, a):
259
+ d = require_channel(root, a.channel)
260
+ body = _read_body(a)
261
+ to = a.to or "all"
262
+ lock = _acquire_lock(d)
263
+ try:
264
+ seq = _next_seq(d)
265
+ fname = f"{seq:04d}-{slugify(a.sender)}-{slugify(a.title)}.md"
266
+ fm = [
267
+ "---",
268
+ f"seq: {seq}",
269
+ f"from: {a.sender}",
270
+ f"to: {to}",
271
+ ]
272
+ if a.reply:
273
+ fm.append(f"reply_to: {a.reply}")
274
+ fm += [
275
+ f"channel: {a.channel}",
276
+ f"ts: {now_iso()}",
277
+ f"status: {a.status}",
278
+ f"title: {a.title}",
279
+ "---",
280
+ "",
281
+ ]
282
+ (d / fname).write_text("\n".join(fm) + body.rstrip() + "\n", encoding="utf-8")
283
+ finally:
284
+ _release_lock(lock)
285
+ print(f"posted #{seq} -> {a.channel}/{fname}")
286
+
287
+
288
+ def _print_message(path: Path):
289
+ print("=" * 70)
290
+ print(path.read_text(encoding="utf-8").rstrip())
291
+ print()
292
+
293
+
294
+ def cmd_read(root: Path, a):
295
+ d = require_channel(root, a.channel)
296
+ cur = 0 if a.all else read_cursor(d, a.agent)
297
+ shown = 0
298
+ for p in message_files(d):
299
+ seq = _seq_from_name(p.name)
300
+ if seq <= cur:
301
+ continue
302
+ meta = parse_frontmatter(p)
303
+ if not a.all and not is_relevant(meta, a.agent):
304
+ continue
305
+ _print_message(p)
306
+ shown += 1
307
+ top = max_seq(d)
308
+ if not a.peek:
309
+ write_cursor(d, a.agent, top)
310
+ if shown == 0:
311
+ print(f"(no new messages for {a.agent} in '{a.channel}'; cursor at #{cur})")
312
+
313
+
314
+ def cmd_wait(root: Path, a):
315
+ d = require_channel(root, a.channel)
316
+ cur = read_cursor(d, a.agent)
317
+ deadline = time.time() + a.timeout
318
+ while True:
319
+ found = []
320
+ for p in message_files(d):
321
+ seq = _seq_from_name(p.name)
322
+ if seq <= cur:
323
+ continue
324
+ meta = parse_frontmatter(p)
325
+ if is_relevant(meta, a.agent):
326
+ found.append(p)
327
+ if found:
328
+ for p in found:
329
+ _print_message(p)
330
+ write_cursor(d, a.agent, max_seq(d))
331
+ return
332
+ if time.time() >= deadline:
333
+ print(f"(timeout after {a.timeout}s: no new messages for {a.agent} in '{a.channel}')",
334
+ file=sys.stderr)
335
+ raise SystemExit(2)
336
+ time.sleep(a.interval)
337
+
338
+
339
+ def cmd_peek(root: Path, a):
340
+ d = require_channel(root, a.channel)
341
+ files = message_files(d)[-a.n:]
342
+ for p in files:
343
+ _print_message(p)
344
+ if not files:
345
+ print(f"(channel '{a.channel}' is empty)")
346
+
347
+
348
+ def cmd_claim(root: Path, a):
349
+ """Atomically claim a task marker file by renaming it (os.replace is atomic).
350
+
351
+ Convention: a claimable task is a file `task-<id>.md`. Claiming renames it to
352
+ `task-<id>.CLAIMED-<agent>.md`. If the source is already gone, another agent
353
+ won the race -- exit non-zero so the caller moves on.
354
+ """
355
+ d = require_channel(root, a.channel)
356
+ src = d / a.task
357
+ dst = d / (Path(a.task).stem + f".CLAIMED-{slugify(a.agent)}.md")
358
+ try:
359
+ os.replace(src, dst) # atomic on Windows + POSIX when same directory
360
+ except FileNotFoundError:
361
+ die(f"task '{a.task}' already claimed or missing (lost the race)", code=3)
362
+ print(f"claimed {a.task} -> {dst.name}")
363
+
364
+
365
+ # --- argparse ----------------------------------------------------------------
366
+
367
+ def build_parser() -> argparse.ArgumentParser:
368
+ p = argparse.ArgumentParser(prog="chat.py", description="peer agent chat over markdown files")
369
+ p.add_argument("--root", help="chat root dir (default: $AGENT_CHAT_ROOT or ~/agent-chat)")
370
+ sub = p.add_subparsers(dest="cmd", required=True)
371
+
372
+ s = sub.add_parser("init", help="create a channel")
373
+ s.add_argument("channel")
374
+ s.add_argument("--members", help="comma-separated agent names")
375
+ s.add_argument("--topic")
376
+ s.set_defaults(func=cmd_init)
377
+
378
+ s = sub.add_parser("channels", help="list channels")
379
+ s.set_defaults(func=cmd_channels)
380
+
381
+ s = sub.add_parser("roster", help="show a channel's members")
382
+ s.add_argument("channel")
383
+ s.set_defaults(func=cmd_roster)
384
+
385
+ s = sub.add_parser("post", help="post a message (body via --body/--body-file/stdin)")
386
+ s.add_argument("channel")
387
+ s.add_argument("--from", dest="sender", required=True)
388
+ s.add_argument("--to", help="recipient agent, or 'all' (default all)")
389
+ s.add_argument("--title", required=True)
390
+ s.add_argument("--reply", type=int, help="seq this replies to")
391
+ s.add_argument("--status", default="discussion")
392
+ s.add_argument("--body")
393
+ s.add_argument("--body-file")
394
+ s.set_defaults(func=cmd_post)
395
+
396
+ s = sub.add_parser("read", help="print new messages for an agent (advances cursor)")
397
+ s.add_argument("channel")
398
+ s.add_argument("--as", dest="agent", required=True)
399
+ s.add_argument("--all", action="store_true", help="show entire thread, ignore relevance")
400
+ s.add_argument("--peek", action="store_true", help="do not advance the cursor")
401
+ s.set_defaults(func=cmd_read)
402
+
403
+ s = sub.add_parser("wait", help="block (sleep-poll, 0 tokens) until a reply arrives")
404
+ s.add_argument("channel")
405
+ s.add_argument("--as", dest="agent", required=True)
406
+ s.add_argument("--timeout", type=float, default=900.0)
407
+ s.add_argument("--interval", type=float, default=5.0)
408
+ s.set_defaults(func=cmd_wait)
409
+
410
+ s = sub.add_parser("peek", help="show last N messages without touching the cursor")
411
+ s.add_argument("channel")
412
+ s.add_argument("-n", type=int, default=3)
413
+ s.set_defaults(func=cmd_peek)
414
+
415
+ s = sub.add_parser("claim", help="atomically claim a task-<id>.md marker")
416
+ s.add_argument("channel")
417
+ s.add_argument("task", help="task marker filename, e.g. task-12.md")
418
+ s.add_argument("--as", dest="agent", required=True)
419
+ s.set_defaults(func=cmd_claim)
420
+ return p
421
+
422
+
423
+ def main(argv=None):
424
+ args = build_parser().parse_args(argv)
425
+ root = root_dir(args.root)
426
+ args.func(root, args)
427
+
428
+
429
+ if __name__ == "__main__":
430
+ main()