kojee-mcp 0.7.5-staging.2 → 0.7.5-staging.20

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 (48) hide show
  1. package/README.md +27 -6
  2. package/dist/{cc-session-id-RURNIHHC.js → cc-session-id-5HHQJ7VC.js} +3 -1
  3. package/dist/{chunk-ZSHCHOPL.js → chunk-676WQSWZ.js} +163 -7
  4. package/dist/{chunk-FJUAMJHU.js → chunk-6NPMVZD7.js} +38 -3
  5. package/dist/{chunk-247WFMCJ.js → chunk-AGZ7ZGLY.js} +30 -3
  6. package/dist/chunk-AXBJQJKA.js +65 -0
  7. package/dist/{chunk-TCWIXG5C.js → chunk-CCVRXJJ2.js} +1 -1
  8. package/dist/{chunk-WBXU27BF.js → chunk-FNMKP34Y.js} +1 -1
  9. package/dist/chunk-GEGUWQYT.js +90 -0
  10. package/dist/{chunk-NE2F5CKS.js → chunk-LKGKDZSO.js} +9 -5
  11. package/dist/{chunk-XPIW4N55.js → chunk-N2YAZRAD.js} +178 -43
  12. package/dist/{chunk-LSUB6QMP.js → chunk-NIIBVKLP.js} +3 -0
  13. package/dist/chunk-P64UR3SE.js +86 -0
  14. package/dist/{chunk-A4IOKD4Z.js → chunk-QYZP6QT2.js} +2 -4
  15. package/dist/{chunk-KNEJTD6G.js → chunk-SZLNNMIZ.js} +6 -2
  16. package/dist/{chunk-CUOJHP2S.js → chunk-UGEBGCYU.js} +48 -12
  17. package/dist/{chunk-6XWTUDWW.js → chunk-WDWVBPXA.js} +7 -5
  18. package/dist/{chunk-TNZITJAB.js → chunk-WVQU3QQ7.js} +14 -4
  19. package/dist/cli.js +42 -23
  20. package/dist/{codex-prompt-submit-hook-J5PZEJSK.js → codex-prompt-submit-hook-UQJI6A54.js} +2 -2
  21. package/dist/{codex-stop-hook-32W4BIOM.js → codex-stop-hook-GTGRQNVV.js} +2 -2
  22. package/dist/{connect-handler-NZINEMG3.js → connect-handler-GTFXNZUC.js} +68 -9
  23. package/dist/{doctor-WUU5BVPT.js → doctor-54S7JGOE.js} +64 -5
  24. package/dist/{doctor-codex-PJFWIABF.js → doctor-codex-6H4OOS6O.js} +84 -10
  25. package/dist/doctor-hermes-KMHM27Y3.js +597 -0
  26. package/dist/{doctor-openclaw-LL6NSPLC.js → doctor-openclaw-B5KADVHD.js} +5 -1
  27. package/dist/{event-log-2NBJEIEP.js → event-log-M22X3FAI.js} +1 -1
  28. package/dist/{event-stream-ATKYXDBV.js → event-stream-5ZEL7FXN.js} +5 -1
  29. package/dist/flap-log-D4KG7RK4.js +23 -0
  30. package/dist/{gateway-client-CbM2OC_w.d.ts → gateway-client-CLmQghON.d.ts} +11 -0
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.js +6 -7
  33. package/dist/{install-JQNDGAAQ.js → install-GJVZEWLI.js} +8 -7
  34. package/dist/lib.d.ts +30 -1
  35. package/dist/lib.js +3 -3
  36. package/dist/plugins/hermes/adapter.py +7 -2
  37. package/dist/{registry-XJ67EO22.js → registry-MCJAVXJW.js} +36 -24
  38. package/dist/{send-cli-45RLGYIC.js → send-cli-WTY7H3BR.js} +2 -2
  39. package/dist/{server-DU3LFS32.js → server-VM3ZIGSZ.js} +2 -3
  40. package/dist/session-start-hook-VL3KYDTT.js +25 -0
  41. package/dist/{setup-handler-44ASXYMS.js → setup-handler-GNAIWAYU.js} +7 -7
  42. package/dist/{stop-hook-N6TX4YQT.js → stop-hook-BLPYRE7T.js} +5 -5
  43. package/dist/stream-watchdog-K2AWNCQW.js +64 -0
  44. package/dist/{tail-stream-G4WGTXDS.js → tail-stream-PQXEY3TI.js} +120 -9
  45. package/dist/{user-prompt-submit-hook-DTXXFDSD.js → user-prompt-submit-hook-YQXGE3JU.js} +3 -3
  46. package/package.json +2 -1
  47. package/dist/chunk-5DHIUN73.js +0 -25
  48. package/dist/chunk-TMCNB4JH.js +0 -33
package/README.md CHANGED
@@ -11,7 +11,7 @@ There are three ways to connect Kojee to an MCP-capable agent:
11
11
 
12
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
13
  2. **Claude Code / Codex / Cursor — the 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).
14
+ 3. **OpenClaw / Hermes — native gateway runtimes.** On those runtimes kojee is registered as the agent's MCP server (so the agent can explore + call kojee tools), and Tandem is a first-class channel wired through the gateway (an in-gateway MCP wake injector on OpenClaw; a sidecar daemon + channel plugin on Hermes). See [Native gateway runtimes](#native-gateway-runtimes-openclaw--hermes).
15
15
 
16
16
  ## Mobile, Web & Desktop (Recommended)
17
17
 
