mixhub-cli 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.
- agy_claude_filter.py +1042 -0
- bin/hub +265 -0
- bin/mixhub +7 -0
- bin/mixhub.js +15 -0
- channels.py +2801 -0
- cost_report.py +623 -0
- handoff.py +1294 -0
- icons_font.py +211 -0
- mixhub_cli-0.1.0.dist-info/METADATA +98 -0
- mixhub_cli-0.1.0.dist-info/RECORD +21 -0
- mixhub_cli-0.1.0.dist-info/WHEEL +4 -0
- mixhub_cli-0.1.0.dist-info/entry_points.txt +3 -0
- mixhub_cli.py +25 -0
- protocols.py +643 -0
- rules_sync.py +291 -0
- server.py +2045 -0
- slim_storage.py +142 -0
- static/app.js +466 -0
- static/index.html +58 -0
- static/style.css +1 -0
- tui.py +2927 -0
bin/hub
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Idempotent launcher for MixHub / Agent Session Hub (TUI, tmux workspace, handoff, sync-rules, notify, mobile-handoff, install-icons, and Web daemon)."""
|
|
3
|
+
import argparse
|
|
4
|
+
try:
|
|
5
|
+
import fcntl
|
|
6
|
+
except ImportError: # Windows native (win32) fallback
|
|
7
|
+
fcntl = None
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import signal
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
import urllib.request
|
|
15
|
+
|
|
16
|
+
ROOT = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
|
|
17
|
+
URL = "http://127.0.0.1:" + os.environ.get("HUB_PORT", "8765")
|
|
18
|
+
PID_FILE = os.path.join(ROOT, ".hub.pid")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def health():
|
|
22
|
+
try:
|
|
23
|
+
with urllib.request.urlopen(URL + "/api/health", timeout=0.25) as response:
|
|
24
|
+
value = json.load(response)
|
|
25
|
+
return value if value.get("service") in ("agent-session-hub", "mixhub") else None
|
|
26
|
+
except (OSError, ValueError):
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def start():
|
|
31
|
+
live = health()
|
|
32
|
+
if live:
|
|
33
|
+
print("running · pid %s · %s" % (live["pid"], URL))
|
|
34
|
+
return
|
|
35
|
+
log_path = os.path.join(ROOT, "hub.log")
|
|
36
|
+
with open(log_path, "ab") as log:
|
|
37
|
+
os.chmod(log_path, 0o600)
|
|
38
|
+
python = os.environ.get("HUB_PYTHON", "/usr/bin/python3" if sys.platform == "darwin" else sys.executable)
|
|
39
|
+
process = subprocess.Popen([python, "-S", os.path.join(ROOT, "server.py")], cwd=ROOT,
|
|
40
|
+
stdin=subprocess.DEVNULL, stdout=log, stderr=log, start_new_session=True)
|
|
41
|
+
with open(PID_FILE, "w") as f:
|
|
42
|
+
f.write(str(process.pid))
|
|
43
|
+
deadline = time.monotonic() + 5
|
|
44
|
+
while time.monotonic() < deadline:
|
|
45
|
+
if process.poll() is not None:
|
|
46
|
+
raise RuntimeError("Hub exited; inspect " + log_path)
|
|
47
|
+
if health():
|
|
48
|
+
print("started · pid %s · %s" % (process.pid, URL))
|
|
49
|
+
return
|
|
50
|
+
time.sleep(0.025)
|
|
51
|
+
process.terminate()
|
|
52
|
+
raise RuntimeError("Hub did not become ready; inspect " + log_path)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def stop():
|
|
56
|
+
live = health()
|
|
57
|
+
if not live:
|
|
58
|
+
print("stopped")
|
|
59
|
+
return
|
|
60
|
+
try:
|
|
61
|
+
with open(PID_FILE) as f:
|
|
62
|
+
pid = int(f.read().strip())
|
|
63
|
+
except (OSError, ValueError):
|
|
64
|
+
pid = int(live["pid"])
|
|
65
|
+
command = subprocess.check_output(["ps", "-p", str(pid), "-o", "command="], text=True).strip()
|
|
66
|
+
if pid != live["pid"] or os.path.join(ROOT, "server.py") not in command:
|
|
67
|
+
raise RuntimeError("PID identity changed; refusing to signal another process")
|
|
68
|
+
os.kill(pid, signal.SIGTERM)
|
|
69
|
+
deadline = time.monotonic() + 3
|
|
70
|
+
while health() and time.monotonic() < deadline:
|
|
71
|
+
time.sleep(0.05)
|
|
72
|
+
if health():
|
|
73
|
+
raise RuntimeError("Hub is still stopping")
|
|
74
|
+
if os.path.exists(PID_FILE):
|
|
75
|
+
os.unlink(PID_FILE)
|
|
76
|
+
print("stopped")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def run_slim(argv):
|
|
80
|
+
sys.path.insert(0, ROOT)
|
|
81
|
+
import slim_storage
|
|
82
|
+
return slim_storage.main(argv)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def run_anchor(argv):
|
|
86
|
+
parser = argparse.ArgumentParser(prog="hub anchor", description="Extract zero-token deterministic fact anchors from session transcripts")
|
|
87
|
+
parser.add_argument("target", nargs="?", default="", help="Session ID (or prefix) to extract anchor for; omit to anchor all compacted/recent sessions")
|
|
88
|
+
parser.add_argument("--tool", default="", help="Optional tool filter (claude|agy|codex)")
|
|
89
|
+
opts = parser.parse_args(argv)
|
|
90
|
+
sys.path.insert(0, ROOT)
|
|
91
|
+
import server
|
|
92
|
+
hub = server.Hub()
|
|
93
|
+
res = hub.build_anchors(target=opts.target, tool_filter=opts.tool)
|
|
94
|
+
if not res["anchors"]:
|
|
95
|
+
print("未找到匹配的会话记录。")
|
|
96
|
+
return 1
|
|
97
|
+
for item in res["anchors"]:
|
|
98
|
+
print(f"● [{item['tool']}] {item['title']} ({item['id'][:8]})")
|
|
99
|
+
print(f" - 锚点文件: {item['anchor_path']}")
|
|
100
|
+
print(f" - 提取统计: 用户指令 {item['user_count']} 条 · 修改文件 {item['file_count']} 个 · 命令 {item['cmd_count']} 条 · 压缩点 {item['compact_count']} 处")
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def run_handoff(argv):
|
|
105
|
+
parser = argparse.ArgumentParser(prog="hub handoff", description="Generate 10-section cross-agent handoff bundle + handoff.yaml with QUIESCING -> SOURCE_FENCED write fence")
|
|
106
|
+
parser.add_argument("target", help="Source session ID or prefix")
|
|
107
|
+
parser.add_argument("--to", required=True, choices=("claude", "agy", "codex", "deepseek"), help="Target agent engine")
|
|
108
|
+
parser.add_argument("--mode", default="auto", choices=("auto", "escalate", "delegate", "review"), help="Directional handoff mode (auto|escalate|delegate|review)")
|
|
109
|
+
parser.add_argument("--provider", default="", help="Optional provider override (e.g. deepseek)")
|
|
110
|
+
parser.add_argument("--from-tool", default="", help="Optional source tool filter (claude|agy|codex)")
|
|
111
|
+
parser.add_argument("--note", default="", help="Optional handoff note")
|
|
112
|
+
parser.add_argument("--dry-run", action="store_true", help="Preview bundle without writing state")
|
|
113
|
+
parser.add_argument("--quiesce", action="store_true", help="Terminate live source processes before fencing")
|
|
114
|
+
opts = parser.parse_args(argv)
|
|
115
|
+
sys.path.insert(0, ROOT)
|
|
116
|
+
import server
|
|
117
|
+
hub = server.Hub()
|
|
118
|
+
res = hub.handoff({
|
|
119
|
+
"id": opts.target,
|
|
120
|
+
"to": opts.to,
|
|
121
|
+
"mode": opts.mode,
|
|
122
|
+
"provider": opts.provider,
|
|
123
|
+
"from_tool": opts.from_tool,
|
|
124
|
+
"note": opts.note,
|
|
125
|
+
"dry_run": opts.dry_run,
|
|
126
|
+
"quiesce_live": opts.quiesce,
|
|
127
|
+
})
|
|
128
|
+
print(f"● Handoff [{res['from_tool']} -> {res['to_tool']}] · mode={res['mode']} · status={res['status']} · fence={res['fence_state']} (epoch {res['workspace_epoch']})")
|
|
129
|
+
print(f" - Bundle: {res['bundle_path']} (~{res['token_estimate']} tokens)")
|
|
130
|
+
print(f" - YAML : {res['yaml_path']}")
|
|
131
|
+
print(f" - Chain : {' -> '.join(res['handoffChain'])}")
|
|
132
|
+
print(f" - Launch: {res['command']}")
|
|
133
|
+
return 0 if res.get("ok") else 1
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def run_sync_rules(argv):
|
|
137
|
+
sys.path.insert(0, ROOT)
|
|
138
|
+
import rules_sync
|
|
139
|
+
return rules_sync.main(argv)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def run_notify(argv):
|
|
143
|
+
sys.path.insert(0, ROOT)
|
|
144
|
+
import channels
|
|
145
|
+
return channels.main(argv)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def run_codex_release_lock(argv):
|
|
149
|
+
parser = argparse.ArgumentParser(prog="hub codex-release-lock", description="Release terminal codex writer lock so ChatGPT.app / mobile app can take over")
|
|
150
|
+
parser.add_argument("target", help="Codex session ID or prefix")
|
|
151
|
+
parser.add_argument("--dry-run", action="store_true", help="Preview without terminating terminal process")
|
|
152
|
+
opts = parser.parse_args(argv)
|
|
153
|
+
sys.path.insert(0, ROOT)
|
|
154
|
+
import server
|
|
155
|
+
hub = server.Hub()
|
|
156
|
+
res = hub.mobile_handoff("codex", opts.target, {"dry_run": opts.dry_run})
|
|
157
|
+
print(f"● Codex lock released for {res['id']} -> {res['companionApp']} (controlMode={res['controlMode']})")
|
|
158
|
+
print(f" - Lock file: {res['lock_path']} (free={res['lock_released']})")
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def run_mobile_handoff(argv):
|
|
163
|
+
parser = argparse.ArgumentParser(prog="hub mobile-handoff", description="Seamless first-party mobile app lock release & remote-control bridge")
|
|
164
|
+
parser.add_argument("target", help="Session ID or prefix")
|
|
165
|
+
parser.add_argument("--tool", default="", choices=("", "claude", "codex", "agy"), help="Optional tool filter")
|
|
166
|
+
parser.add_argument("--relaunch", action="store_true", help="Relaunch Claude session with --remote-control")
|
|
167
|
+
parser.add_argument("--dry-run", action="store_true", help="Preview without side effects")
|
|
168
|
+
opts = parser.parse_args(argv)
|
|
169
|
+
sys.path.insert(0, ROOT)
|
|
170
|
+
import server
|
|
171
|
+
hub = server.Hub()
|
|
172
|
+
res = hub.mobile_handoff(opts.tool, opts.target, {"dry_run": opts.dry_run, "relaunch": opts.relaunch})
|
|
173
|
+
print(json.dumps(res, ensure_ascii=False, indent=2))
|
|
174
|
+
return 0
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def run_install_icons(argv):
|
|
178
|
+
sys.path.insert(0, ROOT)
|
|
179
|
+
import icons_font
|
|
180
|
+
return icons_font.main(argv)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def run_doctor(argv):
|
|
184
|
+
sys.path.insert(0, ROOT)
|
|
185
|
+
import protocols
|
|
186
|
+
return protocols.main(argv)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def run_report(argv):
|
|
190
|
+
sys.path.insert(0, ROOT)
|
|
191
|
+
import cost_report
|
|
192
|
+
return cost_report.main(argv)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def main():
|
|
196
|
+
args = sys.argv[1:]
|
|
197
|
+
command = args[0] if args else ("workspace" if (sys.stdin.isatty() and sys.stdout.isatty() and os.environ.get("HUB_NO_OPEN") != "1") else "open")
|
|
198
|
+
|
|
199
|
+
if command == "agy":
|
|
200
|
+
os.execvp(sys.executable, [sys.executable, os.path.join(ROOT, "agy_claude_filter.py"), *args[1:]])
|
|
201
|
+
if command == "slim":
|
|
202
|
+
return run_slim(args[1:])
|
|
203
|
+
if command == "anchor":
|
|
204
|
+
return run_anchor(args[1:])
|
|
205
|
+
if command == "handoff":
|
|
206
|
+
return run_handoff(args[1:])
|
|
207
|
+
if command == "sync-rules":
|
|
208
|
+
return run_sync_rules(args[1:])
|
|
209
|
+
if command == "notify":
|
|
210
|
+
return run_notify(args[1:])
|
|
211
|
+
if command == "codex-release-lock":
|
|
212
|
+
return run_codex_release_lock(args[1:])
|
|
213
|
+
if command == "mobile-handoff":
|
|
214
|
+
return run_mobile_handoff(args[1:])
|
|
215
|
+
if command == "install-icons":
|
|
216
|
+
return run_install_icons(args[1:])
|
|
217
|
+
if command == "doctor":
|
|
218
|
+
return run_doctor(args[1:])
|
|
219
|
+
if command == "report":
|
|
220
|
+
return run_report(args[1:])
|
|
221
|
+
if command in ("tui", "sidebar", "reader", "workspace", "tmux"):
|
|
222
|
+
extra = []
|
|
223
|
+
if command == "sidebar":
|
|
224
|
+
extra = ["--sidebar"]
|
|
225
|
+
elif command == "reader":
|
|
226
|
+
extra = ["--reader"]
|
|
227
|
+
elif command in ("workspace", "tmux"):
|
|
228
|
+
extra = ["--workspace"]
|
|
229
|
+
os.execvp(sys.executable, [sys.executable, os.path.join(ROOT, "tui.py"), *extra, *args[1:]])
|
|
230
|
+
|
|
231
|
+
if command not in ("web", "open", "start", "stop", "status") or len(args) > 1:
|
|
232
|
+
print(
|
|
233
|
+
"Usage: mixhub|hub [workspace|tui|sidebar|reader|handoff|sync-rules|notify|codex-release-lock|mobile-handoff|install-icons|doctor|report|agy|slim|anchor|web|start|stop|status]",
|
|
234
|
+
file=sys.stderr,
|
|
235
|
+
)
|
|
236
|
+
return 2
|
|
237
|
+
|
|
238
|
+
with open(os.path.join(ROOT, ".hub.lock"), "a") as lock:
|
|
239
|
+
if fcntl is not None:
|
|
240
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
241
|
+
if command == "status":
|
|
242
|
+
live = health()
|
|
243
|
+
print("running · pid %s · %s" % (live["pid"], URL) if live else "stopped · " + URL)
|
|
244
|
+
elif command == "stop":
|
|
245
|
+
stop()
|
|
246
|
+
else:
|
|
247
|
+
start()
|
|
248
|
+
if command in ("open", "web") and os.environ.get("HUB_NO_OPEN") != "1":
|
|
249
|
+
if sys.platform == "win32":
|
|
250
|
+
import webbrowser
|
|
251
|
+
|
|
252
|
+
webbrowser.open(URL)
|
|
253
|
+
elif sys.platform == "darwin":
|
|
254
|
+
subprocess.run(["open", URL], check=True)
|
|
255
|
+
else:
|
|
256
|
+
subprocess.run(["xdg-open", URL], check=False)
|
|
257
|
+
return 0
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
if __name__ == "__main__":
|
|
261
|
+
try:
|
|
262
|
+
sys.exit(main())
|
|
263
|
+
except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
|
|
264
|
+
print(str(exc), file=sys.stderr)
|
|
265
|
+
sys.exit(1)
|
bin/mixhub
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Alias entry point `mixhub` delegating directly to `bin/hub`."""
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
ROOT = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
|
|
7
|
+
os.execvp(sys.executable, [sys.executable, os.path.join(ROOT, "bin", "hub"), *sys.argv[1:]])
|
bin/mixhub.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
const { spawnSync } = require("node:child_process");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
|
|
6
|
+
const hubBin = path.resolve(__dirname, "hub");
|
|
7
|
+
const python = process.env.HUB_PYTHON || "python3";
|
|
8
|
+
const result = spawnSync(python, [hubBin, ...process.argv.slice(2)], {
|
|
9
|
+
stdio: "inherit",
|
|
10
|
+
});
|
|
11
|
+
if (result.error) {
|
|
12
|
+
console.error("MixHub launch error:", result.error.message);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
process.exit(result.status ?? 0);
|