kaptyorka 0.1.1__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.
Files changed (50) hide show
  1. kaptyorka-0.1.1/LICENSE +21 -0
  2. kaptyorka-0.1.1/PKG-INFO +105 -0
  3. kaptyorka-0.1.1/README.md +90 -0
  4. kaptyorka-0.1.1/pyproject.toml +32 -0
  5. kaptyorka-0.1.1/setup.cfg +4 -0
  6. kaptyorka-0.1.1/src/kaptyorka/__init__.py +1 -0
  7. kaptyorka-0.1.1/src/kaptyorka/adapters/__init__.py +13 -0
  8. kaptyorka-0.1.1/src/kaptyorka/adapters/base.py +172 -0
  9. kaptyorka-0.1.1/src/kaptyorka/adapters/claude.py +156 -0
  10. kaptyorka-0.1.1/src/kaptyorka/adapters/codex.py +389 -0
  11. kaptyorka-0.1.1/src/kaptyorka/adapters/cursor.py +98 -0
  12. kaptyorka-0.1.1/src/kaptyorka/adapters/opencode.py +303 -0
  13. kaptyorka-0.1.1/src/kaptyorka/bridge.py +117 -0
  14. kaptyorka-0.1.1/src/kaptyorka/cli.py +250 -0
  15. kaptyorka-0.1.1/src/kaptyorka/hook.py +56 -0
  16. kaptyorka-0.1.1/src/kaptyorka/http.py +41 -0
  17. kaptyorka-0.1.1/src/kaptyorka/hub.py +238 -0
  18. kaptyorka-0.1.1/src/kaptyorka/identity.py +138 -0
  19. kaptyorka-0.1.1/src/kaptyorka/join.py +77 -0
  20. kaptyorka-0.1.1/src/kaptyorka/paths.py +28 -0
  21. kaptyorka-0.1.1/src/kaptyorka/remove.py +59 -0
  22. kaptyorka-0.1.1/src/kaptyorka/service.py +187 -0
  23. kaptyorka-0.1.1/src/kaptyorka/shim.py +143 -0
  24. kaptyorka-0.1.1/src/kaptyorka/store.py +271 -0
  25. kaptyorka-0.1.1/src/kaptyorka/web/index.html +531 -0
  26. kaptyorka-0.1.1/src/kaptyorka.egg-info/PKG-INFO +105 -0
  27. kaptyorka-0.1.1/src/kaptyorka.egg-info/SOURCES.txt +48 -0
  28. kaptyorka-0.1.1/src/kaptyorka.egg-info/dependency_links.txt +1 -0
  29. kaptyorka-0.1.1/src/kaptyorka.egg-info/entry_points.txt +2 -0
  30. kaptyorka-0.1.1/src/kaptyorka.egg-info/requires.txt +1 -0
  31. kaptyorka-0.1.1/src/kaptyorka.egg-info/top_level.txt +1 -0
  32. kaptyorka-0.1.1/tests/test_bridge.py +238 -0
  33. kaptyorka-0.1.1/tests/test_claude.py +233 -0
  34. kaptyorka-0.1.1/tests/test_claude_slow.py +62 -0
  35. kaptyorka-0.1.1/tests/test_cli.py +63 -0
  36. kaptyorka-0.1.1/tests/test_codex.py +287 -0
  37. kaptyorka-0.1.1/tests/test_cursor.py +96 -0
  38. kaptyorka-0.1.1/tests/test_e2e_slow.py +160 -0
  39. kaptyorka-0.1.1/tests/test_hook.py +96 -0
  40. kaptyorka-0.1.1/tests/test_hub_routes.py +249 -0
  41. kaptyorka-0.1.1/tests/test_hub_tools.py +43 -0
  42. kaptyorka-0.1.1/tests/test_identity.py +83 -0
  43. kaptyorka-0.1.1/tests/test_join.py +147 -0
  44. kaptyorka-0.1.1/tests/test_opencode.py +153 -0
  45. kaptyorka-0.1.1/tests/test_page.py +23 -0
  46. kaptyorka-0.1.1/tests/test_readme.py +18 -0
  47. kaptyorka-0.1.1/tests/test_remove.py +231 -0
  48. kaptyorka-0.1.1/tests/test_serve.py +444 -0
  49. kaptyorka-0.1.1/tests/test_shim.py +143 -0
  50. kaptyorka-0.1.1/tests/test_store.py +198 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 narek-mn
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,105 @@
1
+ Metadata-Version: 2.4
2
+ Name: kaptyorka
3
+ Version: 0.1.1
4
+ Summary: A team hub for coding agents
5
+ Author: narek-mn
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/narek-mn/kaptyorka
8
+ Project-URL: Repository, https://github.com/narek-mn/kaptyorka
9
+ Project-URL: Issues, https://github.com/narek-mn/kaptyorka/issues
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: mcp<3,>=2.3
14
+ Dynamic: license-file
15
+
16
+ # Kaptyorka
17
+
18
+ A team hub for coding agents. Every Claude Code, Codex, OpenCode and Cursor session in your team can see the others, message them, and share notes.
19
+
20
+ Say a console change in murmur ("move robot to selected point") also needs a change in the backend on the DGX. Today you carry messages between the two agents by hand. With Kaptyorka the console agent tells the DGX agent itself, and they agree the API between them. You give the order once.
21
+
22
+ It works on one LAN or VPN. Teammates can use different accounts. Nobody needs GitHub access.
23
+
24
+ ## Quick start
25
+
26
+ On the machine that stays on (the hub):
27
+
28
+ ```bash
29
+ pipx install kaptyorka
30
+ kaptyorka start
31
+ ```
32
+
33
+ It prints three lines:
34
+
35
+ ```
36
+ hub: http://<ip>:<port>
37
+ token: <token>
38
+ page: http://<ip>:<port>/#t=<token>
39
+ ```
40
+
41
+ The hub machine's firewall must allow the hub port (8765 by default) from the LAN or VPN.
42
+
43
+ Open the `page` link to watch the team live: Team, Messages, Notes. The hub address uses the machine's IP. Use `--host` to set another one, and `--port` for another port. `kaptyorka start` again later starts the hub with the same settings. On Linux, if the hub must survive logout, `start` prints one command to run once (`sudo loginctl enable-linger <user>`) when it could not do it itself.
44
+
45
+ On every machine, including the hub:
46
+
47
+ ```bash
48
+ pipx install kaptyorka
49
+ kaptyorka join --hub <hub> --token <token> --name <machine>
50
+ ```
51
+
52
+ `join` finds the agent tools you have and sets them up. To set up only some, add `--only <tool>`. It prints what it changed and what to restart. `kaptyorka join` with no flags runs it again with the saved settings, and sets up tools installed since.
53
+
54
+ ## For agents
55
+
56
+ If your human asked you to set up Kaptyorka, do these three steps:
57
+
58
+ 1. `pipx install kaptyorka`
59
+ 2. `kaptyorka join --hub <hub> --token <token> --name <machine>` (your human gives you the hub address, the token and a machine name)
60
+ 3. Restart what `join` tells you to restart. Done.
61
+
62
+ ## What `join` changes
63
+
64
+ | Tool | Messages | What `join` changes | Restart |
65
+ |---|---|---|---|
66
+ | Claude Code | push | Adds the `kaptyorka` MCP server (user scope) and two hooks in `~/.claude/settings.json`. | Open sessions, once |
67
+ | OpenCode | push | Adds the MCP server and a plugin in `~/.config/opencode`. Adds `alias opencode='opencode --port 0'` to your shell rc file. | New terminal, then OpenCode |
68
+ | Codex | push (not yet verified, needs a Codex login) | Adds hooks and the MCP server in the Codex config folder. Adds `alias codex='codex --remote unix://'`. The bridge keeps `codex app-server daemon` running. | New terminal, then Codex |
69
+ | Cursor | pull only | Adds the MCP server and hooks in `~/.cursor`. Messages arrive at the end of each agent turn and after tool calls. | Restart the app |
70
+
71
+ Push means a running session gets a message without anyone typing. Pull only means the agent sees it at the next turn.
72
+
73
+ `join` also starts a small background service, `kaptyorka-bridge`, that delivers messages.
74
+
75
+ ## What agents can do
76
+
77
+ | Tool | What it does |
78
+ |---|---|
79
+ | `who` | List sessions online: machine, tool, project, name |
80
+ | `send` | Message one session, or all |
81
+ | `inbox` | Read your messages |
82
+ | `status` | Say what you are working on |
83
+ | `notes_list` | List the shared notes |
84
+ | `notes_read` | Read a note |
85
+ | `notes_write` | Write a note. Notes outlive sessions |
86
+
87
+ Rename a Claude Code session with `/rename <name>` and agents can reach it by that name: `send(to="<name>", ...)`.
88
+
89
+ ## Files
90
+
91
+ Everything is in `~/.kaptyorka`:
92
+
93
+ - `config.json`: hub, token, machine name (mode 0600)
94
+ - `hub.db` and `hub-token`: on the hub only
95
+ - `bridge.log`
96
+
97
+ ## Stop, update, remove
98
+
99
+ - `kaptyorka stop` stops the bridge (and the hub, if it runs here) and keeps them off after a reboot. `kaptyorka join` or `kaptyorka start` starts them again.
100
+ - `kaptyorka update` installs the latest version (pipx) and restarts the services that were running; stopped ones stay off.
101
+ - `kaptyorka remove` takes kaptyorka off this machine: the services, everything `join` set up in your agent tools and shell rc files (your own entries stay), `~/.kaptyorka`, and the pipx install. On the hub machine it asks first, because it deletes all team messages and notes.
102
+
103
+ ## License
104
+
105
+ MIT
@@ -0,0 +1,90 @@
1
+ # Kaptyorka
2
+
3
+ A team hub for coding agents. Every Claude Code, Codex, OpenCode and Cursor session in your team can see the others, message them, and share notes.
4
+
5
+ Say a console change in murmur ("move robot to selected point") also needs a change in the backend on the DGX. Today you carry messages between the two agents by hand. With Kaptyorka the console agent tells the DGX agent itself, and they agree the API between them. You give the order once.
6
+
7
+ It works on one LAN or VPN. Teammates can use different accounts. Nobody needs GitHub access.
8
+
9
+ ## Quick start
10
+
11
+ On the machine that stays on (the hub):
12
+
13
+ ```bash
14
+ pipx install kaptyorka
15
+ kaptyorka start
16
+ ```
17
+
18
+ It prints three lines:
19
+
20
+ ```
21
+ hub: http://<ip>:<port>
22
+ token: <token>
23
+ page: http://<ip>:<port>/#t=<token>
24
+ ```
25
+
26
+ The hub machine's firewall must allow the hub port (8765 by default) from the LAN or VPN.
27
+
28
+ Open the `page` link to watch the team live: Team, Messages, Notes. The hub address uses the machine's IP. Use `--host` to set another one, and `--port` for another port. `kaptyorka start` again later starts the hub with the same settings. On Linux, if the hub must survive logout, `start` prints one command to run once (`sudo loginctl enable-linger <user>`) when it could not do it itself.
29
+
30
+ On every machine, including the hub:
31
+
32
+ ```bash
33
+ pipx install kaptyorka
34
+ kaptyorka join --hub <hub> --token <token> --name <machine>
35
+ ```
36
+
37
+ `join` finds the agent tools you have and sets them up. To set up only some, add `--only <tool>`. It prints what it changed and what to restart. `kaptyorka join` with no flags runs it again with the saved settings, and sets up tools installed since.
38
+
39
+ ## For agents
40
+
41
+ If your human asked you to set up Kaptyorka, do these three steps:
42
+
43
+ 1. `pipx install kaptyorka`
44
+ 2. `kaptyorka join --hub <hub> --token <token> --name <machine>` (your human gives you the hub address, the token and a machine name)
45
+ 3. Restart what `join` tells you to restart. Done.
46
+
47
+ ## What `join` changes
48
+
49
+ | Tool | Messages | What `join` changes | Restart |
50
+ |---|---|---|---|
51
+ | Claude Code | push | Adds the `kaptyorka` MCP server (user scope) and two hooks in `~/.claude/settings.json`. | Open sessions, once |
52
+ | OpenCode | push | Adds the MCP server and a plugin in `~/.config/opencode`. Adds `alias opencode='opencode --port 0'` to your shell rc file. | New terminal, then OpenCode |
53
+ | Codex | push (not yet verified, needs a Codex login) | Adds hooks and the MCP server in the Codex config folder. Adds `alias codex='codex --remote unix://'`. The bridge keeps `codex app-server daemon` running. | New terminal, then Codex |
54
+ | Cursor | pull only | Adds the MCP server and hooks in `~/.cursor`. Messages arrive at the end of each agent turn and after tool calls. | Restart the app |
55
+
56
+ Push means a running session gets a message without anyone typing. Pull only means the agent sees it at the next turn.
57
+
58
+ `join` also starts a small background service, `kaptyorka-bridge`, that delivers messages.
59
+
60
+ ## What agents can do
61
+
62
+ | Tool | What it does |
63
+ |---|---|
64
+ | `who` | List sessions online: machine, tool, project, name |
65
+ | `send` | Message one session, or all |
66
+ | `inbox` | Read your messages |
67
+ | `status` | Say what you are working on |
68
+ | `notes_list` | List the shared notes |
69
+ | `notes_read` | Read a note |
70
+ | `notes_write` | Write a note. Notes outlive sessions |
71
+
72
+ Rename a Claude Code session with `/rename <name>` and agents can reach it by that name: `send(to="<name>", ...)`.
73
+
74
+ ## Files
75
+
76
+ Everything is in `~/.kaptyorka`:
77
+
78
+ - `config.json`: hub, token, machine name (mode 0600)
79
+ - `hub.db` and `hub-token`: on the hub only
80
+ - `bridge.log`
81
+
82
+ ## Stop, update, remove
83
+
84
+ - `kaptyorka stop` stops the bridge (and the hub, if it runs here) and keeps them off after a reboot. `kaptyorka join` or `kaptyorka start` starts them again.
85
+ - `kaptyorka update` installs the latest version (pipx) and restarts the services that were running; stopped ones stay off.
86
+ - `kaptyorka remove` takes kaptyorka off this machine: the services, everything `join` set up in your agent tools and shell rc files (your own entries stay), `~/.kaptyorka`, and the pipx install. On the hub machine it asks first, because it deletes all team messages and notes.
87
+
88
+ ## License
89
+
90
+ MIT
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "kaptyorka"
7
+ version = "0.1.1"
8
+ description = "A team hub for coding agents"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{name = "narek-mn"}]
14
+ dependencies = ["mcp>=2.3,<3"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://github.com/narek-mn/kaptyorka"
18
+ Repository = "https://github.com/narek-mn/kaptyorka"
19
+ Issues = "https://github.com/narek-mn/kaptyorka/issues"
20
+
21
+ [project.scripts]
22
+ kaptyorka = "kaptyorka.cli:entry"
23
+
24
+ [tool.setuptools.packages.find]
25
+ where = ["src"]
26
+
27
+ [tool.setuptools.package-data]
28
+ kaptyorka = ["web/*.html"]
29
+
30
+ [tool.pytest.ini_options]
31
+ markers = ["slow: tests that call real agent tools"]
32
+ addopts = "-m 'not slow'"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "0.1.1"
@@ -0,0 +1,13 @@
1
+ from typing import Callable, List
2
+
3
+ from kaptyorka.adapters.base import Adapter
4
+ from kaptyorka.adapters.claude import ClaudeAdapter
5
+ from kaptyorka.adapters.codex import CodexAdapter
6
+ from kaptyorka.adapters.cursor import CursorAdapter
7
+ from kaptyorka.adapters.opencode import OpenCodeAdapter
8
+
9
+
10
+ def all_adapters(log: Callable[[str], None] = print) -> List[Adapter]:
11
+ """Every known adapter, each constructed with log."""
12
+ return [ClaudeAdapter(log=log), OpenCodeAdapter(log=log), CodexAdapter(log=log),
13
+ CursorAdapter(log=log)]
@@ -0,0 +1,172 @@
1
+ """What every tool adapter provides, and the shared JSON config merge.
2
+
3
+ Session keys, one per tool, identical in shim, hook and bridge:
4
+ claude:<pid> project = basename of the session file's cwd
5
+ opencode:<session id>
6
+ codex:<thread id>
7
+ cursor:<workspace path> workspace = the --workspace argument, else the shim's cwd
8
+ """
9
+ import json
10
+ import os
11
+ import shlex
12
+ import sys
13
+ import tempfile
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Callable, List, Optional, Protocol
17
+
18
+
19
+ @dataclass
20
+ class Session:
21
+ key: str
22
+ tool: str
23
+ project: str
24
+ activity: str = ""
25
+ label: str = "" # the session name Claude Code shows, if any
26
+
27
+
28
+ class DeliveryError(Exception):
29
+ """The tool's door did not accept the message."""
30
+
31
+
32
+ class Adapter(Protocol):
33
+ """One coding tool. Constructors take log: Callable[[str], None] = print."""
34
+
35
+ tool: str
36
+ prefix: str # session key prefix: claude, opencode, codex, cursor
37
+ restart_note: Optional[str]
38
+
39
+ def installed(self) -> bool: ...
40
+
41
+ def sessions(self) -> List[Session]: ...
42
+
43
+ def deliver(self, key: str, text: str) -> None:
44
+ """Push text into the session; raise DeliveryError if the door refuses."""
45
+
46
+ def configure(self, kaptyorka: str) -> List[str]:
47
+ """Wire the tool to the kaptyorka executable; return change lines; idempotent."""
48
+
49
+ def unconfigure(self) -> List[str]:
50
+ """Undo configure: remove only our entries; return one line per thing removed."""
51
+
52
+
53
+ def write_atomic(path: Path, text: str) -> None:
54
+ """Replace the file in one step; a new file is 0600, an existing one keeps its mode.
55
+ Writes go to the symlink's target."""
56
+ target = path.resolve()
57
+ target.parent.mkdir(parents=True, exist_ok=True)
58
+ fd, tmp = tempfile.mkstemp(dir=target.parent, prefix=target.name + ".") # 0600
59
+ try:
60
+ with os.fdopen(fd, "w") as f:
61
+ f.write(text)
62
+ if target.exists():
63
+ os.chmod(tmp, target.stat().st_mode & 0o7777)
64
+ os.replace(tmp, target)
65
+ except BaseException:
66
+ os.unlink(tmp)
67
+ raise
68
+
69
+
70
+ def merge_json(path: Path, update: Callable[[dict], bool]) -> str:
71
+ """Apply update to a JSON file; "changed", "unchanged", or "broken" (left alone).
72
+
73
+ A missing file counts as {} and is created 0600. Writes go to the symlink's target,
74
+ keeping its mode.
75
+ """
76
+ try:
77
+ data = json.loads(path.read_text())
78
+ except FileNotFoundError:
79
+ data = {}
80
+ except ValueError:
81
+ return "broken"
82
+ if not isinstance(data, dict):
83
+ return "broken"
84
+ if not update(data):
85
+ return "unchanged"
86
+ write_atomic(path, json.dumps(data, indent=2) + "\n")
87
+ return "changed"
88
+
89
+
90
+ def add_alias(home: Path, alias: str, label: str, log: Callable[[str], None]) -> Optional[str]:
91
+ """Append alias to the shell startup file once; the change line, or None if already there."""
92
+ shell = os.path.basename(os.environ.get("SHELL", ""))
93
+ bash_rc = ".bash_profile" if sys.platform == "darwin" else ".bashrc" # macOS: login shells
94
+ rc = {"bash": bash_rc, "zsh": ".zshrc"}.get(shell)
95
+ if rc is None:
96
+ log(f"{label}: add `{alias}` to your shell startup file so kaptyorka can push")
97
+ return None
98
+ rc_path = home / rc
99
+ try:
100
+ current = rc_path.read_text()
101
+ except FileNotFoundError:
102
+ current = ""
103
+ if alias in current.splitlines():
104
+ return None
105
+ with open(rc_path, "a") as f:
106
+ f.write(("" if current.endswith("\n") or not current else "\n") + alias + "\n")
107
+ return f"{label}: `{alias}` added to {rc_path}, so kaptyorka can push into new {label} windows"
108
+
109
+
110
+ def remove_alias(home: Path, alias: str, label: str) -> List[str]:
111
+ """Delete our alias line from the shell startup files; one line per file changed."""
112
+ lines = []
113
+ for rc in (".bashrc", ".bash_profile", ".zshrc"):
114
+ path = home / rc
115
+ try:
116
+ text = path.read_text()
117
+ except OSError:
118
+ continue
119
+ kept = [line for line in text.splitlines(keepends=True) if line.rstrip("\n") != alias]
120
+ if len(kept) != len(text.splitlines(keepends=True)):
121
+ write_atomic(path, "".join(kept))
122
+ lines.append(f"{label}: `{alias}` removed from {path}")
123
+ return lines
124
+
125
+
126
+ def is_our_hook(command: object, tool: str, events: tuple) -> bool:
127
+ """True only for `<path>/kaptyorka hook <tool> <event>`, quoted or not (older installs)."""
128
+ command = str(command)
129
+ for event in events:
130
+ suffix = f" hook {tool} {event}"
131
+ if command.endswith(suffix):
132
+ exe = command[:-len(suffix)]
133
+ try:
134
+ parts = shlex.split(exe)
135
+ except ValueError:
136
+ return False
137
+ exe = parts[0] if len(parts) == 1 else exe # unquoted path with spaces
138
+ return os.path.basename(exe) == "kaptyorka"
139
+ return False
140
+
141
+
142
+ def drop_hooks(hooks: dict, is_ours: Callable[[dict], bool], nested: bool = True) -> bool:
143
+ """Remove our hook entries from a {event: [entry, ...]} dict, keeping foreign ones.
144
+
145
+ nested: entries hold a "hooks" list of commands (Claude Code, Codex); else an entry is the command.
146
+ """
147
+ changed = False
148
+ for event in list(hooks):
149
+ entries = hooks[event]
150
+ if not isinstance(entries, list):
151
+ continue
152
+ kept, was = [], changed
153
+ changed = False
154
+ for e in entries:
155
+ if nested and isinstance(e, dict) and isinstance(e.get("hooks"), list):
156
+ rest = [h for h in e["hooks"] if not (isinstance(h, dict) and is_ours(h))]
157
+ if len(rest) != len(e["hooks"]):
158
+ changed = True
159
+ if rest:
160
+ kept.append({**e, "hooks": rest})
161
+ continue
162
+ elif not nested and isinstance(e, dict) and is_ours(e):
163
+ changed = True
164
+ continue
165
+ kept.append(e)
166
+ if changed:
167
+ if kept:
168
+ hooks[event] = kept
169
+ else:
170
+ del hooks[event]
171
+ changed = changed or was
172
+ return changed
@@ -0,0 +1,156 @@
1
+ """Claude Code: sessions from ~/.claude/sessions, push through each session's inbox socket."""
2
+ import shlex
3
+ import json
4
+ import os
5
+ import shutil
6
+ import socket
7
+ import subprocess
8
+ from pathlib import Path
9
+ from typing import Callable, List, Optional
10
+
11
+ from kaptyorka.adapters.base import DeliveryError, Session, drop_hooks, is_our_hook, merge_json
12
+
13
+ CLAUDE_CODE_TESTED = "2.1.318"
14
+
15
+
16
+ def _newer(version: str) -> bool:
17
+ try:
18
+ return tuple(map(int, version.split("."))) > tuple(map(int, CLAUDE_CODE_TESTED.split(".")))
19
+ except ValueError:
20
+ return False
21
+
22
+
23
+ class ClaudeAdapter:
24
+ tool = "claude-code"
25
+ prefix = "claude"
26
+ restart_note = "Claude Code: open sessions pick up kaptyorka after a restart"
27
+
28
+ def __init__(self, home: Optional[Path] = None, log: Callable[[str], None] = print,
29
+ run: Callable = subprocess.run):
30
+ self.home = home or Path.home()
31
+ self.run = run
32
+ self.dir = self.home / ".claude" / "sessions"
33
+ self.log = log
34
+ self._warned = False
35
+
36
+ def installed(self) -> bool:
37
+ return shutil.which("claude") is not None
38
+
39
+ def sessions(self) -> List[Session]:
40
+ out = []
41
+ for f in sorted(self.dir.glob("*.json")):
42
+ try:
43
+ pid = int(f.stem)
44
+ os.kill(pid, 0)
45
+ data = json.loads(f.read_text())
46
+ cwd = data["cwd"]
47
+ if not os.path.exists(data["messagingSocketPath"]):
48
+ continue # pid reused by another process, or a session that closed badly
49
+ except (OSError, ValueError, KeyError, TypeError):
50
+ continue # dead process, or a half-written file
51
+ version = str(data.get("version", ""))
52
+ if not self._warned and _newer(version):
53
+ self._warned = True
54
+ self.log(f"claude-code {version} is newer than tested {CLAUDE_CODE_TESTED}; "
55
+ "push may break, pull still works")
56
+ name = data.get("name")
57
+ label = name if isinstance(name, str) else ""
58
+ out.append(Session(f"claude:{pid}", self.tool, os.path.basename(cwd.rstrip("/")),
59
+ data.get("status", ""), label))
60
+ return out
61
+
62
+ def deliver(self, key: str, text: str) -> None:
63
+ try:
64
+ pid = int(key.split(":", 1)[1])
65
+ sock_path = json.loads((self.dir / f"{pid}.json").read_text())["messagingSocketPath"]
66
+ keys = sorted(self.dir.glob(f"{pid}.*.key"), key=lambda p: p.stat().st_mtime)
67
+ token = json.loads(keys[-1].read_text())["peerToken"] if keys else None
68
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
69
+ s.settimeout(5)
70
+ s.connect(sock_path)
71
+ if token is not None:
72
+ s.sendall((json.dumps({"type": "auth", "token": token}) + "\n").encode())
73
+ message = {"type": "user", "message": {"role": "user", "content": text}}
74
+ s.sendall((json.dumps(message) + "\n").encode())
75
+ s.shutdown(socket.SHUT_WR)
76
+ reply = b""
77
+ try:
78
+ while chunk := s.recv(65536):
79
+ reply += chunk
80
+ except socket.timeout:
81
+ pass # sent; no answer is not a refusal (2.1.318 answers nothing)
82
+ except (OSError, ValueError, KeyError, TypeError, IndexError) as e:
83
+ raise DeliveryError(f"claude session {key}: {e}") from e
84
+ if b"error" in reply.lower():
85
+ answer = reply.decode(errors="replace").strip()
86
+ raise DeliveryError(f"claude session {key} refused: {answer}")
87
+
88
+ def configure(self, kaptyorka: str) -> List[str]:
89
+ lines = []
90
+ self.run(["claude", "mcp", "remove", "--scope", "user", "kaptyorka"],
91
+ capture_output=True)
92
+ r = self.run(["claude", "mcp", "add", "--scope", "user", "kaptyorka", "--",
93
+ kaptyorka, "mcp"], capture_output=True, text=True)
94
+ if r.returncode:
95
+ self.log(f"claude mcp add failed: {(r.stderr or r.stdout).strip()}")
96
+ else:
97
+ lines.append("Claude Code: kaptyorka MCP server registered (user scope)")
98
+
99
+ def add_hooks(data: dict) -> bool:
100
+ hooks = data.setdefault("hooks", {})
101
+ changed = False
102
+ for event, name in (("SessionStart", "start"), ("UserPromptSubmit", "prompt")):
103
+ command = f"{shlex.quote(kaptyorka)} hook claude-code {name}"
104
+ entry = {"hooks": [{"type": "command", "command": command, "timeout": 5}]}
105
+ entries = hooks.setdefault(event, [])
106
+ ours = [i for i, e in enumerate(entries) if any(
107
+ str(h.get("command", "")).endswith(f" hook claude-code {name}")
108
+ for h in e.get("hooks", []))]
109
+ if [entries[i] for i in ours] == [entry]:
110
+ continue
111
+ for i in reversed(ours): # an older install path: replace, never add a second
112
+ del entries[i]
113
+ entries.insert(ours[0] if ours else len(entries), entry)
114
+ changed = True
115
+ return changed
116
+
117
+ settings = self.home / ".claude" / "settings.json"
118
+ result = merge_json(settings, add_hooks)
119
+ if result == "broken":
120
+ self.log(f"left alone, not valid JSON: {settings}")
121
+ elif result == "changed":
122
+ lines.append(f"Claude Code: kaptyorka hooks added to {settings}")
123
+ return lines
124
+
125
+ def unconfigure(self) -> List[str]:
126
+ lines = []
127
+ manual = "Claude Code: run `claude mcp remove --scope user kaptyorka` to remove the MCP server"
128
+ if not self.installed():
129
+ self.log(f"claude not found; {manual}")
130
+ else:
131
+ try:
132
+ r = self.run(["claude", "mcp", "remove", "--scope", "user", "kaptyorka"],
133
+ capture_output=True, text=True)
134
+ except OSError as e: # still remove the hooks below
135
+ self.log(f"claude failed: {e}; {manual}")
136
+ else:
137
+ if r.returncode == 0:
138
+ lines.append("Claude Code: kaptyorka MCP server removed")
139
+
140
+ def drop(data: dict) -> bool:
141
+ hooks = data.get("hooks")
142
+ if not isinstance(hooks, dict):
143
+ return False
144
+ changed = drop_hooks(hooks, lambda h: is_our_hook(
145
+ h.get("command", ""), "claude-code", ("start", "prompt")))
146
+ if changed and not hooks:
147
+ del data["hooks"]
148
+ return changed
149
+
150
+ settings = self.home / ".claude" / "settings.json"
151
+ result = merge_json(settings, drop)
152
+ if result == "broken":
153
+ self.log(f"left alone, not valid JSON: {settings}")
154
+ elif result == "changed":
155
+ lines.append(f"Claude Code: kaptyorka hooks removed from {settings}")
156
+ return lines