@@ -189,8 +189,11 @@ Per-runtime config-path / hooks-path overrides: `--config-path`, `--hooks-path`.
189
189
  injector — no webhook receiver, no `--webhook-url`.
190
190
  - **hermes** — a **daemon** feeding a webhook receiver. `init --runtime hermes`
191
191
  (or `connect --runtime hermes`) validates the webhook env and prints + records
192
- the env to export (secret redacted) into a source-able `~/.kojee/hermes.env`.
193
- `--webhook-url` is optional (defaults to the hermes-plugin loopback receiver).
192
+ the env (secret redacted) into a single-quoted `~/.kojee/hermes.env`, loaded
193
+ with `set -a; . ~/.kojee/hermes.env; set +a` (the installed service unit's
194
+ `ExecStart` uses this exact wrapper so every var is exported to the daemon on
195
+ both Linux and macOS). `--webhook-url` is optional (defaults to the
196
+ hermes-plugin loopback receiver).
194
197
 
195
198
  `kojee-mcp init --uninstall` is runtime-aware (uses the recorded runtime when
196
199
  `--runtime` is omitted). `kojee-mcp doctor` is runtime-aware too.
@@ -428,12 +431,14 @@ constant beside it):
428
431
  ```
429
432
  { type, id, tandem_id, cursor, time,
430
433
  from{ member_id, principal, agent_id?, session_id?, displayname },
431
- kind, content{ body, format? }, mentions?, reply_to?, severity? }
434
+ kind, content{ body, format? }, mentions?, reply_to?, severity?, wake?, wake_reason? }
432
435
  ```
433
436
 
434
437
  `from.session_id` and `severity` are present only when the wire carried them.
435
- There is no `sender` object — the body is fully normalized and carries
436
- `from.principal`.
438
+ `wake` is the backend's per-subscriber wake stamp (always `true` here — the sink
439
+ fires only for delivered wakes) and `wake_reason` names why it woke (e.g.
440
+ `direct`/`mention`), present when the backend stamped it. There is no `sender`
441
+ object — the body is fully normalized and carries `from.principal`.
437
442
  - **Verify** the `X-Kojee-Signature` header: it is the hex-encoded SHA-256 HMAC
438
443
  of the **raw request-body bytes**, keyed by `KOJEE_WEBHOOK_SECRET`. Recompute
439
444
  over the received bytes (before any re-serialization) and timing-safe compare;
@@ -571,6 +576,22 @@ npm run dev:stub
571
576
  # Stub listens on http://localhost:8765
