@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.
@@ -271,6 +271,9 @@ export function createGatewayControl(ctx: GatewayControlContext) {
271
271
  }
272
272
 
273
273
  const cfg = cfgIO.load();
274
+ const existingProfiles = cfg.thirdPartyGateways ?? [];
275
+ const prevProfile = existingProfiles.find((g) => g.id === params.id);
276
+ const hadExistingProfile = prevProfile !== undefined;
274
277
 
275
278
  // accountId must belong to a daemon-bound agent. An empty agent set
276
279
  // (no agents provisioned yet) is itself a hard reject — otherwise we
@@ -349,43 +352,66 @@ export function createGatewayControl(ctx: GatewayControlContext) {
349
352
  } else if (params.type === "feishu") {
350
353
  const loginId = params.loginId;
351
354
  if (!loginId) {
352
- return badParams("upsert_gateway: feishu requires loginId");
353
- }
354
- const resolved = sessions.resolve(loginId);
355
- if (resolved.state !== "live") {
356
- return {
357
- ok: false,
358
- error:
359
- resolved.state === "missing"
360
- ? { code: "login_missing", message: `feishu login session "${loginId}" not found` }
361
- : { code: "login_expired", message: `feishu login session "${loginId}" expired` },
362
- };
363
- }
364
- const session = resolved.session!;
365
- if (session.provider !== "feishu") {
366
- return badParams(`upsert_gateway: login session provider "${session.provider}" != "feishu"`);
367
- }
368
- if (session.accountId !== params.accountId) {
369
- return {
370
- ok: false,
371
- error: {
372
- code: "login_account_mismatch",
373
- message: "feishu login session accountId does not match upsert request",
374
- },
375
- };
376
- }
377
- if (!session.appId || !session.appSecret) {
378
- return {
379
- ok: false,
380
- error: { code: "login_unconfirmed", message: "feishu login session has no app credentials yet" },
381
- };
355
+ if (
356
+ !prevProfile ||
357
+ prevProfile.type !== "feishu" ||
358
+ prevProfile.accountId !== params.accountId ||
359
+ !prevProfile.appId
360
+ ) {
361
+ return badParams("upsert_gateway: feishu requires loginId");
362
+ }
363
+ const existing = loadGatewaySecret<{ appSecret?: string }>(params.id);
364
+ if (!existing?.appSecret) {
365
+ return badParams("upsert_gateway: feishu requires loginId");
366
+ }
367
+ if (
368
+ params.settings?.domain !== undefined &&
369
+ params.settings.domain !== (prevProfile.domain ?? "feishu")
370
+ ) {
371
+ return badParams("upsert_gateway: feishu domain change requires a fresh loginId");
372
+ }
373
+ secretPayload = { appSecret: existing.appSecret };
374
+ tokenPreviewSource = existing.appSecret;
375
+ feishuAppId = prevProfile.appId;
376
+ feishuDomain = params.settings?.domain ?? prevProfile.domain ?? "feishu";
377
+ feishuUserOpenId = prevProfile.userOpenId;
378
+ } else {
379
+ const resolved = sessions.resolve(loginId);
380
+ if (resolved.state !== "live") {
381
+ return {
382
+ ok: false,
383
+ error:
384
+ resolved.state === "missing"
385
+ ? { code: "login_missing", message: `feishu login session "${loginId}" not found` }
386
+ : { code: "login_expired", message: `feishu login session "${loginId}" expired` },
387
+ };
388
+ }
389
+ const session = resolved.session!;
390
+ if (session.provider !== "feishu") {
391
+ return badParams(`upsert_gateway: login session provider "${session.provider}" != "feishu"`);
392
+ }
393
+ if (session.accountId !== params.accountId) {
394
+ return {
395
+ ok: false,
396
+ error: {
397
+ code: "login_account_mismatch",
398
+ message: "feishu login session accountId does not match upsert request",
399
+ },
400
+ };
401
+ }
402
+ if (!session.appId || !session.appSecret) {
403
+ return {
404
+ ok: false,
405
+ error: { code: "login_unconfirmed", message: "feishu login session has no app credentials yet" },
406
+ };
407
+ }
408
+ secretPayload = { appSecret: session.appSecret };
409
+ tokenPreviewSource = session.appSecret;
410
+ feishuAppId = session.appId;
411
+ feishuDomain = session.domain ?? params.settings?.domain ?? "feishu";
412
+ feishuUserOpenId = session.userOpenId;
413
+ sessions.update(loginId, { gatewayId: params.id });
382
414
  }
383
- secretPayload = { appSecret: session.appSecret };
384
- tokenPreviewSource = session.appSecret;
385
- feishuAppId = session.appId;
386
- feishuDomain = session.domain ?? params.settings?.domain ?? "feishu";
387
- feishuUserOpenId = session.userOpenId;
388
- sessions.update(loginId, { gatewayId: params.id });
389
415
  } else {
390
416
  return badParams(`upsert_gateway: unknown provider "${(params as { type: string }).type}"`);
391
417
  }
