kojee-mcp 0.5.13 → 0.5.15
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.
- package/dist/{chunk-LVL25VLO.js → chunk-77HWBSRH.js} +0 -5
- package/dist/cli.js +2 -2
- package/dist/plugins/hermes/__init__.py +21 -0
- package/dist/plugins/hermes/adapter.py +481 -0
- package/dist/plugins/hermes/kojee_tandem_core.py +344 -0
- package/dist/plugins/hermes/plugin.yaml +58 -0
- package/dist/{runtimes-CO43XUUK.js → runtimes-GG7EOFZH.js} +1 -3
- package/dist/wizard-3FDEWEYO.js +904 -0
- package/package.json +2 -2
- package/dist/wizard-L4MYRLJI.js +0 -382
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
// src/wizard/runtimes.ts
|
|
2
2
|
var WIZARD_RUNTIMES = ["claude-code", "hermes", "openclaw", "codex"];
|
|
3
|
-
var WEBHOOK_RUNTIMES = /* @__PURE__ */ new Set([
|
|
4
|
-
"hermes",
|
|
5
|
-
"openclaw"
|
|
6
|
-
]);
|
|
7
3
|
function isWizardRuntime(value) {
|
|
8
4
|
return WIZARD_RUNTIMES.includes(value);
|
|
9
5
|
}
|
|
@@ -16,7 +12,6 @@ var RUNTIME_MENU = [
|
|
|
16
12
|
|
|
17
13
|
export {
|
|
18
14
|
WIZARD_RUNTIMES,
|
|
19
|
-
WEBHOOK_RUNTIMES,
|
|
20
15
|
isWizardRuntime,
|
|
21
16
|
RUNTIME_MENU
|
|
22
17
|
};
|
package/dist/cli.js
CHANGED
|
@@ -140,7 +140,7 @@ program.command("init").description(
|
|
|
140
140
|
console.error("Not paired. Run `kojee-mcp pair <code> --url <broker>` first, then re-run `init` \u2014 or pass --token/--pair-code, or run `init` in a terminal for the guided wizard.");
|
|
141
141
|
process.exit(1);
|
|
142
142
|
}
|
|
143
|
-
const { runWizard } = await import("./wizard-
|
|
143
|
+
const { runWizard } = await import("./wizard-3FDEWEYO.js");
|
|
144
144
|
const result = await runWizard({
|
|
145
145
|
...opts.runtime !== void 0 ? { runtime: opts.runtime } : {},
|
|
146
146
|
...opts.uninstall ? { uninstall: true } : {},
|
|
@@ -178,7 +178,7 @@ async function withReadline(fn) {
|
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
180
|
async function promptRuntimeFromTty() {
|
|
181
|
-
const { RUNTIME_MENU } = await import("./runtimes-
|
|
181
|
+
const { RUNTIME_MENU } = await import("./runtimes-GG7EOFZH.js");
|
|
182
182
|
return withReadline(async (ask) => {
|
|
183
183
|
const menu = RUNTIME_MENU.map((m) => `[${m.index}] ${m.runtime}`).join(" ");
|
|
184
184
|
const answer = (await ask(`Which runtime is this proxy for? ${menu}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Hermes plugin package entry for kojee-tandem.
|
|
2
|
+
|
|
3
|
+
Inside Hermes, hermes_cli/plugins.py loads this dir as the package
|
|
4
|
+
``hermes_plugins.kojee_tandem`` and calls ``register(ctx)``.
|
|
5
|
+
|
|
6
|
+
FAIL-SOFT IMPORT: outside that loader (e.g. pytest collecting this dir as a
|
|
7
|
+
Package on a box without Hermes) the relative import below cannot resolve —
|
|
8
|
+
``gateway.*`` doesn't exist and there is no parent package. We degrade to
|
|
9
|
+
``register = None`` instead of raising so tooling can walk the tree; the
|
|
10
|
+
Hermes loader itself would surface a real adapter bug as a loud
|
|
11
|
+
"Plugin 'kojee-tandem' has no register() function" + this module's import
|
|
12
|
+
error in the gateway log, not as a silent no-op.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from .adapter import register
|
|
17
|
+
except ImportError as _exc: # pragma: no cover — outside the Hermes loader
|
|
18
|
+
register = None
|
|
19
|
+
_IMPORT_ERROR = _exc
|
|
20
|
+
|
|
21
|
+
__all__ = ["register"]
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
"""Kojee Tandem platform adapter for the Hermes agent gateway (v1, sidecar).
|
|
2
|
+
|
|
3
|
+
Makes Tandem a first-class Hermes channel: install this plugin dir as
|
|
4
|
+
``~/.hermes/plugins/kojee-tandem/`` and Tandem messages wake the agent exactly
|
|
5
|
+
like a Telegram DM — per-tandem chat ids, Hermes-native sessions, allowlists.
|
|
6
|
+
|
|
7
|
+
Architecture (v1 — the kojee-mcp daemon is the sidecar, never forked):
|
|
8
|
+
|
|
9
|
+
Tandem cloud ──SSE──> kojee-mcp daemon ──signed webhook POST──>
|
|
10
|
+
this adapter's loopback HTTP listener ──MessageEvent──> Hermes agent
|
|
11
|
+
agent reply ──send()──> `kojee-mcp send` CLI (0.5.4+, the shipped local
|
|
12
|
+
SEND control surface; same paired ~/.kojee creds + DPoP keystore the
|
|
13
|
+
daemon uses) ──tandem_send──> Tandem cloud
|
|
14
|
+
|
|
15
|
+
Outbound (T10): the reply path shells the shipped ``kojee-mcp send``
|
|
16
|
+
sub-command rather than a tsx helper that dynamically imports source modules,
|
|
17
|
+
so the install no longer requires a kojee-mcp SOURCE checkout — only the
|
|
18
|
+
``kojee-mcp`` binary (KOJEE_MCP_BIN, with a PATH fallback) and a paired
|
|
19
|
+
``~/.kojee``.
|
|
20
|
+
|
|
21
|
+
Coded against (pinned upstream source, NousResearch/hermes-agent @ main):
|
|
22
|
+
- gateway/platforms/base.py: BasePlatformAdapter.__init__(config, platform)
|
|
23
|
+
(line 1826), abstract connect (2244) / disconnect (2253) / send (2258) /
|
|
24
|
+
get_chat_info (4709); send_typing default no-op (2534); build_source
|
|
25
|
+
(4669); handle_message (3839); MessageEvent (1413); SendResult (1542).
|
|
26
|
+
- gateway/config.py Platform._missing_ (167): "kojee-tandem" resolves as a
|
|
27
|
+
runtime-registered pseudo-member AFTER register() runs — registration
|
|
28
|
+
precedes adapter construction, so Platform("kojee-tandem") is safe here.
|
|
29
|
+
- hermes_cli/plugins.py PluginContext.register_platform (770) forwarding
|
|
30
|
+
**entry_kwargs to gateway/platform_registry.py PlatformEntry (39).
|
|
31
|
+
- plugins/platforms/irc/ — the bundled-plugin idiom this file mirrors.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import asyncio
|
|
37
|
+
import json
|
|
38
|
+
import logging
|
|
39
|
+
import os
|
|
40
|
+
import time
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Any, Dict, List, Optional
|
|
43
|
+
|
|
44
|
+
from gateway.platforms.base import (
|
|
45
|
+
BasePlatformAdapter,
|
|
46
|
+
MessageEvent,
|
|
47
|
+
MessageType,
|
|
48
|
+
SendResult,
|
|
49
|
+
)
|
|
50
|
+
from gateway.config import Platform
|
|
51
|
+
|
|
52
|
+
from .kojee_tandem_core import (
|
|
53
|
+
EventDeduper,
|
|
54
|
+
build_send_command,
|
|
55
|
+
is_self_event,
|
|
56
|
+
load_self_session_id,
|
|
57
|
+
normalize_event,
|
|
58
|
+
parse_allowlist,
|
|
59
|
+
parse_send_envelope,
|
|
60
|
+
resolve_kojee_mcp_bin,
|
|
61
|
+
tandem_allowed,
|
|
62
|
+
verify_signature,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
logger = logging.getLogger(__name__)
|
|
66
|
+
|
|
67
|
+
PLATFORM_NAME = "kojee-tandem"
|
|
68
|
+
|
|
69
|
+
# Loopback listener defaults. 8645 sits next to Hermes's own webhook platform
|
|
70
|
+
# (8644) — same box, same loopback-only posture, never exposed publicly.
|
|
71
|
+
DEFAULT_LISTEN_HOST = "127.0.0.1"
|
|
72
|
+
DEFAULT_LISTEN_PORT = 8645
|
|
73
|
+
DEFAULT_LISTEN_PATH = "/kojee-tandem"
|
|
74
|
+
DEFAULT_SEND_TIMEOUT_S = 45.0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _env_or_extra(extra: dict, env_name: str, extra_key: str, default: str = "") -> str:
|
|
78
|
+
"""env var wins over config.yaml extra (Hermes precedence convention)."""
|
|
79
|
+
return os.getenv(env_name) or str(extra.get(extra_key, "") or "") or default
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class KojeeTandemAdapter(BasePlatformAdapter):
|
|
83
|
+
"""Tandem as a Hermes platform, driving the kojee-mcp daemon as a sidecar."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, config, **kwargs):
|
|
86
|
+
platform = Platform(PLATFORM_NAME)
|
|
87
|
+
super().__init__(config=config, platform=platform)
|
|
88
|
+
|
|
89
|
+
extra = getattr(config, "extra", {}) or {}
|
|
90
|
+
|
|
91
|
+
self.listen_host = _env_or_extra(
|
|
92
|
+
extra, "KOJEE_TANDEM_LISTEN_HOST", "listen_host", DEFAULT_LISTEN_HOST
|
|
93
|
+
)
|
|
94
|
+
try:
|
|
95
|
+
self.listen_port = int(
|
|
96
|
+
_env_or_extra(
|
|
97
|
+
extra, "KOJEE_TANDEM_LISTEN_PORT", "listen_port", str(DEFAULT_LISTEN_PORT)
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
except (TypeError, ValueError):
|
|
101
|
+
self.listen_port = DEFAULT_LISTEN_PORT
|
|
102
|
+
self.listen_path = _env_or_extra(
|
|
103
|
+
extra, "KOJEE_TANDEM_LISTEN_PATH", "listen_path", DEFAULT_LISTEN_PATH
|
|
104
|
+
)
|
|
105
|
+
if not self.listen_path.startswith("/"):
|
|
106
|
+
self.listen_path = "/" + self.listen_path
|
|
107
|
+
|
|
108
|
+
# The HMAC secret MUST equal the daemon's KOJEE_WEBHOOK_SECRET — both
|
|
109
|
+
# ends of one wire. Never logged.
|
|
110
|
+
self.webhook_secret = _env_or_extra(
|
|
111
|
+
extra, "KOJEE_WEBHOOK_SECRET", "webhook_secret"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Outbound (T10): shell the shipped `kojee-mcp send` CLI. The binary is
|
|
115
|
+
# KOJEE_MCP_BIN (absolute — fold B, login-shell PATH may not apply inside
|
|
116
|
+
# the gateway process) with a `kojee-mcp`-on-PATH fallback. No source
|
|
117
|
+
# checkout required anymore.
|
|
118
|
+
# TODO(hermes-installer): wizard writes KOJEE_MCP_BIN at install
|
|
119
|
+
# (src/wizard/installers/hermes.ts); until then the PATH fallback carries it.
|
|
120
|
+
self.kojee_mcp_bin = resolve_kojee_mcp_bin(os.environ)
|
|
121
|
+
self.kojee_dir = Path(
|
|
122
|
+
_env_or_extra(extra, "KOJEE_DIR", "kojee_dir", str(Path.home() / ".kojee"))
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Tandem allowlist (which tandems may inject). Empty = all tandems;
|
|
126
|
+
# per-USER authorization stays with Hermes via KOJEE_TANDEM_ALLOWED_USERS.
|
|
127
|
+
self.tandem_allowlist = parse_allowlist(
|
|
128
|
+
_env_or_extra(extra, "KOJEE_TANDEM_ALLOWLIST", "tandem_allowlist")
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Self-echo filters: derived session id (paired config) + optional principal.
|
|
132
|
+
self.self_principal = _env_or_extra(
|
|
133
|
+
extra, "KOJEE_TANDEM_SELF_PRINCIPAL", "self_principal"
|
|
134
|
+
) or None
|
|
135
|
+
self._self_session_id = load_self_session_id(self.kojee_dir)
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
self.send_timeout_s = float(
|
|
139
|
+
_env_or_extra(
|
|
140
|
+
extra, "KOJEE_TANDEM_SEND_TIMEOUT_S", "send_timeout_s",
|
|
141
|
+
str(DEFAULT_SEND_TIMEOUT_S),
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
except (TypeError, ValueError):
|
|
145
|
+
self.send_timeout_s = DEFAULT_SEND_TIMEOUT_S
|
|
146
|
+
|
|
147
|
+
self._deduper = EventDeduper()
|
|
148
|
+
self._runner = None # aiohttp.web.AppRunner
|
|
149
|
+
self._site = None # aiohttp.web.TCPSite
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def name(self) -> str:
|
|
153
|
+
return "Kojee Tandem"
|
|
154
|
+
|
|
155
|
+
# ── Connection lifecycle ────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
async def connect(self) -> bool:
|
|
158
|
+
"""Start the loopback webhook listener the kojee-mcp daemon POSTs to."""
|
|
159
|
+
if not self.webhook_secret:
|
|
160
|
+
logger.error("kojee-tandem: KOJEE_WEBHOOK_SECRET must be set (signed-only inbound)")
|
|
161
|
+
self._set_fatal_error(
|
|
162
|
+
"config_missing",
|
|
163
|
+
"KOJEE_WEBHOOK_SECRET must be set — the adapter never accepts unsigned events",
|
|
164
|
+
retryable=False,
|
|
165
|
+
)
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
logger.info(
|
|
169
|
+
"kojee-tandem: outbound via `%s send` (set KOJEE_MCP_BIN to an "
|
|
170
|
+
"absolute path if `kojee-mcp` is not on the gateway process PATH)",
|
|
171
|
+
self.kojee_mcp_bin,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
from aiohttp import web
|
|
176
|
+
except ImportError:
|
|
177
|
+
logger.error("kojee-tandem: aiohttp not available (pip install aiohttp)")
|
|
178
|
+
self._set_fatal_error(
|
|
179
|
+
"missing_dependency", "aiohttp is required", retryable=False
|
|
180
|
+
)
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
app = web.Application()
|
|
184
|
+
app.router.add_post(self.listen_path, self._handle_webhook)
|
|
185
|
+
app.router.add_get("/health", self._handle_health)
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
self._runner = web.AppRunner(app)
|
|
189
|
+
await self._runner.setup()
|
|
190
|
+
self._site = web.TCPSite(self._runner, self.listen_host, self.listen_port)
|
|
191
|
+
await self._site.start()
|
|
192
|
+
except OSError as e:
|
|
193
|
+
logger.error(
|
|
194
|
+
"kojee-tandem: cannot bind %s:%s — %s",
|
|
195
|
+
self.listen_host, self.listen_port, e,
|
|
196
|
+
)
|
|
197
|
+
self._set_fatal_error("bind_failed", str(e), retryable=True)
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
self._mark_connected()
|
|
201
|
+
logger.info(
|
|
202
|
+
"kojee-tandem: listening on http://%s:%s%s (point the kojee-mcp "
|
|
203
|
+
"daemon's KOJEE_WEBHOOK_URL here)",
|
|
204
|
+
self.listen_host, self.listen_port, self.listen_path,
|
|
205
|
+
)
|
|
206
|
+
return True
|
|
207
|
+
|
|
208
|
+
async def disconnect(self) -> None:
|
|
209
|
+
self._mark_disconnected()
|
|
210
|
+
if self._runner is not None:
|
|
211
|
+
try:
|
|
212
|
+
await self._runner.cleanup()
|
|
213
|
+
except Exception:
|
|
214
|
+
pass
|
|
215
|
+
self._runner = None
|
|
216
|
+
self._site = None
|
|
217
|
+
|
|
218
|
+
# ── Inbound: webhook → MessageEvent ─────────────────────────────────
|
|
219
|
+
|
|
220
|
+
async def _handle_health(self, request):
|
|
221
|
+
from aiohttp import web
|
|
222
|
+
return web.json_response({"ok": True, "platform": PLATFORM_NAME})
|
|
223
|
+
|
|
224
|
+
async def _handle_webhook(self, request):
|
|
225
|
+
"""One TandemEvent per POST (webhook-sink contract). Verify HMAC over
|
|
226
|
+
the RAW bytes, normalize, filter, inject. Filtered events are ACKed
|
|
227
|
+
with 200 — a 4xx would make the sink drop-as-permanent or retry-burn,
|
|
228
|
+
and redelivery on daemon restart is NORMAL (at-least-once)."""
|
|
229
|
+
from aiohttp import web
|
|
230
|
+
|
|
231
|
+
raw = await request.read()
|
|
232
|
+
if not verify_signature(request.headers, raw, self.webhook_secret):
|
|
233
|
+
# 401 is correct here: a bad signature is never OUR event.
|
|
234
|
+
# (4xx = permanent drop in the sink — desired for forged traffic.)
|
|
235
|
+
return web.json_response({"error": "invalid signature"}, status=401)
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
239
|
+
except (ValueError, UnicodeDecodeError):
|
|
240
|
+
return web.json_response({"error": "invalid json"}, status=400)
|
|
241
|
+
|
|
242
|
+
msg = normalize_event(payload)
|
|
243
|
+
if msg is None:
|
|
244
|
+
return web.json_response({"ok": True, "ignored": "not an injectable message"})
|
|
245
|
+
if self._deduper.seen_before(msg.event_id):
|
|
246
|
+
return web.json_response({"ok": True, "ignored": "duplicate"})
|
|
247
|
+
if not tandem_allowed(msg.chat_id, self.tandem_allowlist):
|
|
248
|
+
logger.debug("kojee-tandem: tandem %s not in allowlist — ignored", msg.chat_id)
|
|
249
|
+
return web.json_response({"ok": True, "ignored": "tandem not allowed"})
|
|
250
|
+
if is_self_event(msg, self._self_session_id, self.self_principal):
|
|
251
|
+
return web.json_response({"ok": True, "ignored": "self echo"})
|
|
252
|
+
|
|
253
|
+
source = self.build_source(
|
|
254
|
+
chat_id=msg.chat_id, # tandem_id = the chat id
|
|
255
|
+
chat_name=f"Tandem {msg.chat_id}",
|
|
256
|
+
chat_type="group", # tandems are multi-member rooms
|
|
257
|
+
user_id=msg.sender_id, # principal — Hermes authz key
|
|
258
|
+
user_name=msg.sender_name, # sender display
|
|
259
|
+
message_id=msg.message_id, # cursor
|
|
260
|
+
)
|
|
261
|
+
event = MessageEvent(
|
|
262
|
+
text=msg.text,
|
|
263
|
+
message_type=MessageType.TEXT,
|
|
264
|
+
source=source,
|
|
265
|
+
raw_message=payload,
|
|
266
|
+
message_id=msg.message_id, # cursor as the message id
|
|
267
|
+
reply_to_message_id=msg.reply_to,
|
|
268
|
+
)
|
|
269
|
+
# handle_message returns fast (spawns background tasks) — base.py:3839.
|
|
270
|
+
await self.handle_message(event)
|
|
271
|
+
return web.json_response({"ok": True})
|
|
272
|
+
|
|
273
|
+
# ── Outbound: send → `kojee-mcp send` CLI → tandem_send ─────────────
|
|
274
|
+
|
|
275
|
+
async def send(
|
|
276
|
+
self,
|
|
277
|
+
chat_id: str,
|
|
278
|
+
content: str,
|
|
279
|
+
reply_to: Optional[str] = None,
|
|
280
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
281
|
+
) -> SendResult:
|
|
282
|
+
return await _cli_send(
|
|
283
|
+
kojee_mcp_bin=self.kojee_mcp_bin,
|
|
284
|
+
kojee_dir=self.kojee_dir,
|
|
285
|
+
tandem_id=chat_id,
|
|
286
|
+
body=content,
|
|
287
|
+
reply_to=reply_to,
|
|
288
|
+
timeout_s=self.send_timeout_s,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
|
292
|
+
"""v1 no-op: the helper does not expose a Tandem typing call yet."""
|
|
293
|
+
pass
|
|
294
|
+
|
|
295
|
+
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
|
296
|
+
return {
|
|
297
|
+
"name": f"Tandem {chat_id}",
|
|
298
|
+
"type": "group",
|
|
299
|
+
"chat_id": chat_id,
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _send_env(kojee_dir: Path) -> Dict[str, str]:
|
|
304
|
+
"""Subprocess env for the `kojee-mcp send` CLI.
|
|
305
|
+
|
|
306
|
+
The CLI resolves paired credentials from ``os.homedir()/.kojee`` with no
|
|
307
|
+
flag/env override on the `send` sub-command. To honor a configured KOJEE_DIR
|
|
308
|
+
of the form ``<base>/.kojee`` we point HOME at ``<base>`` so the CLI's
|
|
309
|
+
``~/.kojee`` resolves to the configured dir; otherwise the inherited HOME
|
|
310
|
+
(default ``~/.kojee``) applies."""
|
|
311
|
+
env = dict(os.environ)
|
|
312
|
+
if kojee_dir.name == ".kojee":
|
|
313
|
+
env["HOME"] = str(kojee_dir.parent)
|
|
314
|
+
return env
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
async def _cli_send(
|
|
318
|
+
kojee_mcp_bin: str,
|
|
319
|
+
kojee_dir: Path,
|
|
320
|
+
tandem_id: str,
|
|
321
|
+
body: str,
|
|
322
|
+
reply_to: Optional[str],
|
|
323
|
+
timeout_s: float,
|
|
324
|
+
) -> SendResult:
|
|
325
|
+
"""Shell `kojee-mcp send <tandem_id> --body <text> [--reply-to]`; the CLI
|
|
326
|
+
signs with the same paired ~/.kojee creds + DPoP keystore the daemon uses
|
|
327
|
+
and prints one JSON envelope on stdout. Shared by the live adapter send()
|
|
328
|
+
and the standalone cron sender. No source checkout required (T10)."""
|
|
329
|
+
argv = build_send_command(kojee_mcp_bin, tandem_id, body, reply_to)
|
|
330
|
+
env = _send_env(kojee_dir)
|
|
331
|
+
|
|
332
|
+
try:
|
|
333
|
+
proc = await asyncio.create_subprocess_exec(
|
|
334
|
+
*argv,
|
|
335
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
336
|
+
stdout=asyncio.subprocess.PIPE,
|
|
337
|
+
stderr=asyncio.subprocess.PIPE,
|
|
338
|
+
env=env,
|
|
339
|
+
)
|
|
340
|
+
stdout_b, stderr_b = await asyncio.wait_for(
|
|
341
|
+
proc.communicate(), timeout=timeout_s
|
|
342
|
+
)
|
|
343
|
+
except asyncio.TimeoutError:
|
|
344
|
+
try:
|
|
345
|
+
proc.kill()
|
|
346
|
+
except ProcessLookupError:
|
|
347
|
+
pass
|
|
348
|
+
return SendResult(success=False, error=f"`kojee-mcp send` timed out after {timeout_s}s")
|
|
349
|
+
except FileNotFoundError:
|
|
350
|
+
return SendResult(
|
|
351
|
+
success=False,
|
|
352
|
+
error=(
|
|
353
|
+
f"`kojee-mcp` binary not found ({kojee_mcp_bin}) — set KOJEE_MCP_BIN "
|
|
354
|
+
"to an absolute path or put kojee-mcp on PATH"
|
|
355
|
+
),
|
|
356
|
+
)
|
|
357
|
+
except OSError as e:
|
|
358
|
+
return SendResult(success=False, error=f"failed to launch `kojee-mcp send`: {e}")
|
|
359
|
+
|
|
360
|
+
verdict = parse_send_envelope(stdout_b.decode("utf-8", errors="replace"))
|
|
361
|
+
if verdict["ok"]:
|
|
362
|
+
return SendResult(
|
|
363
|
+
success=True,
|
|
364
|
+
message_id=verdict["message_id"] or str(int(time.time() * 1000)),
|
|
365
|
+
)
|
|
366
|
+
stderr_tail = stderr_b.decode("utf-8", errors="replace").strip()[-300:]
|
|
367
|
+
error = verdict["error"] or "tandem_send failed"
|
|
368
|
+
if stderr_tail:
|
|
369
|
+
error = f"{error} | stderr: {stderr_tail}"
|
|
370
|
+
return SendResult(success=False, error=error)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
# ── Registration plumbing (mirrors plugins/platforms/irc/adapter.py) ────────
|
|
374
|
+
|
|
375
|
+
def check_requirements() -> bool:
|
|
376
|
+
"""Dependencies + minimal config: aiohttp importable and a secret set.
|
|
377
|
+
(Config-yaml-only setups validate via validate_config instead.)"""
|
|
378
|
+
try:
|
|
379
|
+
import aiohttp # noqa: F401
|
|
380
|
+
except ImportError:
|
|
381
|
+
return False
|
|
382
|
+
return bool(os.getenv("KOJEE_WEBHOOK_SECRET"))
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def validate_config(config) -> bool:
|
|
386
|
+
extra = getattr(config, "extra", {}) or {}
|
|
387
|
+
secret = os.getenv("KOJEE_WEBHOOK_SECRET") or extra.get("webhook_secret", "")
|
|
388
|
+
return bool(secret)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def is_connected(config) -> bool:
|
|
392
|
+
return validate_config(config)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _env_enablement() -> Optional[dict]:
|
|
396
|
+
"""Seed PlatformConfig.extra from env BEFORE adapter construction so
|
|
397
|
+
env-only setups surface in `hermes gateway status` (registry hook —
|
|
398
|
+
see ADDING_A_PLATFORM.md optional hooks)."""
|
|
399
|
+
secret = os.getenv("KOJEE_WEBHOOK_SECRET", "").strip()
|
|
400
|
+
if not secret:
|
|
401
|
+
return None
|
|
402
|
+
seed: dict = {"webhook_secret": secret}
|
|
403
|
+
for env_name, key in (
|
|
404
|
+
("KOJEE_TANDEM_LISTEN_HOST", "listen_host"),
|
|
405
|
+
("KOJEE_TANDEM_LISTEN_PORT", "listen_port"),
|
|
406
|
+
("KOJEE_TANDEM_LISTEN_PATH", "listen_path"),
|
|
407
|
+
("KOJEE_DIR", "kojee_dir"),
|
|
408
|
+
("KOJEE_TANDEM_ALLOWLIST", "tandem_allowlist"),
|
|
409
|
+
("KOJEE_TANDEM_SELF_PRINCIPAL", "self_principal"),
|
|
410
|
+
):
|
|
411
|
+
value = os.getenv(env_name, "").strip()
|
|
412
|
+
if value:
|
|
413
|
+
seed[key] = value
|
|
414
|
+
home = os.getenv("KOJEE_TANDEM_HOME_CHANNEL", "").strip()
|
|
415
|
+
if home:
|
|
416
|
+
# Handled by the core hook: becomes a HomeChannel on the PlatformConfig.
|
|
417
|
+
seed["home_channel"] = {"chat_id": home, "name": f"Tandem {home}"}
|
|
418
|
+
return seed
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
async def _standalone_send(
|
|
422
|
+
pconfig,
|
|
423
|
+
chat_id: str,
|
|
424
|
+
message: str,
|
|
425
|
+
*,
|
|
426
|
+
thread_id: Optional[str] = None,
|
|
427
|
+
media_files: Optional[List[str]] = None,
|
|
428
|
+
force_document: bool = False,
|
|
429
|
+
) -> Dict[str, Any]:
|
|
430
|
+
"""Out-of-process delivery for `deliver=kojee-tandem` cron jobs (the
|
|
431
|
+
standalone_sender_fn registry hook). Reuses the same `kojee-mcp send` CLI —
|
|
432
|
+
no live adapter, no source checkout needed. thread_id/media_files accepted
|
|
433
|
+
for signature parity; Tandem has no native thread/attachment primitive in v1."""
|
|
434
|
+
kojee_mcp_bin = resolve_kojee_mcp_bin(os.environ)
|
|
435
|
+
extra = getattr(pconfig, "extra", {}) or {}
|
|
436
|
+
kojee_dir = Path(os.getenv("KOJEE_DIR") or extra.get("kojee_dir", "") or (Path.home() / ".kojee"))
|
|
437
|
+
result = await _cli_send(
|
|
438
|
+
kojee_mcp_bin=kojee_mcp_bin,
|
|
439
|
+
kojee_dir=kojee_dir,
|
|
440
|
+
tandem_id=chat_id,
|
|
441
|
+
body=message,
|
|
442
|
+
reply_to=None,
|
|
443
|
+
timeout_s=DEFAULT_SEND_TIMEOUT_S,
|
|
444
|
+
)
|
|
445
|
+
if result.success:
|
|
446
|
+
return {"success": True, "message_id": result.message_id}
|
|
447
|
+
return {"error": result.error or "kojee-tandem standalone send failed"}
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def register(ctx):
|
|
451
|
+
"""Plugin entry point — called by the Hermes plugin system
|
|
452
|
+
(hermes_cli/plugins.py _load_plugin → register(ctx))."""
|
|
453
|
+
ctx.register_platform(
|
|
454
|
+
name=PLATFORM_NAME,
|
|
455
|
+
label="Kojee Tandem",
|
|
456
|
+
adapter_factory=lambda cfg: KojeeTandemAdapter(cfg),
|
|
457
|
+
check_fn=check_requirements,
|
|
458
|
+
validate_config=validate_config,
|
|
459
|
+
is_connected=is_connected,
|
|
460
|
+
required_env=["KOJEE_WEBHOOK_SECRET"],
|
|
461
|
+
install_hint=(
|
|
462
|
+
"pip install aiohttp; install + pair kojee-mcp (`npm i -g kojee-mcp` "
|
|
463
|
+
"or set KOJEE_MCP_BIN to its absolute path); set KOJEE_WEBHOOK_SECRET"
|
|
464
|
+
),
|
|
465
|
+
env_enablement_fn=_env_enablement,
|
|
466
|
+
cron_deliver_env_var="KOJEE_TANDEM_HOME_CHANNEL",
|
|
467
|
+
standalone_sender_fn=_standalone_send,
|
|
468
|
+
# Hermes-side per-user authorization (gateway _is_user_authorized):
|
|
469
|
+
# user_id is the Tandem principal (e.g. "user:daria@cohen.io").
|
|
470
|
+
allowed_users_env="KOJEE_TANDEM_ALLOWED_USERS",
|
|
471
|
+
allow_all_env="KOJEE_TANDEM_ALLOW_ALL_USERS",
|
|
472
|
+
emoji="🤝",
|
|
473
|
+
pii_safe=False,
|
|
474
|
+
allow_update_command=False,
|
|
475
|
+
platform_hint=(
|
|
476
|
+
"You are in a Kojee Tandem — a shared conversation that can include "
|
|
477
|
+
"humans and other agents. Messages render as plain text (no markdown "
|
|
478
|
+
"guarantees). Be concise. The chat id is the tandem id; replies go "
|
|
479
|
+
"to the whole tandem, not just the sender."
|
|
480
|
+
),
|
|
481
|
+
)
|