572
577
  ```
573
578
 
579
+ ## Releases & staging versioning
580
+
581
+ Two channels, and one rule that keeps them clean:
582
+
583
+ - **`latest` (production)** — a **manual** release off `main` (`npm publish` → `latest`, or promote a verified staging build via `npm dist-tag add kojee-mcp@<ver> latest`).
584
+ - **`staging` (pre-release)** — **automatic** on every merge to the `staging` branch, via `.github/workflows/publish-staging.yml`, published under the `staging` dist-tag (never touches `latest`).
585
+
586
+ **How the staging version is computed** (`scripts/next-staging-version.mjs`):
587
+
588
+ 1. **base** = `package.json.version` with its **PATCH bumped once** — e.g. `0.7.4` → `0.7.5`.
589
+ 2. **counter** = `1 + highest existing <base>-staging.N on npm`. This is **per-base**, so it **RESETS to 1 automatically when the base progresses** (a brand-new base has no prior `-staging.N`).
590
+
591
+ So a run yields e.g. `0.7.5-staging.6`, and once `0.7.5` ships to `latest` and `package.json` moves to `0.7.5`, the next staging build resets to `0.7.6-staging.1`.
592
+
593
+ > ⚠️ **THE RULE: `package.json.version` must hold the LAST STABLE version — never a `-staging`/`-beta` pre-release.** The CI bumps the patch itself; if the file already carries the *next* version (or a `-beta` whose stable part is the next version), the base **double-bumps**. That is exactly how a `0.7.5-beta.1` base once published `0.7.6-staging.6` instead of the intended `0.7.5-staging.6`. Cutting a stable release is the *only* time `package.json.version` advances, and it advances to the version you just shipped to `latest`.
594
+
574
595
  ## How Approvals Work
575
596
 
576
597
  Some tools are governed by approval policies configured in the Kojee dashboard. When an agent calls a governed tool:
@@ -1,10 +1,12 @@
1
1
  import {
2
2
  SESSION_ID_ENV_VARS,
3
+ STABLE_RUNTIMES,
3
4
  resolveInstanceKey,
4
5
  resolveSharedSessionId
5
- } from "./chunk-KNEJTD6G.js";
6
+ } from "./chunk-SZLNNMIZ.js";
6
7
  export {
7
8
  SESSION_ID_ENV_VARS,
9
+ STABLE_RUNTIMES,
8
10
  resolveInstanceKey,
9
11
  resolveSharedSessionId
10
12
  };
@@ -1,17 +1,17 @@
1
1
  import {
2
2
  claudeCodeAdapter
3
- } from "./chunk-TCWIXG5C.js";
3
+ } from "./chunk-CCVRXJJ2.js";
4
4
  import {
5
5
  GatewayClient,
6
6
  applyStableSessionId
7
- } from "./chunk-247WFMCJ.js";
7
+ } from "./chunk-AGZ7ZGLY.js";
8
8
  import {
9
9
  AuthModule
10
10
  } from "./chunk-I67C2HYA.js";
11
11
  import {
12
12
  createMcpServer,
13
13
  startMcpServer
14
- } from "./chunk-A4IOKD4Z.js";
14
+ } from "./chunk-QYZP6QT2.js";
15
15
  import {
16
16
  findClaudeAncestorPid
17
17
  } from "./chunk-XJEBJIQE.js";
@@ -22,6 +22,10 @@ import os from "os";
22
22
  import path from "path";
23
23
 
24
24
  // src/tool-registry.ts
25
+ import { readFileSync, statSync } from "fs";
26
+ import { basename } from "path";
27
+ var UPLOAD_TOOL = "tandem_upload_attachment";
28
+ var MAX_UPLOAD_FILE_BYTES = 25 * 1024 * 1024;
25
29
  var ToolRegistry = class {
26
30
  constructor(gateway) {
27
31
  this.gateway = gateway;
@@ -68,8 +72,87 @@ var ToolRegistry = class {
68
72
  }
69
73
  this.tools.set(tool.name, tool);
70
74
  }
75
+ this.augmentUploadTool();
71
76
  console.error(`[tools] Registered ${this.tools.size} tools from gateway`);
72
77
  }
78
+ /**
79
+ * Teach the gateway's `tandem_upload_attachment` tool about a `file_path` arg
80
+ * that THIS proxy resolves in-process (see {@link resolveUploadFilePath}): the
81
+ * model emits a local path, the proxy reads the bytes and forwards them as
82
+ * `data_base64`, so a multi-MB image never has to be generated into the model's
83
+ * tool call. Purely additive — the original `data_base64` shape still works. A
84
+ * no-op if the gateway doesn't expose the tool (older backend).
85
+ */
86
+ augmentUploadTool() {
87
+ const tool = this.tools.get(UPLOAD_TOOL);
88
+ if (!tool) return;
89
+ const schema = { ...tool.inputSchema ?? {} };
90
+ const properties = { ...schema.properties ?? {} };
91
+ if ("file_path" in properties) return;
92
+ properties.file_path = {
93
+ type: "string",
94
+ description: "Absolute path to a local image file. Your local kojee-mcp-server reads the bytes and uploads them for you (downscaled server-side), so the image is NEVER placed in your prompt/tool-call. Prefer this over data_base64 for photos. Provide file_path OR data_base64, not both; filename is derived from the path when omitted."
95
+ };
96
+ schema.properties = properties;
97
+ if (Array.isArray(schema.required)) {
98
+ schema.required = schema.required.filter(
99
+ (k) => k !== "data_base64" && k !== "filename"
100
+ );
101
+ }
102
+ const dependent = {
103
+ ...schema.dependentRequired ?? {},
104
+ data_base64: ["filename"]
105
+ };
106
+ schema.dependentRequired = dependent;
107
+ this.tools.set(UPLOAD_TOOL, { ...tool, inputSchema: schema });
108
+ }
109
+ /**
110
+ * Resolve a `tandem_upload_attachment` call that used `file_path`: read the file
111
+ * locally, base64-encode the RAW bytes (Buffer→base64 is byte-exact — no text
112
+ * round-trip, so no U+FFFD corruption), and rewrite the args to the `data_base64`
113
+ * shape the gateway expects. Returns the args to forward, or throws with a clear
114
+ * message the caller surfaces as an isError result.
115
+ */
116
+ resolveUploadFilePath(args) {
117
+ const filePath = args.file_path;
118
+ const hasFilePath = typeof filePath === "string" && filePath.length > 0;
119
+ const hasBase64 = typeof args.data_base64 === "string" && args.data_base64.length > 0;
120
+ const hasFilename = typeof args.filename === "string" && args.filename.trim().length > 0;
121
+ if (!hasFilePath) {
122
+ if (hasBase64 && !hasFilename) {
123
+ throw new Error("filename is required when uploading with data_base64");
124
+ }
125
+ return args;
126
+ }
127
+ if (hasBase64) {
128
+ throw new Error("provide file_path OR data_base64, not both");
129
+ }
130
+ let size;
131
+ try {
132
+ const st = statSync(filePath);
133
+ if (!st.isFile()) throw new Error("not a regular file");
134
+ size = st.size;
135
+ } catch (err) {
136
+ throw new Error(`cannot read file_path '${filePath}': ${err?.message ?? String(err)}`);
137
+ }
138
+ if (size > MAX_UPLOAD_FILE_BYTES) {
139
+ throw new Error(
140
+ `file_path is ${size} bytes; exceeds the ${MAX_UPLOAD_FILE_BYTES}-byte upload cap`
141
+ );
142
+ }
143
+ const buf = readFileSync(filePath);
144
+ if (buf.length > MAX_UPLOAD_FILE_BYTES) {
145
+ throw new Error(
146
+ `file_path is ${buf.length} bytes; exceeds the ${MAX_UPLOAD_FILE_BYTES}-byte upload cap`
147
+ );
148
+ }
149
+ const { file_path: _drop, ...rest } = args;
150
+ return {
151
+ ...rest,
152
+ data_base64: buf.toString("base64"),
153
+ filename: hasFilename ? args.filename : basename(filePath)
154
+ };
155
+ }
73
156
  /**
74
157
  * Return all registered tools for the MCP ListTools response — gateway tools
75
158
  * first (minus any a local tool shadows), then local tools.
@@ -114,9 +197,20 @@ var ToolRegistry = class {
114
197
  isError: true
115
198
  };
116
199
  }
200
+ let outgoing = args;
201
+ if (name === UPLOAD_TOOL) {
202
+ try {
203
+ outgoing = this.resolveUploadFilePath(args);
204
+ } catch (err) {
205
+ return {
206
+ content: [{ type: "text", text: err?.message ?? String(err) }],
207
+ isError: true
208
+ };
209
+ }
210
+ }
117
211
  return this.gateway.sendRpc("tools/call", {
118
212
  name,
119
- arguments: args
213
+ arguments: outgoing
120
214
  });
121
215
  }
122
216
  /** Total number of registered tools (gateway + local, shadowed names once). */
@@ -295,9 +389,11 @@ async function startProxy(config) {
295
389
  }
296
390
  console.error(`[kojee-mcp] Tandem memberships: ${tandemMembershipCount === -1 ? "unknown" : tandemMembershipCount}`);
297
391
  let server;
298
- const { selectDelivery } = await import("./registry-XJ67EO22.js");
392
+ const isHeadlessDaemon = !!process.env["KOJEE_HEADLESS"] || !!process.env["KOJEE_WEBHOOK_EXPECTED"];
393
+ const { selectDelivery } = await import("./registry-MCJAVXJW.js");
299
394
  const delivery = selectDelivery(adapter.runtime, {
300
395
  supportsChannels: adapter.supportsChannels,
396
+ headless: isHeadlessDaemon,
301
397
  // Per-window delivered mirror for the tandem_pending tool (codex only —
302
398
  // other runtimes' deliveries ignore this even when passed).
303
399
  ...pendingState ? { onDelivered: (e) => pendingState.noteDelivered(e.tandem_id, e.cursor) } : {}
@@ -331,10 +427,21 @@ async function startProxy(config) {
331
427
  }
332
428
  for (const step of started.teardown) teardownSteps.push(step);
333
429
  } else {
430
+ if (process.env["KOJEE_WEBHOOK_EXPECTED"]) {
431
+ console.error(
432
+ "[kojee-mcp] webhook expected (KOJEE_WEBHOOK_EXPECTED=1) but no delivery configured \u2014 env injection likely failed; check the daemon env file (~/.kojee/hermes.env) / service wiring (ExecStart should be `/bin/sh -c 'set -a; . <envFile>; set +a; exec <bin>'`)."
433
+ );
434
+ }
334
435
  server = createMcpServer(registry, adapter, tandemMembershipCount);
335
436
  }
336
- process.stdin.on("end", () => shutdown("stdin end"));
337
- process.stdin.on("close", () => shutdown("stdin close"));
437
+ if (!isHeadlessDaemon) {
438
+ process.stdin.on("end", () => shutdown("stdin end"));
439
+ process.stdin.on("close", () => shutdown("stdin close"));
440
+ } else {
441
+ console.error(
442
+ "[kojee-mcp] headless daemon (KOJEE_HEADLESS / KOJEE_WEBHOOK_EXPECTED): stdin EOF ignored; lifecycle owned by the service manager (SIGTERM/SIGINT/SIGHUP)."
443
+ );
444
+ }
338
445
  process.on("SIGHUP", () => shutdown("SIGHUP"));
339
446
  process.on("SIGINT", () => shutdown("SIGINT"));
340
447
  process.on("SIGTERM", () => shutdown("SIGTERM"));
@@ -351,6 +458,55 @@ async function startProxy(config) {
351
458
  "[kojee-mcp] no resolvable Claude ancestor (Windows fallback) \u2014 parent-liveness watchdog not armed; stdin close/end + SIGHUP/SIGINT/SIGTERM cover shutdown, and per-window wake is preserved via the session-id discovery key."
352
459
  );
353
460
  }
461
+ if (isHeadlessDaemon && activeStreamHandle) {
462
+ const { createStreamWatchdog } = await import("./stream-watchdog-K2AWNCQW.js");
463
+ const { recordFlap } = await import("./flap-log-D4KG7RK4.js");
464
+ const handleForWatchdog = activeStreamHandle;
465
+ const bootAt = Date.now();
466
+ const streamWatchdog = createStreamWatchdog({
467
+ getState: () => {
468
+ const s = handleForWatchdog.getState();
469
+ return { connected: s.connected, lastEventAt: s.lastEventAt };
470
+ },
471
+ onUnhealthy: ({ disconnectedForMs, reason }) => {
472
+ recordFlap({
473
+ ts: Date.now(),
474
+ instanceKey,
475
+ reason,
476
+ disconnectedForMs,
477
+ uptimeMs: Date.now() - bootAt
478
+ });
479
+ console.error(
480
+ `[kojee-mcp] stream-liveness watchdog: ${reason} \u2014 self-exiting(1); the service manager (Restart=always) will start a fresh process. Repeated exits are recorded and surfaced by \`kojee-mcp doctor\` (flap detection).`
481
+ );
482
+ process.exit(1);
483
+ }
484
+ });
485
+ streamWatchdog.start();
486
+ teardownSteps.push(() => streamWatchdog.stop());
487
+ }
488
+ if (!isHeadlessDaemon && activeStreamHandle) {
489
+ const { createStreamWatchdog } = await import("./stream-watchdog-K2AWNCQW.js");
490
+ const handleForWatchdog = activeStreamHandle;
491
+ const streamWatchdog = createStreamWatchdog({
492
+ getState: () => {
493
+ const s = handleForWatchdog.getState();
494
+ return { connected: s.connected, lastEventAt: s.lastEventAt };
495
+ },
496
+ reArmAfterUnhealthy: true,
497
+ onUnhealthy: ({ reason }) => {
498
+ console.error(
499
+ `[kojee-mcp] stream-liveness watchdog: ${reason} \u2014 reconnecting in place (no supervisor on the stdio path; recovering the wedged stream).`
500
+ );
501
+ try {
502
+ handleForWatchdog.reconnect();
503
+ } catch {
504
+ }
505
+ }
506
+ });
507
+ streamWatchdog.start();
508
+ teardownSteps.push(() => streamWatchdog.stop());
509
+ }
354
510
  await startMcpServer(server);
