kojee-mcp 0.7.4 → 0.7.5-beta.1

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
@@ -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.
@@ -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-SRLD2UY7.js";
6
7
  export {
7
8
  SESSION_ID_ENV_VARS,
9
+ STABLE_RUNTIMES,
8
10
  resolveInstanceKey,
9
11
  resolveSharedSessionId
10
12
  };
@@ -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-SRLD2UY7.js";
11
11
 
12
12
  // src/hooks/discovery.ts
13
13
  async function resolveHookDiscovery(stdinSessionId, deps = {}) {
@@ -4,7 +4,7 @@ import {
4
4
  import {
5
5
  GatewayClient,
6
6
  applyStableSessionId
7
- } from "./chunk-247WFMCJ.js";
7
+ } from "./chunk-TBVJOXIR.js";
8
8
  import {
9
9
  AuthModule
10
10
  } from "./chunk-I67C2HYA.js";
@@ -295,7 +295,7 @@ async function startProxy(config) {
295
295
  }
296
296
  console.error(`[kojee-mcp] Tandem memberships: ${tandemMembershipCount === -1 ? "unknown" : tandemMembershipCount}`);
297
297
  let server;
298
- const { selectDelivery } = await import("./registry-XJ67EO22.js");
298
+ const { selectDelivery } = await import("./registry-DKWVVXJM.js");
299
299
  const delivery = selectDelivery(adapter.runtime, {
300
300
  supportsChannels: adapter.supportsChannels,
301
301
  // Per-window delivered mirror for the tandem_pending tool (codex only —
@@ -331,10 +331,22 @@ async function startProxy(config) {
331
331
  }
332
332
  for (const step of started.teardown) teardownSteps.push(step);
333
333
  } else {
334
+ if (process.env["KOJEE_WEBHOOK_EXPECTED"]) {
335
+ console.error(
336
+ "[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>'`)."
337
+ );
338
+ }
334
339
  server = createMcpServer(registry, adapter, tandemMembershipCount);
335
340
  }
336
- process.stdin.on("end", () => shutdown("stdin end"));
337
- process.stdin.on("close", () => shutdown("stdin close"));
341
+ const isHeadlessDaemon = !!process.env["KOJEE_WEBHOOK_EXPECTED"];
342
+ if (!isHeadlessDaemon) {
343
+ process.stdin.on("end", () => shutdown("stdin end"));
344
+ process.stdin.on("close", () => shutdown("stdin close"));
345
+ } else {
346
+ console.error(
347
+ "[kojee-mcp] headless daemon (KOJEE_WEBHOOK_EXPECTED=1): stdin EOF ignored; lifecycle owned by the service manager (SIGTERM/SIGINT/SIGHUP)."
348
+ );
349
+ }
338
350
  process.on("SIGHUP", () => shutdown("SIGHUP"));
339
351
  process.on("SIGINT", () => shutdown("SIGINT"));
340
352
  process.on("SIGTERM", () => shutdown("SIGTERM"));
@@ -10,6 +10,12 @@ import {
10
10
  removeOpenclawMcpServer,
11
11
  writeOpenclawMcpConfig
12
12
  } from "./chunk-E6WMFMM2.js";
13
+ import {
14
+ HERMES_INSTANCE_KEY,
15
+ defaultHermesConfigPath,
16
+ removeHermesMcpServer,
17
+ writeHermesMcpConfig
18
+ } from "./chunk-GEGUWQYT.js";
13
19
  import {
14
20
  WIZARD_RUNTIMES,
15
21
  isWizardRuntime
@@ -350,11 +356,11 @@ function writeWebhookSecretBothSides(opts) {
350
356
  ["KOJEE_RUNTIME", opts.runtime],
351
357
  ["KOJEE_WEBHOOK_URL", opts.webhookUrl],
352
358
  ["KOJEE_WEBHOOK_SECRET", secret],
353
- ...opts.signatureEnv ?? []
359
+ ...opts.signatureEnv ?? [],
360
+ ...opts.daemonExtraEnv ?? []
354
361
  ];
355
362
  upsertEnvFile(opts.daemonEnvPath, daemonVars, {
356
- exportPrefix: true,
357
- header: `# kojee daemon env for runtime=${opts.runtime} (source this before starting the daemon)`
363
+ header: `# kojee daemon env for runtime=${opts.runtime} (load with: set -a; . <this file>; set +a)`
358
364
  });
359
365
  secureFile(opts.daemonEnvPath);
360
366
  const adapterVars = [
@@ -418,6 +424,13 @@ function installBundledPayload(opts) {
418
424
  // src/wizard/service.ts
419
425
  import fs5 from "fs";
420
426
  import path5 from "path";
427
+ function daemonSourceExecCommand(spec) {
428
+ const target = spec.nodePath ? `${spec.nodePath} ${spec.binPath}` : spec.binPath;
429
+ return `set -a; . ${spec.envFile}; set +a; exec ${target}`;
430
+ }
431
+ function xmlEscape(value) {
432
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
433
+ }
421
434
  function systemdUnit(spec) {
422
435
  return [
423
436
  "[Unit]",
@@ -427,9 +440,14 @@ function systemdUnit(spec) {
427
440
  "",
428
441
  "[Service]",
429
442
  "Type=simple",
430
- `EnvironmentFile=${spec.envFile}`,
431
- `Environment=KOJEE_RUNTIME=${spec.runtime}`,
432
- `ExecStart=${spec.binPath}`,
443
+ // §2b DISCRIMINATOR: KOJEE_WEBHOOK_EXPECTED lives in the WRAPPER env (this
444
+ // Environment= line), NOT in the sourced env file — a same-class drop would
445
+ // otherwise kill the marker too. It lets startProxy tell the "webhook was
446
+ // meant to be here" daemon apart from the tools-only child (which never sets
447
+ // it) when the sourced URL is absent → null delivery.
448
+ `Environment=KOJEE_RUNTIME=${spec.runtime} KOJEE_WEBHOOK_EXPECTED=1`,
449
+ // §2a: source the env file inside a shell wrapper (replaces EnvironmentFile=).
450
+ `ExecStart=/bin/sh -c '${daemonSourceExecCommand(spec)}'`,
433
451
  "Restart=always",
434
452
  "RestartSec=5",
435
453
  "StandardOutput=journal",
@@ -450,11 +468,19 @@ function launchdPlist(spec, label) {
450
468
  '<plist version="1.0">',
451
469
  "<dict>",
452
470
  " <key>Label</key>",
453
- ` <string>${label}</string>`,
471
+ ` <string>${xmlEscape(label)}</string>`,
472
+ // §2a: same source-wrapper as the systemd unit so macOS actually loads the
473
+ // env file (the plist historically never did). The secret is never inlined
474
+ // here — it stays in the 0o600 sourced file.
454
475
  " <key>ProgramArguments</key>",
455
- ` <array><string>${spec.binPath}</string></array>`,
476
+ " <array>",
477
+ " <string>/bin/sh</string>",
478
+ " <string>-c</string>",
479
+ ` <string>${xmlEscape(daemonSourceExecCommand(spec))}</string>`,
480
+ " </array>",
481
+ // §2b: KOJEE_WEBHOOK_EXPECTED in the wrapper env (NOT the sourced file).
456
482
  " <key>EnvironmentVariables</key>",
457
- ` <dict><key>KOJEE_RUNTIME</key><string>${spec.runtime}</string></dict>`,
483
+ ` <dict><key>KOJEE_RUNTIME</key><string>${xmlEscape(spec.runtime)}</string><key>KOJEE_WEBHOOK_EXPECTED</key><string>1</string></dict>`,
458
484
  " <key>RunAtLoad</key><true/>",
459
485
  " <key>KeepAlive</key><true/>",
460
486
  "</dict>",
@@ -529,11 +555,14 @@ function installHermes(inp) {
529
555
  adapterEnvPath: adapterEnv,
530
556
  webhookUrl: inp.webhookUrl,
531
557
  runtime: "hermes",
558
+ daemonExtraEnv: [["KOJEE_INSTANCE", HERMES_INSTANCE_KEY]],
532
559
  ...inp.mcpDir ? { mcpDir: inp.mcpDir } : {},
533
560
  ...inp.allowAllUsers ? { allowAllUsers: true } : {},
534
561
  ...inp.signatureEnv && inp.signatureEnv.length > 0 ? { signatureEnv: inp.signatureEnv } : {},
535
562
  ...inp.webhookSecret ? { existingSecret: inp.webhookSecret } : {}
536
563
  });
564
+ const hermesConfig = defaultHermesConfigPath(inp.homeDir);
565
+ const mcp = writeHermesMcpConfig(hermesConfig, { binPath: inp.binPath });
537
566
  const staged = inp.skipPayload ? [] : installBundledPayload({
538
567
  runtime: "hermes",
539
568
  baseDir: inp.payloadBaseDir,
@@ -543,6 +572,7 @@ function installHermes(inp) {
543
572
  const svc = writeService(inp.platform, {
544
573
  serviceName: "kojee-hermes",
545
574
  binPath: inp.binPath,
575
+ ...inp.nodePath ? { nodePath: inp.nodePath } : {},
546
576
  envFile: daemonEnv,
547
577
  runtime: "hermes",
548
578
  homeDir: inp.homeDir
@@ -552,6 +582,8 @@ function installHermes(inp) {
552
582
  ` secret: ${sec.reused ? "reused existing" : "generated"} (matched on daemon + adapter env)`,
553
583
  ` daemon env: ${daemonEnv}`,
554
584
  ` adapter env: ${adapterEnv}`,
585
+ ` mcp config: ${hermesConfig} (mcp_servers.kojee \u2014 agent explores kojee via mcp_kojee_*)`,
586
+ ...mcp.backedUp ? [` NOTE: prior ${hermesConfig} was unparseable; backed up to ${mcp.backedUp} before writing.`] : [],
555
587
  inp.skipPayload ? ` plugin: SKIPPED \u2014 no bundled payload (run \`npm run build\` to stage dist/plugins/hermes)` : ` plugin: ${pluginDir} (${staged.length} files)`,
556
588
  svc.supported ? ` service: ${svc.unitPath}` : ` service: manual \u2014 ${svc.note ?? "unsupported platform"}`,
557
589
  // NB: the "Receiver contract" section is intentionally NOT printed here.
@@ -559,11 +591,18 @@ function installHermes(inp) {
559
591
  // every daemon runtime) on the same URL-present path before this output, so
560
592
  // emitting it here too would print the contract twice.
561
593
  "",
562
- "Next:",
563
- ` - start the daemon service: ${svc.activateCmd}`,
564
- " - reload the Hermes gateway to load the plugin: systemctl --user restart hermes-gateway (or: hermes gateway restart)",
565
- " - join a tandem once: tandem_join <tandem_id>",
566
- " - verify: kojee-mcp doctor"
594
+ "Next \u2014 ACTIVATE (owner steps; the receiver never binds until you do these, in order):",
595
+ " 1. enable the Tandem receiver plugin (WITHOUT this, the gateway restart loads nothing):",
596
+ " hermes plugins enable kojee-tandem",
597
+ " 2. restart the gateway to bind the receiver (:8645) AND load the kojee MCP tools:",
598
+ " hermes gateway restart (or: systemctl --user restart hermes-gateway)",
599
+ ` 3. start the pusher daemon: ${svc.activateCmd}`,
600
+ " 4. verify end-to-end (should read HEALTHY): kojee-mcp doctor --runtime hermes",
601
+ "",
602
+ " Notes:",
603
+ " - the agent joins rooms from its own tools: mcp_kojee_tandem_join <tandem_id>",
604
+ " - if tools don't register: ensure Hermes has the MCP extra",
605
+ ' (cd ~/.hermes/hermes-agent && uv pip install -e ".[mcp]") and re-check connect_timeout.'
567
606
  ];
568
607
  return {
569
608
  runtime: "hermes",
@@ -923,7 +962,7 @@ function buildDaemonEnvBlock(runtime, wh, envFile) {
923
962
  lines.push(` export KOJEE_WEBHOOK_SECRET=<generated; in ${envFile}>`);
924
963
  for (const [k, v] of wh.signatureEnv) lines.push(` export ${k}=${shellSingleQuote(v)}`);
925
964
  lines.push(` (validated: ${wh.redactedSummary})`);
926
- lines.push(` source ${envFile}`);
965
+ lines.push(` set -a; . ${envFile}; set +a`);
927
966
  } else {
928
967
  lines.push("Set the daemon env (no receiver URL supplied yet):");
929
968
  lines.push(` export KOJEE_RUNTIME=${shellSingleQuote(runtime)}`);
@@ -941,7 +980,18 @@ function distDir() {
941
980
  }
942
981
  function resolveBinPath() {
943
982
  const entry = process.argv[1];
944
- return entry && entry.length > 0 ? entry : "kojee-mcp";
983
+ if (!entry || entry.length === 0) return "kojee-mcp";
984
+ try {
985
+ return fs6.realpathSync(entry);
986
+ } catch {
987
+ return entry;
988
+ }
989
+ }
990
+ function resolveDaemonExec() {
991
+ const nodePath = process.execPath;
992
+ const binPath = resolveBinPath();
993
+ const ephemeral = /[\\/]_npx[\\/]/.test(binPath);
994
+ return { nodePath, binPath, ephemeral };
945
995
  }
946
996
  function configureHermes(opts) {
947
997
  const runtime = "hermes";
@@ -955,7 +1005,10 @@ function configureHermes(opts) {
955
1005
  opts = effectiveOpts;
956
1006
  const lines = [];
957
1007
  lines.push(`Configured runtime: ${runtime}`);
958
- lines.push("Wake mode: webhook sink (daemon-consumed). NO MCP-config file, NO hooks written.");
1008
+ lines.push(
1009
+ "Wake mode: webhook sink (daemon-consumed). ALSO registers kojee as the Hermes agent's",
1010
+ "MCP server (mcp_servers.kojee) so the agent can explore kojee tools. NO hooks written."
1011
+ );
959
1012
  lines.push("");
960
1013
  if (!suppliedUrl) {
961
1014
  lines.push(
@@ -981,11 +1034,13 @@ function configureHermes(opts) {
981
1034
  }
982
1035
  const env = opts.env ?? process.env;
983
1036
  const suppliedSecret = (opts.webhookSecret ?? env["KOJEE_WEBHOOK_SECRET"] ?? "").trim();
1037
+ const daemonExec = resolveDaemonExec();
984
1038
  const install = installHermes({
985
1039
  homeDir: home,
986
1040
  payloadBaseDir: base,
987
1041
  platform: process.platform,
988
- binPath: resolveBinPath(),
1042
+ binPath: daemonExec.binPath,
1043
+ nodePath: daemonExec.nodePath,
989
1044
  webhookUrl: wh.url,
990
1045
  ...suppliedSecret ? { webhookSecret: suppliedSecret } : {},
991
1046
  ...wh.signatureEnv.length > 0 ? { signatureEnv: wh.signatureEnv } : {},
@@ -998,6 +1053,15 @@ function configureHermes(opts) {
998
1053
  return { runtime, output: lines.join("\n"), exitCode: install.exitCode };
999
1054
  }
1000
1055
  recordRuntime(runtime);
1056
+ if (daemonExec.ephemeral) {
1057
+ lines.push(
1058
+ "\u26A0 DURABILITY: this ran from an ephemeral npx cache, so the daemon points at a",
1059
+ " path npx will eventually delete (the daemon would then fail to start).",
1060
+ " For a durable daemon, install globally and re-run init:",
1061
+ " npm i -g kojee-mcp && kojee-mcp init --runtime hermes",
1062
+ ""
1063
+ );
1064
+ }
1001
1065
  const envFile = path7.join(home, ".kojee", "hermes.env");
1002
1066
  lines.push(...buildDaemonEnvBlock(runtime, wh, envFile));
1003
1067
  lines.push("");
@@ -1062,8 +1126,12 @@ async function runWizardUninstall(runtime, opts) {
1062
1126
  const un = uninstallOpenclaw({ openclawConfigPath: openclawConfigPath(opts) });
1063
1127
  lines.push(un.output);
1064
1128
  } else {
1065
- lines.push(" (hermes writes no MCP-config or hooks \u2014 nothing to tear down.");
1066
- lines.push(" Stop the daemon and unset KOJEE_WEBHOOK_URL/SECRET to disable the sink.)");
1129
+ const hermesConfig = defaultHermesConfigPath(kojeeHomeDir());
1130
+ const removedMcp = removeHermesMcpServer(hermesConfig);
1131
+ lines.push(
1132
+ removedMcp ? ` removed mcp_servers.kojee from ${hermesConfig} (operator config preserved)` : ` no mcp_servers.kojee in ${hermesConfig} \u2014 nothing to remove`
1133
+ );
1134
+ lines.push(" (stop the daemon service + reload the gateway to disable the webhook wake path.)");
1067
1135
  const envPath = path7.join(kojeeHomeDir(), ".kojee", `${effective}.env`);
1068
1136
  try {
1069
1137
  fs6.unlinkSync(envPath);
@@ -1109,7 +1177,11 @@ registerBuiltinInstaller(
1109
1177
  "webhook",
1110
1178
  "webhook-secret-both-sides",
1111
1179
  "plugin-payload-copy",
1112
- "service-install"
1180
+ "service-install",
1181
+ // kojee registered as the Hermes agent's MCP server (mcp_servers.kojee in
1182
+ // ~/.hermes/config.yaml) so the agent can EXPLORE + call kojee tools —
1183
+ // additive to the channel plugin, does not change the webhook wake path.
1184
+ "mcp-config-write"
1113
1185
  ],
1114
1186
  (o) => configureHermes(o)
1115
1187
  );
@@ -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
+ };
@@ -1,6 +1,7 @@
1
1
  // src/runtime/cc-session-id.ts
2
2
  import { randomUUID } from "crypto";
3
3
  var SESSION_ID_ENV_VARS = ["CLAUDE_CODE_SESSION_ID"];
4
+ var STABLE_RUNTIMES = /* @__PURE__ */ new Set(["openclaw", "open_claw"]);
4
5
  function sanitizeKey(value) {
5
6
  return value.replace(/[^A-Za-z0-9_-]/g, "");
6
7
  }
@@ -12,6 +13,8 @@ function resolveInstanceKey(deps = {}) {
12
13
  }
13
14
  const explicit = sanitizeKey((env.KOJEE_INSTANCE ?? "").trim());
14
15
  if (explicit) return `inst-${explicit}`;
16
+ const runtime = sanitizeKey((env.KOJEE_RUNTIME ?? "").trim().toLowerCase());
17
+ if (STABLE_RUNTIMES.has(runtime)) return `rt-${runtime}`;
15
18
  const mint = deps.randomUUID ?? randomUUID;
16
19
  return `mint-${mint()}`;
17
20
  }
@@ -26,6 +29,7 @@ function resolveSharedSessionId(deps = {}) {
26
29
 
27
30
  export {
28
31
  SESSION_ID_ENV_VARS,
32
+ STABLE_RUNTIMES,
29
33
  resolveInstanceKey,
30
34
  resolveSharedSessionId
31
35
  };
@@ -13,7 +13,7 @@ import {
13
13
  } from "./chunk-PPTKGWFF.js";
14
14
  import {
15
15
  resolveInstanceKey
16
- } from "./chunk-KNEJTD6G.js";
16
+ } from "./chunk-SRLD2UY7.js";
17
17
 
18
18
  // src/gateway-client.ts
19
19
  import crypto from "crypto";
package/dist/cli.js CHANGED
@@ -4,12 +4,12 @@ import {
4
4
  } from "./chunk-EIAUW6KO.js";
5
5
  import {
6
6
  startProxy
7
- } from "./chunk-ZSHCHOPL.js";
7
+ } from "./chunk-4ATSAH7H.js";
8
8
  import "./chunk-TCWIXG5C.js";
9
9
  import {
10
10
  pairedConfigPath
11
11
  } from "./chunk-5SZHXYPK.js";
12
- import "./chunk-247WFMCJ.js";
12
+ import "./chunk-TBVJOXIR.js";
13
13
  import "./chunk-Z5LPNJQ6.js";
14
14
  import "./chunk-I67C2HYA.js";
15
15
  import "./chunk-MIEI4PLB.js";
@@ -25,7 +25,7 @@ import {
25
25
  import "./chunk-FJUAMJHU.js";
26
26
  import "./chunk-PPTKGWFF.js";
27
27
  import "./chunk-XJEBJIQE.js";
28
- import "./chunk-KNEJTD6G.js";
28
+ import "./chunk-SRLD2UY7.js";
29
29
 
30
30
  // src/cli.ts
31
31
  import { Command } from "commander";
@@ -48,7 +48,7 @@ program.command("pair <code>").description("Pair this machine against Kojee usin
48
48
  program.command("connect <code>").description(
49
49
  "Connect this runtime to Kojee with a per-agent pair code from the dashboard (claude-code | codex | openclaw | hermes). claude-code/codex/openclaw write a per-runtime paired slot (~/.kojee/agents/<runtime>/config.json) and point the runtime launcher at it via --paired-config; hermes writes the global ~/.kojee/config.json (its daemon + `kojee-mcp send` read that). Idempotent."
50
50
  ).requiredOption("--runtime <id>", "Target runtime: claude-code | codex | openclaw | hermes").option("--url <url>", "Broker base URL (default: the canonical staging broker)").action(async (code, opts) => {
51
- const { runConnect } = await import("./connect-handler-NZINEMG3.js");
51
+ const { runConnect } = await import("./connect-handler-NCHC6WPU.js");
52
52
  const result = await runConnect({
53
53
  code,
54
54
  runtime: opts.runtime,
@@ -63,11 +63,11 @@ program.command("hook").description("Run a kojee MCP hook script (called by Clau
63
63
  "Hook type: stop, user-prompt-submit, codex-stop, or codex-prompt-submit"
64
64
  ).action(async (opts) => {
65
65
  if (opts.type === "stop") {
66
- const { runStopHook } = await import("./stop-hook-N6TX4YQT.js");
66
+ const { runStopHook } = await import("./stop-hook-4MNOSDCB.js");
67
67
  await runStopHook();
68
68
  process.exit(0);
69
69
  } else if (opts.type === "user-prompt-submit") {
70
- const { runUserPromptSubmitHook } = await import("./user-prompt-submit-hook-DTXXFDSD.js");
70
+ const { runUserPromptSubmitHook } = await import("./user-prompt-submit-hook-44N6SCNJ.js");
71
71
  await runUserPromptSubmitHook();
72
72
  process.exit(0);
73
73
  } else if (opts.type === "codex-stop") {
@@ -104,7 +104,7 @@ Restart Claude Code for hooks to take effect.`
104
104
  program.command("send <tandem_id>").description(
105
105
  "Send a Tandem message using this machine's paired credentials (~/.kojee). Prints one JSON envelope to stdout: {ok, message_id, cursor, text} on success, {ok:false, error:<typed code>, message} on failure (exit 1)."
106
106
  ).requiredOption("--body <text>", "Message body (required)").option("--reply-to <message_id>", "Message id this send replies to").option("--kind <kind>", "Message kind: message | status (default: backend default)").action(async (tandemId, opts) => {
107
- const { runSendCli } = await import("./send-cli-45RLGYIC.js");
107
+ const { runSendCli } = await import("./send-cli-OLNEVSBI.js");
108
108
  const { exitCode, envelope } = await runSendCli({
109
109
  tandemId,
110
110
  body: opts.body,
@@ -124,7 +124,7 @@ program.command("tail <path>").description("Stream a file's contents and follow
124
124
  }
125
125
  });
126
126
  program.command("doctor").description("Diagnose the kojee wake path (proxy, hook-server, SSE stream, event log, Monitor) and print the exact wake recipe").action(async () => {
127
- const { runDoctor } = await import("./doctor-WUU5BVPT.js");
127
+ const { runDoctor } = await import("./doctor-RKLXZR2B.js");
128
128
  const code = await runDoctor();
129
129
  process.exit(code);
130
130
  });
@@ -143,7 +143,7 @@ function addInstallOptions(cmd, runtimeHelp) {
143
143
  function makeInstallAction(verb) {
144
144
  return async (opts) => {
145
145
  const interactive = process.stdin.isTTY === true && opts.runtime === void 0;
146
- const { runSetup, resolvePairCode } = await import("./setup-handler-44ASXYMS.js");
146
+ const { runSetup, resolvePairCode } = await import("./setup-handler-DA4465VH.js");
147
147
  const pairCode = resolvePairCode(opts);
148
148
  const result = await runSetup({
149
149
  verb,
@@ -2,9 +2,10 @@ import {
2
2
  DEFAULT_BROKER_URL,
3
3
  reconcileConnectPairSlot,
4
4
  runWizard
5
- } from "./chunk-XPIW4N55.js";
5
+ } from "./chunk-CPOK642I.js";
6
6
  import "./chunk-6XWTUDWW.js";
7
7
  import "./chunk-E6WMFMM2.js";
8
+ import "./chunk-GEGUWQYT.js";
8
9
  import "./chunk-77HWBSRH.js";
9
10
  import "./chunk-TMCNB4JH.js";
10
11
  import "./chunk-D6JKFJ6A.js";
@@ -25,7 +25,7 @@ import {
25
25
  } from "./chunk-XJEBJIQE.js";
26
26
  import {
27
27
  resolveSharedSessionId
28
- } from "./chunk-KNEJTD6G.js";
28
+ } from "./chunk-SRLD2UY7.js";
29
29
 
30
30
  // src/doctor.ts
31
31
  import fs from "fs";
@@ -365,6 +365,12 @@ async function runDoctor() {
365
365
  console.error(formatOpenclawDoctorReport(report2));
366
366
  return report2.verdict === "broken" ? 1 : 0;
367
367
  }
368
+ const { hermesArtifactsPresent, collectHermesDoctorReport, formatHermesDoctorReport } = await import("./doctor-hermes-WLDI4R6P.js");
369
+ if (hermesArtifactsPresent()) {
370
+ const hermesReport = collectHermesDoctorReport();
371
+ console.error(formatHermesDoctorReport(hermesReport));
372
+ return hermesReport.verdict === "broken" ? 1 : 0;
373
+ }
368
374
  const report = await collectDoctorReport();
369
375
  console.error(formatDoctorReport(report));
370
376
  return report.verdict === "broken" ? 1 : 0;
@@ -0,0 +1,580 @@
1
+ import {
2
+ HERMES_INSTANCE_KEY,
3
+ HERMES_MCP_SERVER_NAME
4
+ } from "./chunk-GEGUWQYT.js";
5
+
6
+ // src/doctor-hermes.ts
7
+ import fs from "fs";
8
+ import os from "os";
9
+ import path from "path";
10
+ import crypto from "crypto";
11
+ import { execFileSync } from "child_process";
12
+ import { parse } from "yaml";
13
+
14
+ // src/reconcile/index.ts
15
+ var FindingCollector = class {
16
+ items = [];
17
+ /** Append a pre-built Finding. */
18
+ add(finding) {
19
+ this.items.push(finding);
20
+ }
21
+ /** Append a Finding from its parts (the common call site). */
22
+ push(id, severity, detail) {
23
+ this.items.push({ id, severity, detail });
24
+ }
25
+ /** The findings in insertion order (read-only view). */
26
+ get findings() {
27
+ return this.items;
28
+ }
29
+ /**
30
+ * Roll the findings up into the doctor verdict: any `broken` ⇒ broken; else any
31
+ * `warn` ⇒ degraded; else healthy. `unknown` never fails the verdict (it is an
32
+ * "I couldn't determine this" signal, same as the doctor's `?` marks).
33
+ */
34
+ verdict() {
35
+ if (this.items.some((f) => f.severity === "broken")) return "broken";
36
+ if (this.items.some((f) => f.severity === "warn")) return "degraded";
37
+ return "healthy";
38
+ }
39
+ };
40
+
41
+ // src/doctor-hermes.ts
42
+ var WIZARD_RERUN = "re-run `kojee-mcp init --runtime hermes`";
43
+ var DEFAULT_LISTEN_HOST = "127.0.0.1";
44
+ var DEFAULT_LISTEN_PORT = "8645";
45
+ var DEFAULT_LISTEN_PATH = "/kojee-tandem";
46
+ function serviceUnitPath(homeDir, platform) {
47
+ if (platform === "darwin") {
48
+ return path.join(homeDir, "Library", "LaunchAgents", "net.kojee.kojee-hermes.plist");
49
+ }
50
+ return path.join(homeDir, ".config", "systemd", "user", "kojee-hermes.service");
51
+ }
52
+ function hermesEnvPathFor(homeDir) {
53
+ return path.join(homeDir, ".kojee", "hermes.env");
54
+ }
55
+ function hermesArtifactsPresent(deps = {}) {
56
+ const homeDir = deps.homeDir ?? os.homedir();
57
+ const platform = deps.platform ?? process.platform;
58
+ const exists = deps.exists ?? ((p) => fs.existsSync(p));
59
+ return exists(hermesEnvPathFor(homeDir)) || exists(serviceUnitPath(homeDir, platform));
60
+ }
61
+ function unquoteEnvValue(raw) {
62
+ let v = raw.trim();
63
+ if (v.length >= 2 && (v.startsWith("'") && v.endsWith("'") || v.startsWith('"') && v.endsWith('"'))) {
64
+ v = v.slice(1, -1);
65
+ }
66
+ return v.replace(/'\\''/g, "'");
67
+ }
68
+ function parseEnvFile(text) {
69
+ const vars = /* @__PURE__ */ new Map();
70
+ const exportKeys = [];
71
+ for (const rawLine of text.split("\n")) {
72
+ const line = rawLine.trim();
73
+ if (line === "" || line.startsWith("#")) continue;
74
+ const m = line.match(/^(export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
75
+ if (!m) continue;
76
+ const key = m[2];
77
+ if (m[1] !== void 0) exportKeys.push(key);
78
+ vars.set(key, unquoteEnvValue(m[3]));
79
+ }
80
+ return { vars, exportKeys };
81
+ }
82
+ function readChildEnv(configText) {
83
+ if (configText === null) return null;
84
+ let doc;
85
+ try {
86
+ doc = parse(configText);
87
+ } catch {
88
+ return null;
89
+ }
90
+ if (!doc || typeof doc !== "object") return null;
91
+ const servers = doc["mcp_servers"];
92
+ if (!servers || typeof servers !== "object") return null;
93
+ const kojee = servers[HERMES_MCP_SERVER_NAME];
94
+ if (!kojee || typeof kojee !== "object") return null;
95
+ const env = kojee["env"];
96
+ if (!env || typeof env !== "object") return null;
97
+ const out = {};
98
+ for (const [k, v] of Object.entries(env)) {
99
+ if (typeof v === "string") out[k] = v;
100
+ }
101
+ return out;
102
+ }
103
+ function sha256(value) {
104
+ return crypto.createHash("sha256").update(value, "utf8").digest("hex");
105
+ }
106
+ function readPluginEnabled(configText) {
107
+ if (configText === null) return null;
108
+ let doc;
109
+ try {
110
+ doc = parse(configText);
111
+ } catch {
112
+ return null;
113
+ }
114
+ if (!doc || typeof doc !== "object") return null;
115
+ const plugins = doc["plugins"];
116
+ if (!plugins || typeof plugins !== "object") return false;
117
+ const enabled = plugins["enabled"];
118
+ if (!Array.isArray(enabled)) return false;
119
+ return enabled.includes("kojee-tandem");
120
+ }
121
+ function parseExecTargets(unitText) {
122
+ if (unitText === null) return null;
123
+ const m = unitText.match(/exec\s+([^'"\n<]+)/);
124
+ if (!m) return null;
125
+ return m[1].trim().split(/\s+/).filter((tok) => tok.startsWith("/"));
126
+ }
127
+ function defaultReadText(filePath) {
128
+ try {
129
+ return fs.readFileSync(filePath, "utf8");
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+ function defaultProbeService(platform) {
135
+ return () => {
136
+ try {
137
+ if (platform === "darwin") {
138
+ const out2 = execFileSync("launchctl", ["list"], { encoding: "utf8" });
139
+ return { active: out2.includes("net.kojee.kojee-hermes") };
140
+ }
141
+ const out = execFileSync("systemctl", ["--user", "is-active", "kojee-hermes"], {
142
+ encoding: "utf8"
143
+ }).trim();
144
+ return { active: out === "active" };
145
+ } catch (err) {
146
+ const stdout = err.stdout;
147
+ if (stdout !== void 0) {
148
+ const s = (typeof stdout === "string" ? stdout : stdout.toString()).trim();
149
+ if (s.length > 0) return { active: s === "active" };
150
+ }
151
+ return null;
152
+ }
153
+ };
154
+ }
155
+ function defaultProbeReceiver(platform) {
156
+ return (_host, port) => {
157
+ try {
158
+ if (platform === "darwin") {
159
+ try {
160
+ execFileSync("lsof", [`-iTCP:${port}`, "-sTCP:LISTEN", "-n", "-P"], { encoding: "utf8" });
161
+ return true;
162
+ } catch (err) {
163
+ if (err.status === 1) return false;
164
+ return null;
165
+ }
166
+ }
167
+ const out = execFileSync("ss", ["-ltnH"], { encoding: "utf8" });
168
+ const re = new RegExp(`:${port}(\\s|$)`);
169
+ return out.split("\n").some((l) => re.test(l));
170
+ } catch {
171
+ return null;
172
+ }
173
+ };
174
+ }
175
+ function defaultReadDaemonEnv(platform) {
176
+ return () => {
177
+ if (platform !== "linux") return null;
178
+ try {
179
+ const pidStr = execFileSync(
180
+ "systemctl",
181
+ ["--user", "show", "-p", "MainPID", "--value", "kojee-hermes"],
182
+ { encoding: "utf8" }
183
+ ).trim();
184
+ const pid = Number.parseInt(pidStr, 10);
185
+ if (!Number.isFinite(pid) || pid <= 0) return null;
186
+ const raw = fs.readFileSync(`/proc/${pid}/environ`, "utf8");
187
+ const out = {};
188
+ for (const pair of raw.split("\0")) {
189
+ const eq = pair.indexOf("=");
190
+ if (eq <= 0) continue;
191
+ out[pair.slice(0, eq)] = pair.slice(eq + 1);
192
+ }
193
+ return out;
194
+ } catch {
195
+ return null;
196
+ }
197
+ };
198
+ }
199
+ function okFromSeverity(sev) {
200
+ return sev === "ok" ? true : sev === "warn" ? "warn" : sev === "broken" ? false : "unknown";
201
+ }
202
+ function emit(checks, collector, id, severity, detail) {
203
+ checks.push({ name: id, ok: okFromSeverity(severity), detail });
204
+ collector.push(id, severity, detail);
205
+ }
206
+ function collectHermesDoctorReport(deps = {}) {
207
+ const homeDir = deps.homeDir ?? os.homedir();
208
+ const platform = deps.platform ?? process.platform;
209
+ const readText = deps.readText ?? defaultReadText;
210
+ const exists = deps.exists ?? ((p) => fs.existsSync(p));
211
+ const probeService = deps.probeService ?? defaultProbeService(platform);
212
+ const probeReceiver = deps.probeReceiver ?? defaultProbeReceiver(platform);
213
+ const readDaemonEnv = deps.readDaemonEnv ?? defaultReadDaemonEnv(platform);
214
+ const hermesEnvPath = hermesEnvPathFor(homeDir);
215
+ const adapterEnvPath = path.join(homeDir, ".hermes", ".env");
216
+ const configYamlPath = path.join(homeDir, ".hermes", "config.yaml");
217
+ const unitPath = serviceUnitPath(homeDir, platform);
218
+ const daemonEnvText = readText(hermesEnvPath);
219
+ const adapterEnvText = readText(adapterEnvPath);
220
+ const unitText = readText(unitPath);
221
+ const configText = readText(configYamlPath);
222
+ const daemonEnv = daemonEnvText !== null ? parseEnvFile(daemonEnvText) : null;
223
+ const adapterEnv = adapterEnvText !== null ? parseEnvFile(adapterEnvText) : null;
224
+ const childEnv = readChildEnv(configText);
225
+ const pluginEnabled = readPluginEnabled(configText);
226
+ const checks = [];
227
+ const collector = new FindingCollector();
228
+ if (daemonEnv === null) {
229
+ emit(checks, collector, "webhook.env-format", "unknown", `hermes.env unreadable at ${hermesEnvPath}`);
230
+ } else if (daemonEnv.exportKeys.length > 0) {
231
+ const unitUsesEnvFile = unitText !== null && /^\s*EnvironmentFile=/m.test(unitText);
232
+ const keys = daemonEnv.exportKeys.join(", ");
233
+ if (unitUsesEnvFile) {
234
+ emit(
235
+ checks,
236
+ collector,
237
+ "webhook.env-format",
238
+ "broken",
239
+ `hermes.env has export-prefixed line(s) [${keys}] AND the service unit uses EnvironmentFile= \u2014 systemd DROPS them, so the daemon boots with those vars UNSET (silent webhook-off + seat churn). ${WIZARD_RERUN} to rewrite the unit (source wrapper) + env (bare).`
240
+ );
241
+ } else {
242
+ emit(
243
+ checks,
244
+ collector,
245
+ "webhook.env-format",
246
+ "warn",
247
+ `hermes.env has legacy export-prefixed line(s) [${keys}] \u2014 harmless under the source-wrapper unit (set -a exports them) but ${WIZARD_RERUN} to normalize to bare form.`
248
+ );
249
+ }
250
+ } else {
251
+ emit(
252
+ checks,
253
+ collector,
254
+ "webhook.env-format",
255
+ "ok",
256
+ "hermes.env is bare single-quoted (no export-prefixed lines) \u2014 sources cleanly."
257
+ );
258
+ }
259
+ const daemonUrl = (daemonEnv?.vars.get("KOJEE_WEBHOOK_URL") ?? "").trim();
260
+ const listenHost = adapterEnv?.vars.get("KOJEE_TANDEM_LISTEN_HOST") ?? DEFAULT_LISTEN_HOST;
261
+ const listenPort = adapterEnv?.vars.get("KOJEE_TANDEM_LISTEN_PORT") ?? DEFAULT_LISTEN_PORT;
262
+ const listenPath = adapterEnv?.vars.get("KOJEE_TANDEM_LISTEN_PATH") ?? DEFAULT_LISTEN_PATH;
263
+ if (!daemonUrl) {
264
+ emit(
265
+ checks,
266
+ collector,
267
+ "webhook.url-receiver-align",
268
+ "broken",
269
+ `daemon KOJEE_WEBHOOK_URL is EMPTY \u2014 no event stream (null delivery); the daemon will not POST wakes. Set the receiver URL: ${WIZARD_RERUN}.`
270
+ );
271
+ } else {
272
+ let parsed = null;
273
+ try {
274
+ parsed = new URL(daemonUrl);
275
+ } catch {
276
+ parsed = null;
277
+ }
278
+ if (parsed === null) {
279
+ emit(
280
+ checks,
281
+ collector,
282
+ "webhook.url-receiver-align",
283
+ "broken",
284
+ `daemon KOJEE_WEBHOOK_URL is not a valid URL \u2014 ${WIZARD_RERUN}.`
285
+ );
286
+ } else {
287
+ const urlPort = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
288
+ const aligned = parsed.hostname === listenHost && urlPort === listenPort && parsed.pathname === listenPath;
289
+ emit(
290
+ checks,
291
+ collector,
292
+ "webhook.url-receiver-align",
293
+ aligned ? "ok" : "broken",
294
+ aligned ? `daemon URL host/port/path aligns with the receiver bind (${listenHost}:${listenPort}${listenPath}).` : `daemon URL (${parsed.hostname}:${urlPort}${parsed.pathname}) != receiver bind (${listenHost}:${listenPort}${listenPath}) \u2014 POSTs miss the listener. Align them.`
295
+ );
296
+ }
297
+ }
298
+ const daemonSecret = daemonEnv?.vars.get("KOJEE_WEBHOOK_SECRET");
299
+ const adapterSecret = adapterEnv?.vars.get("KOJEE_WEBHOOK_SECRET");
300
+ if (!daemonSecret || !adapterSecret) {
301
+ const which = [!daemonSecret ? "daemon (hermes.env)" : null, !adapterSecret ? "adapter (~/.hermes/.env)" : null].filter((s) => s !== null).join(" and ");
302
+ emit(
303
+ checks,
304
+ collector,
305
+ "webhook.secret-match",
306
+ "broken",
307
+ `KOJEE_WEBHOOK_SECRET missing on ${which} \u2014 the receiver can't verify signed POSTs (401). ${WIZARD_RERUN}.`
308
+ );
309
+ } else {
310
+ const same = sha256(daemonSecret) === sha256(adapterSecret);
311
+ emit(
312
+ checks,
313
+ collector,
314
+ "webhook.secret-match",
315
+ same ? "ok" : "broken",
316
+ same ? "daemon + adapter secrets match (compared by sha256; values never printed)." : `daemon vs adapter KOJEE_WEBHOOK_SECRET DIFFER (compared by sha256; values never printed) \u2014 every POST is rejected 401. Resync one side: ${WIZARD_RERUN} (writes one secret both sides).`
317
+ );
318
+ }
319
+ const execTargets = parseExecTargets(unitText);
320
+ if (unitText === null) {
321
+ emit(checks, collector, "webhook.daemon-bin", "unknown", `service unit unreadable at ${unitPath}.`);
322
+ } else if (execTargets === null || execTargets.length === 0) {
323
+ emit(
324
+ checks,
325
+ collector,
326
+ "webhook.daemon-bin",
327
+ "warn",
328
+ "could not parse the ExecStart exec target from the unit \u2014 cannot verify the daemon binary."
329
+ );
330
+ } else {
331
+ const missing = execTargets.filter((p) => !exists(p));
332
+ const ephemeral = execTargets.find((p) => /[\\/]_npx[\\/]/.test(p));
333
+ if (missing.length > 0) {
334
+ emit(
335
+ checks,
336
+ collector,
337
+ "webhook.daemon-bin",
338
+ "broken",
339
+ `daemon ExecStart points at ${missing.join(", ")} which does NOT exist \u2014 the service fails to start (exit 127) and crash-loops. Install globally + ${WIZARD_RERUN}: \`npm i -g kojee-mcp && kojee-mcp init --runtime hermes\`.`
340
+ );
341
+ } else if (ephemeral) {
342
+ emit(
343
+ checks,
344
+ collector,
345
+ "webhook.daemon-bin",
346
+ "warn",
347
+ `daemon ExecStart runs from an EPHEMERAL npx cache (${ephemeral}) \u2014 it works now but npx will eventually delete it, then the daemon dies (exit 127). For durability: \`npm i -g kojee-mcp && kojee-mcp init --runtime hermes\`.`
348
+ );
349
+ } else {
350
+ emit(
351
+ checks,
352
+ collector,
353
+ "webhook.daemon-bin",
354
+ "ok",
355
+ `daemon ExecStart targets exist (${execTargets.join(" ")}) \u2014 node + CLI resolve without PATH.`
356
+ );
357
+ }
358
+ }
359
+ const svc = probeService();
360
+ if (svc === null) {
361
+ emit(
362
+ checks,
363
+ collector,
364
+ "webhook.pusher-up",
365
+ "unknown",
366
+ "could not determine kojee-hermes service state (systemctl/launchctl unavailable)."
367
+ );
368
+ } else if (svc.active) {
369
+ emit(checks, collector, "webhook.pusher-up", "ok", "kojee-hermes service is active.");
370
+ } else {
371
+ const why = execTargets && execTargets.some((p) => !exists(p)) ? " (likely the missing ExecStart binary above \u2014 exit 127)" : "";
372
+ emit(
373
+ checks,
374
+ collector,
375
+ "webhook.pusher-up",
376
+ "broken",
377
+ `kojee-hermes service is NOT active${why} \u2014 no wakes are being pushed. Enable+start it (\`systemctl --user enable --now kojee-hermes\`) \u2014 shared infra, so the principal's call.`
378
+ );
379
+ }
380
+ if (pluginEnabled === null) {
381
+ emit(
382
+ checks,
383
+ collector,
384
+ "webhook.plugin-enabled",
385
+ "unknown",
386
+ "could not read plugins.enabled from ~/.hermes/config.yaml."
387
+ );
388
+ } else if (pluginEnabled) {
389
+ emit(
390
+ checks,
391
+ collector,
392
+ "webhook.plugin-enabled",
393
+ "ok",
394
+ "kojee-tandem receiver plugin is enabled in Hermes (plugins.enabled)."
395
+ );
396
+ } else {
397
+ emit(
398
+ checks,
399
+ collector,
400
+ "webhook.plugin-enabled",
401
+ "broken",
402
+ "kojee-tandem receiver plugin is NOT enabled \u2014 the gateway loads no receiver, so wakes never arrive (a gateway restart alone can't fix this). Enable it, then restart the gateway: `hermes plugins enable kojee-tandem && hermes gateway restart`."
403
+ );
404
+ }
405
+ const receiver = probeReceiver(listenHost, Number.parseInt(listenPort, 10));
406
+ if (receiver === null) {
407
+ emit(
408
+ checks,
409
+ collector,
410
+ "webhook.receiver-up",
411
+ "unknown",
412
+ `could not probe the receiver bind ${listenHost}:${listenPort} (ss/lsof unavailable).`
413
+ );
414
+ } else if (receiver) {
415
+ emit(
416
+ checks,
417
+ collector,
418
+ "webhook.receiver-up",
419
+ "ok",
420
+ `receiver is listening on ${listenHost}:${listenPort}.`
421
+ );
422
+ } else {
423
+ const fix = pluginEnabled === false ? "the kojee-tandem plugin is DISABLED (see webhook.plugin-enabled) \u2014 enable it, then restart the gateway" : "the Hermes gateway (kojee-tandem plugin) receiver is down \u2014 restart the gateway to load the plugin";
424
+ emit(
425
+ checks,
426
+ collector,
427
+ "webhook.receiver-up",
428
+ "warn",
429
+ `nothing is listening on ${listenHost}:${listenPort} \u2014 ${fix}.`
430
+ );
431
+ }
432
+ if (childEnv === null) {
433
+ emit(
434
+ checks,
435
+ collector,
436
+ "webhook.no-duplicate",
437
+ "unknown",
438
+ `could not read mcp_servers.${HERMES_MCP_SERVER_NAME}.env from ~/.hermes/config.yaml.`
439
+ );
440
+ } else {
441
+ const childUrl = (childEnv["KOJEE_WEBHOOK_URL"] ?? "").trim();
442
+ emit(
443
+ checks,
444
+ collector,
445
+ "webhook.no-duplicate",
446
+ childUrl === "" ? "ok" : "broken",
447
+ childUrl === "" ? "mcp child KOJEE_WEBHOOK_URL is empty (hard-off) \u2014 no duplicate delivery." : `mcp_servers.${HERMES_MCP_SERVER_NAME} has a NON-EMPTY KOJEE_WEBHOOK_URL \u2014 the tools-only child would DOUBLE-DELIVER wakes for the seat. Clear it (keep the empty hard-off).`
448
+ );
449
+ }
450
+ const daemonInstance = (daemonEnv?.vars.get("KOJEE_INSTANCE") ?? "").trim();
451
+ const childInstance = (childEnv?.["KOJEE_INSTANCE"] ?? "").trim();
452
+ if (!daemonInstance) {
453
+ emit(
454
+ checks,
455
+ collector,
456
+ "webhook.instance-align",
457
+ "broken",
458
+ `daemon KOJEE_INSTANCE is unset \u2014 daemon + MCP child may resolve to DIFFERENT sessions (split-brain seat). Expected '${HERMES_INSTANCE_KEY}'. ${WIZARD_RERUN}.`
459
+ );
460
+ } else if (childInstance && daemonInstance !== childInstance) {
461
+ emit(
462
+ checks,
463
+ collector,
464
+ "webhook.instance-align",
465
+ "broken",
466
+ `daemon KOJEE_INSTANCE='${daemonInstance}' != mcp child KOJEE_INSTANCE='${childInstance}' \u2014 split-brain: a tandem_send from the agent lands on a different seat than the daemon streams wakes for. ${WIZARD_RERUN}.`
467
+ );
468
+ } else if (daemonInstance !== HERMES_INSTANCE_KEY) {
469
+ emit(
470
+ checks,
471
+ collector,
472
+ "webhook.instance-align",
473
+ "warn",
474
+ `daemon KOJEE_INSTANCE='${daemonInstance}' != expected '${HERMES_INSTANCE_KEY}' (non-standard but self-consistent with the child).`
475
+ );
476
+ } else {
477
+ emit(
478
+ checks,
479
+ collector,
480
+ "webhook.instance-align",
481
+ "ok",
482
+ `daemon + mcp child share KOJEE_INSTANCE='${HERMES_INSTANCE_KEY}' \u2014 one backend seat.`
483
+ );
484
+ }
485
+ if (platform !== "linux") {
486
+ emit(
487
+ checks,
488
+ collector,
489
+ "webhook.env-reaches-daemon",
490
+ "unknown",
491
+ "n/a \u2014 reads /proc/<pid>/environ (Linux only)."
492
+ );
493
+ } else {
494
+ const liveEnv = readDaemonEnv();
495
+ if (liveEnv === null) {
496
+ emit(
497
+ checks,
498
+ collector,
499
+ "webhook.env-reaches-daemon",
500
+ "unknown",
501
+ "daemon not running (no MainPID) or its /proc environ is unreadable."
502
+ );
503
+ } else {
504
+ const missing = ["KOJEE_WEBHOOK_URL", "KOJEE_WEBHOOK_SECRET", "KOJEE_INSTANCE"].filter(
505
+ (k) => !((liveEnv[k] ?? "").length > 0)
506
+ );
507
+ emit(
508
+ checks,
509
+ collector,
510
+ "webhook.env-reaches-daemon",
511
+ missing.length === 0 ? "ok" : "broken",
512
+ missing.length === 0 ? "live daemon env carries KOJEE_WEBHOOK_URL/SECRET/INSTANCE (injection reached the process; secret value never read)." : `live daemon env is MISSING ${missing.join(", ")} \u2014 env injection did NOT reach the running daemon (source wrapper not applied / stale unit). ${WIZARD_RERUN}, then restart the service.`
513
+ );
514
+ }
515
+ }
516
+ return { checks, findings: collector.findings, verdict: collector.verdict() };
517
+ }
518
+ var CHECK_GROUP = {
519
+ "webhook.env-format": "Config",
520
+ "webhook.url-receiver-align": "Config",
521
+ "webhook.secret-match": "Config",
522
+ "webhook.no-duplicate": "Config",
523
+ "webhook.instance-align": "Config",
524
+ "webhook.plugin-enabled": "Receiver",
525
+ "webhook.receiver-up": "Receiver",
526
+ "webhook.daemon-bin": "Pusher",
527
+ "webhook.pusher-up": "Pusher",
528
+ "webhook.env-reaches-daemon": "Pusher"
529
+ };
530
+ function formatHermesDoctorReport(report) {
531
+ const mark = (ok) => ok === true ? "\u2713" : ok === "warn" ? "\u26A0" : ok === "unknown" ? "?" : "\u2717";
532
+ const shortName = (name) => name.replace(/^webhook\./, "");
533
+ const nOk = report.checks.filter((c) => c.ok === true).length;
534
+ const nWarn = report.checks.filter((c) => c.ok === "warn").length;
535
+ const nBad = report.checks.filter((c) => c.ok === false).length;
536
+ const nUnknown = report.checks.filter((c) => c.ok === "unknown").length;
537
+ const verdictMark = report.verdict === "healthy" ? "\u2713" : report.verdict === "degraded" ? "\u26A0" : "\u2717";
538
+ const lines = [];
539
+ lines.push(`kojee-mcp doctor \xB7 hermes wake-path \xB7 ${verdictMark} ${report.verdict.toUpperCase()}`);
540
+ const tally = [
541
+ `${nOk} ok`,
542
+ nWarn ? `${nWarn} warning${nWarn > 1 ? "s" : ""}` : null,
543
+ nBad ? `${nBad} problem${nBad > 1 ? "s" : ""}` : null,
544
+ nUnknown ? `${nUnknown} unknown` : null
545
+ ].filter(Boolean).join(", ");
546
+ lines.push(` ${tally}`);
547
+ lines.push(" path: daemon (SSE \u2192 HMAC POST) \u2192 :8645 receiver plugin \u2192 wakes the agent");
548
+ lines.push("");
549
+ for (const group of ["Config", "Receiver", "Pusher"]) {
550
+ const groupChecks = report.checks.filter((c) => CHECK_GROUP[c.name] === group);
551
+ if (groupChecks.length === 0) continue;
552
+ lines.push(` ${group}:`);
553
+ for (const c of groupChecks) {
554
+ lines.push(` ${mark(c.ok)} ${shortName(c.name)}: ${c.detail}`);
555
+ }
556
+ lines.push("");
557
+ }
558
+ const actionable = [
559
+ ...report.checks.filter((c) => c.ok === false),
560
+ ...report.checks.filter((c) => c.ok === "warn")
561
+ ];
562
+ if (actionable.length === 0) {
563
+ lines.push("\u2192 Wake-path is healthy. Nothing to do.");
564
+ } else {
565
+ lines.push("\u2192 Next steps (in order):");
566
+ actionable.forEach((c, i) => {
567
+ lines.push(` ${i + 1}. [${shortName(c.name)}] ${c.detail}`);
568
+ });
569
+ }
570
+ lines.push("");
571
+ lines.push(
572
+ " (read-only. Enabling/restarting the daemon, plugin, or gateway is an owner step \u2014 shared infra needs the principal's word.)"
573
+ );
574
+ return lines.join("\n");
575
+ }
576
+ export {
577
+ collectHermesDoctorReport,
578
+ formatHermesDoctorReport,
579
+ hermesArtifactsPresent
580
+ };
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  listTandemIds,
3
3
  startProxy
4
- } from "./chunk-ZSHCHOPL.js";
4
+ } from "./chunk-4ATSAH7H.js";
5
5
  import "./chunk-TCWIXG5C.js";
6
- import "./chunk-247WFMCJ.js";
6
+ import "./chunk-TBVJOXIR.js";
7
7
  import "./chunk-Z5LPNJQ6.js";
8
8
  import "./chunk-I67C2HYA.js";
9
9
  import "./chunk-MIEI4PLB.js";
@@ -14,7 +14,7 @@ import "./chunk-5DHIUN73.js";
14
14
  import "./chunk-FJUAMJHU.js";
15
15
  import "./chunk-PPTKGWFF.js";
16
16
  import "./chunk-XJEBJIQE.js";
17
- import "./chunk-KNEJTD6G.js";
17
+ import "./chunk-SRLD2UY7.js";
18
18
  export {
19
19
  listTandemIds,
20
20
  startProxy
package/dist/lib.js CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  import {
14
14
  GatewayClient,
15
15
  applyStableSessionId
16
- } from "./chunk-247WFMCJ.js";
16
+ } from "./chunk-TBVJOXIR.js";
17
17
  import {
18
18
  deriveStableSessionId
19
19
  } from "./chunk-Z5LPNJQ6.js";
@@ -32,7 +32,7 @@ import {
32
32
  } from "./chunk-6G6YYST6.js";
33
33
  import "./chunk-U5HHHRXA.js";
34
34
  import "./chunk-PPTKGWFF.js";
35
- import "./chunk-KNEJTD6G.js";
35
+ import "./chunk-SRLD2UY7.js";
36
36
  export {
37
37
  AuthModule,
38
38
  GatewayClient,
@@ -154,8 +154,13 @@ class KojeeTandemAdapter(BasePlatformAdapter):
154
154
 
155
155
  # ── Connection lifecycle ────────────────────────────────────────────
156
156
 
157
- async def connect(self) -> bool:
158
- """Start the loopback webhook listener the kojee-mcp daemon POSTs to."""
157
+ async def connect(self, *, is_reconnect: bool = False) -> bool:
158
+ """Start the loopback webhook listener the kojee-mcp daemon POSTs to.
159
+
160
+ ``is_reconnect`` is part of the BasePlatformAdapter.connect contract
161
+ (the gateway's reconnection watcher calls ``connect(is_reconnect=True)``);
162
+ this listener is stateless per-connect so the flag is accepted and ignored.
163
+ """
159
164
  if not self.webhook_secret:
160
165
  logger.error("kojee-tandem: KOJEE_WEBHOOK_SECRET must be set (signed-only inbound)")
161
166
  self._set_fatal_error(
@@ -129,7 +129,7 @@ function createClaudeCodeDelivery() {
129
129
  const { startEventStream } = await import("./event-stream-ATKYXDBV.js");
130
130
  const { createMcpServer } = await import("./server-DU3LFS32.js");
131
131
  const { deriveDiscoveryKey } = await import("./ancestry-A2F5KQ6A.js");
132
- const { resolveSharedSessionId } = await import("./cc-session-id-RURNIHHC.js");
132
+ const { resolveSharedSessionId } = await import("./cc-session-id-NNCZCHZZ.js");
133
133
  sweepStaleDiscovery();
134
134
  sweepStaleEventLogs();
135
135
  const projectDir = process.env["CLAUDE_PROJECT_DIR"];
@@ -13,7 +13,7 @@ import {
13
13
  import {
14
14
  GatewayClient,
15
15
  applyStableSessionId
16
- } from "./chunk-247WFMCJ.js";
16
+ } from "./chunk-TBVJOXIR.js";
17
17
  import "./chunk-Z5LPNJQ6.js";
18
18
  import "./chunk-MIEI4PLB.js";
19
19
  import {
@@ -22,7 +22,7 @@ import {
22
22
  } from "./chunk-6G6YYST6.js";
23
23
  import "./chunk-U5HHHRXA.js";
24
24
  import "./chunk-PPTKGWFF.js";
25
- import "./chunk-KNEJTD6G.js";
25
+ import "./chunk-SRLD2UY7.js";
26
26
 
27
27
  // src/tandem/send-cli.ts
28
28
  import os from "os";
@@ -3,9 +3,10 @@ import {
3
3
  pairSlotFor,
4
4
  reconcileConnectPairSlot,
5
5
  runWizard
6
- } from "./chunk-XPIW4N55.js";
6
+ } from "./chunk-CPOK642I.js";
7
7
  import "./chunk-6XWTUDWW.js";
8
8
  import "./chunk-E6WMFMM2.js";
9
+ import "./chunk-GEGUWQYT.js";
9
10
  import {
10
11
  isWizardRuntime
11
12
  } from "./chunk-77HWBSRH.js";
@@ -1,7 +1,7 @@
1
1
  import "./chunk-XLKGPGZT.js";
2
2
  import {
3
3
  resolveHookDiscovery
4
- } from "./chunk-WBXU27BF.js";
4
+ } from "./chunk-3P24YPR7.js";
5
5
  import {
6
6
  readHookStdin
7
7
  } from "./chunk-LSUB6QMP.js";
@@ -21,7 +21,7 @@ import {
21
21
  buildMonitorNudge
22
22
  } from "./chunk-FJUAMJHU.js";
23
23
  import "./chunk-XJEBJIQE.js";
24
- import "./chunk-KNEJTD6G.js";
24
+ import "./chunk-SRLD2UY7.js";
25
25
 
26
26
  // src/hooks/stop-hook.ts
27
27
  import fs from "fs";
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  resolveHookDiscovery
3
- } from "./chunk-WBXU27BF.js";
3
+ } from "./chunk-3P24YPR7.js";
4
4
  import {
5
5
  readHookStdin
6
6
  } from "./chunk-LSUB6QMP.js";
@@ -13,7 +13,7 @@ import {
13
13
  import "./chunk-67F67AQ6.js";
14
14
  import "./chunk-U5HHHRXA.js";
15
15
  import "./chunk-XJEBJIQE.js";
16
- import "./chunk-KNEJTD6G.js";
16
+ import "./chunk-SRLD2UY7.js";
17
17
 
18
18
  // src/hooks/user-prompt-submit-hook.ts
19
19
  async function runUserPromptSubmitHook() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kojee-mcp",
3
- "version": "0.7.4",
3
+ "version": "0.7.5-beta.1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -35,6 +35,7 @@
35
35
  "ps-list": "^8.1.1",
36
36
  "ulidx": "^2.3.0",
37
37
  "undici": "^8.5.0",
38
+ "yaml": "^2.9.0",
38
39
  "zod": "^3.22.0"
39
40
  },
40
41
  "devDependencies": {