kojee-mcp 0.5.7 → 0.5.9

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/README.md CHANGED
@@ -1,11 +1,17 @@
1
1
  # kojee-mcp
2
2
 
3
- > **First time using Kojee Tandem in Claude Code?** See [docs/getting-started-tandem.md](docs/getting-started-tandem.md) for the 3-command setup + verification walkthrough.
3
+ > Published version **0.5.8**.
4
+ >
5
+ > **First time using Kojee Tandem in Claude Code?** Three commands get you from
6
+ > zero to a woken agent — see the [Quick start (Claude Code + Tandem)](#quick-start-claude-code--tandem)
7
+ > below, or [docs/getting-started-tandem.md](docs/getting-started-tandem.md) for
8
+ > the full walkthrough.
4
9
 
5
- There are two ways to connect Kojee to an MCP-capable agent:
10
+ There are three ways to connect Kojee to an MCP-capable agent:
6
11
 
7
- 1. **Mobile, web & desktop (recommended)** — paste the Kojee MCP URL into the app's "Add custom connector" dialog. The app handles OAuth login and consent. No local install. Works on Claude (web/desktop/iOS/Android) and ChatGPT (web/desktop/iOS/Android with Developer Mode enabled).
8
- 2. **Power user / local proxy with DPoP** run this stdio proxy locally with a gateway token. Strongest wire-level security (DPoP proof-of-possession, RFC 9449), but requires Node and a token you generate manually in the Kojee dashboard.
12
+ 1. **Mobile, web & desktop (recommended for chat clients)** — paste the Kojee MCP URL into the app's "Add custom connector" dialog. The app handles OAuth login and consent. No local install. Works on Claude (web/desktop/iOS/Android) and ChatGPT (web/desktop/iOS/Android with Developer Mode enabled).
13
+ 2. **Claude Code / Codex / Cursorthe local stdio proxy.** Run `kojee-mcp` locally; it holds a `gw_` gateway token + ES256 keypair and signs every request with DPoP (RFC 9449). The runtime-aware [`init` wizard](#quick-start-claude-code--tandem) wires it into your harness and sets up the wake path so an idle agent is woken by Tandem messages between turns. **This is the recommended path for agentic runtimes** and the focus of this README.
14
+ 3. **OpenClaw / Hermes — native Tandem channel plugins.** On those runtimes Tandem is a first-class channel (peer to Telegram/Discord), wired through a plugin that wraps the same gateway client — **not** MCP. See [Native gateway runtimes](#native-gateway-runtimes-openclaw--hermes).
9
15
 
10
16
  ## Mobile, Web & Desktop (Recommended)
11
17
 
@@ -30,92 +36,156 @@ The OAuth login + consent flow takes care of everything — no token paste, no c
30
36
  | Discovery | `https://api.kojee.ai/.well-known/oauth-protected-resource` |
31
37
  | Scopes | `mcp:tools`, `mcp:read` |
32
38
 
33
- ## Power User: Local Proxy with DPoP
39
+ ## Quick start (Claude Code + Tandem)
34
40
 
35
- The `kojee-mcp` stdio proxy holds a `gw_` gateway token + ES256 keypair locally and signs every request with DPoP (RFC 9449). This survives token-in-transit theft (TLS-terminating proxy logs, etc.) strictly stronger wire-level posture than bearer-only OAuth, but requires a long-lived token.
41
+ From zero to a woken agentthe recommended path for Claude Code:
36
42
 
37
- ### Claude Code / Claude Desktop
43
+ ```bash
44
+ # 1. Pair this machine + configure your runtime in one guided step.
45
+ # Run it in a terminal (TTY): it walks you through broker URL → auth
46
+ # (paste a token OR enter a pair code) → wake setup. Defaults to claude-code.
47
+ npx -y kojee-mcp@latest init
38
48
 
39
- ```json
40
- {
41
- "mcpServers": {
42
- "kojee": {
43
- "command": "npx",
44
- "args": ["kojee-mcp", "--token", "YOUR_TOKEN", "--url", "https://kojee.ai"]
45
- }
46
- }
47
- }
49
+ # 2. Confirm the wake path is healthy.
50
+ npx -y kojee-mcp@latest doctor
51
+
52
+ # 3. Start a fresh Claude Code session.
53
+ claude
48
54
  ```
49
55
 
50
- ### Generic MCP Client
56
+ `init` is a **guided wizard** when stdin is a TTY (and no `--runtime` flag is
57
+ given). It prompts for, in order:
51
58
 
52
- Any MCP client that supports stdio transports can use kojee-mcp:
59
+ 1. **Runtime** `claude-code` (default), `hermes`, `openclaw`, or `codex`.
60
+ 2. **Broker URL** — Enter accepts the default `https://rosie-staging.kojee.net`.
61
+ 3. **Auth** — paste an existing gateway token (**token mode**), or enter a
62
+ **pair code** from the dashboard (**pair mode** — runs the DPoP enrollment and
63
+ writes `~/.kojee/config.json` for you, so you don't run `pair` separately).
64
+ 4. **Webhook receiver** (codex/hermes/openclaw only) — Enter to skip and set it up later.
53
65
 
54
- ```bash
55
- npx kojee-mcp --token gw_abc123... --url https://kojee.ai
56
- ```
66
+ For non-interactive / CI use, pass flags instead (see
67
+ [Non-interactive `init`](#non-interactive-init-flags--ci) below). Either way the
68
+ final wake check is `kojee-mcp doctor`.
57
69
 
58
- Or install globally:
70
+ > **Already have a pair code and just want one command?**
71
+ > `npx -y kojee-mcp@latest init --pair-code YOUR-CODE` pairs **and** configures
72
+ > claude-code in a single non-interactive step (URL defaults to
73
+ > `https://rosie-staging.kojee.net`; override with `--url`).
59
74
 
60
- ```bash
61
- npm install -g kojee-mcp
62
- kojee-mcp --token gw_abc123... --url https://kojee.ai
63
- ```
75
+ ### What `init` writes (claude-code)
64
76
 
65
- ## CLI Flags
77
+ It adds the MCP entry to **every detected Claude installation**:
66
78
 
67
- | Flag | Required | Default | Description |
68
- |------|----------|---------|-------------|
69
- | `--token` | yes | -- | Your Kojee gateway token (starts with `gw_`) |
70
- | `--url` | yes | -- | Kojee broker URL (e.g. `https://kojee.ai`) |
71
- | `--keystore-path` | no | `~/.kojee/keypair.json` | Path for DPoP keypair storage |
79
+ - `~/.claude.json` MCP server entry (terminal `claude` CLI **and** Claude.app).
80
+ - `~/Library/Application Support/Claude/claude_desktop_config.json` (Claude.app on
81
+ macOS Windows / Linux paths in the spec) if present.
82
+ - `~/.claude/settings.json` the **Stop + UserPromptSubmit hooks** (terminal CLI
83
+ only; Claude.app agent mode reads MCP servers but not hooks).
84
+
85
+ The canonical MCP entry includes `env.KOJEE_RUNTIME=claude-code` so the proxy
86
+ identifies as Claude Code even when the desktop app strips environment variables
87
+ from MCP subprocesses. In **token mode** the entry launches the proxy with
88
+ `--token`/`--url` (it enrolls its own per-token keystore); in **pair mode** the
89
+ args stay `["kojee-mcp"]` and the proxy reads `~/.kojee/config.json` (mode 0600).
90
+ Existing entries (other MCP servers, other hooks like babysitter wrappers) are
91
+ preserved.
72
92
 
73
- ## Pair Mode (Tandem)
93
+ **Important for Claude.app users:** existing agent-mode sessions snapshot the MCP
94
+ config at creation time and won't pick up changes from `init`. Start a NEW session
95
+ (not a resumed one) to use the updated kojee config.
74
96
 
75
- If your token comes from the Kojee Tandem web wizard (a "pair code"), you can persist it for future runs:
97
+ To remove kojee from your runtime (runtime-aware uses the recorded runtime):
76
98
 
77
99
  ```bash
78
- # One-time setup:
79
- npx kojee-mcp pair ABCD-1234 --url https://rosie-server.kojee.net
80
- npx kojee-mcp init # adds the MCP entry to EVERY detected Claude installation
100
+ npx kojee-mcp init --uninstall
81
101
  ```
82
102
 
83
- `init` writes to both `~/.claude.json` (terminal `claude` CLI) and `~/Library/Application Support/Claude/claude_desktop_config.json` (Claude.app on macOS Windows / Linux paths in the spec) if either exists. The canonical MCP entry includes `env.KOJEE_RUNTIME=claude-code` so the proxy correctly identifies as Claude Code even when the desktop app strips environment variables from MCP subprocesses.
103
+ ### Verify the wake path — `kojee-mcp doctor`
84
104
 
85
- **Important for Claude.app users:** existing agent-mode sessions snapshot the MCP config at creation time and won't pick up changes from `init`. Start a NEW session (not a resumed one) to use the updated kojee config.
105
+ `doctor` is the one-command health check for the whole wake path. It derives the
106
+ same discovery key the hooks use, finds the running proxy, and walks every link
107
+ in order — paired config, proxy process alive, hook-server `/health` + `/status`,
108
+ the **SSE stream** (connected? heartbeat age? subscribed tandem count?), the
109
+ event log, and whether a **Monitor** is reading it — then prints a one-screen
110
+ `HEALTHY` / `DEGRADED` / `BROKEN` verdict **plus the exact wake recipe to spawn**.
111
+ It's runtime-aware (a codex install gets the codex webhook-sink + stop-hook
112
+ checks). Exit code is non-zero only when the verdict is `BROKEN`.
86
113
 
87
- Subsequent runs of `claude` will find kojee automatically. The proxy writes credentials to `~/.kojee/config.json` (mode 0600). Existing `--token` users are unaffected.
114
+ ```bash
115
+ npx kojee-mcp doctor
116
+ ```
117
+
118
+ ### CLI command reference
119
+
120
+ | Command | What it does |
121
+ |---|---|
122
+ | `kojee-mcp` *(default, no subcommand)* | Run the stdio MCP proxy. Token mode (`--token`/`--url`) or paired mode (reads `~/.kojee/config.json`). |
123
+ | `kojee-mcp init` | Runtime-aware setup wizard (guided in a TTY; flag-driven in CI). |
124
+ | `kojee-mcp pair <code> --url <broker>` | Pair this machine (DPoP enroll + write `~/.kojee/config.json`). |
125
+ | `kojee-mcp doctor` | Diagnose the wake path and print the exact wake recipe. |
126
+ | `kojee-mcp install-hooks` | Install/remove (`--uninstall`) just the Claude Code Stop + UserPromptSubmit hooks. |
127
+ | `kojee-mcp send <tandem_id> --body <text>` | Send a Tandem message from outside the proxy using paired creds (see [Local Send Control Surface](#local-send-control-surface-054)). |
128
+ | `kojee-mcp tail <path>` | Portable line-streamer (`tail -F` replacement) — the Monitor command on every platform. |
129
+ | `kojee-mcp hook --type <stop\|user-prompt-submit\|codex-stop>` | Internal hook entry point (Claude Code / Codex invoke it; you don't run it directly). |
130
+
131
+ ## Standalone pairing — `kojee-mcp pair`
132
+
133
+ The guided `init` pairs for you, but you can also pair as a separate step (then
134
+ run `init` to configure your runtime). Use this when scripting, or to re-pair
135
+ after a code expires:
88
136
 
89
- To remove kojee from Claude:
90
137
  ```bash
91
- npx kojee-mcp init --uninstall
138
+ npx kojee-mcp pair ABCD-1234 --url https://rosie-staging.kojee.net
92
139
  ```
93
140
 
141
+ This runs the DPoP enrollment and persists credentials to `~/.kojee/config.json`
142
+ (mode 0600) + the keypair to `~/.kojee/keypair.json`. Subsequent runs of the
143
+ proxy find them automatically. `--keystore-path` overrides the keypair location.
144
+
94
145
  ## Runtime-aware setup wizard (`init` selects the runtime)
95
146
 
96
147
  `kojee-mcp init` is a runtime-aware wizard. The runtime selector is its core — one
97
- proxy adapts to four harnesses. Bare `init` with no flag and no TTY still defaults
98
- to `claude-code` (unchanged), so existing setups have zero regression.
148
+ proxy adapts to four harnesses (`claude-code | hermes | openclaw | codex`). Bare
149
+ `init` with no flag and no TTY still defaults to `claude-code` (unchanged), so
150
+ existing setups have zero regression.
151
+
152
+ ### Non-interactive `init` (flags / CI)
153
+
154
+ Passing `--runtime` makes `init` non-interactive (the contract for CI). Auth and
155
+ webhook details come from flags:
99
156
 
100
157
  ```bash
101
- npx kojee-mcp init # interactive (prompts when stdin is a TTY)
102
- npx kojee-mcp init --runtime claude-code # Claude Code (default): MCP entry + Stop/UserPromptSubmit hooks
158
+ npx kojee-mcp init # interactive guided wizard (TTY, no --runtime)
159
+ npx kojee-mcp init --pair-code ABCD-1234 # pair + configure claude-code in one shot
160
+ npx kojee-mcp init --runtime claude-code --token gw_abc... --url https://rosie-staging.kojee.net
161
+ # claude-code, token mode (no pair step)
103
162
  npx kojee-mcp init --runtime codex --webhook-url https://your-receiver/kojee
104
163
  # Codex: writes ~/.codex/config.toml + a codex-stop hook
105
- npx kojee-mcp init --runtime hermes --webhook-url https://your-receiver/kojee
164
+ npx kojee-mcp init --runtime hermes --webhook-url https://your-receiver/kojee
106
165
  npx kojee-mcp init --runtime openclaw --webhook-url https://your-receiver/kojee
107
166
  # daemon runtimes: print/record the webhook-sink env to export
108
167
  ```
109
168
 
169
+ Auth flags (all runtimes): `--token <gw_…>` + `--url <broker>` (token mode), or
170
+ `--pair-code <code>` (runs the pair flow up front, then configures). `--url`
171
+ defaults to `https://rosie-staging.kojee.net`. A non-interactive `init` with **no**
172
+ credential at all (no paired config, no `--token`/`--pair-code`) errors and tells
173
+ you to pair first.
174
+
175
+ Per-runtime config-path / hooks-path overrides: `--config-path`, `--hooks-path`.
176
+
110
177
  - **codex** — writes `[mcp_servers.kojee]` (with `env.KOJEE_RUNTIME="codex"` + the
111
178
  webhook env) into `~/.codex/config.toml`, and a `codex-stop` Stop hook into
112
179
  `~/.codex/hooks.json`. A `KOJEE_WEBHOOK_SECRET` is generated if you don't supply
113
180
  one. Codex has no Claude-style channel injection, so its wake is the **webhook
114
181
  sink + a stop-hook fast PEEK + a model-chosen bounded `tandem_listen` (cap 8s,
115
182
  never a blanket long-poll)**. See `docs/RUNTIMES.md`.
116
- - **hermes / openclaw** — run the proxy as a daemon and consume the webhook sink;
117
- the wizard writes no config/hooks of ours, validates the webhook env, and prints
118
- + records the env to export (secret redacted).
183
+ - **hermes / openclaw** — these runtimes have **native channel plugins** (see
184
+ [Native gateway runtimes](#native-gateway-runtimes-openclaw--hermes)). When you
185
+ instead run the proxy as a **daemon** feeding a webhook receiver, `init --runtime
186
+ hermes|openclaw` writes no MCP-config/hooks of ours, validates the webhook env,
187
+ and prints + records the env to export (secret redacted) into a source-able
188
+ `~/.kojee/<runtime>.env`.
119
189
 
120
190
  `kojee-mcp init --uninstall` is runtime-aware (uses the recorded runtime when
121
191
  `--runtime` is omitted). `kojee-mcp doctor` is runtime-aware too.
@@ -124,6 +194,75 @@ npx kojee-mcp init --runtime openclaw --webhook-url https://your-receiver/kojee
124
194
  > adapter + wizard are unit/integration-tested only. Live-Codex end-to-end is
125
195
  > unverified — the wizard prints this note after a `--runtime codex` install.
126
196
 
197
+ ## Direct proxy invocation (advanced / generic MCP clients)
198
+
199
+ Most users let `init` write the launch command. If you manage the MCP config by
200
+ hand, the proxy runs in **token mode** with `--token` + `--url`:
201
+
202
+ ```json
203
+ {
204
+ "mcpServers": {
205
+ "kojee": {
206
+ "command": "npx",
207
+ "args": ["kojee-mcp", "--token", "YOUR_TOKEN", "--url", "https://rosie-staging.kojee.net"]
208
+ }
209
+ }
210
+ }
211
+ ```
212
+
213
+ Any stdio-capable MCP client works the same way:
214
+
215
+ ```bash
216
+ npx kojee-mcp --token gw_abc123... --url https://rosie-staging.kojee.net
217
+ # or install globally:
218
+ npm install -g kojee-mcp
219
+ kojee-mcp --token gw_abc123... --url https://rosie-staging.kojee.net
220
+ ```
221
+
222
+ Run bare (no `--token`) and the proxy uses **paired** credentials from
223
+ `~/.kojee/config.json` — that's what the claude-code MCP entry does in pair mode.
224
+
225
+ ### Proxy CLI flags (default command)
226
+
227
+ | Flag | Required | Default | Description |
228
+ |------|----------|---------|-------------|
229
+ | `--token` | token mode only | — | Your Kojee gateway token (starts with `gw_`). Omit to use paired credentials. |
230
+ | `--url` | with `--token` | paired `broker_url` | Kojee broker URL (e.g. `https://rosie-staging.kojee.net`). |
231
+ | `--keystore-path` | no | per-token path under `~/.kojee/` | Path for DPoP keypair storage. |
232
+
233
+ ## How each runtime reaches Tandem
234
+
235
+ | Runtime | How it connects | Wake path |
236
+ |---|---|---|
237
+ | **claude-code** | MCP stdio proxy (this package), wired by `init` | CC Channels (preview) · **Monitor** (`tail` of the event log) · **Stop/UserPromptSubmit hooks** — see below |
238
+ | **cursor** & other MCP clients | MCP stdio proxy | poll/`tandem_listen`; no CC channel injection |
239
+ | **codex** | MCP stdio proxy + `~/.codex` config/hook | webhook sink + stop-hook peek + bounded `tandem_listen` (cap 8s) |
240
+ | **openclaw** | **native channel plugin** (not MCP) | OpenClaw's own routing/sessions wake the agent on each Tandem event |
241
+ | **hermes** | **native channel plugin** (sidecar daemon + webhook) | Hermes gateway wakes the agent like a Telegram DM |
242
+
243
+ ## Native gateway runtimes (OpenClaw + Hermes)
244
+
245
+ On OpenClaw and Hermes, Tandem is a **native channel** — peer to Telegram/Discord
246
+ — not an MCP server. A plugin makes the agent *present* in its tandems whenever
247
+ the gateway is up: it wakes on Tandem messages through the runtime's own
248
+ routing/sessions and replies on the same channel. Both plugins wrap the **same**
249
+ gateway client + DPoP auth + SSE event stream this proxy uses (`kojee-mcp/lib`);
250
+ nothing is forked.
251
+
252
+ - **OpenClaw** — `integrations/openclaw-plugin/` (`openclaw-channel-kojee-tandem`).
253
+ Imports `kojee-mcp/lib` directly; one SSE subscription covers all joined
254
+ tandems; outbound `sendText` → `tandem_send`. Shares the daemon's keystore so
255
+ pairing once works for both. See its [README](integrations/openclaw-plugin/README.md).
256
+ - **Hermes** — `integrations/hermes-plugin/` (sidecar design v1). The kojee-mcp
257
+ proxy runs as a **daemon** and POSTs signed webhooks to a loopback adapter
258
+ listener; the adapter turns each event into a Hermes message. Configure the
259
+ daemon side with `kojee-mcp init --runtime hermes --webhook-url …`. See its
260
+ [README](integrations/hermes-plugin/README.md).
261
+
262
+ Pair once (`kojee-mcp pair`, or any kojee-mcp-attached session), and the agent
263
+ must be a member of the tandem (`tandem_join` once, or be invited); after that,
264
+ presence is zero-config — gateway up ⇒ agent reachable in the tandem.
265
+
127
266
  ## Claude Code Channels Support
128
267
 
129
268
  When this proxy detects it's running under Claude Code, it declares the `claude/channel` capability and pushes Tandem messages directly into the Claude session via `notifications/claude/channel`. Other MCP clients (Cursor, Codex, Openclaw, etc.) see no behavior change.
@@ -15,11 +15,17 @@ function defaultCodexHooksPath() {
15
15
  return path.join(kojeeHomeDir(), ".codex", "hooks.json");
16
16
  }
17
17
  var CODEX_STOP_HOOK_COMMAND = "npx -y kojee-mcp hook --type=codex-stop";
18
+ function codexArgsLiteral(token, url) {
19
+ if (token && url) {
20
+ return `["-y", "kojee-mcp", "--token", "${escapeTomlString(token)}", "--url", "${escapeTomlString(url)}"]`;
21
+ }
22
+ return '["-y", "kojee-mcp"]';
23
+ }
18
24
  function buildCodexMcpServerTable(opts) {
19
25
  return [
20
26
  "[mcp_servers.kojee]",
21
27
  'command = "npx"',
22
- 'args = ["-y", "kojee-mcp"]',
28
+ `args = ${codexArgsLiteral(opts.token, opts.url)}`,
23
29
  "",
24
30
  "[mcp_servers.kojee.env]",
25
31
  'KOJEE_RUNTIME = "codex"',
@@ -50,7 +56,14 @@ function writeCodexConfig(inputs) {
50
56
  toml = fs.readFileSync(configPath, "utf8");
51
57
  } catch {
52
58
  }
53
- toml = upsertKojeeTomlTables(toml, inputs.webhookUrl, inputs.webhookSecret, inputs.signatureEnv ?? []);
59
+ toml = upsertKojeeTomlTables(
60
+ toml,
61
+ inputs.webhookUrl,
62
+ inputs.webhookSecret,
63
+ inputs.signatureEnv ?? [],
64
+ inputs.token,
65
+ inputs.url
66
+ );
54
67
  writeFile600(configPath, toml);
55
68
  const hooks = readJson(hooksPath);
56
69
  hooks.hooks ??= {};
@@ -95,20 +108,22 @@ function removeCodexConfig(opts = {}) {
95
108
  }
96
109
  return result;
97
110
  }
98
- function upsertKojeeTomlTables(existing, webhookUrl, webhookSecret, signatureEnv = []) {
111
+ function upsertKojeeTomlTables(existing, webhookUrl, webhookSecret, signatureEnv = [], token, url) {
99
112
  const parsed = extractKojeeBlock(existing);
100
113
  if (!parsed) {
101
114
  const block = buildCodexMcpServerTable({
102
115
  webhookUrl,
103
116
  webhookSecret,
104
- ...signatureEnv.length > 0 ? { signatureEnv } : {}
117
+ ...signatureEnv.length > 0 ? { signatureEnv } : {},
118
+ ...token ? { token } : {},
119
+ ...url ? { url } : {}
105
120
  });
106
121
  const base2 = existing.replace(/\s*$/, "");
107
122
  return base2.length === 0 ? block + "\n" : base2 + "\n\n" + block + "\n";
108
123
  }
109
124
  const tableKeys = upsertKeyLines(parsed.tableKeys, [
110
125
  ["command", '"npx"'],
111
- ["args", '["-y", "kojee-mcp"]']
126
+ ["args", codexArgsLiteral(token, url)]
112
127
  ]);
113
128
  const envKeys = upsertKeyLines(parsed.envKeys, [
114
129
  ["KOJEE_RUNTIME", '"codex"'],
@@ -0,0 +1,68 @@
1
+ import {
2
+ secureDir,
3
+ secureFile
4
+ } from "./chunk-BLEGIR35.js";
5
+
6
+ // src/auth/keystore.ts
7
+ import { importJWK, exportJWK, generateKeyPair } from "jose";
8
+ import crypto from "crypto";
9
+ import fs from "fs";
10
+ import os from "os";
11
+ import path from "path";
12
+ var DEFAULT_PATH = path.join(os.homedir(), ".kojee", "keypair.json");
13
+ function defaultPairedKeystorePath() {
14
+ return path.join(os.homedir(), ".kojee", "keypair.json");
15
+ }
16
+ function deriveKeystorePath(token) {
17
+ const hash = crypto.createHash("sha256").update(token).digest("hex").slice(0, 12);
18
+ return path.join(os.homedir(), ".kojee", `keypair-${hash}.json`);
19
+ }
20
+ async function loadKeystore(keystorePath = DEFAULT_PATH, expectedBrokerUrl) {
21
+ if (!fs.existsSync(keystorePath)) {
22
+ return null;
23
+ }
24
+ const raw = fs.readFileSync(keystorePath, "utf-8");
25
+ const data = JSON.parse(raw);
26
+ if (expectedBrokerUrl && data.broker_url !== expectedBrokerUrl) {
27
+ return null;
28
+ }
29
+ const privateKey = await importJWK(data.private_key_jwk, "ES256");
30
+ return {
31
+ privateKey,
32
+ publicJwk: data.public_jwk,
33
+ kid: data.kid,
34
+ data
35
+ };
36
+ }
37
+ async function saveKeystore(privateKey, publicJwk, kid, brokerUrl, keystorePath = DEFAULT_PATH) {
38
+ const dir = path.dirname(keystorePath);
39
+ fs.mkdirSync(dir, { recursive: true, mode: 448 });
40
+ secureDir(dir);
41
+ const privateJwk = await exportJWK(privateKey);
42
+ const data = {
43
+ private_key_jwk: privateJwk,
44
+ kid,
45
+ broker_url: brokerUrl,
46
+ public_jwk: publicJwk,
47
+ enrolled_at: (/* @__PURE__ */ new Date()).toISOString()
48
+ };
49
+ fs.writeFileSync(keystorePath, JSON.stringify(data, null, 2), {
50
+ mode: 384
51
+ });
52
+ secureFile(keystorePath);
53
+ }
54
+ async function generateES256KeyPair() {
55
+ const { privateKey, publicKey } = await generateKeyPair("ES256");
56
+ const publicJwk = await exportJWK(publicKey);
57
+ publicJwk.kty = "EC";
58
+ publicJwk.crv = "P-256";
59
+ return { privateKey, publicJwk };
60
+ }
61
+
62
+ export {
63
+ defaultPairedKeystorePath,
64
+ deriveKeystorePath,
65
+ loadKeystore,
66
+ saveKeystore,
67
+ generateES256KeyPair
68
+ };
@@ -117,11 +117,22 @@ function startEventLog(opts) {
117
117
  function formatStatusLine(fields) {
118
118
  return `[${(/* @__PURE__ */ new Date()).toISOString()}] ${STATUS_LINE_PREFIX} ${fields}`;
119
119
  }
120
+ function oneLineField(value) {
121
+ return String(value ?? "").replace(/[\u0000-\u001f\u007f]/g, "");
122
+ }
120
123
  function formatLine(event) {
121
124
  const body = (event.content?.body ?? "").replace(/[\r\n]+/g, " ").slice(0, MAX_BODY_CHARS + 1);
122
125
  const truncated = body.length > MAX_BODY_CHARS;
123
- const safeBody = truncated ? body.slice(0, MAX_BODY_CHARS) + "\u2026" : body;
124
- return `[${event.time}] tandem=${event.tandem_id} from=${event.from.displayname} (${event.from.principal}) kind=${event.kind} cursor=${event.cursor} msg=${event.id}: ${safeBody}`;
126
+ const safeBody = oneLineField(truncated ? body.slice(0, MAX_BODY_CHARS) + "\u2026" : body);
127
+ const time = oneLineField(event.time);
128
+ const tandemId = oneLineField(event.tandem_id);
129
+ const displayname = oneLineField(event.from.displayname);
130
+ const principal = oneLineField(event.from.principal);
131
+ const kind = oneLineField(event.kind);
132
+ const cursor = oneLineField(event.cursor);
133
+ const id = oneLineField(event.id);
134
+ const wakeReason = event.wake_reason ? ` wake_reason=${oneLineField(event.wake_reason)}` : "";
135
+ return `[${time}] tandem=${tandemId} from=${displayname} (${principal}) kind=${kind}${wakeReason} cursor=${cursor} msg=${id}: ${safeBody}`;
125
136
  }
126
137
  function monitorHeartbeatPath(eventLogPath) {
127
138
  const dir = path.dirname(eventLogPath);
@@ -7,69 +7,9 @@ import {
7
7
  translateJsonRpcError,
8
8
  translateNetworkError
9
9
  } from "./chunk-LDZXU3DW.js";
10
- import {
11
- secureDir,
12
- secureFile
13
- } from "./chunk-BLEGIR35.js";
14
-
15
- // src/auth/keystore.ts
16
- import { importJWK, exportJWK, generateKeyPair } from "jose";
17
- import crypto from "crypto";
18
- import fs from "fs";
19
- import os from "os";
20
- import path from "path";
21
- var DEFAULT_PATH = path.join(os.homedir(), ".kojee", "keypair.json");
22
- function defaultPairedKeystorePath() {
23
- return path.join(os.homedir(), ".kojee", "keypair.json");
24
- }
25
- function deriveKeystorePath(token) {
26
- const hash = crypto.createHash("sha256").update(token).digest("hex").slice(0, 12);
27
- return path.join(os.homedir(), ".kojee", `keypair-${hash}.json`);
28
- }
29
- async function loadKeystore(keystorePath = DEFAULT_PATH, expectedBrokerUrl) {
30
- if (!fs.existsSync(keystorePath)) {
31
- return null;
32
- }
33
- const raw = fs.readFileSync(keystorePath, "utf-8");
34
- const data = JSON.parse(raw);
35
- if (expectedBrokerUrl && data.broker_url !== expectedBrokerUrl) {
36
- return null;
37
- }
38
- const privateKey = await importJWK(data.private_key_jwk, "ES256");
39
- return {
40
- privateKey,
41
- publicJwk: data.public_jwk,
42
- kid: data.kid,
43
- data
44
- };
45
- }
46
- async function saveKeystore(privateKey, publicJwk, kid, brokerUrl, keystorePath = DEFAULT_PATH) {
47
- const dir = path.dirname(keystorePath);
48
- fs.mkdirSync(dir, { recursive: true, mode: 448 });
49
- secureDir(dir);
50
- const privateJwk = await exportJWK(privateKey);
51
- const data = {
52
- private_key_jwk: privateJwk,
53
- kid,
54
- broker_url: brokerUrl,
55
- public_jwk: publicJwk,
56
- enrolled_at: (/* @__PURE__ */ new Date()).toISOString()
57
- };
58
- fs.writeFileSync(keystorePath, JSON.stringify(data, null, 2), {
59
- mode: 384
60
- });
61
- secureFile(keystorePath);
62
- }
63
- async function generateES256KeyPair() {
64
- const { privateKey, publicKey } = await generateKeyPair("ES256");
65
- const publicJwk = await exportJWK(publicKey);
66
- publicJwk.kty = "EC";
67
- publicJwk.crv = "P-256";
68
- return { privateKey, publicJwk };
69
- }
70
10
 
71
11
  // src/gateway-client.ts
72
- import crypto2 from "crypto";
12
+ import crypto from "crypto";
73
13
  var GatewayClient = class {
74
14
  constructor(brokerUrl, token, privateKey, kid, sessionId) {
75
15
  this.brokerUrl = brokerUrl;
@@ -105,7 +45,7 @@ var GatewayClient = class {
105
45
  * session_id = sha256(token + "proxy").slice(0, 16)
106
46
  */
107
47
  static deriveSessionId(token) {
108
- const hash = crypto2.createHash("sha256").update(token + "proxy").digest("hex");
48
+ const hash = crypto.createHash("sha256").update(token + "proxy").digest("hex");
109
49
  return hash.slice(0, 16);
110
50
  }
111
51
  /**
@@ -214,10 +154,5 @@ var GatewayClient = class {
214
154
  };
215
155
 
216
156
  export {
217
- defaultPairedKeystorePath,
218
- deriveKeystorePath,
219
- loadKeystore,
220
- saveKeystore,
221
- generateES256KeyPair,
222
157
  GatewayClient
223
158
  };
@@ -2,7 +2,7 @@ import {
2
2
  generateES256KeyPair,
3
3
  loadKeystore,
4
4
  saveKeystore
5
- } from "./chunk-3XDJOHMZ.js";
5
+ } from "./chunk-CH32ELFX.js";
6
6
 
7
7
  // src/auth/auth-module.ts
8
8
  import { calculateJwkThumbprint } from "jose";
@@ -231,6 +231,7 @@ async function consumeSse(body, opts, controller, state, onCursor) {
231
231
  const raw = JSON.parse(evt.data);
232
232
  const parsed = normalizeBackendEvent(raw, evt.event);
233
233
  onCursor(parsed.tandem_id, parsed.cursor);
234
+ if (parsed.wake === false) continue;
234
235
  opts.queue?.push(parsed);
235
236
  if (opts.eventLog) {
236
237
  try {
@@ -340,8 +341,13 @@ function resolveDisplayname(rawDisplay, principal) {
340
341
  }
341
342
  function normalizeBackendEvent(raw, sseEventType) {
342
343
  const obj = raw ?? {};
344
+ const rawWake = obj["wake"];
345
+ const wake = typeof rawWake === "boolean" ? rawWake : void 0;
346
+ const rawWakeReason = obj["wake_reason"];
347
+ const wakeReason = typeof rawWakeReason === "string" && rawWakeReason.trim() ? sanitizeDisplayname(rawWakeReason) : void 0;
348
+ const senderPresent = typeof obj === "object" && obj !== null && "sender" in obj;
343
349
  const maybeFrom = obj["from"];
344
- if (maybeFrom && typeof maybeFrom["principal"] === "string") {
350
+ if (!senderPresent && maybeFrom && typeof maybeFrom["principal"] === "string") {
345
351
  const canonical = raw;
346
352
  const canonicalPrincipal = sanitizeDisplayname(maybeFrom["principal"]);
347
353
  return {
@@ -350,7 +356,9 @@ function normalizeBackendEvent(raw, sseEventType) {
350
356
  ...canonical.from,
351
357
  principal: canonicalPrincipal,
352
358
  displayname: resolveDisplayname(maybeFrom["displayname"], canonicalPrincipal)
353
- }
359
+ },
360
+ ...wake !== void 0 ? { wake } : {},
361
+ ...wakeReason !== void 0 ? { wake_reason: wakeReason } : {}
354
362
  };
355
363
  }
356
364
  const sender = obj["sender"] ?? {};
@@ -383,7 +391,9 @@ function normalizeBackendEvent(raw, sseEventType) {
383
391
  },
384
392
  ...Array.isArray(obj["mentions"]) ? { mentions: obj["mentions"] } : {},
385
393
  ...obj["reply_to"] !== void 0 ? { reply_to: obj["reply_to"] } : {},
386
- ...severity ? { severity } : {}
394
+ ...severity ? { severity } : {},
395
+ ...wake !== void 0 ? { wake } : {},
396
+ ...wakeReason !== void 0 ? { wake_reason: wakeReason } : {}
387
397
  };
388
398
  }
389
399
 
@@ -0,0 +1,50 @@
1
+ import {
2
+ loadPairedConfig,
3
+ savePairedConfig
4
+ } from "./chunk-YH27B6SW.js";
5
+ import {
6
+ AuthModule
7
+ } from "./chunk-JXMVZEQ7.js";
8
+
9
+ // src/tandem/pair.ts
10
+ import fs from "fs";
11
+ async function runPair(opts) {
12
+ if (loadPairedConfig(opts.configPath) !== null) {
13
+ throw new Error(
14
+ `Already paired (config exists at ${opts.configPath}). To re-pair this slot, delete the config file first. For a second account, use --keystore-path /custom/keypair.json.`
15
+ );
16
+ }
17
+ try {
18
+ fs.unlinkSync(opts.keystorePath);
19
+ } catch {
20
+ }
21
+ const auth = new AuthModule(opts.code, opts.url, opts.keystorePath);
22
+ await auth.ensureEnrolled();
23
+ let principal_id;
24
+ let agent_id;
25
+ try {
26
+ const me = await fetch(`${opts.url}/api/v1/users/me/`, {
27
+ headers: { Authorization: `DPoP ${opts.code}` }
28
+ });
29
+ if (me.ok) {
30
+ const body = await me.json();
31
+ principal_id = body.principal_id;
32
+ agent_id = body.agent_id;
33
+ }
34
+ } catch {
35
+ }
36
+ const config = {
37
+ token: opts.code,
38
+ broker_url: opts.url,
39
+ paired_at: (/* @__PURE__ */ new Date()).toISOString(),
40
+ ...principal_id ? { principal_id } : {},
41
+ ...agent_id ? { agent_id } : {}
42
+ };
43
+ savePairedConfig(opts.configPath, config);
44
+ const who = principal_id && agent_id ? `${principal_id} (${agent_id})` : "(use kojee-mcp without args to start the proxy)";
45
+ return { message: `Paired as ${who}. Keypair: ${opts.keystorePath}. Config: ${opts.configPath}.` };
46
+ }
47
+
48
+ export {
49
+ runPair
50
+ };