@botcord/daemon 0.2.90 → 0.2.92

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.
@@ -187,6 +187,35 @@ function normalizeInboxBatch(msgs, options) {
187
187
  raw: { ...latest, batch: msgs },
188
188
  };
189
189
  }
190
+ /**
191
+ * Fire-and-forget authenticated POST for presence/streaming control requests
192
+ * (`/hub/typing`, `/hub/stream-block`). Mirrors `BotCordClient.hubFetch`'s 401
193
+ * handling: a stale-but-unexpired token (e.g. after a Hub JWT secret rotation,
194
+ * which `ensureToken()` won't refresh because it only refreshes near expiry) is
195
+ * refreshed once and the request retried. Without this, typing/stream-block
196
+ * silently 401 in a loop until the next actual message send happens to refresh
197
+ * the token — leaving the conversation with no typing indicator or live stream.
198
+ */
199
+ async function postControlWithRefresh(client, hubUrl, path, body) {
200
+ let token = await client.ensureToken();
201
+ for (let attempt = 0; attempt <= 1; attempt++) {
202
+ const resp = await fetch(`${hubUrl}${path}`, {
203
+ method: "POST",
204
+ headers: {
205
+ "Content-Type": "application/json",
206
+ Authorization: `Bearer ${token}`,
207
+ },
208
+ body: JSON.stringify(body),
209
+ signal: AbortSignal.timeout(10_000),
210
+ });
211
+ if (resp.status === 401 && attempt === 0) {
212
+ token = await client.refreshToken();
213
+ continue;
214
+ }
215
+ return resp;
216
+ }
217
+ throw new Error("postControlWithRefresh: exhausted retries");
218
+ }
190
219
  /**
191
220
  * Construct a BotCord channel adapter.
192
221
  *
@@ -758,21 +787,12 @@ export function createBotCordChannel(options) {
758
787
  const client = ensureClient();
759
788
  const hubUrl = options.hubBaseUrl ?? client.getHubUrl();
760
789
  try {
761
- const token = await client.ensureToken();
762
790
  const block = ctx.block;
763
791
  const seq = typeof block?.seq === "number" ? block.seq : 0;
764
- const resp = await fetch(`${hubUrl}/hub/stream-block`, {
765
- method: "POST",
766
- headers: {
767
- "Content-Type": "application/json",
768
- Authorization: `Bearer ${token}`,
769
- },
770
- body: JSON.stringify({
771
- trace_id: ctx.traceId,
772
- seq,
773
- block: normalizeBlockForHub(block, seq),
774
- }),
775
- signal: AbortSignal.timeout(10_000),
792
+ const resp = await postControlWithRefresh(client, hubUrl, "/hub/stream-block", {
793
+ trace_id: ctx.traceId,
794
+ seq,
795
+ block: normalizeBlockForHub(block, seq),
776
796
  });
777
797
  if (!resp.ok && resp.status !== 204) {
778
798
  const body = await resp.text().catch(() => "");
@@ -790,15 +810,8 @@ export function createBotCordChannel(options) {
790
810
  const client = ensureClient();
791
811
  const hubUrl = options.hubBaseUrl ?? client.getHubUrl();
792
812
  try {
793
- const token = await client.ensureToken();
794
- const resp = await fetch(`${hubUrl}/hub/typing`, {
795
- method: "POST",
796
- headers: {
797
- "Content-Type": "application/json",
798
- Authorization: `Bearer ${token}`,
799
- },
800
- body: JSON.stringify({ room_id: ctx.conversationId }),
801
- signal: AbortSignal.timeout(10_000),
813
+ const resp = await postControlWithRefresh(client, hubUrl, "/hub/typing", {
814
+ room_id: ctx.conversationId,
802
815
  });
803
816
  if (!resp.ok && resp.status !== 204) {
804
817
  const body = await resp.text().catch(() => "");
@@ -66,6 +66,9 @@ export function createGatewayControl(ctx) {
66
66
  throw urlErr;
67
67
  }
68
68
  const cfg = cfgIO.load();
69
+ const existingProfiles = cfg.thirdPartyGateways ?? [];
70
+ const prevProfile = existingProfiles.find((g) => g.id === params.id);
71
+ const hadExistingProfile = prevProfile !== undefined;
69
72
  // accountId must belong to a daemon-bound agent. An empty agent set
70
73
  // (no agents provisioned yet) is itself a hard reject — otherwise we
71
74
  // would silently accept upserts against a daemon that has nowhere to
@@ -144,42 +147,62 @@ export function createGatewayControl(ctx) {
144
147
  else if (params.type === "feishu") {
145
148
  const loginId = params.loginId;
146
149
  if (!loginId) {
147
- return badParams("upsert_gateway: feishu requires loginId");
148
- }
149
- const resolved = sessions.resolve(loginId);
150
- if (resolved.state !== "live") {
151
- return {
152
- ok: false,
153
- error: resolved.state === "missing"
154
- ? { code: "login_missing", message: `feishu login session "${loginId}" not found` }
155
- : { code: "login_expired", message: `feishu login session "${loginId}" expired` },
156
- };
157
- }
158
- const session = resolved.session;
159
- if (session.provider !== "feishu") {
160
- return badParams(`upsert_gateway: login session provider "${session.provider}" != "feishu"`);
161
- }
162
- if (session.accountId !== params.accountId) {
163
- return {
164
- ok: false,
165
- error: {
166
- code: "login_account_mismatch",
167
- message: "feishu login session accountId does not match upsert request",
168
- },
169
- };
150
+ if (!prevProfile ||
151
+ prevProfile.type !== "feishu" ||
152
+ prevProfile.accountId !== params.accountId ||
153
+ !prevProfile.appId) {
154
+ return badParams("upsert_gateway: feishu requires loginId");
155
+ }
156
+ const existing = loadGatewaySecret(params.id);
157
+ if (!existing?.appSecret) {
158
+ return badParams("upsert_gateway: feishu requires loginId");
159
+ }
160
+ if (params.settings?.domain !== undefined &&
161
+ params.settings.domain !== (prevProfile.domain ?? "feishu")) {
162
+ return badParams("upsert_gateway: feishu domain change requires a fresh loginId");
163
+ }
164
+ secretPayload = { appSecret: existing.appSecret };
165
+ tokenPreviewSource = existing.appSecret;
166
+ feishuAppId = prevProfile.appId;
167
+ feishuDomain = params.settings?.domain ?? prevProfile.domain ?? "feishu";
168
+ feishuUserOpenId = prevProfile.userOpenId;
170
169
  }
171
- if (!session.appId || !session.appSecret) {
172
- return {
173
- ok: false,
174
- error: { code: "login_unconfirmed", message: "feishu login session has no app credentials yet" },
175
- };
170
+ else {
171
+ const resolved = sessions.resolve(loginId);
172
+ if (resolved.state !== "live") {
173
+ return {
174
+ ok: false,
175
+ error: resolved.state === "missing"
176
+ ? { code: "login_missing", message: `feishu login session "${loginId}" not found` }
177
+ : { code: "login_expired", message: `feishu login session "${loginId}" expired` },
178
+ };
179
+ }
180
+ const session = resolved.session;
181
+ if (session.provider !== "feishu") {
182
+ return badParams(`upsert_gateway: login session provider "${session.provider}" != "feishu"`);
183
+ }
184
+ if (session.accountId !== params.accountId) {
185
+ return {
186
+ ok: false,
187
+ error: {
188
+ code: "login_account_mismatch",
189
+ message: "feishu login session accountId does not match upsert request",
190
+ },
191
+ };
192
+ }
193
+ if (!session.appId || !session.appSecret) {
194
+ return {
195
+ ok: false,
196
+ error: { code: "login_unconfirmed", message: "feishu login session has no app credentials yet" },
197
+ };
198
+ }
199
+ secretPayload = { appSecret: session.appSecret };
200
+ tokenPreviewSource = session.appSecret;
201
+ feishuAppId = session.appId;
202
+ feishuDomain = session.domain ?? params.settings?.domain ?? "feishu";
203
+ feishuUserOpenId = session.userOpenId;
204
+ sessions.update(loginId, { gatewayId: params.id });
176
205
  }
177
- secretPayload = { appSecret: session.appSecret };
178
- tokenPreviewSource = session.appSecret;
179
- feishuAppId = session.appId;
180
- feishuDomain = session.domain ?? params.settings?.domain ?? "feishu";
181
- feishuUserOpenId = session.userOpenId;
182
- sessions.update(loginId, { gatewayId: params.id });
183
206
  }
184
207
  else {
185
208
  return badParams(`upsert_gateway: unknown provider "${params.type}"`);
@@ -187,9 +210,6 @@ export function createGatewayControl(ctx) {
187
210
  // W3/W6: remember whether a profile already exists for this id BEFORE we
188
211
  // write the secret/config. For UPDATE path, capture previous profile +
189
212
  // previous secret so addChannel failure can restore prior state.
190
- const existingProfiles = cfg.thirdPartyGateways ?? [];
191
- const hadExistingProfile = existingProfiles.some((g) => g.id === params.id);
192
- const prevProfile = existingProfiles.find((g) => g.id === params.id);
193
213
  // W6: load the previous secret for UPDATE rollback BEFORE overwriting.
194
214
  const prevSecret = hadExistingProfile
195
215
  ? loadGatewaySecret(params.id)
@@ -253,19 +273,24 @@ export function createGatewayControl(ctx) {
253
273
  }
254
274
  try {
255
275
  if (prevProfile) {
256
- cfgIO.save(upsertProfileInConfig(cfgIO.load(), prevProfile));
276
+ cfgIO.save(replaceProfileInConfig(cfgIO.load(), prevProfile));
257
277
  }
258
278
  }
259
279
  catch {
260
280
  // best-effort
261
281
  }
262
282
  try {
263
- if (prevProfile && prevSecret?.botToken) {
283
+ if (prevProfile &&
284
+ ((prevProfile.type === "telegram" && prevSecret?.botToken) ||
285
+ (prevProfile.type === "feishu" && prevSecret?.appSecret))) {
264
286
  await ctx.gateway.addChannel(buildChannelConfig({
265
287
  ...params,
266
288
  type: prevProfile.type,
289
+ accountId: prevProfile.accountId,
267
290
  enabled: prevProfile.enabled !== false,
268
- secret: { botToken: prevSecret.botToken },
291
+ ...(prevProfile.type === "telegram"
292
+ ? { secret: { botToken: prevSecret.botToken } }
293
+ : {}),
269
294
  settings: {
270
295
  baseUrl: prevProfile.baseUrl,
271
296
  allowedSenderIds: prevProfile.allowedSenderIds,
@@ -854,6 +879,9 @@ function validateOutboundConversation(profile, conversationId) {
854
879
  },
855
880
  };
856
881
  }
882
+ if (profile.type === "feishu" && (profile.allowedChatIds ?? []).length === 0) {
883
+ return null;
884
+ }
857
885
  const allowed = new Set((profile.allowedChatIds ?? []).map(String));
858
886
  if (!allowed.has(chatId)) {
859
887
  return {
@@ -929,6 +957,18 @@ function upsertProfileInConfig(cfg, patch) {
929
957
  }
930
958
  return { ...cfg, thirdPartyGateways: list };
931
959
  }
960
+ function replaceProfileInConfig(cfg, profile) {
961
+ const list = (cfg.thirdPartyGateways ?? []).slice();
962
+ const idx = list.findIndex((g) => g.id === profile.id);
963
+ const compact = compactProfile(profile);
964
+ if (idx >= 0) {
965
+ list[idx] = compact;
966
+ }
967
+ else {
968
+ list.push(compact);
969
+ }
970
+ return { ...cfg, thirdPartyGateways: list };
971
+ }
932
972
  function compactProfile(p) {
933
973
  const out = {
934
974
  id: p.id,
package/dist/provision.js CHANGED
@@ -23,6 +23,7 @@ import { collectAgentSkillSnapshot } from "./skill-index.js";
23
23
  import { installAgentSkillManifest, installBotLearnArchiveManifest, installVercelSkillsForAgent, } from "./skill-installer.js";
24
24
  import { buildRuntimeSelectionExtraArgs, mergeRuntimeExtraArgs, } from "./runtime-route-options.js";
25
25
  import { handleCloudGatewayRuntimeInbound, } from "./cloud-gateway-runtime.js";
26
+ import { scheduleDaemonSelfRestart } from "./self-restart.js";
26
27
  function skillIndexOptionsForLoadedAgent(gateway, agentId) {
27
28
  const route = gateway.listManagedRoutes()
28
29
  .find((entry) => entry.match?.accountId === agentId);
@@ -240,6 +241,16 @@ export function createProvisioner(opts) {
240
241
  daemonLog.debug("list_runtimes", { count: snapshot.runtimes.length });
241
242
  return { ok: true, result: snapshot };
242
243
  }
244
+ case CONTROL_FRAME_TYPES.RESTART_DAEMON: {
245
+ const plan = scheduleDaemonSelfRestart({ update: true });
246
+ daemonLog.warn("restart_daemon: scheduled self restart", {
247
+ updateRequested: plan.updateRequested,
248
+ updateSupported: plan.updateSupported,
249
+ installPrefix: plan.installPrefix,
250
+ packageSpec: plan.packageSpec,
251
+ });
252
+ return { ok: true, result: plan };
253
+ }
243
254
  case "list_gateways":
244
255
  return gatewayControl.handleList();
245
256
  case "upsert_gateway": {
@@ -530,8 +541,15 @@ async function handleWakeAgent(gateway, raw) {
530
541
  streamable: false,
531
542
  },
532
543
  };
533
- await gateway.injectInbound(msg);
534
- return { ok: true, result: { agent_id: agentId } };
544
+ void gateway.injectInbound(msg).catch((err) => {
545
+ daemonLog.error("wake_agent: async inject failed", {
546
+ agentId,
547
+ scheduleId: scheduleId ?? null,
548
+ runId,
549
+ error: err instanceof Error ? err.message : String(err),
550
+ });
551
+ });
552
+ return { ok: true, result: { agent_id: agentId, queued: true } };
535
553
  }
536
554
  function validateGatewayParams(raw, spec) {
537
555
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
@@ -0,0 +1,29 @@
1
+ import { spawn } from "node:child_process";
2
+ export interface DaemonRestartPlan {
3
+ scheduled: boolean;
4
+ updateRequested: boolean;
5
+ updateSupported: boolean;
6
+ installPrefix: string | null;
7
+ packageSpec: string;
8
+ }
9
+ export interface ScheduleDaemonSelfRestartOptions {
10
+ update?: boolean;
11
+ packageSpec?: string;
12
+ delayMs?: number;
13
+ forceExitAfterMs?: number;
14
+ entrypoint?: string;
15
+ restartArgs?: string[];
16
+ }
17
+ export interface ScheduleDaemonSelfRestartDeps {
18
+ spawn?: typeof spawn;
19
+ setTimeout?: typeof setTimeout;
20
+ kill?: (pid: number, signal: NodeJS.Signals) => void;
21
+ exit?: (code?: number) => never;
22
+ pid?: number;
23
+ execPath?: string;
24
+ argv?: string[];
25
+ env?: NodeJS.ProcessEnv;
26
+ }
27
+ export declare function findDaemonInstallPrefix(entrypoint?: string): string | null;
28
+ export declare function resolveNpmBin(nodePath?: string): string;
29
+ export declare function scheduleDaemonSelfRestart(opts?: ScheduleDaemonSelfRestartOptions, deps?: ScheduleDaemonSelfRestartDeps): DaemonRestartPlan;
@@ -0,0 +1,172 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, realpathSync } from "node:fs";
3
+ import path from "node:path";
4
+ const DEFAULT_DAEMON_PACKAGE = "@botcord/daemon@latest";
5
+ const DEFAULT_SHUTDOWN_DELAY_MS = 750;
6
+ const DEFAULT_FORCE_EXIT_MS = 10_000;
7
+ const DEFAULT_PARENT_EXIT_WAIT_MS = 30_000;
8
+ const DEFAULT_INSTALL_TIMEOUT_MS = 120_000;
9
+ export function findDaemonInstallPrefix(entrypoint) {
10
+ if (!entrypoint)
11
+ return null;
12
+ const candidates = [entrypoint, safeRealpath(entrypoint)];
13
+ for (const candidate of candidates) {
14
+ if (!candidate)
15
+ continue;
16
+ const prefix = installPrefixFromPath(candidate);
17
+ if (prefix)
18
+ return prefix;
19
+ }
20
+ return null;
21
+ }
22
+ export function resolveNpmBin(nodePath = process.execPath) {
23
+ const name = process.platform === "win32" ? "npm.cmd" : "npm";
24
+ const sibling = path.join(path.dirname(nodePath), name);
25
+ return existsSync(sibling) ? sibling : name;
26
+ }
27
+ export function scheduleDaemonSelfRestart(opts = {}, deps = {}) {
28
+ const env = deps.env ?? process.env;
29
+ const argv = deps.argv ?? process.argv;
30
+ const entrypoint = opts.entrypoint ?? argv[1];
31
+ if (!entrypoint) {
32
+ throw new Error("cannot restart daemon: process entrypoint is unknown");
33
+ }
34
+ const updateRequested = opts.update !== false;
35
+ const installPrefix = updateRequested ? findDaemonInstallPrefix(entrypoint) : null;
36
+ const packageSpec = opts.packageSpec ?? env.BOTCORD_DAEMON_PACKAGE ?? DEFAULT_DAEMON_PACKAGE;
37
+ const execPath = deps.execPath ?? process.execPath;
38
+ const pid = deps.pid ?? process.pid;
39
+ const restartArgs = opts.restartArgs ?? ["start", "--foreground"];
40
+ const supervisorEnv = {
41
+ ...env,
42
+ BOTCORD_DAEMON_CHILD: "1",
43
+ BOTCORD_RESTART_PARENT_PID: String(pid),
44
+ BOTCORD_RESTART_ENTRYPOINT: entrypoint,
45
+ BOTCORD_RESTART_ARGS_JSON: JSON.stringify(restartArgs),
46
+ BOTCORD_RESTART_NODE: execPath,
47
+ BOTCORD_RESTART_NPM_BIN: resolveNpmBin(execPath),
48
+ BOTCORD_RESTART_UPDATE: updateRequested && installPrefix ? "1" : "0",
49
+ BOTCORD_RESTART_INSTALL_PREFIX: installPrefix ?? "",
50
+ BOTCORD_RESTART_PACKAGE: packageSpec,
51
+ BOTCORD_RESTART_PARENT_EXIT_WAIT_MS: String(DEFAULT_PARENT_EXIT_WAIT_MS),
52
+ BOTCORD_RESTART_INSTALL_TIMEOUT_MS: String(DEFAULT_INSTALL_TIMEOUT_MS),
53
+ };
54
+ const spawnImpl = deps.spawn ?? spawn;
55
+ const child = spawnImpl(execPath, ["-e", RESTART_SUPERVISOR_SCRIPT], {
56
+ detached: true,
57
+ stdio: "ignore",
58
+ env: supervisorEnv,
59
+ });
60
+ child.unref();
61
+ const setTimer = deps.setTimeout ?? setTimeout;
62
+ const kill = deps.kill ?? process.kill.bind(process);
63
+ const exit = deps.exit ?? process.exit.bind(process);
64
+ const delayMs = opts.delayMs ?? DEFAULT_SHUTDOWN_DELAY_MS;
65
+ const forceExitAfterMs = opts.forceExitAfterMs ?? DEFAULT_FORCE_EXIT_MS;
66
+ const shutdownTimer = setTimer(() => {
67
+ try {
68
+ kill(pid, "SIGTERM");
69
+ }
70
+ catch {
71
+ exit(0);
72
+ return;
73
+ }
74
+ const exitTimer = setTimer(() => exit(0), forceExitAfterMs);
75
+ unrefTimer(exitTimer);
76
+ }, delayMs);
77
+ unrefTimer(shutdownTimer);
78
+ return {
79
+ scheduled: true,
80
+ updateRequested,
81
+ updateSupported: installPrefix !== null,
82
+ installPrefix,
83
+ packageSpec,
84
+ };
85
+ }
86
+ function safeRealpath(input) {
87
+ try {
88
+ return realpathSync(input);
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ }
94
+ function installPrefixFromPath(input) {
95
+ const parts = path.resolve(input).split(path.sep);
96
+ for (let i = parts.length - 3; i >= 0; i--) {
97
+ if (parts[i] !== "node_modules" ||
98
+ parts[i + 1] !== "@botcord" ||
99
+ parts[i + 2] !== "daemon") {
100
+ continue;
101
+ }
102
+ const prefix = parts.slice(0, i).join(path.sep) || path.sep;
103
+ const packageJson = path.join(prefix, "node_modules", "@botcord", "daemon", "package.json");
104
+ return existsSync(packageJson) ? prefix : null;
105
+ }
106
+ return null;
107
+ }
108
+ function unrefTimer(timer) {
109
+ const maybe = timer;
110
+ if (typeof maybe.unref === "function") {
111
+ maybe.unref();
112
+ }
113
+ }
114
+ const RESTART_SUPERVISOR_SCRIPT = `
115
+ const cp = require("node:child_process");
116
+
117
+ function sleep(ms) {
118
+ return new Promise((resolve) => setTimeout(resolve, ms));
119
+ }
120
+
121
+ function alive(pid) {
122
+ try {
123
+ process.kill(pid, 0);
124
+ return true;
125
+ } catch {
126
+ return false;
127
+ }
128
+ }
129
+
130
+ async function main() {
131
+ const parentPid = Number(process.env.BOTCORD_RESTART_PARENT_PID || "0");
132
+ const waitMs = Number(process.env.BOTCORD_RESTART_PARENT_EXIT_WAIT_MS || "30000");
133
+ const deadline = Date.now() + waitMs;
134
+ while (parentPid > 0 && alive(parentPid) && Date.now() < deadline) {
135
+ await sleep(250);
136
+ }
137
+
138
+ const update = process.env.BOTCORD_RESTART_UPDATE === "1";
139
+ const installPrefix = process.env.BOTCORD_RESTART_INSTALL_PREFIX || "";
140
+ if (update && installPrefix) {
141
+ const npmBin = process.env.BOTCORD_RESTART_NPM_BIN || "npm";
142
+ const packageSpec = process.env.BOTCORD_RESTART_PACKAGE || "${DEFAULT_DAEMON_PACKAGE}";
143
+ const timeout = Number(process.env.BOTCORD_RESTART_INSTALL_TIMEOUT_MS || "120000");
144
+ cp.spawnSync(npmBin, ["install", "--prefix", installPrefix, packageSpec], {
145
+ stdio: "ignore",
146
+ env: process.env,
147
+ timeout,
148
+ });
149
+ }
150
+
151
+ const node = process.env.BOTCORD_RESTART_NODE || process.execPath;
152
+ const entrypoint = process.env.BOTCORD_RESTART_ENTRYPOINT;
153
+ if (!entrypoint) process.exit(1);
154
+ let args = ["start", "--foreground"];
155
+ try {
156
+ const parsed = JSON.parse(process.env.BOTCORD_RESTART_ARGS_JSON || "[]");
157
+ if (Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) {
158
+ args = parsed;
159
+ }
160
+ } catch {
161
+ // keep default args
162
+ }
163
+ const child = cp.spawn(node, [entrypoint, ...args], {
164
+ detached: true,
165
+ stdio: "ignore",
166
+ env: { ...process.env, BOTCORD_DAEMON_CHILD: "1" },
167
+ });
168
+ child.unref();
169
+ }
170
+
171
+ main().catch(() => process.exit(1));
172
+ `;
@@ -18,6 +18,18 @@ export function defaultSkillDirs(agentId, opts = {}) {
18
18
  source: "agent-codex",
19
19
  runtime: "codex",
20
20
  };
21
+ const agentGemini = [
22
+ {
23
+ dir: path.join(agentWorkspaceDir(agentId), ".gemini", "skills"),
24
+ source: "agent-gemini",
25
+ runtime: "gemini",
26
+ },
27
+ {
28
+ dir: path.join(agentWorkspaceDir(agentId), ".agents", "skills"),
29
+ source: "agent-agents",
30
+ runtime: "gemini",
31
+ },
32
+ ];
21
33
  const agentHermes = hermesSkillRoot(agentId, opts.hermesProfile);
22
34
  const dirs = [];
23
35
  switch (runtimeFamily(opts.runtime)) {
@@ -34,6 +46,20 @@ export function defaultSkillDirs(agentId, opts = {}) {
34
46
  case "hermes":
35
47
  dirs.push(agentHermes);
36
48
  break;
49
+ case "gemini":
50
+ dirs.push(...agentGemini);
51
+ if (includeGlobal) {
52
+ dirs.push({
53
+ dir: path.join(homedir(), ".gemini", "skills"),
54
+ source: "global-gemini",
55
+ runtime: "gemini",
56
+ }, {
57
+ dir: path.join(homedir(), ".agents", "skills"),
58
+ source: "global-agents",
59
+ runtime: "gemini",
60
+ });
61
+ }
62
+ break;
37
63
  case "claude":
38
64
  dirs.push(agentClaude);
39
65
  if (includeGlobal) {
@@ -228,6 +254,8 @@ function hermesSkillRoot(agentId, profile) {
228
254
  function runtimeFamily(runtime) {
229
255
  if (runtime === "codex")
230
256
  return "codex";
257
+ if (runtime === "gemini")
258
+ return "gemini";
231
259
  if (runtime === "hermes-agent")
232
260
  return "hermes";
233
261
  if (!runtime)
@@ -237,18 +265,11 @@ function runtimeFamily(runtime) {
237
265
  return "other";
238
266
  }
239
267
  function priority(source, _runtime) {
240
- switch (source) {
241
- case "agent-claude":
242
- case "agent-codex":
243
- case "agent-hermes":
244
- case "agent-hermes-profile":
245
- return 0;
246
- case "global-claude":
247
- case "global-codex":
248
- return 1;
249
- default:
250
- return 2;
251
- }
268
+ if (source.startsWith("agent-"))
269
+ return 0;
270
+ if (source.startsWith("global-"))
271
+ return 1;
272
+ return 2;
252
273
  }
253
274
  function snapshotSource(source) {
254
275
  return source.startsWith("agent-") ? "workspace" : "runtime-global";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.90",
3
+ "version": "0.2.92",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,8 +23,8 @@
23
23
  "dependencies": {
24
24
  "@larksuiteoapi/node-sdk": "^1.63.1",
25
25
  "ws": "^8.20.1",
26
- "@botcord/protocol-core": "^0.2.13",
27
- "@botcord/cli": "^0.1.18"
26
+ "@botcord/cli": "^0.1.19",
27
+ "@botcord/protocol-core": "^0.2.13"
28
28
  },
29
29
  "overrides": {
30
30
  "axios": "^1.15.2"