claude-agent-sync 0.2.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.
- agent_sync/__init__.py +13 -0
- agent_sync/__main__.py +8 -0
- agent_sync/cli.py +520 -0
- agent_sync/console.py +454 -0
- agent_sync/db.py +401 -0
- agent_sync/errors.py +51 -0
- agent_sync/git_utils.py +59 -0
- agent_sync/hooks.py +309 -0
- agent_sync/locks.py +158 -0
- agent_sync/messages.py +203 -0
- agent_sync/models.py +172 -0
- agent_sync/paths.py +105 -0
- agent_sync/render.py +280 -0
- agent_sync/tasks.py +229 -0
- claude_agent_sync-0.2.1.dist-info/METADATA +417 -0
- claude_agent_sync-0.2.1.dist-info/RECORD +20 -0
- claude_agent_sync-0.2.1.dist-info/WHEEL +5 -0
- claude_agent_sync-0.2.1.dist-info/entry_points.txt +2 -0
- claude_agent_sync-0.2.1.dist-info/licenses/LICENSE +21 -0
- claude_agent_sync-0.2.1.dist-info/top_level.txt +1 -0
agent_sync/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""agent-sync: coordinate multiple AI coding-agent sessions in one repository.
|
|
2
|
+
|
|
3
|
+
This package exposes a small, dependency-free CLI (``agent-sync``) backed by a
|
|
4
|
+
SQLite database stored inside the target repository at
|
|
5
|
+
``.claude/coordination/state.sqlite``. It lets independent CLI coding-agent
|
|
6
|
+
sessions (Claude Code and any other agent or shell) see each other, claim tasks,
|
|
7
|
+
lock files, exchange messages and log activity so they avoid stepping on each
|
|
8
|
+
other's edits.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.2.1"
|
|
12
|
+
|
|
13
|
+
__all__ = ["__version__"]
|
agent_sync/__main__.py
ADDED
agent_sync/cli.py
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
"""Command-line interface for agent-sync.
|
|
2
|
+
|
|
3
|
+
This module is intentionally thin: it parses arguments, resolves the acting
|
|
4
|
+
agent, calls into the domain modules (:mod:`agent_sync.tasks`,
|
|
5
|
+
:mod:`agent_sync.locks`, :mod:`agent_sync.messages`) and prints human-readable
|
|
6
|
+
output. All persistence and rules live in those modules so the CLI stays easy to
|
|
7
|
+
read and the behaviour stays easy to test without a subprocess.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import os
|
|
14
|
+
import sqlite3
|
|
15
|
+
import sys
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
|
|
18
|
+
from . import __version__, db, hooks, locks, messages, paths, render, tasks
|
|
19
|
+
from .errors import AgentSyncError
|
|
20
|
+
from .models import AGENT_ACTIVE
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# --------------------------------------------------------------------------- #
|
|
24
|
+
# Helpers
|
|
25
|
+
# --------------------------------------------------------------------------- #
|
|
26
|
+
def _open() -> sqlite3.Connection:
|
|
27
|
+
"""Open (auto-initialising) the coordination database."""
|
|
28
|
+
return db.connect()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _acting_agent(
|
|
32
|
+
conn: sqlite3.Connection,
|
|
33
|
+
*,
|
|
34
|
+
name: str | None = None,
|
|
35
|
+
role: str | None = None,
|
|
36
|
+
) -> str:
|
|
37
|
+
"""Resolve the current agent id, ensure the row exists and heartbeat it."""
|
|
38
|
+
agent_id = db.resolve_agent_id(cwd=os.getcwd())
|
|
39
|
+
db.ensure_agent(
|
|
40
|
+
conn, agent_id, name=name, role=role, cwd=os.getcwd(), status=AGENT_ACTIVE
|
|
41
|
+
)
|
|
42
|
+
return agent_id
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# --------------------------------------------------------------------------- #
|
|
46
|
+
# Command handlers
|
|
47
|
+
# --------------------------------------------------------------------------- #
|
|
48
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
49
|
+
conn = _open()
|
|
50
|
+
try:
|
|
51
|
+
path = paths.db_path()
|
|
52
|
+
print(f"Initialised coordination database at {path}")
|
|
53
|
+
print(f"Tables: {', '.join(db.TABLE_NAMES)}")
|
|
54
|
+
finally:
|
|
55
|
+
conn.close()
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def cmd_register(args: argparse.Namespace) -> int:
|
|
60
|
+
conn = _open()
|
|
61
|
+
try:
|
|
62
|
+
agent_id = _acting_agent(conn, name=args.name, role=args.role)
|
|
63
|
+
agent = db.get_agent(conn, agent_id)
|
|
64
|
+
assert agent is not None
|
|
65
|
+
role = f" ({agent.role})" if agent.role else ""
|
|
66
|
+
print(f"Registered agent {agent.name}{role} as `{agent.id}`")
|
|
67
|
+
finally:
|
|
68
|
+
conn.close()
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def cmd_heartbeat(args: argparse.Namespace) -> int:
|
|
73
|
+
conn = _open()
|
|
74
|
+
try:
|
|
75
|
+
agent_id = db.resolve_agent_id(cwd=os.getcwd())
|
|
76
|
+
agent = db.heartbeat(conn, agent_id)
|
|
77
|
+
print(f"Heartbeat: {agent.name} (`{agent.id}`) last_seen {agent.last_seen}")
|
|
78
|
+
finally:
|
|
79
|
+
conn.close()
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
84
|
+
conn = _open()
|
|
85
|
+
try:
|
|
86
|
+
agent_id = db.resolve_agent_id(cwd=os.getcwd())
|
|
87
|
+
if args.compact:
|
|
88
|
+
sys.stdout.write(render.render_compact(conn, agent_id))
|
|
89
|
+
else:
|
|
90
|
+
sys.stdout.write(render.render_status(conn, agent_id))
|
|
91
|
+
finally:
|
|
92
|
+
conn.close()
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def cmd_tasks(args: argparse.Namespace) -> int:
|
|
97
|
+
conn = _open()
|
|
98
|
+
try:
|
|
99
|
+
items = tasks.list_tasks(conn)
|
|
100
|
+
if not items:
|
|
101
|
+
print("No tasks.")
|
|
102
|
+
return 0
|
|
103
|
+
for task in items:
|
|
104
|
+
files = tasks.task_files(conn, task.id)
|
|
105
|
+
owner = f" @{task.owner_agent_id}" if task.owner_agent_id else ""
|
|
106
|
+
files_str = f" files: {', '.join(files)}" if files else ""
|
|
107
|
+
print(f"[{task.status:<11}] {task.id}{owner} {task.title}{files_str}")
|
|
108
|
+
finally:
|
|
109
|
+
conn.close()
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def cmd_create_task(args: argparse.Namespace) -> int:
|
|
114
|
+
conn = _open()
|
|
115
|
+
try:
|
|
116
|
+
_acting_agent(conn)
|
|
117
|
+
task = tasks.create_task(
|
|
118
|
+
conn,
|
|
119
|
+
args.title,
|
|
120
|
+
description=args.description,
|
|
121
|
+
files=args.file,
|
|
122
|
+
priority=args.priority,
|
|
123
|
+
)
|
|
124
|
+
print(f"Created task `{task.id}`: {task.title}")
|
|
125
|
+
if args.file:
|
|
126
|
+
print(f" files: {', '.join(args.file)}")
|
|
127
|
+
finally:
|
|
128
|
+
conn.close()
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def cmd_claim_task(args: argparse.Namespace) -> int:
|
|
133
|
+
conn = _open()
|
|
134
|
+
try:
|
|
135
|
+
agent_id = _acting_agent(conn)
|
|
136
|
+
task = tasks.claim_task(conn, agent_id, args.task)
|
|
137
|
+
print(f"Claimed task `{task.id}`: {task.title} [{task.status}]")
|
|
138
|
+
finally:
|
|
139
|
+
conn.close()
|
|
140
|
+
return 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def cmd_claim_next(args: argparse.Namespace) -> int:
|
|
144
|
+
conn = _open()
|
|
145
|
+
try:
|
|
146
|
+
agent_id = _acting_agent(conn)
|
|
147
|
+
task = tasks.claim_next_task(conn, agent_id)
|
|
148
|
+
if task is None:
|
|
149
|
+
print("No available tasks to claim.")
|
|
150
|
+
return 0
|
|
151
|
+
files = tasks.task_files(conn, task.id)
|
|
152
|
+
files_str = f" files: {', '.join(files)}" if files else ""
|
|
153
|
+
print(f"Claimed task `{task.id}`: {task.title} [{task.status}]{files_str}")
|
|
154
|
+
finally:
|
|
155
|
+
conn.close()
|
|
156
|
+
return 0
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def cmd_complete_task(args: argparse.Namespace) -> int:
|
|
160
|
+
conn = _open()
|
|
161
|
+
try:
|
|
162
|
+
agent_id = _acting_agent(conn)
|
|
163
|
+
task = tasks.complete_task(conn, agent_id, args.task)
|
|
164
|
+
print(f"Completed task `{task.id}`: {task.title}")
|
|
165
|
+
finally:
|
|
166
|
+
conn.close()
|
|
167
|
+
return 0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def cmd_block_task(args: argparse.Namespace) -> int:
|
|
171
|
+
conn = _open()
|
|
172
|
+
try:
|
|
173
|
+
agent_id = _acting_agent(conn)
|
|
174
|
+
task = tasks.block_task(conn, agent_id, args.task, args.reason)
|
|
175
|
+
print(f"Blocked task `{task.id}`: {task.title} — {args.reason}")
|
|
176
|
+
finally:
|
|
177
|
+
conn.close()
|
|
178
|
+
return 0
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def cmd_lock(args: argparse.Namespace) -> int:
|
|
182
|
+
conn = _open()
|
|
183
|
+
try:
|
|
184
|
+
agent_id = _acting_agent(conn)
|
|
185
|
+
norm = paths.normalize_repo_path(args.file)
|
|
186
|
+
lock = locks.acquire_lock(
|
|
187
|
+
conn, agent_id, norm, reason=args.reason, ttl_minutes=args.ttl
|
|
188
|
+
)
|
|
189
|
+
print(f"Locked `{lock.file_path}` until {lock.expires_at}")
|
|
190
|
+
finally:
|
|
191
|
+
conn.close()
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def cmd_unlock(args: argparse.Namespace) -> int:
|
|
196
|
+
conn = _open()
|
|
197
|
+
try:
|
|
198
|
+
agent_id = _acting_agent(conn)
|
|
199
|
+
norm = paths.normalize_repo_path(args.file)
|
|
200
|
+
removed = locks.release_lock(conn, agent_id, norm, force=args.force)
|
|
201
|
+
if removed:
|
|
202
|
+
print(f"Unlocked `{norm}`")
|
|
203
|
+
else:
|
|
204
|
+
print(f"No lock held on `{norm}`")
|
|
205
|
+
finally:
|
|
206
|
+
conn.close()
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def cmd_locks(args: argparse.Namespace) -> int:
|
|
211
|
+
conn = _open()
|
|
212
|
+
try:
|
|
213
|
+
items = locks.list_locks(conn, include_expired=args.all)
|
|
214
|
+
if not items:
|
|
215
|
+
print("No active locks.")
|
|
216
|
+
return 0
|
|
217
|
+
for lock in items:
|
|
218
|
+
reason = f" — {lock.reason}" if lock.reason else ""
|
|
219
|
+
print(
|
|
220
|
+
f"{lock.file_path} → {lock.owner_agent_id} "
|
|
221
|
+
f"(expires {lock.expires_at}){reason}"
|
|
222
|
+
)
|
|
223
|
+
finally:
|
|
224
|
+
conn.close()
|
|
225
|
+
return 0
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def cmd_send(args: argparse.Namespace) -> int:
|
|
229
|
+
conn = _open()
|
|
230
|
+
try:
|
|
231
|
+
agent_id = _acting_agent(conn)
|
|
232
|
+
msg = messages.send_message(conn, agent_id, args.to, args.message)
|
|
233
|
+
print(f"Sent `{msg.id}` to {msg.recipient}")
|
|
234
|
+
finally:
|
|
235
|
+
conn.close()
|
|
236
|
+
return 0
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def cmd_inbox(args: argparse.Namespace) -> int:
|
|
240
|
+
conn = _open()
|
|
241
|
+
try:
|
|
242
|
+
agent_id = db.resolve_agent_id(cwd=os.getcwd())
|
|
243
|
+
items = messages.inbox(conn, agent_id, unread_only=not args.all)
|
|
244
|
+
if not items:
|
|
245
|
+
print("Inbox empty." if args.all else "No unread messages.")
|
|
246
|
+
return 0
|
|
247
|
+
for msg in items:
|
|
248
|
+
mark = " " if msg.read_at else "*"
|
|
249
|
+
sender = db.get_agent(conn, msg.sender_agent_id)
|
|
250
|
+
sender_name = sender.name if sender else msg.sender_agent_id
|
|
251
|
+
print(
|
|
252
|
+
f"{mark} {msg.id} {msg.created_at} "
|
|
253
|
+
f"{sender_name} → {msg.recipient}: {msg.body}"
|
|
254
|
+
)
|
|
255
|
+
if not args.all:
|
|
256
|
+
print("\n(* = unread; use `agent-sync read-message ID` to mark read)")
|
|
257
|
+
finally:
|
|
258
|
+
conn.close()
|
|
259
|
+
return 0
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def cmd_read_message(args: argparse.Namespace) -> int:
|
|
263
|
+
conn = _open()
|
|
264
|
+
try:
|
|
265
|
+
agent_id = db.resolve_agent_id(cwd=os.getcwd())
|
|
266
|
+
msg = messages.read_message(conn, agent_id, args.message_id)
|
|
267
|
+
sender = db.get_agent(conn, msg.sender_agent_id)
|
|
268
|
+
sender_name = sender.name if sender else msg.sender_agent_id
|
|
269
|
+
print(f"From: {sender_name} ({msg.sender_agent_id})")
|
|
270
|
+
print(f"To: {msg.recipient}")
|
|
271
|
+
print(f"At: {msg.created_at}")
|
|
272
|
+
print("")
|
|
273
|
+
print(msg.body)
|
|
274
|
+
finally:
|
|
275
|
+
conn.close()
|
|
276
|
+
return 0
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def cmd_decision(args: argparse.Namespace) -> int:
|
|
280
|
+
conn = _open()
|
|
281
|
+
try:
|
|
282
|
+
agent_id = _acting_agent(conn)
|
|
283
|
+
dec = messages.add_decision(conn, agent_id, args.text)
|
|
284
|
+
print(f"Recorded decision `{dec.id}`")
|
|
285
|
+
finally:
|
|
286
|
+
conn.close()
|
|
287
|
+
return 0
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def cmd_log(args: argparse.Namespace) -> int:
|
|
291
|
+
conn = _open()
|
|
292
|
+
try:
|
|
293
|
+
agent_id = _acting_agent(conn)
|
|
294
|
+
file_path = paths.normalize_repo_path(args.file) if args.file else None
|
|
295
|
+
act = messages.log_activity(
|
|
296
|
+
conn,
|
|
297
|
+
agent_id,
|
|
298
|
+
event_type=args.type,
|
|
299
|
+
body=args.message,
|
|
300
|
+
file_path=file_path,
|
|
301
|
+
)
|
|
302
|
+
print(f"Logged `{act.id}` [{act.event_type}]")
|
|
303
|
+
finally:
|
|
304
|
+
conn.close()
|
|
305
|
+
return 0
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def cmd_gc(args: argparse.Namespace) -> int:
|
|
309
|
+
conn = _open()
|
|
310
|
+
try:
|
|
311
|
+
agents_changed = db.gc_agents(conn)
|
|
312
|
+
locks_removed = locks.gc_locks(conn)
|
|
313
|
+
print(
|
|
314
|
+
f"GC complete: {agents_changed} agent(s) re-statused, "
|
|
315
|
+
f"{locks_removed} expired lock(s) removed."
|
|
316
|
+
)
|
|
317
|
+
finally:
|
|
318
|
+
conn.close()
|
|
319
|
+
return 0
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def cmd_console(args: argparse.Namespace) -> int:
|
|
323
|
+
# Imported lazily so the optional TUI dependency is only needed by this one
|
|
324
|
+
# command; the rest of the CLI stays standard-library-only.
|
|
325
|
+
from . import console
|
|
326
|
+
|
|
327
|
+
return console.run(interval=args.interval, name=args.name)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def cmd_hook(args: argparse.Namespace) -> int:
|
|
331
|
+
return hooks.run_hook(args.event)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# --------------------------------------------------------------------------- #
|
|
335
|
+
# Parser
|
|
336
|
+
# --------------------------------------------------------------------------- #
|
|
337
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
338
|
+
parser = argparse.ArgumentParser(
|
|
339
|
+
prog="agent-sync",
|
|
340
|
+
description=(
|
|
341
|
+
"Coordinate multiple Claude Code sessions in one repository: see other "
|
|
342
|
+
"agents, claim tasks, lock files, exchange messages and avoid conflicts."
|
|
343
|
+
),
|
|
344
|
+
)
|
|
345
|
+
parser.add_argument("--version", action="version", version=f"agent-sync {__version__}")
|
|
346
|
+
sub = parser.add_subparsers(dest="command", metavar="<command>")
|
|
347
|
+
|
|
348
|
+
sub.add_parser("init", help="Create the coordination database and tables").set_defaults(
|
|
349
|
+
func=cmd_init
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
p_reg = sub.add_parser("register", help="Register or update the current agent")
|
|
353
|
+
p_reg.add_argument("--name", required=True, help="Human-friendly agent name")
|
|
354
|
+
p_reg.add_argument("--role", default=None, help="Agent role, e.g. 'React UI'")
|
|
355
|
+
p_reg.set_defaults(func=cmd_register)
|
|
356
|
+
|
|
357
|
+
sub.add_parser("heartbeat", help="Mark the current agent active now").set_defaults(
|
|
358
|
+
func=cmd_heartbeat
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
p_status = sub.add_parser("status", help="Show coordination state")
|
|
362
|
+
p_status.add_argument(
|
|
363
|
+
"--compact",
|
|
364
|
+
action="store_true",
|
|
365
|
+
help="Terse Markdown suitable for injecting into Claude context",
|
|
366
|
+
)
|
|
367
|
+
p_status.set_defaults(func=cmd_status)
|
|
368
|
+
|
|
369
|
+
sub.add_parser("tasks", help="List all tasks").set_defaults(func=cmd_tasks)
|
|
370
|
+
|
|
371
|
+
p_ct = sub.add_parser("create-task", help="Create a task")
|
|
372
|
+
p_ct.add_argument("title", help="Task title")
|
|
373
|
+
p_ct.add_argument("--description", default=None, help="Longer description")
|
|
374
|
+
p_ct.add_argument(
|
|
375
|
+
"--file",
|
|
376
|
+
action="append",
|
|
377
|
+
default=[],
|
|
378
|
+
metavar="PATH",
|
|
379
|
+
help="Associate a file with the task (repeatable)",
|
|
380
|
+
)
|
|
381
|
+
p_ct.add_argument("--priority", type=int, default=0, help="Higher sorts first")
|
|
382
|
+
p_ct.set_defaults(func=cmd_create_task)
|
|
383
|
+
|
|
384
|
+
p_claim = sub.add_parser("claim-task", help="Claim a task by id or title")
|
|
385
|
+
p_claim.add_argument("task", metavar="TASK", help="Task id or title")
|
|
386
|
+
p_claim.set_defaults(func=cmd_claim_task)
|
|
387
|
+
|
|
388
|
+
sub.add_parser(
|
|
389
|
+
"claim-next",
|
|
390
|
+
help="Claim the next available task automatically (highest priority first)",
|
|
391
|
+
).set_defaults(func=cmd_claim_next)
|
|
392
|
+
|
|
393
|
+
p_done = sub.add_parser("complete-task", help="Mark a task done")
|
|
394
|
+
p_done.add_argument("task", metavar="TASK", help="Task id or title")
|
|
395
|
+
p_done.set_defaults(func=cmd_complete_task)
|
|
396
|
+
|
|
397
|
+
p_block = sub.add_parser("block-task", help="Mark a task blocked")
|
|
398
|
+
p_block.add_argument("task", metavar="TASK", help="Task id or title")
|
|
399
|
+
p_block.add_argument("--reason", required=True, help="Why it is blocked")
|
|
400
|
+
p_block.set_defaults(func=cmd_block_task)
|
|
401
|
+
|
|
402
|
+
p_lock = sub.add_parser("lock", help="Lock a file for editing")
|
|
403
|
+
p_lock.add_argument("file", help="File path to lock")
|
|
404
|
+
p_lock.add_argument("--reason", default=None, help="Why you are locking it")
|
|
405
|
+
p_lock.add_argument(
|
|
406
|
+
"--ttl",
|
|
407
|
+
type=int,
|
|
408
|
+
default=db.DEFAULT_LOCK_TTL_MINUTES,
|
|
409
|
+
help="Lock lifetime in minutes (default: 60)",
|
|
410
|
+
)
|
|
411
|
+
p_lock.set_defaults(func=cmd_lock)
|
|
412
|
+
|
|
413
|
+
p_unlock = sub.add_parser("unlock", help="Release a file lock")
|
|
414
|
+
p_unlock.add_argument("file", help="File path to unlock")
|
|
415
|
+
p_unlock.add_argument(
|
|
416
|
+
"--force", action="store_true", help="Release even if you are not the owner"
|
|
417
|
+
)
|
|
418
|
+
p_unlock.set_defaults(func=cmd_unlock)
|
|
419
|
+
|
|
420
|
+
p_locks = sub.add_parser("locks", help="List locks")
|
|
421
|
+
p_locks.add_argument(
|
|
422
|
+
"--all", action="store_true", help="Include expired/inactive locks"
|
|
423
|
+
)
|
|
424
|
+
p_locks.set_defaults(func=cmd_locks)
|
|
425
|
+
|
|
426
|
+
p_send = sub.add_parser("send", help="Send a message")
|
|
427
|
+
p_send.add_argument(
|
|
428
|
+
"--to", required=True, help="Recipient: agent id, name, role, or 'all'"
|
|
429
|
+
)
|
|
430
|
+
p_send.add_argument("--message", required=True, help="Message body")
|
|
431
|
+
p_send.set_defaults(func=cmd_send)
|
|
432
|
+
|
|
433
|
+
p_inbox = sub.add_parser("inbox", help="Show messages addressed to you")
|
|
434
|
+
p_inbox.add_argument(
|
|
435
|
+
"--all", action="store_true", help="Include already-read messages"
|
|
436
|
+
)
|
|
437
|
+
p_inbox.set_defaults(func=cmd_inbox)
|
|
438
|
+
|
|
439
|
+
p_read = sub.add_parser("read-message", help="Show a message and mark it read")
|
|
440
|
+
p_read.add_argument("message_id", metavar="MESSAGE_ID", help="Message id")
|
|
441
|
+
p_read.set_defaults(func=cmd_read_message)
|
|
442
|
+
|
|
443
|
+
p_dec = sub.add_parser("decision", help="Record a shared decision")
|
|
444
|
+
p_dec.add_argument("text", help="Decision text")
|
|
445
|
+
p_dec.set_defaults(func=cmd_decision)
|
|
446
|
+
|
|
447
|
+
p_log = sub.add_parser("log", help="Append an activity log entry")
|
|
448
|
+
p_log.add_argument("--type", default="note", help="Event type, e.g. edit/note")
|
|
449
|
+
p_log.add_argument("--message", required=True, help="Log body")
|
|
450
|
+
p_log.add_argument("--file", default=None, help="Optional related file")
|
|
451
|
+
p_log.set_defaults(func=cmd_log)
|
|
452
|
+
|
|
453
|
+
sub.add_parser("gc", help="Re-status stale agents and drop expired locks").set_defaults(
|
|
454
|
+
func=cmd_gc
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
p_console = sub.add_parser(
|
|
458
|
+
"console",
|
|
459
|
+
help="Live console: watch agents in real time and steer them (needs the 'tui' extra)",
|
|
460
|
+
)
|
|
461
|
+
p_console.add_argument(
|
|
462
|
+
"--interval",
|
|
463
|
+
type=float,
|
|
464
|
+
default=1.0,
|
|
465
|
+
help="Seconds between refreshes (default: 1.0)",
|
|
466
|
+
)
|
|
467
|
+
p_console.add_argument(
|
|
468
|
+
"--name",
|
|
469
|
+
default=None,
|
|
470
|
+
help="Display name for you, the operator (default: 'operator')",
|
|
471
|
+
)
|
|
472
|
+
p_console.set_defaults(func=cmd_console)
|
|
473
|
+
|
|
474
|
+
p_hook = sub.add_parser("hook", help="Run a Claude Code hook handler")
|
|
475
|
+
p_hook.add_argument(
|
|
476
|
+
"event",
|
|
477
|
+
choices=sorted(hooks.HANDLERS.keys()),
|
|
478
|
+
help="Which hook event to handle (reads JSON from stdin)",
|
|
479
|
+
)
|
|
480
|
+
p_hook.set_defaults(func=cmd_hook)
|
|
481
|
+
|
|
482
|
+
return parser
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _configure_streams() -> None:
|
|
486
|
+
"""Make stdout/stderr tolerate non-ASCII on legacy consoles (e.g. Windows).
|
|
487
|
+
|
|
488
|
+
The renderers use a few box-drawing/arrow characters. On a console whose
|
|
489
|
+
code page can't encode them (cp1251, cp437, …) a bare ``print`` would raise
|
|
490
|
+
``UnicodeEncodeError`` and, for a hook, crash the edit. Reconfiguring to
|
|
491
|
+
UTF-8 with ``errors='replace'`` keeps output flowing everywhere.
|
|
492
|
+
"""
|
|
493
|
+
for stream in (sys.stdout, sys.stderr):
|
|
494
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
495
|
+
if reconfigure is None:
|
|
496
|
+
continue
|
|
497
|
+
try:
|
|
498
|
+
reconfigure(encoding="utf-8", errors="replace")
|
|
499
|
+
except (ValueError, OSError):
|
|
500
|
+
pass
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
504
|
+
_configure_streams()
|
|
505
|
+
parser = build_parser()
|
|
506
|
+
args = parser.parse_args(argv)
|
|
507
|
+
if not getattr(args, "func", None):
|
|
508
|
+
parser.print_help()
|
|
509
|
+
return 1
|
|
510
|
+
try:
|
|
511
|
+
return args.func(args)
|
|
512
|
+
except AgentSyncError as exc:
|
|
513
|
+
print(f"error: {exc.message}", file=sys.stderr)
|
|
514
|
+
return exc.exit_code
|
|
515
|
+
except BrokenPipeError: # piping into head/etc.
|
|
516
|
+
return 0
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
if __name__ == "__main__": # pragma: no cover
|
|
520
|
+
raise SystemExit(main())
|