@botcord/daemon 0.2.26 → 0.2.28

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.
@@ -10,6 +10,14 @@ const DEFAULT_TOKEN_FILE_PATHS = [
10
10
  "/var/run/openclaw/gateway-token",
11
11
  "~/.openclaw/gateway-token",
12
12
  ];
13
+ const DEFAULT_SYSTEMD_UNIT_PATHS = [
14
+ "/etc/systemd/system/openclaw.service",
15
+ "/etc/systemd/system/openclaw-gateway.service",
16
+ "/lib/systemd/system/openclaw.service",
17
+ "/lib/systemd/system/openclaw-gateway.service",
18
+ "/usr/lib/systemd/system/openclaw.service",
19
+ "/usr/lib/systemd/system/openclaw-gateway.service",
20
+ ];
13
21
  export async function discoverLocalOpenclawGateways(opts = {}) {
14
22
  const found = [];
15
23
  for (const root of opts.searchPaths ?? DEFAULT_SEARCH_PATHS) {
@@ -17,6 +25,7 @@ export async function discoverLocalOpenclawGateways(opts = {}) {
17
25
  }
18
26
  const env = opts.env ?? process.env;
19
27
  found.push(...discoverFromEnv(env));
28
+ found.push(...discoverFromSystemdUnits(opts.systemdUnitPaths ?? DEFAULT_SYSTEMD_UNIT_PATHS));
20
29
  const envAuth = pickOpenclawEnvAuth(env) ?? pickDefaultTokenFile();
21
30
  const ports = opts.defaultPorts ?? DEFAULT_PORTS;
22
31
  if (ports.length > 0) {
@@ -38,6 +47,167 @@ export async function discoverLocalOpenclawGateways(opts = {}) {
38
47
  }
39
48
  return dedupeDiscovered(found);
40
49
  }
50
+ function discoverFromSystemdUnits(paths) {
51
+ const out = [];
52
+ for (const unitPath of paths) {
53
+ try {
54
+ if (!existsSync(unitPath))
55
+ continue;
56
+ const parsed = parseSystemdUnit(readFileSync(unitPath, "utf8"), path.dirname(unitPath));
57
+ const url = parsed.url ?? urlFromGatewayPort(parsed.env);
58
+ if (!url)
59
+ continue;
60
+ const auth = pickOpenclawEnvAuth(parsed.env);
61
+ out.push({
62
+ name: nameFromUrl(url),
63
+ url,
64
+ source: "systemd-unit",
65
+ ...auth,
66
+ });
67
+ }
68
+ catch (err) {
69
+ daemonLog.debug("openclaw discovery systemd unit skipped", {
70
+ file: unitPath,
71
+ error: err instanceof Error ? err.message : String(err),
72
+ });
73
+ }
74
+ }
75
+ return out;
76
+ }
77
+ function parseSystemdUnit(raw, unitDir) {
78
+ const env = {};
79
+ let url;
80
+ for (const line of joinedSystemdLines(raw)) {
81
+ const trimmed = line.trim();
82
+ if (!trimmed || trimmed.startsWith("#"))
83
+ continue;
84
+ const eq = trimmed.indexOf("=");
85
+ if (eq <= 0)
86
+ continue;
87
+ const key = trimmed.slice(0, eq);
88
+ const value = trimmed.slice(eq + 1).trim();
89
+ if (key === "Environment") {
90
+ Object.assign(env, parseSystemdEnvironment(value));
91
+ }
92
+ else if (key === "EnvironmentFile") {
93
+ for (const file of splitSystemdWords(value)) {
94
+ const optional = file.startsWith("-");
95
+ const resolved = path.resolve(unitDir, expandHome(optional ? file.slice(1) : file));
96
+ try {
97
+ Object.assign(env, parseEnvFile(readFileSync(resolved, "utf8")));
98
+ }
99
+ catch (err) {
100
+ if (!optional) {
101
+ daemonLog.debug("openclaw discovery environment file skipped", {
102
+ file: resolved,
103
+ error: err instanceof Error ? err.message : String(err),
104
+ });
105
+ }
106
+ }
107
+ }
108
+ }
109
+ else if (key === "ExecStart") {
110
+ url = urlFromExecStart(value) ?? url;
111
+ }
112
+ }
113
+ return { env, url };
114
+ }
115
+ function joinedSystemdLines(raw) {
116
+ const out = [];
117
+ let cur = "";
118
+ for (const line of raw.split(/\r?\n/)) {
119
+ const trimmedEnd = line.replace(/\s+$/, "");
120
+ if (trimmedEnd.endsWith("\\")) {
121
+ cur += trimmedEnd.slice(0, -1) + " ";
122
+ continue;
123
+ }
124
+ out.push(cur + line);
125
+ cur = "";
126
+ }
127
+ if (cur)
128
+ out.push(cur);
129
+ return out;
130
+ }
131
+ function parseSystemdEnvironment(raw) {
132
+ const env = {};
133
+ for (const word of splitSystemdWords(raw)) {
134
+ const eq = word.indexOf("=");
135
+ if (eq <= 0)
136
+ continue;
137
+ env[word.slice(0, eq)] = word.slice(eq + 1);
138
+ }
139
+ return env;
140
+ }
141
+ function parseEnvFile(raw) {
142
+ const env = {};
143
+ for (const line of raw.split(/\r?\n/)) {
144
+ const trimmed = line.trim();
145
+ if (!trimmed || trimmed.startsWith("#"))
146
+ continue;
147
+ const eq = trimmed.indexOf("=");
148
+ if (eq <= 0)
149
+ continue;
150
+ env[trimmed.slice(0, eq)] = unquote(trimmed.slice(eq + 1).trim());
151
+ }
152
+ return env;
153
+ }
154
+ function urlFromExecStart(raw) {
155
+ const words = splitSystemdWords(raw);
156
+ const portIdx = words.indexOf("--port");
157
+ const rawPort = portIdx >= 0 ? words[portIdx + 1] : words.find((w) => w.startsWith("--port="))?.slice(7);
158
+ if (!rawPort)
159
+ return undefined;
160
+ const port = Number(rawPort);
161
+ if (!Number.isInteger(port) || port <= 0 || port > 65535)
162
+ return undefined;
163
+ return `ws://127.0.0.1:${port}`;
164
+ }
165
+ function splitSystemdWords(raw) {
166
+ const words = [];
167
+ let cur = "";
168
+ let quote = null;
169
+ let escaped = false;
170
+ for (const ch of raw) {
171
+ if (escaped) {
172
+ cur += ch;
173
+ escaped = false;
174
+ continue;
175
+ }
176
+ if (ch === "\\") {
177
+ escaped = true;
178
+ continue;
179
+ }
180
+ if (quote) {
181
+ if (ch === quote)
182
+ quote = null;
183
+ else
184
+ cur += ch;
185
+ continue;
186
+ }
187
+ if (ch === '"' || ch === "'") {
188
+ quote = ch;
189
+ continue;
190
+ }
191
+ if (/\s/.test(ch)) {
192
+ if (cur) {
193
+ words.push(cur);
194
+ cur = "";
195
+ }
196
+ continue;
197
+ }
198
+ cur += ch;
199
+ }
200
+ if (cur)
201
+ words.push(cur);
202
+ return words.map(unquote);
203
+ }
204
+ function unquote(raw) {
205
+ if ((raw.startsWith('"') && raw.endsWith('"')) ||
206
+ (raw.startsWith("'") && raw.endsWith("'"))) {
207
+ return raw.slice(1, -1);
208
+ }
209
+ return raw;
210
+ }
41
211
  function discoverFromEnv(env) {
42
212
  const url = pickEnv(env, "OPENCLAW_ACP_URL") ??
43
213
  pickEnv(env, "OPENCLAW_GATEWAY_URL") ??
@@ -244,8 +414,9 @@ function pickString(obj, keys) {
244
414
  }
245
415
  function dedupeDiscovered(items) {
246
416
  const priority = {
247
- "config-file": 3,
248
- env: 2,
417
+ "config-file": 4,
418
+ env: 3,
419
+ "systemd-unit": 2,
249
420
  "default-port": 1,
250
421
  };
251
422
  const byUrl = new Map();
@@ -307,6 +478,9 @@ export function defaultOpenclawDiscoveryPorts() {
307
478
  export function defaultOpenclawDiscoveryTokenFilePaths() {
308
479
  return DEFAULT_TOKEN_FILE_PATHS.slice();
309
480
  }
481
+ export function defaultOpenclawDiscoverySystemdUnitPaths() {
482
+ return DEFAULT_SYSTEMD_UNIT_PATHS.slice();
483
+ }
310
484
  export function openclawDiscoveryConfigEnabled(cfg) {
311
485
  return cfg.openclawDiscovery?.enabled !== false;
312
486
  }
@@ -90,14 +90,6 @@ export declare function addAgentToConfig(cfg: DaemonConfig, agentId: string): Da
90
90
  export declare function removeAgentFromConfig(cfg: DaemonConfig, agentId: string): DaemonConfig | null;
91
91
  /** Drop the cache (e.g. before a `doctor`-style interactive re-probe). */
92
92
  export declare function clearRuntimeProbeCache(): void;
93
- /**
94
- * Probe every registered adapter and shape the result as the wire-level
95
- * {@link ListRuntimesResult} — used by both the `list_runtimes` ack path and
96
- * the daemon-side first-connect `runtime_snapshot` push in `daemon.ts`.
97
- *
98
- * Cached for {@link RUNTIME_PROBE_CACHE_TTL_MS}; pass `{ force: true }` to
99
- * bypass the cache.
100
- */
101
93
  export declare function collectRuntimeSnapshot(opts?: {
102
94
  force?: boolean;
103
95
  }): ListRuntimesResult;
@@ -200,6 +192,18 @@ interface ReloadResult {
200
192
  export declare function reloadConfig(ctx: {
201
193
  gateway: Gateway;
202
194
  }): Promise<ReloadResult>;
195
+ /**
196
+ * Scan `~/.botcord/credentials/*.json` and return a mapping from hermes
197
+ * profile name to the BotCord agent currently bound to it. Used by the
198
+ * runtime snapshot path to mark profiles as occupied in the picker, and by
199
+ * the provision path to enforce the 1 BotCord agent : 1 hermes profile
200
+ * invariant. The scan is cheap and authoritative — running daemon state may
201
+ * lag (e.g. between provision and reconcile), so we always read disk.
202
+ */
203
+ export declare function profileOccupancyMap(): Map<string, {
204
+ agentId: string;
205
+ agentName?: string;
206
+ }>;
203
207
  /**
204
208
  * Per-agent entry returned by `list_agents`. Wire shape: `{id, name, online}`.
205
209
  * `status` and `lastMessageAt` are extra daemon-only fields the dashboard
package/dist/provision.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * side effects (register agent, write credentials, load route, add/remove
5
5
  * gateway channel) and return an ack payload.
6
6
  */
7
- import { existsSync, readFileSync, rmSync, unlinkSync } from "node:fs";
7
+ import { existsSync, readdirSync, readFileSync, rmSync, unlinkSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
9
  import path from "node:path";
10
10
  import { BotCordClient, CONTROL_FRAME_TYPES, defaultCredentialsFile, derivePublicKey, loadStoredCredentials, writeCredentialsFile, } from "@botcord/protocol-core";
@@ -12,6 +12,7 @@ import { loadConfig, resolveConfiguredAgentIds, saveConfig, } from "./config.js"
12
12
  import { BOTCORD_CHANNEL_TYPE, buildManagedRoutes, prepareGatewayProfile, } from "./daemon-config-map.js";
13
13
  import { agentHomeDir, agentStateDir, agentWorkspaceDir, applyAgentIdentity, ensureAgentWorkspace, } from "./agent-workspace.js";
14
14
  import { detectRuntimes, getAdapterModule } from "./adapters/runtimes.js";
15
+ import { hermesProfileHomeDir, isValidHermesProfileName, listHermesProfiles, } from "./gateway/runtimes/hermes-agent.js";
15
16
  import { log as daemonLog } from "./log.js";
16
17
  import { discoverAgentCredentials } from "./agent-discovery.js";
17
18
  /**
@@ -67,7 +68,33 @@ export function createProvisioner(opts) {
67
68
  runtime: pickRuntime(params) ?? null,
68
69
  name: params.name ?? null,
69
70
  });
70
- const agent = await provisionAgent(params, { gateway, register, onAgentInstalled });
71
+ let agent;
72
+ try {
73
+ agent = await provisionAgent(params, {
74
+ gateway,
75
+ register,
76
+ onAgentInstalled,
77
+ });
78
+ }
79
+ catch (err) {
80
+ if (err instanceof HermesProfileError) {
81
+ daemonLog.warn("provision_agent: hermes profile rejected", {
82
+ code: err.code,
83
+ profile: err.profile,
84
+ occupiedBy: err.occupiedBy ?? null,
85
+ });
86
+ return {
87
+ ok: false,
88
+ error: {
89
+ code: err.code,
90
+ message: err.message,
91
+ ...(err.occupiedBy ? { occupiedBy: err.occupiedBy } : {}),
92
+ profile: err.profile,
93
+ },
94
+ };
95
+ }
96
+ throw err;
97
+ }
71
98
  // Seed the policy resolver from the optional `defaultAttention` /
72
99
  // `attentionKeywords` fields (PR3, control-frame.ts). Hub builds that
73
100
  // don't yet emit these stay backwards-compatible — the resolver just
@@ -213,6 +240,23 @@ async function provisionAgent(params, ctx) {
213
240
  });
214
241
  });
215
242
  }
243
+ const hermesSel = pickHermesSelection(resolvedParams);
244
+ if (hermesSel) {
245
+ return withHermesProvisionLock(hermesSel, async () => {
246
+ // Race-safe re-check inside the per-profile lock so two concurrent
247
+ // provisions for the same profile (e.g. two dashboard tabs) cannot
248
+ // both succeed.
249
+ validateHermesProfileForProvision(hermesSel, params.credentials?.agentId);
250
+ const cfg = loadConfig();
251
+ const credentials = await materializeCredentials(resolvedParams, cfg, ctx, explicitCwd);
252
+ return installLocalAgent(credentials, {
253
+ ...ctx,
254
+ cfg,
255
+ bio: params.bio,
256
+ source: params.credentials ? "hub-supplied" : "registered",
257
+ });
258
+ });
259
+ }
216
260
  const credentials = await materializeCredentials(resolvedParams, initialCfg, ctx, explicitCwd);
217
261
  return installLocalAgent(credentials, {
218
262
  ...ctx,
@@ -391,6 +435,9 @@ function upsertManagedRouteForCredentials(credentials, cfg, gateway) {
391
435
  };
392
436
  }
393
437
  }
438
+ if (synthRoute.runtime === "hermes-agent" && credentials.hermesProfile) {
439
+ synthRoute.hermesProfile = credentials.hermesProfile;
440
+ }
394
441
  gateway.upsertManagedRoute(credentials.agentId, synthRoute);
395
442
  }
396
443
  async function installExistingOpenclawBinding(agentId, ctx) {
@@ -482,6 +529,9 @@ async function materializeCredentials(params, cfg, ctx, explicitCwd) {
482
529
  record.openclawGateway = openclawSel.gateway;
483
530
  if (openclawSel.agent)
484
531
  record.openclawAgent = openclawSel.agent;
532
+ const hermesSel = pickHermesSelection(params);
533
+ if (hermesSel)
534
+ record.hermesProfile = hermesSel;
485
535
  return record;
486
536
  }
487
537
  // Slow path: daemon registers a fresh identity against Hub. We need a
@@ -514,6 +564,9 @@ async function materializeCredentials(params, cfg, ctx, explicitCwd) {
514
564
  record.openclawGateway = openclawSel.gateway;
515
565
  if (openclawSel.agent)
516
566
  record.openclawAgent = openclawSel.agent;
567
+ const hermesSel = pickHermesSelection(params);
568
+ if (hermesSel)
569
+ record.hermesProfile = hermesSel;
517
570
  return record;
518
571
  }
519
572
  /**
@@ -596,6 +649,69 @@ function withResolvedOpenclawSelection(params, selection) {
596
649
  },
597
650
  };
598
651
  }
652
+ /**
653
+ * Resolve hermes profile selection from a `provision_agent` frame. Top-level
654
+ * `params.hermes.profile` (nested) wins over the flat
655
+ * `credentials.hermesProfile` mirror. Returning `undefined` is fine — the
656
+ * adapter falls back to the BotCord-isolated HERMES_HOME under
657
+ * `~/.botcord/agents/<id>/` when the agent is not attached to a profile.
658
+ */
659
+ function pickHermesSelection(params) {
660
+ const top = params.hermes;
661
+ if (top && typeof top.profile === "string" && top.profile.length > 0) {
662
+ return top.profile;
663
+ }
664
+ const flat = params.credentials;
665
+ if (flat && typeof flat.hermesProfile === "string" && flat.hermesProfile.length > 0) {
666
+ return flat.hermesProfile;
667
+ }
668
+ return undefined;
669
+ }
670
+ const hermesProvisionLocks = new Map();
671
+ async function withHermesProvisionLock(profile, fn) {
672
+ const prev = hermesProvisionLocks.get(profile) ?? Promise.resolve();
673
+ const next = prev.then(fn, fn);
674
+ hermesProvisionLocks.set(profile, next);
675
+ try {
676
+ return (await next);
677
+ }
678
+ finally {
679
+ if (hermesProvisionLocks.get(profile) === next) {
680
+ hermesProvisionLocks.delete(profile);
681
+ }
682
+ }
683
+ }
684
+ class HermesProfileError extends Error {
685
+ code;
686
+ profile;
687
+ occupiedBy;
688
+ constructor(code, message, profile, occupiedBy) {
689
+ super(message);
690
+ this.code = code;
691
+ this.profile = profile;
692
+ this.occupiedBy = occupiedBy;
693
+ this.name = "HermesProfileError";
694
+ }
695
+ }
696
+ /**
697
+ * Validate that `profile` exists on disk and is not already bound to another
698
+ * BotCord agent. Throws {@link HermesProfileError} on failure so the caller
699
+ * can surface the structured error code via the control-frame ack.
700
+ */
701
+ function validateHermesProfileForProvision(profile, selfAgentId) {
702
+ if (!isValidHermesProfileName(profile)) {
703
+ throw new HermesProfileError("hermes_profile_invalid", `Hermes profile "${profile}" is not a valid profile name.`, profile);
704
+ }
705
+ const home = hermesProfileHomeDir(profile);
706
+ if (!existsSync(home)) {
707
+ throw new HermesProfileError("hermes_profile_not_found", `Hermes profile "${profile}" does not exist at ${home}. Create it via "hermes profile create ${profile}" and retry.`, profile);
708
+ }
709
+ const occupancy = profileOccupancyMap();
710
+ const owner = occupancy.get(profile);
711
+ if (owner && owner.agentId !== selfAgentId) {
712
+ throw new HermesProfileError("hermes_profile_occupied", `Hermes profile "${profile}" is already bound to BotCord agent ${owner.agentId}.`, profile, owner.agentId);
713
+ }
714
+ }
599
715
  async function withOpenclawProvisionLock(gateway, agent, fn) {
600
716
  const key = `${gateway}\0${agent}`;
601
717
  const prev = openclawProvisionLocks.get(key) ?? Promise.resolve();
@@ -922,6 +1038,33 @@ export function clearRuntimeProbeCache() {
922
1038
  * Cached for {@link RUNTIME_PROBE_CACHE_TTL_MS}; pass `{ force: true }` to
923
1039
  * bypass the cache.
924
1040
  */
1041
+ /**
1042
+ * Build the wire-shape `HermesProfileProbe[]` for the runtime snapshot,
1043
+ * joining the on-disk profile listing with the BotCord-side occupancy map
1044
+ * so dashboards can render disabled rows without a second round trip.
1045
+ */
1046
+ function listHermesProfilesForSnapshot(occupancy) {
1047
+ return listHermesProfiles().map((p) => {
1048
+ const out = { name: p.name, home: p.home };
1049
+ if (p.isDefault)
1050
+ out.isDefault = true;
1051
+ if (p.isActive)
1052
+ out.isActive = true;
1053
+ if (p.modelName)
1054
+ out.modelName = p.modelName;
1055
+ if (typeof p.sessionsCount === "number")
1056
+ out.sessionsCount = p.sessionsCount;
1057
+ if (p.hasSoul)
1058
+ out.hasSoul = true;
1059
+ const owner = occupancy.get(p.name);
1060
+ if (owner) {
1061
+ out.occupiedBy = owner.agentId;
1062
+ if (owner.agentName)
1063
+ out.occupiedByName = owner.agentName;
1064
+ }
1065
+ return out;
1066
+ });
1067
+ }
925
1068
  export function collectRuntimeSnapshot(opts = {}) {
926
1069
  if (!opts.force &&
927
1070
  _runtimeProbeCache &&
@@ -929,6 +1072,7 @@ export function collectRuntimeSnapshot(opts = {}) {
929
1072
  return _runtimeProbeCache.value;
930
1073
  }
931
1074
  const entries = detectRuntimes();
1075
+ const occupancy = profileOccupancyMap();
932
1076
  const runtimes = entries.map((entry) => {
933
1077
  const record = {
934
1078
  id: entry.id,
@@ -945,6 +1089,9 @@ export function collectRuntimeSnapshot(opts = {}) {
945
1089
  // already swallows throws into `{available: false}`. We leave the wire
946
1090
  // field blank in that case and let callers treat `!available` as reason
947
1091
  // enough; filling a synthetic message would be misleading.
1092
+ if (entry.id === "hermes-agent" && entry.result.available) {
1093
+ record.profiles = listHermesProfilesForSnapshot(occupancy);
1094
+ }
948
1095
  return record;
949
1096
  });
950
1097
  const value = { runtimes, probedAt: Date.now() };
@@ -1457,7 +1604,9 @@ function readAgentRuntimesFromCredentials(agentIds) {
1457
1604
  entry.openclawGateway = creds.openclawGateway;
1458
1605
  if (creds.openclawAgent)
1459
1606
  entry.openclawAgent = creds.openclawAgent;
1460
- if (entry.runtime || entry.cwd || entry.openclawGateway || entry.openclawAgent)
1607
+ if (creds.hermesProfile)
1608
+ entry.hermesProfile = creds.hermesProfile;
1609
+ if (entry.runtime || entry.cwd || entry.openclawGateway || entry.openclawAgent || entry.hermesProfile)
1461
1610
  out[id] = entry;
1462
1611
  }
1463
1612
  catch {
@@ -1466,6 +1615,48 @@ function readAgentRuntimesFromCredentials(agentIds) {
1466
1615
  }
1467
1616
  return out;
1468
1617
  }
1618
+ /**
1619
+ * Scan `~/.botcord/credentials/*.json` and return a mapping from hermes
1620
+ * profile name to the BotCord agent currently bound to it. Used by the
1621
+ * runtime snapshot path to mark profiles as occupied in the picker, and by
1622
+ * the provision path to enforce the 1 BotCord agent : 1 hermes profile
1623
+ * invariant. The scan is cheap and authoritative — running daemon state may
1624
+ * lag (e.g. between provision and reconcile), so we always read disk.
1625
+ */
1626
+ export function profileOccupancyMap() {
1627
+ const out = new Map();
1628
+ const dir = path.join(homedir(), ".botcord", "credentials");
1629
+ let names = [];
1630
+ try {
1631
+ names = readdirSync(dir);
1632
+ }
1633
+ catch {
1634
+ return out;
1635
+ }
1636
+ for (const file of names) {
1637
+ if (!file.endsWith(".json"))
1638
+ continue;
1639
+ try {
1640
+ const creds = loadStoredCredentials(path.join(dir, file));
1641
+ if (creds.runtime !== "hermes-agent")
1642
+ continue;
1643
+ if (!creds.hermesProfile)
1644
+ continue;
1645
+ // First write wins — ties shouldn't happen, but if they do, the
1646
+ // provision path's race check will surface it.
1647
+ if (!out.has(creds.hermesProfile)) {
1648
+ out.set(creds.hermesProfile, {
1649
+ agentId: creds.agentId,
1650
+ agentName: creds.displayName,
1651
+ });
1652
+ }
1653
+ }
1654
+ catch {
1655
+ // skip unreadable
1656
+ }
1657
+ }
1658
+ return out;
1659
+ }
1469
1660
  function listAgentsFromGateway(gateway) {
1470
1661
  const snap = gateway.snapshot();
1471
1662
  // Include any configured agents that the gateway may not have a status for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.26",
3
+ "version": "0.2.28",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {