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 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
@@ -0,0 +1,8 @@
1
+ """Enable ``python -m agent_sync ...``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
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())