@@ -393,12 +419,9 @@ export function createGatewayControl(ctx: GatewayControlContext) {
393
419
  // W3/W6: remember whether a profile already exists for this id BEFORE we
394
420
  // write the secret/config. For UPDATE path, capture previous profile +
395
421
  // previous secret so addChannel failure can restore prior state.
396
- const existingProfiles = cfg.thirdPartyGateways ?? [];
397
- const hadExistingProfile = existingProfiles.some((g) => g.id === params.id);
398
- const prevProfile = existingProfiles.find((g) => g.id === params.id);
399
422
  // W6: load the previous secret for UPDATE rollback BEFORE overwriting.
400
423
  const prevSecret = hadExistingProfile
401
- ? loadGatewaySecret<{ botToken?: string }>(params.id)
424
+ ? loadGatewaySecret<{ botToken?: string; appSecret?: string }>(params.id)
402
425
  : null;
403
426
 
404
427
  // Persist secret first (so a config write that succeeds is never
@@ -458,20 +481,27 @@ export function createGatewayControl(ctx: GatewayControlContext) {
458
481
  }
459
482
  try {
460
483
  if (prevProfile) {
461
- cfgIO.save(upsertProfileInConfig(cfgIO.load(), prevProfile));
484
+ cfgIO.save(replaceProfileInConfig(cfgIO.load(), prevProfile));
462
485
  }
463
486
  } catch {
464
487
  // best-effort
465
488
  }
466
489
  try {
467
- if (prevProfile && prevSecret?.botToken) {
490
+ if (
491
+ prevProfile &&
492
+ ((prevProfile.type === "telegram" && prevSecret?.botToken) ||
493
+ (prevProfile.type === "feishu" && prevSecret?.appSecret))
494
+ ) {
468
495
  await ctx.gateway.addChannel(
469
496
  buildChannelConfig(
470
497
  {
471
498
  ...params,
472
499
  type: prevProfile.type as typeof params.type,
500
+ accountId: prevProfile.accountId,
473
501
  enabled: prevProfile.enabled !== false,
474
- secret: { botToken: prevSecret.botToken },
502
+ ...(prevProfile.type === "telegram"
503
+ ? { secret: { botToken: prevSecret.botToken } }
504
+ : {}),
475
505
  settings: {
476
506
  baseUrl: prevProfile.baseUrl,
477
507
  allowedSenderIds: prevProfile.allowedSenderIds,
@@ -1074,6 +1104,9 @@ function validateOutboundConversation(
1074
1104
  },
1075
1105
  };
1076
1106
  }
1107
+ if (profile.type === "feishu" && (profile.allowedChatIds ?? []).length === 0) {
1108
+ return null;
1109
+ }
1077
1110
  const allowed = new Set((profile.allowedChatIds ?? []).map(String));
1078
1111
  if (!allowed.has(chatId)) {
1079
1112
  return {
@@ -1161,6 +1194,21 @@ function upsertProfileInConfig(
1161
1194
  return { ...cfg, thirdPartyGateways: list };
1162
1195
  }
1163
1196
 
1197
+ function replaceProfileInConfig(
1198
+ cfg: DaemonConfig,
1199
+ profile: ThirdPartyGatewayProfile,
1200
+ ): DaemonConfig {
1201
+ const list = (cfg.thirdPartyGateways ?? []).slice();
1202
+ const idx = list.findIndex((g) => g.id === profile.id);
1203
+ const compact = compactProfile(profile);
1204
+ if (idx >= 0) {
1205
+ list[idx] = compact;
1206
+ } else {
1207
+ list.push(compact);
1208
+ }
1209
+ return { ...cfg, thirdPartyGateways: list };
1210
+ }
1211
+
1164
1212
  function compactProfile(p: ThirdPartyGatewayProfile): ThirdPartyGatewayProfile {
1165
1213
  const out: ThirdPartyGatewayProfile = {
1166
1214
  id: p.id,
package/src/provision.ts CHANGED
@@ -91,6 +91,7 @@ import {
91
91
  handleCloudGatewayRuntimeInbound,
92
92
  type CloudGatewayTypingEmitter,
93
93
  } from "./cloud-gateway-runtime.js";
94
+ import { scheduleDaemonSelfRestart } from "./self-restart.js";
94
95
 
95
96
  function skillIndexOptionsForLoadedAgent(gateway: Gateway, agentId: string): SkillIndexOptions {
96
97
  const route = gateway.listManagedRoutes()
@@ -397,6 +398,17 @@ export function createProvisioner(opts: ProvisionerOptions): (
397
398
  return { ok: true, result: snapshot };
398
399
  }
399
400
 
401
+ case CONTROL_FRAME_TYPES.RESTART_DAEMON: {
402
+ const plan = scheduleDaemonSelfRestart({ update: true });
403
+ daemonLog.warn("restart_daemon: scheduled self restart", {
404
+ updateRequested: plan.updateRequested,
405
+ updateSupported: plan.updateSupported,
406
+ installPrefix: plan.installPrefix,
407
+ packageSpec: plan.packageSpec,
408
+ });
409
+ return { ok: true, result: plan };
410
+ }
411
+
400
412
  case "list_gateways":
401
413
  return gatewayControl.handleList();
402
414
 
@@ -729,8 +741,15 @@ async function handleWakeAgent(gateway: Gateway, raw: unknown): Promise<AckBody>
729
741
  },
730
742
  };
731
743
 
732
- await gateway.injectInbound(msg);
733
- return { ok: true, result: { agent_id: agentId } };
744
+ void gateway.injectInbound(msg).catch((err) => {
745
+ daemonLog.error("wake_agent: async inject failed", {
746
+ agentId,
747
+ scheduleId: scheduleId ?? null,
748
+ runId,
749
+ error: err instanceof Error ? err.message : String(err),
750
+ });
751
+ });
752
+ return { ok: true, result: { agent_id: agentId, queued: true } };
734
753
  }
735
754
 
736
755
  // W8: hand-written runtime validator for the third-party gateway frame
@@ -0,0 +1,218 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import { existsSync, realpathSync } from "node:fs";
3
+ import path from "node:path";
4
+
5
+ const DEFAULT_DAEMON_PACKAGE = "@botcord/daemon@latest";
6
+ const DEFAULT_SHUTDOWN_DELAY_MS = 750;
7
+ const DEFAULT_FORCE_EXIT_MS = 10_000;
8
+ const DEFAULT_PARENT_EXIT_WAIT_MS = 30_000;
9
+ const DEFAULT_INSTALL_TIMEOUT_MS = 120_000;
10
+
11
+ export interface DaemonRestartPlan {
12
+ scheduled: boolean;
13
+ updateRequested: boolean;
14
+ updateSupported: boolean;
15
+ installPrefix: string | null;
16
+ packageSpec: string;
17
+ }
18
+
19
+ export interface ScheduleDaemonSelfRestartOptions {
20
+ update?: boolean;
21
+ packageSpec?: string;
22
+ delayMs?: number;
23
+ forceExitAfterMs?: number;
24
+ entrypoint?: string;
25
+ restartArgs?: string[];
26
+ }
27
+
28
+ export interface ScheduleDaemonSelfRestartDeps {
29
+ spawn?: typeof spawn;
30
+ setTimeout?: typeof setTimeout;
31
+ kill?: (pid: number, signal: NodeJS.Signals) => void;
32
+ exit?: (code?: number) => never;
33
+ pid?: number;
34
+ execPath?: string;
35
+ argv?: string[];
36
+ env?: NodeJS.ProcessEnv;
37
+ }
38
+
39
+ export function findDaemonInstallPrefix(entrypoint?: string): string | null {
40
+ if (!entrypoint) return null;
41
+ const candidates = [entrypoint, safeRealpath(entrypoint)];
42
+ for (const candidate of candidates) {
43
+ if (!candidate) continue;
44
+ const prefix = installPrefixFromPath(candidate);
45
+ if (prefix) return prefix;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ export function resolveNpmBin(nodePath = process.execPath): string {
51
+ const name = process.platform === "win32" ? "npm.cmd" : "npm";
52
+ const sibling = path.join(path.dirname(nodePath), name);
53
+ return existsSync(sibling) ? sibling : name;
54
+ }
55
+
56
+ export function scheduleDaemonSelfRestart(
57
+ opts: ScheduleDaemonSelfRestartOptions = {},
58
+ deps: ScheduleDaemonSelfRestartDeps = {},
59
+ ): DaemonRestartPlan {
60
+ const env = deps.env ?? process.env;
61
+ const argv = deps.argv ?? process.argv;
62
+ const entrypoint = opts.entrypoint ?? argv[1];
63
+ if (!entrypoint) {
64
+ throw new Error("cannot restart daemon: process entrypoint is unknown");
65
+ }
66
+
67
+ const updateRequested = opts.update !== false;
68
+ const installPrefix = updateRequested ? findDaemonInstallPrefix(entrypoint) : null;
69
+ const packageSpec = opts.packageSpec ?? env.BOTCORD_DAEMON_PACKAGE ?? DEFAULT_DAEMON_PACKAGE;
70
+ const execPath = deps.execPath ?? process.execPath;
71
+ const pid = deps.pid ?? process.pid;
72
+ const restartArgs = opts.restartArgs ?? ["start", "--foreground"];
73
+ const supervisorEnv: NodeJS.ProcessEnv = {
74
+ ...env,
75
+ BOTCORD_DAEMON_CHILD: "1",
76
+ BOTCORD_RESTART_PARENT_PID: String(pid),
77
+ BOTCORD_RESTART_ENTRYPOINT: entrypoint,
78
+ BOTCORD_RESTART_ARGS_JSON: JSON.stringify(restartArgs),
79
+ BOTCORD_RESTART_NODE: execPath,
80
+ BOTCORD_RESTART_NPM_BIN: resolveNpmBin(execPath),
81
+ BOTCORD_RESTART_UPDATE: updateRequested && installPrefix ? "1" : "0",
82
+ BOTCORD_RESTART_INSTALL_PREFIX: installPrefix ?? "",
83
+ BOTCORD_RESTART_PACKAGE: packageSpec,
84
+ BOTCORD_RESTART_PARENT_EXIT_WAIT_MS: String(DEFAULT_PARENT_EXIT_WAIT_MS),
85
+ BOTCORD_RESTART_INSTALL_TIMEOUT_MS: String(DEFAULT_INSTALL_TIMEOUT_MS),
86
+ };
87
+
88
+ const spawnImpl = deps.spawn ?? spawn;
89
+ const child = spawnImpl(execPath, ["-e", RESTART_SUPERVISOR_SCRIPT], {
90
+ detached: true,
91
+ stdio: "ignore",
92
+ env: supervisorEnv,
93
+ }) as ChildProcess;
94
+ child.unref();
95
+
96
+ const setTimer = deps.setTimeout ?? setTimeout;
97
+ const kill = deps.kill ?? process.kill.bind(process);
98
+ const exit = deps.exit ?? process.exit.bind(process);
99
+ const delayMs = opts.delayMs ?? DEFAULT_SHUTDOWN_DELAY_MS;
100
+ const forceExitAfterMs = opts.forceExitAfterMs ?? DEFAULT_FORCE_EXIT_MS;
101
+ const shutdownTimer = setTimer(() => {
102
+ try {
103
+ kill(pid, "SIGTERM");
104
+ } catch {
105
+ exit(0);
106
+ return;
107
+ }
108
+ const exitTimer = setTimer(() => exit(0), forceExitAfterMs);
109
+ unrefTimer(exitTimer);
110
+ }, delayMs);
111
+ unrefTimer(shutdownTimer);
112
+
113
+ return {
114
+ scheduled: true,
115
+ updateRequested,
116
+ updateSupported: installPrefix !== null,
117
+ installPrefix,
118
+ packageSpec,
119
+ };
120
+ }
121
+
122
+ function safeRealpath(input: string): string | null {
123
+ try {
124
+ return realpathSync(input);
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ function installPrefixFromPath(input: string): string | null {
131
+ const parts = path.resolve(input).split(path.sep);
132
+ for (let i = parts.length - 3; i >= 0; i--) {
133
+ if (
134
+ parts[i] !== "node_modules" ||
135
+ parts[i + 1] !== "@botcord" ||
136
+ parts[i + 2] !== "daemon"
137
+ ) {
138
+ continue;
139
+ }
140
+ const prefix = parts.slice(0, i).join(path.sep) || path.sep;
141
+ const packageJson = path.join(
142
+ prefix,
143
+ "node_modules",
144
+ "@botcord",
145
+ "daemon",
146
+ "package.json",
147
+ );
148
+ return existsSync(packageJson) ? prefix : null;
149
+ }
150
+ return null;
151
+ }
152
+
153
+ function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
154
+ const maybe = timer as { unref?: () => void };
155
+ if (typeof maybe.unref === "function") {
156
+ maybe.unref();
157
+ }
158
+ }
159
+
160
+ const RESTART_SUPERVISOR_SCRIPT = `
161
+ const cp = require("node:child_process");
162
+
163
+ function sleep(ms) {
164
+ return new Promise((resolve) => setTimeout(resolve, ms));
165
+ }
166
+
167
+ function alive(pid) {
168
+ try {
169
+ process.kill(pid, 0);
170
+ return true;
171
+ } catch {
172
+ return false;
173
+ }
174
+ }
175
+
176
+ async function main() {
177
+ const parentPid = Number(process.env.BOTCORD_RESTART_PARENT_PID || "0");
178
+ const waitMs = Number(process.env.BOTCORD_RESTART_PARENT_EXIT_WAIT_MS || "30000");
179
+ const deadline = Date.now() + waitMs;
180
+ while (parentPid > 0 && alive(parentPid) && Date.now() < deadline) {
181
+ await sleep(250);
182
+ }
183
+
184
+ const update = process.env.BOTCORD_RESTART_UPDATE === "1";
185
+ const installPrefix = process.env.BOTCORD_RESTART_INSTALL_PREFIX || "";
186
+ if (update && installPrefix) {
187
+ const npmBin = process.env.BOTCORD_RESTART_NPM_BIN || "npm";
188
+ const packageSpec = process.env.BOTCORD_RESTART_PACKAGE || "${DEFAULT_DAEMON_PACKAGE}";
189
+ const timeout = Number(process.env.BOTCORD_RESTART_INSTALL_TIMEOUT_MS || "120000");
190
+ cp.spawnSync(npmBin, ["install", "--prefix", installPrefix, packageSpec], {
191
+ stdio: "ignore",
192
+ env: process.env,
193
+ timeout,
194
+ });
195
+ }
196
+
197
+ const node = process.env.BOTCORD_RESTART_NODE || process.execPath;
198
+ const entrypoint = process.env.BOTCORD_RESTART_ENTRYPOINT;
199
+ if (!entrypoint) process.exit(1);
200
+ let args = ["start", "--foreground"];
201
+ try {
202
+ const parsed = JSON.parse(process.env.BOTCORD_RESTART_ARGS_JSON || "[]");
203
+ if (Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) {
204
+ args = parsed;
205
+ }
206
+ } catch {
207
+ // keep default args
208
+ }
209
+ const child = cp.spawn(node, [entrypoint, ...args], {
210
+ detached: true,
211
+ stdio: "ignore",
212
+ env: { ...process.env, BOTCORD_DAEMON_CHILD: "1" },
213
+ });
214
+ child.unref();
215
+ }
216
+
217
+ main().catch(() => process.exit(1));
218
+ `;
@@ -74,6 +74,18 @@ export function defaultSkillDirs(
74
74
  source: "agent-codex",
75
75
  runtime: "codex",
76
76
  };
77
+ const agentGemini = [
78
+ {
79
+ dir: path.join(agentWorkspaceDir(agentId), ".gemini", "skills"),
80
+ source: "agent-gemini",
81
+ runtime: "gemini",
82
+ },
83
+ {
84
+ dir: path.join(agentWorkspaceDir(agentId), ".agents", "skills"),
85
+ source: "agent-agents",
86
+ runtime: "gemini",
87
+ },
88
+ ];
77
89
  const agentHermes = hermesSkillRoot(agentId, opts.hermesProfile);
78
90
 
79
91
  const dirs: SkillRoot[] = [];
@@ -91,6 +103,23 @@ export function defaultSkillDirs(
91
103
  case "hermes":
92
104
  dirs.push(agentHermes);
93
105
  break;
106
+ case "gemini":
107
+ dirs.push(...agentGemini);
108
+ if (includeGlobal) {
109
+ dirs.push(
110
+ {
111
+ dir: path.join(homedir(), ".gemini", "skills"),
112
+ source: "global-gemini",
113
+ runtime: "gemini",
114
+ },
115
+ {
116
+ dir: path.join(homedir(), ".agents", "skills"),
117
+ source: "global-agents",
118
+ runtime: "gemini",
119
+ },
120
+ );
121
+ }
122
+ break;
94
123
  case "claude":
95
124
  dirs.push(agentClaude);
96
125
  if (includeGlobal) {
@@ -308,8 +337,9 @@ function hermesSkillRoot(agentId: string, profile: string | undefined): SkillRoo
308
337
  };
309
338
  }
310
339
 
311
- function runtimeFamily(runtime: string | undefined): "codex" | "claude" | "hermes" | "other" {
340
+ function runtimeFamily(runtime: string | undefined): "codex" | "claude" | "gemini" | "hermes" | "other" {
312
341
  if (runtime === "codex") return "codex";
342
+ if (runtime === "gemini") return "gemini";
313
343
  if (runtime === "hermes-agent") return "hermes";
314
344
  if (!runtime) return "claude";
315
345
  if (runtime === "claude-code") return "claude";
@@ -317,18 +347,9 @@ function runtimeFamily(runtime: string | undefined): "codex" | "claude" | "herme
317
347
  }
318
348
 
319
349
  function priority(source: string, _runtime: string | undefined): number {
320
- switch (source) {
321
- case "agent-claude":
322
- case "agent-codex":
323
- case "agent-hermes":
324
- case "agent-hermes-profile":
325
- return 0;
326
- case "global-claude":
327
- case "global-codex":
328
- return 1;
329
- default:
330
- return 2;
331
- }
350
+ if (source.startsWith("agent-")) return 0;
351
+ if (source.startsWith("global-")) return 1;
352
+ return 2;
332
353
  }
333
354
 
334
355
  function snapshotSource(source: string): "workspace" | "runtime-global" {