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.
- agent_chat_plugin-0.1.0.dist-info/METADATA +103 -0
- agent_chat_plugin-0.1.0.dist-info/RECORD +7 -0
- agent_chat_plugin-0.1.0.dist-info/WHEEL +5 -0
- agent_chat_plugin-0.1.0.dist-info/entry_points.txt +2 -0
- agent_chat_plugin-0.1.0.dist-info/licenses/LICENSE +21 -0
- agent_chat_plugin-0.1.0.dist-info/top_level.txt +1 -0
- chat.py +430 -0
|
@@ -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
|
+
[](https://pypi.org/project/agent-chat-plugin/)
|
|
22
|
+
[](https://pypi.org/project/agent-chat-plugin/)
|
|
23
|
+
[](LICENSE)
|
|
24
|
+
[](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,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()
|