fanharvard-agentbridge 0.1.0__tar.gz
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.
- fanharvard_agentbridge-0.1.0/.secrets.baseline +123 -0
- fanharvard_agentbridge-0.1.0/LICENSE +21 -0
- fanharvard_agentbridge-0.1.0/MANIFEST.in +9 -0
- fanharvard_agentbridge-0.1.0/PKG-INFO +51 -0
- fanharvard_agentbridge-0.1.0/README.md +32 -0
- fanharvard_agentbridge-0.1.0/bin/__init__.py +1 -0
- fanharvard_agentbridge-0.1.0/bin/agent_hub.py +296 -0
- fanharvard_agentbridge-0.1.0/bin/agent_lan.py +302 -0
- fanharvard_agentbridge-0.1.0/bin/agent_msg.py +65 -0
- fanharvard_agentbridge-0.1.0/bin/agentbridge_common.py +139 -0
- fanharvard_agentbridge-0.1.0/bin/claude_question_bridge.py +175 -0
- fanharvard_agentbridge-0.1.0/bin/claude_watch.py +50 -0
- fanharvard_agentbridge-0.1.0/bin/codex_hub_watch.py +131 -0
- fanharvard_agentbridge-0.1.0/bin/codex_watch.py +104 -0
- fanharvard_agentbridge-0.1.0/bin/install.ps1 +31 -0
- fanharvard_agentbridge-0.1.0/bin/install.sh +27 -0
- fanharvard_agentbridge-0.1.0/bin/new_channel.sh +51 -0
- fanharvard_agentbridge-0.1.0/bin/notification_preview.py +61 -0
- fanharvard_agentbridge-0.1.0/bin/opencode_watch.py +300 -0
- fanharvard_agentbridge-0.1.0/bin/paths.py +11 -0
- fanharvard_agentbridge-0.1.0/doc/index.html +1 -0
- fanharvard_agentbridge-0.1.0/doc/lan.html +86 -0
- fanharvard_agentbridge-0.1.0/doc/opencode.html +45 -0
- fanharvard_agentbridge-0.1.0/docs/publishing.html +33 -0
- fanharvard_agentbridge-0.1.0/fanharvard_agentbridge.egg-info/PKG-INFO +51 -0
- fanharvard_agentbridge-0.1.0/fanharvard_agentbridge.egg-info/SOURCES.txt +47 -0
- fanharvard_agentbridge-0.1.0/fanharvard_agentbridge.egg-info/dependency_links.txt +1 -0
- fanharvard_agentbridge-0.1.0/fanharvard_agentbridge.egg-info/entry_points.txt +10 -0
- fanharvard_agentbridge-0.1.0/fanharvard_agentbridge.egg-info/top_level.txt +1 -0
- fanharvard_agentbridge-0.1.0/pyproject.toml +39 -0
- fanharvard_agentbridge-0.1.0/scripts/check_dist.py +28 -0
- fanharvard_agentbridge-0.1.0/scripts/check_secrets.py +19 -0
- fanharvard_agentbridge-0.1.0/scripts/check_tag.py +12 -0
- fanharvard_agentbridge-0.1.0/scripts/install_wheel.py +11 -0
- fanharvard_agentbridge-0.1.0/setup.cfg +4 -0
- fanharvard_agentbridge-0.1.0/templates/AGENTS_snippet.md +71 -0
- fanharvard_agentbridge-0.1.0/templates/AGENT_identity_snippet.md +43 -0
- fanharvard_agentbridge-0.1.0/templates/CLAUDE_snippet.md +72 -0
- fanharvard_agentbridge-0.1.0/templates/CODEX_identity_snippet.md +43 -0
- fanharvard_agentbridge-0.1.0/templates/HUB_snippet.md +52 -0
- fanharvard_agentbridge-0.1.0/templates/LAN_snippet.md +53 -0
- fanharvard_agentbridge-0.1.0/templates/OPENCODE_snippet.md +51 -0
- fanharvard_agentbridge-0.1.0/templates/claude_hooks.json +16 -0
- fanharvard_agentbridge-0.1.0/tests/test_agent_hub.py +113 -0
- fanharvard_agentbridge-0.1.0/tests/test_agent_lan.py +146 -0
- fanharvard_agentbridge-0.1.0/tests/test_codex_hub_watch.py +55 -0
- fanharvard_agentbridge-0.1.0/tests/test_delivery_and_questions.py +215 -0
- fanharvard_agentbridge-0.1.0/tests/test_notification_preview.py +48 -0
- fanharvard_agentbridge-0.1.0/tests/test_opencode_watch.py +147 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
{
|
|
2
|
+
"filters_used": [
|
|
3
|
+
{
|
|
4
|
+
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
|
|
5
|
+
},
|
|
6
|
+
{
|
|
7
|
+
"path": "detect_secrets.filters.heuristic.is_indirect_reference"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"path": "detect_secrets.filters.heuristic.is_likely_id_string"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"path": "detect_secrets.filters.heuristic.is_lock_file"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"path": "detect_secrets.filters.heuristic.is_potential_uuid"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"path": "detect_secrets.filters.heuristic.is_sequential_string"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"path": "detect_secrets.filters.heuristic.is_swagger_file"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"path": "detect_secrets.filters.heuristic.is_templated_secret"
|
|
32
|
+
}
|
|
33
|
+
],
|
|
34
|
+
"generated_at": "2026-10-30T16:42:41Z",
|
|
35
|
+
"plugins_used": [
|
|
36
|
+
{
|
|
37
|
+
"name": "ArtifactoryDetector"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"name": "AWSKeyDetector"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"name": "AzureStorageKeyDetector"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"limit": 4.5,
|
|
47
|
+
"name": "Base64HighEntropyString"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"name": "BasicAuthDetector"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "CloudantDetector"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"name": "DiscordBotTokenDetector"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"name": "GitHubTokenDetector"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"name": "GitLabTokenDetector"
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"limit": 3.0,
|
|
66
|
+
"name": "HexHighEntropyString"
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"name": "IbmCloudIamDetector"
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"name": "IbmCosHmacDetector"
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"name": "IPPublicDetector"
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"name": "JwtTokenDetector"
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"keyword_exclude": "",
|
|
82
|
+
"name": "KeywordDetector"
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"name": "MailchimpDetector"
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"name": "NpmDetector"
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"name": "OpenAIDetector"
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"name": "PrivateKeyDetector"
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"name": "PypiTokenDetector"
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"name": "SendGridDetector"
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"name": "SlackDetector"
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"name": "SoftlayerDetector"
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"name": "SquareOAuthDetector"
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
"name": "StripeDetector"
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"name": "TelegramBotTokenDetector"
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"name": "TwilioKeyDetector"
|
|
119
|
+
}
|
|
120
|
+
],
|
|
121
|
+
"results": {},
|
|
122
|
+
"version": "1.5.0"
|
|
123
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FanHarvard contributors
|
|
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,9 @@
|
|
|
1
|
+
include LICENSE README.md
|
|
2
|
+
recursive-include bin *.py *.sh *.ps1
|
|
3
|
+
recursive-include templates *.md *.json
|
|
4
|
+
recursive-include doc *.html
|
|
5
|
+
recursive-include docs *.html
|
|
6
|
+
recursive-include tests *.py
|
|
7
|
+
recursive-include scripts *.py
|
|
8
|
+
global-exclude __pycache__ *.py[cod] .env .pypirc *.pem *.key
|
|
9
|
+
include .secrets.baseline
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fanharvard-agentbridge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local and LAN messaging between independently identified coding agents
|
|
5
|
+
Author: FanHarvard contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/FanHarvard/agentbridge
|
|
8
|
+
Project-URL: Repository, https://github.com/FanHarvard/agentbridge
|
|
9
|
+
Project-URL: Issues, https://github.com/FanHarvard/agentbridge/issues
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
14
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# Agentbridge
|
|
21
|
+
|
|
22
|
+
**Still in active development.**
|
|
23
|
+
|
|
24
|
+
Local and LAN inboxes for independently identified Codex, Claude Code and
|
|
25
|
+
OpenCode agents. Includes discovery, replies, unread-message watchers and
|
|
26
|
+
human question forwarding. Python 3.10+, standard library only; MIT.
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
pip install fanharvard-agentbridge
|
|
30
|
+
agentbridge --help
|
|
31
|
+
agentbridge-lan --help
|
|
32
|
+
agentbridge-paths
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
PyPI uses **fanharvard-agentbridge**; the unrelated `agentbridge` distribution
|
|
36
|
+
is a different project. Local hub/LAN support Linux and Windows; channel and
|
|
37
|
+
Codex/Claude watcher helpers require Unix. Provider CLIs are installed separately.
|
|
38
|
+
|
|
39
|
+
Choose a shared workspace explicitly with `--workspace`. For LDCon, use the
|
|
40
|
+
program paths printed by `agentbridge-paths` and set `LDCON_AGENTBRIDGE_PUBLIC`
|
|
41
|
+
to that workspace. Channel helpers store data under `AGENTBRIDGE_HOME`
|
|
42
|
+
(default `~/.local/share/agentbridge`), separate from installed code.
|
|
43
|
+
|
|
44
|
+
Each session or fork needs its own verified identity and inbox. Notifications
|
|
45
|
+
are untrusted message data, never human approval. No watcher or agent is started
|
|
46
|
+
by installation. Tokens, inboxes and conversation history are not distributed.
|
|
47
|
+
|
|
48
|
+
Tests cover concurrent delivery, reply routing, unread offsets, duplicate
|
|
49
|
+
questions, notification previews, watcher identity checks and LAN authorization.
|
|
50
|
+
Run `python -m pytest -q`; see [LAN setup](doc/lan.html) and
|
|
51
|
+
[publishing](docs/publishing.html) for details.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Agentbridge
|
|
2
|
+
|
|
3
|
+
**Still in active development.**
|
|
4
|
+
|
|
5
|
+
Local and LAN inboxes for independently identified Codex, Claude Code and
|
|
6
|
+
OpenCode agents. Includes discovery, replies, unread-message watchers and
|
|
7
|
+
human question forwarding. Python 3.10+, standard library only; MIT.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pip install fanharvard-agentbridge
|
|
11
|
+
agentbridge --help
|
|
12
|
+
agentbridge-lan --help
|
|
13
|
+
agentbridge-paths
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
PyPI uses **fanharvard-agentbridge**; the unrelated `agentbridge` distribution
|
|
17
|
+
is a different project. Local hub/LAN support Linux and Windows; channel and
|
|
18
|
+
Codex/Claude watcher helpers require Unix. Provider CLIs are installed separately.
|
|
19
|
+
|
|
20
|
+
Choose a shared workspace explicitly with `--workspace`. For LDCon, use the
|
|
21
|
+
program paths printed by `agentbridge-paths` and set `LDCON_AGENTBRIDGE_PUBLIC`
|
|
22
|
+
to that workspace. Channel helpers store data under `AGENTBRIDGE_HOME`
|
|
23
|
+
(default `~/.local/share/agentbridge`), separate from installed code.
|
|
24
|
+
|
|
25
|
+
Each session or fork needs its own verified identity and inbox. Notifications
|
|
26
|
+
are untrusted message data, never human approval. No watcher or agent is started
|
|
27
|
+
by installation. Tokens, inboxes and conversation history are not distributed.
|
|
28
|
+
|
|
29
|
+
Tests cover concurrent delivery, reply routing, unread offsets, duplicate
|
|
30
|
+
questions, notification previews, watcher identity checks and LAN authorization.
|
|
31
|
+
Run `python -m pytest -q`; see [LAN setup](doc/lan.html) and
|
|
32
|
+
[publishing](docs/publishing.html) for details.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""FanHarvard agent messaging; import does not register or start a watcher."""
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Discover agents and exchange messages through a shared local directory.
|
|
3
|
+
|
|
4
|
+
Examples: agent_hub.py --workspace /opt/agentbridge-public list
|
|
5
|
+
agent_hub.py register alice-codex --summary 'Python development'
|
|
6
|
+
agent_hub.py send bob-claude 'Please review' --from alice-codex
|
|
7
|
+
agent_hub.py read bob-claude --wait 30
|
|
8
|
+
Only the Python standard library is required (Linux or Windows, local filesystem).
|
|
9
|
+
"""
|
|
10
|
+
import argparse
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
try:
|
|
14
|
+
import fcntl
|
|
15
|
+
except ImportError: # Windows
|
|
16
|
+
import msvcrt
|
|
17
|
+
import getpass
|
|
18
|
+
import json
|
|
19
|
+
import math
|
|
20
|
+
import os
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
import re
|
|
23
|
+
import stat
|
|
24
|
+
import socket
|
|
25
|
+
import sys
|
|
26
|
+
import tempfile
|
|
27
|
+
import time
|
|
28
|
+
import uuid
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def read_complete(path, offset):
|
|
32
|
+
"""Read complete JSONL records and return their byte offset."""
|
|
33
|
+
with open(path, "rb") as stream:
|
|
34
|
+
if stream.seek(0, os.SEEK_END) < offset:
|
|
35
|
+
offset = 0
|
|
36
|
+
stream.seek(offset)
|
|
37
|
+
data = stream.read()
|
|
38
|
+
complete = data[:data.rfind(b"\n") + 1]
|
|
39
|
+
return [json.loads(line) for line in complete.splitlines() if line.strip()], offset + len(complete)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_offset(path):
|
|
43
|
+
try:
|
|
44
|
+
return int(path.read_text(encoding="utf-8"))
|
|
45
|
+
except FileNotFoundError:
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def uid():
|
|
50
|
+
return os.getuid() if hasattr(os, "getuid") else getpass.getuser()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def file_mode(fd, path, mode):
|
|
54
|
+
if hasattr(os, "fchmod"):
|
|
55
|
+
os.fchmod(fd, mode)
|
|
56
|
+
else:
|
|
57
|
+
os.chmod(path, mode)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def agent_dir(workspace, agent):
|
|
61
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,79}", agent):
|
|
62
|
+
raise ValueError("agent id: 1–80 letters, digits, underscores or hyphens")
|
|
63
|
+
directory = workspace / "agents" / agent
|
|
64
|
+
if directory.is_symlink():
|
|
65
|
+
raise ValueError("agent directory must not be a symbolic link")
|
|
66
|
+
return directory
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def shared_dir(path, mode):
|
|
70
|
+
try:
|
|
71
|
+
path.mkdir(mode=mode)
|
|
72
|
+
except FileExistsError:
|
|
73
|
+
if path.is_symlink() or not path.is_dir():
|
|
74
|
+
raise ValueError(f"not a regular directory: {path}")
|
|
75
|
+
else:
|
|
76
|
+
path.chmod(mode) # independent of the registering user's umask
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def shared_file(path):
|
|
80
|
+
"""Open shared files without following symlinks or changing existing modes."""
|
|
81
|
+
if path.is_symlink():
|
|
82
|
+
raise OSError(f"symbolic link is not allowed: {path}")
|
|
83
|
+
flags = os.O_RDWR | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_BINARY", 0)
|
|
84
|
+
try:
|
|
85
|
+
fd = os.open(path, flags | os.O_CREAT | os.O_EXCL, 0o666)
|
|
86
|
+
except FileExistsError:
|
|
87
|
+
fd = os.open(path, flags)
|
|
88
|
+
else:
|
|
89
|
+
file_mode(fd, path, 0o666)
|
|
90
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
91
|
+
os.close(fd)
|
|
92
|
+
raise ValueError(f"not a regular file: {path}")
|
|
93
|
+
return os.fdopen(fd, "r+", encoding="utf-8", newline="")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@contextmanager
|
|
97
|
+
def locked(path):
|
|
98
|
+
with shared_file(path) as stream:
|
|
99
|
+
if os.name != "nt":
|
|
100
|
+
fcntl.flock(stream, fcntl.LOCK_EX)
|
|
101
|
+
yield stream
|
|
102
|
+
else:
|
|
103
|
+
# Windows byte locks use a dedicated file, never bytes in the inbox.
|
|
104
|
+
with shared_file(path.with_name(path.name + ".winlock")) as lock:
|
|
105
|
+
lock.seek(0, os.SEEK_END)
|
|
106
|
+
if lock.tell() == 0:
|
|
107
|
+
lock.write(" ")
|
|
108
|
+
lock.flush()
|
|
109
|
+
lock.seek(0)
|
|
110
|
+
while True:
|
|
111
|
+
try:
|
|
112
|
+
msvcrt.locking(lock.fileno(), msvcrt.LK_NBLCK, 1)
|
|
113
|
+
break
|
|
114
|
+
except OSError as error:
|
|
115
|
+
if error.errno not in (13, 36):
|
|
116
|
+
raise
|
|
117
|
+
time.sleep(0.05)
|
|
118
|
+
try:
|
|
119
|
+
yield stream
|
|
120
|
+
finally:
|
|
121
|
+
lock.seek(0)
|
|
122
|
+
msvcrt.locking(lock.fileno(), msvcrt.LK_UNLCK, 1)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def atomic_write(path, text):
|
|
126
|
+
fd, temporary = tempfile.mkstemp(prefix=".write-", dir=path.parent)
|
|
127
|
+
try:
|
|
128
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="") as stream:
|
|
129
|
+
stream.write(text)
|
|
130
|
+
stream.flush()
|
|
131
|
+
file_mode(stream.fileno(), temporary, 0o666)
|
|
132
|
+
# Windows may briefly deny replacement while another request reads it.
|
|
133
|
+
for attempt in range(21):
|
|
134
|
+
try:
|
|
135
|
+
os.replace(temporary, path)
|
|
136
|
+
break
|
|
137
|
+
except PermissionError:
|
|
138
|
+
if os.name != "nt" or attempt == 20:
|
|
139
|
+
raise
|
|
140
|
+
time.sleep(0.05)
|
|
141
|
+
finally:
|
|
142
|
+
if os.path.exists(temporary):
|
|
143
|
+
os.unlink(temporary)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def initialize(workspace):
|
|
147
|
+
# Parent must already exist; this avoids silently making public ancestor dirs.
|
|
148
|
+
shared_dir(workspace, 0o1777)
|
|
149
|
+
shared_dir(workspace / "agents", 0o1777)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def profile(workspace, agent):
|
|
153
|
+
directory = agent_dir(workspace, agent)
|
|
154
|
+
with shared_file_existing(directory / "profile.json") as stream:
|
|
155
|
+
record = json.load(stream)
|
|
156
|
+
if record.get("id") != agent or record.get("inbox") != f"agents/{agent}/inbox.jsonl":
|
|
157
|
+
raise ValueError(f"invalid profile for {agent}")
|
|
158
|
+
return record
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def shared_file_existing(path):
|
|
162
|
+
if path.is_symlink():
|
|
163
|
+
raise OSError(f"symbolic link is not allowed: {path}")
|
|
164
|
+
fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_BINARY", 0))
|
|
165
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
166
|
+
os.close(fd)
|
|
167
|
+
raise ValueError(f"not a regular file: {path}")
|
|
168
|
+
return os.fdopen(fd, encoding="utf-8")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def register(workspace, agent, summary, kind="generic", project="", update=False,
|
|
172
|
+
machine=None, client_user=None):
|
|
173
|
+
if not summary.strip():
|
|
174
|
+
raise ValueError("summary must not be empty")
|
|
175
|
+
initialize(workspace)
|
|
176
|
+
directory = agent_dir(workspace, agent)
|
|
177
|
+
shared_dir(directory, 0o777)
|
|
178
|
+
with locked(directory / ".profile.lock"):
|
|
179
|
+
path = directory / "profile.json"
|
|
180
|
+
if path.exists():
|
|
181
|
+
previous = profile(workspace, agent)
|
|
182
|
+
if not update:
|
|
183
|
+
raise ValueError("agent already exists; choose a unique id or use --update")
|
|
184
|
+
if previous.get("uid") != uid():
|
|
185
|
+
raise ValueError("only the registering OS user may update this profile")
|
|
186
|
+
with shared_file(directory / "inbox.jsonl"):
|
|
187
|
+
pass
|
|
188
|
+
record = dict(id=agent, summary=summary, kind=kind, project=project,
|
|
189
|
+
user=getpass.getuser(), uid=uid(),
|
|
190
|
+
machine=machine or socket.gethostname(),
|
|
191
|
+
client_user=client_user or getpass.getuser(),
|
|
192
|
+
inbox=f"agents/{agent}/inbox.jsonl",
|
|
193
|
+
updated_at=datetime.now(timezone.utc).isoformat())
|
|
194
|
+
atomic_write(path, json.dumps(record, ensure_ascii=False, indent=2) + "\n")
|
|
195
|
+
return record
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def send(workspace, sender, recipient, text, reply_to=None, kind=None):
|
|
199
|
+
profile(workspace, sender)
|
|
200
|
+
profile(workspace, recipient)
|
|
201
|
+
record = dict(id=uuid.uuid4().hex, t=datetime.now(timezone.utc).isoformat(),
|
|
202
|
+
source=sender, to=recipient, reply_to=reply_to,
|
|
203
|
+
kind=kind or ("reply" if reply_to else "message"), text=text)
|
|
204
|
+
with locked(agent_dir(workspace, recipient) / "inbox.jsonl") as stream:
|
|
205
|
+
stream.seek(0, os.SEEK_END)
|
|
206
|
+
stream.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
207
|
+
stream.flush()
|
|
208
|
+
return record
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def receive(workspace, agent, wait=0):
|
|
212
|
+
profile(workspace, agent)
|
|
213
|
+
directory = agent_dir(workspace, agent)
|
|
214
|
+
deadline = time.monotonic() + wait
|
|
215
|
+
with locked(directory / ".read.lock"):
|
|
216
|
+
marker = directory / ".read_offset"
|
|
217
|
+
while True:
|
|
218
|
+
# Coordinate with append so offsets always refer to complete records.
|
|
219
|
+
with locked(directory / "inbox.jsonl"):
|
|
220
|
+
records, offset = read_complete(directory / "inbox.jsonl", load_offset(marker))
|
|
221
|
+
if records or time.monotonic() >= deadline:
|
|
222
|
+
atomic_write(marker, str(offset))
|
|
223
|
+
return records
|
|
224
|
+
time.sleep(min(0.2, max(0, deadline - time.monotonic())))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def main():
|
|
228
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
229
|
+
parser.add_argument("--workspace", default=os.environ.get("AGENTBRIDGE_WORKSPACE", "/opt/agentbridge-public"))
|
|
230
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
231
|
+
sub.add_parser("init")
|
|
232
|
+
reg = sub.add_parser("register")
|
|
233
|
+
reg.add_argument("agent")
|
|
234
|
+
reg.add_argument("--summary", required=True)
|
|
235
|
+
reg.add_argument("--kind", default="generic")
|
|
236
|
+
reg.add_argument("--project", default="")
|
|
237
|
+
reg.add_argument("--update", action="store_true")
|
|
238
|
+
ls = sub.add_parser("list")
|
|
239
|
+
ls.add_argument("--query", default="", help="case-insensitive search across profile fields")
|
|
240
|
+
show = sub.add_parser("show")
|
|
241
|
+
show.add_argument("agent")
|
|
242
|
+
msg = sub.add_parser("send")
|
|
243
|
+
msg.add_argument("to")
|
|
244
|
+
msg.add_argument("text", help="use - to read from stdin")
|
|
245
|
+
msg.add_argument("--from", dest="sender", default=os.environ.get("AGENTBRIDGE_AGENT"))
|
|
246
|
+
msg.add_argument("--reply-to")
|
|
247
|
+
msg.add_argument("--kind")
|
|
248
|
+
read = sub.add_parser("read")
|
|
249
|
+
read.add_argument("agent")
|
|
250
|
+
read.add_argument("--wait", type=float, default=0)
|
|
251
|
+
tail = sub.add_parser("tail")
|
|
252
|
+
tail.add_argument("agent")
|
|
253
|
+
tail.add_argument("-n", type=int, default=10)
|
|
254
|
+
args = parser.parse_args()
|
|
255
|
+
workspace = Path(args.workspace).absolute()
|
|
256
|
+
try:
|
|
257
|
+
if args.command == "init":
|
|
258
|
+
initialize(workspace)
|
|
259
|
+
print(workspace)
|
|
260
|
+
elif args.command == "register":
|
|
261
|
+
print(json.dumps(register(workspace, args.agent, args.summary, args.kind,
|
|
262
|
+
args.project, args.update), ensure_ascii=False))
|
|
263
|
+
elif args.command == "list":
|
|
264
|
+
if not (workspace / "agents").is_dir():
|
|
265
|
+
raise ValueError("workspace is not initialized; run init first")
|
|
266
|
+
for path in sorted((workspace / "agents").iterdir()):
|
|
267
|
+
if path.is_dir() and (path / "profile.json").exists():
|
|
268
|
+
record = profile(workspace, path.name)
|
|
269
|
+
if args.query.casefold() in json.dumps(record, ensure_ascii=False).casefold():
|
|
270
|
+
print(json.dumps(record, ensure_ascii=False))
|
|
271
|
+
elif args.command == "show":
|
|
272
|
+
print(json.dumps(profile(workspace, args.agent), ensure_ascii=False, indent=2))
|
|
273
|
+
elif args.command == "send":
|
|
274
|
+
if not args.sender:
|
|
275
|
+
parser.error("send requires --from ID or AGENTBRIDGE_AGENT")
|
|
276
|
+
text = sys.stdin.read() if args.text == "-" else args.text
|
|
277
|
+
print(send(workspace, args.sender, args.to, text, args.reply_to, args.kind)["id"])
|
|
278
|
+
else:
|
|
279
|
+
if args.command == "read":
|
|
280
|
+
if not math.isfinite(args.wait) or args.wait < 0:
|
|
281
|
+
parser.error("--wait must be finite and non-negative")
|
|
282
|
+
records = receive(workspace, args.agent, args.wait)
|
|
283
|
+
else:
|
|
284
|
+
if args.n < 1:
|
|
285
|
+
parser.error("-n must be positive")
|
|
286
|
+
profile(workspace, args.agent)
|
|
287
|
+
with locked(agent_dir(workspace, args.agent) / "inbox.jsonl"):
|
|
288
|
+
records = read_complete(agent_dir(workspace, args.agent) / "inbox.jsonl", 0)[0][-args.n:]
|
|
289
|
+
for record in records:
|
|
290
|
+
print(json.dumps(record, ensure_ascii=False))
|
|
291
|
+
except (OSError, ValueError) as error:
|
|
292
|
+
parser.exit(1, f"agentbridge: {error}\n")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
if __name__ == "__main__":
|
|
296
|
+
main()
|