355
511
  }
356
512
  async function enrollAndDiscover(config, keystorePath, isRetry = false) {
@@ -1,7 +1,32 @@
1
+ // src/version.ts
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { fileURLToPath } from "url";
5
+ var FALLBACK_VERSION = "0.0.0-unknown";
6
+ function resolveVersion() {
7
+ try {
8
+ const here = path.dirname(fileURLToPath(import.meta.url));
9
+ const parsed = JSON.parse(
10
+ fs.readFileSync(path.join(here, "..", "package.json"), "utf8")
11
+ );
12
+ return typeof parsed?.version === "string" && parsed.version ? parsed.version : FALLBACK_VERSION;
13
+ } catch (err) {
14
+ process.stderr.write(
15
+ `kojee-mcp: could not resolve version from package.json, falling back to ${FALLBACK_VERSION}: ${String(err)}
16
+ `
17
+ );
18
+ return FALLBACK_VERSION;
19
+ }
20
+ }
21
+ var VERSION = resolveVersion();
22
+
1
23
  // src/tandem/recipe.ts
2
24
  var SEND_BODY_PARAM = "body";
25
+ function monitorPackageSpec() {
26
+ return VERSION && VERSION !== "0.0.0-unknown" ? `kojee-mcp@${VERSION}` : "kojee-mcp";
27
+ }
3
28
  function buildMonitorCommand(logPath) {
4
- return `npx kojee-mcp tail "${logPath}"`;
29
+ return `npx ${monitorPackageSpec()} tail --wake-only "${logPath}"`;
5
30
  }
6
31
  function buildMonitorSpawn(logPath) {
7
32
  return `Monitor(command=\`${buildMonitorCommand(logPath)}\`, persistent=true, description="kojee Tandem events")`;
@@ -15,26 +40,36 @@ function buildReplyRecipe(event) {
15
40
  function buildCatchUpNote() {
16
41
  return "Cursors are allocated per-Tandem, and the event-log interleaves every subscribed room into one stream \u2014 so track the last `cursor=<n>` you saw keyed by the line's `tandem=<id>` field, and only treat a jump WITHIN the same tandem_id as a gap (a jump between lines from different rooms is normal interleaving, not a missed message). If the cursor jumps within one tandem, or the log resets (the proxy caps and truncates the messages log in place on overflow \u2014 a `status=rotated` line is recorded in the status sibling), you may have missed messages: catch up with tandem_messages(tandem_id, since=<last cursor you saw for THAT tandem>).";
17
42
  }
43
+ function buildSessionStartReminder(source) {
44
+ const freshProcess = source === "startup" || source === "resume";
45
+ const catchUp = "Catch up on anything sent while your context was away: " + buildCatchUpNote();
46
+ if (!freshProcess) {
47
+ return "[kojee] Context reset \u2014 your kojee Tandem connection and wake watcher from this process are still running; do NOT spawn another watcher (a second one would double-deliver every wake). " + catchUp;
48
+ }
49
+ return "[kojee] New session \u2014 reconnect to Tandem before anything else. FIRST ACTION this turn, before other work: ensure your kojee wake watcher is running \u2014 if you don't already have one, spawn it now by running the exact Monitor(command=`\u2026 kojee-mcp tail \u2026`, persistent=true) call given in your kojee MCP server instructions (they carry the version-pinned command + your live event-log path). Without it you receive no Tandem wakes. THEN " + catchUp;
50
+ }
18
51
  function buildCondensedTandemRules() {
19
52
  return "Tandem ground rules: (1) catch up before you speak \u2014 fetch messages since your last cursor (cursors are per-Tandem); never answer from a stale view. (2) close every loop \u2014 taking / done (with evidence) / blocked, said out loud; silence is a bug on a team. (3) your seat_name IS your room identity \u2014 keep it stable across sessions and reconnects. (4) plan your wake path per task and set your wake filter deliberately (heads-down \u2192 mentions; waiting on one reply \u2192 listen; co-working \u2192 all), and tell the room your posture WHEN IT CHANGES \u2014 skip the check-in ceremony when you are merely re-joining/reconnecting with the same posture. (5) send all replies and check-ins as kind=message \u2014 never kind=status; status is lifecycle-only (joined/left, system-generated).";
20
53
  }
21
54
  function buildMonitorNudge(logPath) {
22
55
  return `[kojee] Tandem events are being logged but no Monitor is reading them \u2014 you may be missing wake notifications. Spawn the watcher once: ${buildMonitorSpawn(logPath)}. Then ${buildReplyRecipe()}.`;
23
56
  }
24
- var WEBHOOK_BODY_SHAPE = "{ type, id, tandem_id, cursor, time, from{ member_id, principal, agent_id?, session_id?, displayname }, kind, content{ body, format? }, mentions?, reply_to?, severity? }";
57
+ var WEBHOOK_BODY_SHAPE = "{ type, id, tandem_id, cursor, time, from{ member_id, principal, agent_id?, session_id?, displayname }, kind, content{ body, format? }, mentions?, reply_to?, severity?, wake?, wake_reason? }";
25
58
  function buildWebhookReceiverNote(sig) {
26
59
  const header = sig?.header ?? "X-Kojee-Signature";
27
60
  const prefix = sig?.prefix ?? "";
28
61
  const digestDesc = prefix ? `the literal prefix \`${prefix}\` followed by the hex-encoded SHA-256 HMAC` : "the hex-encoded SHA-256 HMAC";
29
- return "Webhook sink (optional, OFF unless KOJEE_WEBHOOK_URL + KOJEE_WEBHOOK_SECRET are set): the proxy POSTs every Tandem event as JSON to your endpoint. The body is the canonical normalized TandemEvent \u2014 " + WEBHOOK_BODY_SHAPE + ` \u2014 where from.session_id and severity are present only when the wire carried them (the body is fully normalized: it carries from.principal, never the raw backend sender envelope). To build a receiver: (1) verify the ${header} header \u2014 it is ${digestDesc} of the RAW request body bytes keyed by your KOJEE_WEBHOOK_SECRET; recompute over the received bytes and timing-safe compare, reject mismatches. (2) Dedupe by message_id \u2014 the body's \`id\`, also in the X-Kojee-Delivery header: delivery is AT-LEAST-ONCE (the proxy replays backlog from the cursor on restart), so the same event may arrive more than once \u2014 there is no exactly-once promise.`;
62
+ return "Webhook sink (optional, OFF unless KOJEE_WEBHOOK_URL + KOJEE_WEBHOOK_SECRET are set): the proxy POSTs every Tandem event as JSON to your endpoint. The body is the canonical normalized TandemEvent \u2014 " + WEBHOOK_BODY_SHAPE + ` \u2014 where from.session_id and severity are present only when the wire carried them (the body is fully normalized: it carries from.principal, never the raw backend sender envelope). \`wake\` is the backend's per-subscriber wake stamp (always true here \u2014 the sink fires only for delivered wakes) and \`wake_reason\` names WHY it woke (e.g. direct/mention), present when the backend stamped it. To build a receiver: (1) verify the ${header} header \u2014 it is ${digestDesc} of the RAW request body bytes keyed by your KOJEE_WEBHOOK_SECRET; recompute over the received bytes and timing-safe compare, reject mismatches. (2) Dedupe by message_id \u2014 the body's \`id\`, also in the X-Kojee-Delivery header: delivery is AT-LEAST-ONCE (the proxy replays backlog from the cursor on restart), so the same event may arrive more than once \u2014 there is no exactly-once promise.`;
30
63
  }
31
64
  var CODEX_LISTEN_CAP_MS = 8e3;
32
65
  var CODEX_WAKE_BELL = "[kojee] Tandem events may be pending. Call tandem_pending now and drain each room it lists (tandem_messages(id, since=cursor)), then reply in the room with a normal message (kind=message \u2014 never status). If it returns none for you, ignore this.";
33
66
 
34
67
  export {
68
+ VERSION,
35
69
  buildMonitorSpawn,
36
70
  buildReplyRecipe,
37
71
  buildCatchUpNote,
72
+ buildSessionStartReminder,
38
73
  buildCondensedTandemRules,
39
74
  buildMonitorNudge,
40
75
  buildWebhookReceiverNote,
@@ -13,10 +13,13 @@ import {
13
13
  } from "./chunk-PPTKGWFF.js";
14
14
  import {
15
15
  resolveInstanceKey
16
- } from "./chunk-KNEJTD6G.js";
16
+ } from "./chunk-SZLNNMIZ.js";
17
17
 
18
18
  // src/gateway-client.ts
19
19
  import crypto from "crypto";
20
+ import zlib from "zlib";
21
+ import { promisify } from "util";
22
+ var gunzipAsync = promisify(zlib.gunzip);
20
23
  var GatewayClient = class {
21
24
  constructor(brokerUrl, token, privateKey, kid, sessionId) {
22
25
  this.brokerUrl = brokerUrl;
@@ -117,7 +120,7 @@ var GatewayClient = class {
117
120
  isError: true
118
121
  };
119
122
  }
120
- const rpcResponse = await response.json();
123
+ const rpcResponse = await this.readJsonBody(response);
121
124
  if (rpcResponse.error) {
122
125
  return translateJsonRpcError(rpcResponse.error);
123
126
  }
@@ -154,6 +157,14 @@ var GatewayClient = class {
154
157
  );
155
158
  return {
156
159
  "Content-Type": "application/json",
160
+ // Accept gzip (bandwidth win) but DECOMPRESS EXPLICITLY on read (readJsonBody),
161
+ // rather than relying on the HTTP client's transparent decompression — that
162
+ // was inconsistent across the two undici versions in play (Node-builtin fetch
163
+ // here vs the npm undici used by the SSE stream), which let raw gzip bytes
164
+ // (`1f 8b 08…`) reach JSON.parse and wedge every in-session read (incident
165
+ // 2026-09-22, hot-fixed first with `identity`). We request ONLY gzip (never
166
+ // br/deflate) so readJsonBody's gzip-magic check fully covers what's on the wire.
167
+ "Accept-Encoding": "gzip",
157
168
  Authorization: `DPoP ${this.token}`,
158
169
  DPoP: proof,
159
170
  "Mcp-Session-Id": getSessionId()
@@ -187,11 +198,27 @@ var GatewayClient = class {
187
198
  }
188
199
  async tryParseErrorBody(response) {
189
200
  try {
190
- return await response.json();
201
+ return await this.readJsonBody(response);
191
202
  } catch {
192
203
  return null;
193
204
  }
194
205
  }
206
+ /**
207
+ * Read + JSON-parse a response body, decompressing gzip EXPLICITLY. The gateway
208
+ * may return `Content-Encoding: gzip`; transparent decompression by the HTTP
209
+ * client proved unreliable in the long-lived in-session context (two undici
210
+ * versions in play), so we detect the gzip magic (0x1f 0x8b) on the raw bytes
211
+ * and gunzip ourselves. Plain (already-decompressed or identity) bodies pass
212
+ * through untouched — valid JSON never starts with 0x1f, so there's no false
213
+ * positive. Version-agnostic: correct whether or not the client transparently
214
+ * decompressed.
215
+ */
216
+ async readJsonBody(response) {
217
+ const buf = Buffer.from(await response.arrayBuffer());
218
+ const isGzip = buf.length >= 2 && buf[0] === 31 && buf[1] === 139;
219
+ const text = (isGzip ? await gunzipAsync(buf) : buf).toString("utf8");
220
+ return JSON.parse(text);
221
+ }
195
222
  };
196
223
 
197
224
  // src/runtime/stable-session.ts
@@ -0,0 +1,65 @@
1
+ // src/hooks/hook-command.ts
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { fileURLToPath } from "url";
5
+ function npxHookCommand(type) {
6
+ return `npx -y kojee-mcp hook --type=${type}`;
7
+ }
8
+ var NPX_CACHE_RE = /[\\/]_npx[\\/]/;
9
+ function resolveCliEntry(moduleUrl = import.meta.url) {
10
+ try {
11
+ const here = fileURLToPath(moduleUrl);
12
+ const candidate = path.join(path.dirname(here), "cli.js");
13
+ if (!fs.existsSync(candidate)) return null;
14
+ if (NPX_CACHE_RE.test(candidate)) return null;
15
+ return candidate;
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+ function stableExecPath(execPath = process.execPath) {
21
+ const m = /^(.*)\/Cellar\/node(?:@[^/]+)?\/[^/]+\/bin\/node$/.exec(execPath);
22
+ if (!m || !m[1]) return execPath;
23
+ const alias = path.join(m[1], "bin", "node");
24
+ try {
25
+ if (!fs.existsSync(alias)) return execPath;
26
+ const real = fs.realpathSync(alias);
27
+ return /[\\/]Cellar[\\/]node(@[^/]+)?[\\/]/.test(real) ? alias : execPath;
28
+ } catch {
29
+ return execPath;
30
+ }
31
+ }
32
+ function buildHookCommand(type, opts = {}) {
33
+ const cliEntry = opts.cliEntry !== void 0 ? opts.cliEntry : resolveCliEntry();
34
+ if (!cliEntry) return npxHookCommand(type);
35
+ const execPath = stableExecPath(opts.execPath ?? process.execPath);
36
+ return `"${execPath}" "${cliEntry}" hook --type=${type}`;
37
+ }
38
+ var PINNED_HOOK_RE = /^"[^"]+" "[^"]+" hook --type=(stop|user-prompt-submit|session-start|codex-stop|codex-prompt-submit)$/;
39
+ function isKojeeHookCommand(command) {
40
+ if (command.startsWith("npx -y kojee-mcp hook --type=")) return true;
41
+ return PINNED_HOOK_RE.test(command);
42
+ }
43
+ function classifyHookCommand(command, deps = {}) {
44
+ if (command.startsWith("npx -y kojee-mcp hook --type=")) return "npx";
45
+ if (!PINNED_HOOK_RE.test(command)) return "foreign";
46
+ const paths = [...command.matchAll(/"([^"]+)"/g)].map((m) => m[1] ?? "");
47
+ const cliPath = paths[1] ?? "";
48
+ if (NPX_CACHE_RE.test(cliPath)) return "pinned-ephemeral";
49
+ const exists = deps.exists ?? ((p) => {
50
+ try {
51
+ return fs.existsSync(p);
52
+ } catch {
53
+ return true;
54
+ }
55
+ });
56
+ if (cliPath && !exists(cliPath)) return "pinned-broken";
57
+ return "pinned-durable";
58
+ }
59
+
60
+ export {
61
+ npxHookCommand,
62
+ buildHookCommand,
63
+ isKojeeHookCommand,
64
+ classifyHookCommand
65
+ };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  buildReplyRecipe
3
- } from "./chunk-FJUAMJHU.js";
3
+ } from "./chunk-6NPMVZD7.js";
4
4
 
5
5
  // src/adapters/claude-code.ts
6
6
  function computeSeverity(event) {
@@ -7,7 +7,7 @@ import {
7
7
  } from "./chunk-XJEBJIQE.js";
8
8
  import {
9
9
  resolveSharedSessionId
10
- } from "./chunk-KNEJTD6G.js";
10
+ } from "./chunk-SZLNNMIZ.js";
11
11
 
12
12
  // src/hooks/discovery.ts
13
13
  async function resolveHookDiscovery(stdinSessionId, deps = {}) {
@@ -0,0 +1,90 @@
1
+ // src/wizard/capabilities/hermes-mcp-config.ts
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { parseDocument, isMap } from "yaml";
5
+ var HERMES_MCP_SERVER_NAME = "kojee";
6
+ var HERMES_INSTANCE_KEY = "hermes-gateway";
7
+ var HERMES_MCP_CONNECT_TIMEOUT_S = 120;
8
+ function defaultHermesConfigPath(homeDir) {
9
+ return path.join(homeDir, ".hermes", "config.yaml");
10
+ }
11
+ function buildKojeeEntry(binPath) {
12
+ return {
13
+ command: binPath,
14
+ args: [],
15
+ env: {
16
+ KOJEE_RUNTIME: "hermes",
17
+ KOJEE_INSTANCE: HERMES_INSTANCE_KEY,
18
+ // Vanilla tools-only proxy: NEVER a second webhook delivery (see header).
19
+ KOJEE_WEBHOOK_URL: ""
20
+ },
21
+ connect_timeout: HERMES_MCP_CONNECT_TIMEOUT_S
22
+ };
23
+ }
24
+ function resolveMode(filePath) {
25
+ try {
26
+ return fs.statSync(filePath).mode & 511;
27
+ } catch {
28
+ return 384;
29
+ }
30
+ }
31
+ function atomicWrite(filePath, content, mode) {
32
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
33
+ const tmp = `${filePath}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
34
+ fs.writeFileSync(tmp, content, { mode });
35
+ fs.renameSync(tmp, filePath);
36
+ }
37
+ function backupSuffix(now) {
38
+ return (now ? now() : /* @__PURE__ */ new Date()).toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
39
+ }
40
+ function writeHermesMcpConfig(configPath, opts) {
41
+ let raw = null;
42
+ try {
43
+ raw = fs.readFileSync(configPath, "utf8");
44
+ } catch {
45
+ raw = null;
46
+ }
47
+ let backedUp;
48
+ let doc = parseDocument("");
49
+ if (raw !== null && raw.trim() !== "") {
50
+ const parsed = parseDocument(raw);
51
+ if (parsed.errors.length > 0) {
52
+ backedUp = `${configPath}.corrupt-${backupSuffix(opts.now)}`;
53
+ fs.copyFileSync(configPath, backedUp);
54
+ } else {
55
+ doc = parsed;
56
+ }
57
+ }
58
+ if (doc.hasIn(["mcp_servers"]) && !isMap(doc.getIn(["mcp_servers"], true))) {
59
+ doc.deleteIn(["mcp_servers"]);
60
+ }
61
+ doc.setIn(["mcp_servers", HERMES_MCP_SERVER_NAME], buildKojeeEntry(opts.binPath));
62
+ atomicWrite(configPath, String(doc), resolveMode(configPath));
63
+ return { configPath, ...backedUp ? { backedUp } : {} };
64
+ }
65
+ function removeHermesMcpServer(configPath) {
66
+ let raw;
67
+ try {
68
+ raw = fs.readFileSync(configPath, "utf8");
69
+ } catch {
70
+ return false;
71
+ }
72
+ const doc = parseDocument(raw);
73
+ if (doc.errors.length > 0) return false;
74
+ if (!doc.hasIn(["mcp_servers", HERMES_MCP_SERVER_NAME])) return false;
75
+ doc.deleteIn(["mcp_servers", HERMES_MCP_SERVER_NAME]);
76
+ const servers = doc.getIn(["mcp_servers"], true);
77
+ if (isMap(servers) && servers.items.length === 0) {
78
+ doc.deleteIn(["mcp_servers"]);
79
+ }
80
+ atomicWrite(configPath, String(doc), resolveMode(configPath));
81
+ return true;
82
+ }
83
+
84
+ export {
85
+ HERMES_MCP_SERVER_NAME,
86
+ HERMES_INSTANCE_KEY,
87
+ defaultHermesConfigPath,
88
+ writeHermesMcpConfig,
89
+ removeHermesMcpServer
90
+ };
@@ -87,18 +87,22 @@ function createCachedOpenclawWakeResolver(opts = {}) {
87
87
  return resolution;
88
88
  };
89
89
  }
90
- async function postOpenclawWake(target, body, deps = {}) {
90
+ function openclawAgentUrl(wakeUrl) {
91
+ return wakeUrl.replace(/\/wake$/, "/agent");
92
+ }
93
+ async function postOpenclawAgent(target, body, deps = {}) {
91
94
  const doFetch = deps.fetch ?? fetch;
95
+ const url = openclawAgentUrl(target.url);
92
96
  const controller = new AbortController();
93
97
  const timer = setTimeout(() => controller.abort(), deps.timeoutMs ?? 4e3);
94
98
  try {
95
- const res = await doFetch(target.url, {
99
+ const res = await doFetch(url, {
96
100
  method: "POST",
97
101
  headers: { "content-type": "application/json", authorization: `Bearer ${target.token}` },
98
- body: JSON.stringify(body),
102
+ body: JSON.stringify({ name: "Kojee", wakeMode: "now", ...body }),
99
103
  signal: controller.signal
100
104
  });
101
- return res.ok ? { ok: true, status: res.status } : { ok: false, status: res.status, error: `wake POST ${res.status}` };
105
+ return res.ok ? { ok: true, status: res.status } : { ok: false, status: res.status, error: `agent POST ${res.status}` };
102
106
  } catch (err) {
103
107
  return { ok: false, error: err.message };
104
108
  } finally {
@@ -109,5 +113,5 @@ async function postOpenclawWake(target, body, deps = {}) {
109
113
  export {
110
114
  resolveOpenclawWakeTarget,
111
115
  createCachedOpenclawWakeResolver,
112
- postOpenclawWake
116
+ postOpenclawAgent
113
117
  };