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.
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);