@botcord/daemon 0.2.89 → 0.2.91

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/src/daemon.ts CHANGED
@@ -52,7 +52,7 @@ import { PolicyResolver, type DaemonAttentionPolicy } from "./gateway/policy-res
52
52
  import { scanMention } from "./mention-scan.js";
53
53
  import { createDiagnosticBundle, uploadDiagnosticBundle } from "./diagnostics.js";
54
54
  import { createAttentionPolicyFetcher } from "./attention-policy-fetcher.js";
55
- import { collectAgentSkillSnapshot } from "./skill-index.js";
55
+ import { collectAgentSkillSnapshot, type SkillIndexOptions } from "./skill-index.js";
56
56
 
57
57
  /**
58
58
  * Default hard cap for a single runtime turn. Long-running coding/research
@@ -249,7 +249,7 @@ export function pushRuntimeSnapshot(
249
249
  export function pushAgentSkillSnapshot(
250
250
  sink: RuntimeSnapshotSink,
251
251
  agentId: string,
252
- opts: { runtime?: string } = {},
252
+ opts: SkillIndexOptions = {},
253
253
  ): boolean {
254
254
  const snap = collectAgentSkillSnapshot(agentId, opts);
255
255
  const ok = sink.send({
@@ -354,6 +354,13 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
354
354
  );
355
355
 
356
356
  const gwConfig = toGatewayConfig(opts.config, { agentIds, agentRuntimes });
357
+ const skillIndexOptionsForAgent = (agentId: string): SkillIndexOptions => {
358
+ const meta = agentRuntimes[agentId];
359
+ return {
360
+ runtime: meta?.runtime ?? opts.config.defaultRoute.adapter,
361
+ ...(meta?.hermesProfile ? { hermesProfile: meta.hermesProfile } : {}),
362
+ };
363
+ };
357
364
 
358
365
  // Per-agent hub URL — read from each credential file at boot. Used to
359
366
  // populate `BOTCORD_HUB` for runtime CLI subprocesses so the bundled
@@ -420,6 +427,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
420
427
  activityTracker,
421
428
  roomContextBuilder,
422
429
  loopRiskBuilder,
430
+ skillIndexOptions: () => skillIndexOptionsForAgent(aid),
423
431
  }),
424
432
  );
425
433
  }
@@ -530,11 +538,16 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
530
538
  // next room-context fetch re-loads the BotCordClient against the new
531
539
  // credential file.
532
540
  credentialPathByAgentId.set(info.agentId, info.credentialsFile);
533
- if (info.runtime) {
534
- agentRuntimes[info.agentId] = {
541
+ if (info.runtime || info.hermesProfile) {
542
+ const next = {
535
543
  ...(agentRuntimes[info.agentId] ?? {}),
536
- runtime: info.runtime,
544
+ ...(info.runtime ? { runtime: info.runtime } : {}),
545
+ ...(info.hermesProfile ? { hermesProfile: info.hermesProfile } : {}),
537
546
  };
547
+ if (info.runtime && !info.hermesProfile) {
548
+ delete next.hermesProfile;
549
+ }
550
+ agentRuntimes[info.agentId] = next;
538
551
  }
539
552
  if (info.hubUrl) hubUrlByAgentId.set(info.agentId, info.hubUrl);
540
553
  if (info.displayName) displayNameByAgent.set(info.agentId, info.displayName);
@@ -546,6 +559,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
546
559
  activityTracker,
547
560
  roomContextBuilder,
548
561
  loopRiskBuilder,
562
+ skillIndexOptions: () => skillIndexOptionsForAgent(info.agentId),
549
563
  }),
550
564
  );
551
565
  }
@@ -677,11 +691,12 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
677
691
  ok: pushed,
678
692
  });
679
693
  for (const agentId of agentIds) {
680
- const runtime = agentRuntimes[agentId]?.runtime ?? opts.config.defaultRoute.adapter;
681
- const skillsPushed = pushAgentSkillSnapshot(controlChannel, agentId, { runtime });
694
+ const skillIndexOptions = skillIndexOptionsForAgent(agentId);
695
+ const skillsPushed = pushAgentSkillSnapshot(controlChannel, agentId, skillIndexOptions);
682
696
  logger.info("control-channel: initial agent_skill_snapshot push", {
683
697
  agentId,
684
- runtime,
698
+ runtime: skillIndexOptions.runtime,
699
+ hermesProfile: skillIndexOptions.hermesProfile ?? null,
685
700
  ok: skillsPushed,
686
701
  });
687
702
  }
@@ -1347,6 +1347,44 @@ describe("createBotCordChannel — typing()", () => {
1347
1347
  globalThis.fetch = realFetch;
1348
1348
  }
1349
1349
  });
1350
+
1351
+ it("refreshes the token and retries once on 401 (stale-token recovery)", async () => {
1352
+ const fetchSpy = vi
1353
+ .fn()
1354
+ .mockResolvedValueOnce(new Response('{"code":"invalid_token"}', { status: 401 }))
1355
+ .mockResolvedValueOnce(new Response(null, { status: 204 }));
1356
+ const realFetch = globalThis.fetch;
1357
+ globalThis.fetch = fetchSpy as unknown as typeof fetch;
1358
+ try {
1359
+ const client = makeClient({
1360
+ getHubUrl: vi.fn().mockReturnValue("https://hub.example.com"),
1361
+ });
1362
+ const channel = createBotCordChannel({
1363
+ id: "botcord-main",
1364
+ accountId: "ag_self",
1365
+ agentId: "ag_self",
1366
+ client,
1367
+ hubBaseUrl: "https://hub.example.com",
1368
+ });
1369
+ await channel.typing!({
1370
+ traceId: "trace_401",
1371
+ accountId: "ag_self",
1372
+ conversationId: "rm_oc_42",
1373
+ log: silentLog,
1374
+ });
1375
+ expect(client.refreshToken).toHaveBeenCalledTimes(1);
1376
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
1377
+ // First attempt uses the stale token; the retry uses the refreshed one.
1378
+ expect((fetchSpy.mock.calls[0][1].headers as Record<string, string>).Authorization).toBe(
1379
+ "Bearer test-token",
1380
+ );
1381
+ expect((fetchSpy.mock.calls[1][1].headers as Record<string, string>).Authorization).toBe(
1382
+ "Bearer test-token-2",
1383
+ );
1384
+ } finally {
1385
+ globalThis.fetch = realFetch;
1386
+ }
1387
+ });
1350
1388
  });
1351
1389
 
1352
1390
  describe("createBotCordChannel — websocket logging", () => {
@@ -308,6 +308,41 @@ function normalizeInboxBatch(
308
308
  };
309
309
  }
310
310
 
311
+ /**
312
+ * Fire-and-forget authenticated POST for presence/streaming control requests
313
+ * (`/hub/typing`, `/hub/stream-block`). Mirrors `BotCordClient.hubFetch`'s 401
314
+ * handling: a stale-but-unexpired token (e.g. after a Hub JWT secret rotation,
315
+ * which `ensureToken()` won't refresh because it only refreshes near expiry) is
316
+ * refreshed once and the request retried. Without this, typing/stream-block
317
+ * silently 401 in a loop until the next actual message send happens to refresh
318
+ * the token — leaving the conversation with no typing indicator or live stream.
319
+ */
320
+ async function postControlWithRefresh(
321
+ client: BotCordChannelClient,
322
+ hubUrl: string,
323
+ path: string,
324
+ body: unknown,
325
+ ): Promise<Response> {
326
+ let token = await client.ensureToken();
327
+ for (let attempt = 0; attempt <= 1; attempt++) {
328
+ const resp = await fetch(`${hubUrl}${path}`, {
329
+ method: "POST",
330
+ headers: {
331
+ "Content-Type": "application/json",
332
+ Authorization: `Bearer ${token}`,
333
+ },
334
+ body: JSON.stringify(body),
335
+ signal: AbortSignal.timeout(10_000),
336
+ });
337
+ if (resp.status === 401 && attempt === 0) {
338
+ token = await client.refreshToken();
339
+ continue;
340
+ }
341
+ return resp;
342
+ }
343
+ throw new Error("postControlWithRefresh: exhausted retries");
344
+ }
345
+
311
346
  /**
312
347
  * Construct a BotCord channel adapter.
313
348
  *
@@ -895,21 +930,12 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
895
930
  const client = ensureClient();
896
931
  const hubUrl = options.hubBaseUrl ?? client.getHubUrl();
897
932
  try {
898
- const token = await client.ensureToken();
899
933
  const block = ctx.block as { raw?: unknown; kind?: string; seq?: number } | undefined;
900
934
  const seq = typeof block?.seq === "number" ? block.seq : 0;
901
- const resp = await fetch(`${hubUrl}/hub/stream-block`, {
902
- method: "POST",
903
- headers: {
904
- "Content-Type": "application/json",
905
- Authorization: `Bearer ${token}`,
906
- },
907
- body: JSON.stringify({
908
- trace_id: ctx.traceId,
909
- seq,
910
- block: normalizeBlockForHub(block, seq),
911
- }),
912
- signal: AbortSignal.timeout(10_000),
935
+ const resp = await postControlWithRefresh(client, hubUrl, "/hub/stream-block", {
936
+ trace_id: ctx.traceId,
937
+ seq,
938
+ block: normalizeBlockForHub(block, seq),
913
939
  });
914
940
  if (!resp.ok && resp.status !== 204) {
915
941
  const body = await resp.text().catch(() => "");
@@ -927,15 +953,8 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
927
953
  const client = ensureClient();
928
954
  const hubUrl = options.hubBaseUrl ?? client.getHubUrl();
929
955
  try {
930
- const token = await client.ensureToken();
931
- const resp = await fetch(`${hubUrl}/hub/typing`, {
932
- method: "POST",
933
- headers: {
934
- "Content-Type": "application/json",
935
- Authorization: `Bearer ${token}`,
936
- },
937
- body: JSON.stringify({ room_id: ctx.conversationId }),
938
- signal: AbortSignal.timeout(10_000),
956
+ const resp = await postControlWithRefresh(client, hubUrl, "/hub/typing", {
957
+ room_id: ctx.conversationId,
939
958
  });
940
959
  if (!resp.ok && resp.status !== 204) {
941
960
  const body = await resp.text().catch(() => "");
package/src/provision.ts CHANGED
@@ -28,6 +28,8 @@ import {
28
28
  type StoredBotCordCredentials,
29
29
  type UpdateAgentParams,
30
30
  type GatewayInboundFrame,
31
+ type InstallAgentSkillParams,
32
+ type ListAgentSkillsParams,
31
33
  } from "@botcord/protocol-core";
32
34
  import type { Gateway } from "./gateway/index.js";
33
35
  import type { GatewayInboundMessage } from "./gateway/index.js";
@@ -75,7 +77,12 @@ import { log as daemonLog } from "./log.js";
75
77
  import { discoverAgentCredentials } from "./agent-discovery.js";
76
78
  import { resolveMemoryDir } from "./working-memory.js";
77
79
  import { discoverRuntimeModelCatalog } from "./runtime-models.js";
78
- import { collectAgentSkillSnapshot } from "./skill-index.js";
80
+ import { collectAgentSkillSnapshot, type SkillIndexOptions } from "./skill-index.js";
81
+ import {
82
+ installAgentSkillManifest,
83
+ installBotLearnArchiveManifest,
84
+ installVercelSkillsForAgent,
85
+ } from "./skill-installer.js";
79
86
  import {
80
87
  buildRuntimeSelectionExtraArgs,
81
88
  mergeRuntimeExtraArgs,
@@ -84,15 +91,23 @@ import {
84
91
  handleCloudGatewayRuntimeInbound,
85
92
  type CloudGatewayTypingEmitter,
86
93
  } from "./cloud-gateway-runtime.js";
94
+ import { scheduleDaemonSelfRestart } from "./self-restart.js";
87
95
 
88
- interface ListAgentSkillsParams {
89
- agentId: string;
90
- }
91
-
92
- function runtimeForLoadedAgent(gateway: Gateway, agentId: string): string | undefined {
93
- return gateway.listManagedRoutes()
94
- .find((route) => route.match?.accountId === agentId)
95
- ?.runtime;
96
+ function skillIndexOptionsForLoadedAgent(gateway: Gateway, agentId: string): SkillIndexOptions {
97
+ const route = gateway.listManagedRoutes()
98
+ .find((entry) => entry.match?.accountId === agentId);
99
+ let credentials: StoredBotCordCredentials | null = null;
100
+ try {
101
+ credentials = loadStoredCredentials(defaultCredentialsFile(agentId));
102
+ } catch {
103
+ credentials = null;
104
+ }
105
+ const runtime = route?.runtime ?? credentials?.runtime;
106
+ const hermesProfile = route?.hermesProfile ?? credentials?.hermesProfile;
107
+ return {
108
+ ...(runtime ? { runtime } : {}),
109
+ ...(hermesProfile ? { hermesProfile } : {}),
110
+ };
96
111
  }
97
112
 
98
113
  /**
@@ -108,6 +123,7 @@ export interface InstalledAgentInfo {
108
123
  hubUrl: string;
109
124
  displayName?: string;
110
125
  runtime?: string;
126
+ hermesProfile?: string;
111
127
  }
112
128
 
113
129
  /**
@@ -382,6 +398,17 @@ export function createProvisioner(opts: ProvisionerOptions): (
382
398
  return { ok: true, result: snapshot };
383
399
  }
384
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
+
385
412
  case "list_gateways":
386
413
  return gatewayControl.handleList();
387
414
 
@@ -516,16 +543,77 @@ export function createProvisioner(opts: ProvisionerOptions): (
516
543
  },
517
544
  };
518
545
  }
519
- const runtime = runtimeForLoadedAgent(gateway, params.agentId);
520
- const result = collectAgentSkillSnapshot(params.agentId, { runtime });
546
+ const skillIndexOptions = skillIndexOptionsForLoadedAgent(gateway, params.agentId);
547
+ const result = collectAgentSkillSnapshot(params.agentId, skillIndexOptions);
521
548
  daemonLog.debug("list_agent_skills", {
522
549
  agentId: params.agentId,
523
- runtime,
550
+ runtime: skillIndexOptions.runtime,
551
+ hermesProfile: skillIndexOptions.hermesProfile ?? null,
524
552
  count: result.skills.length,
525
553
  });
526
554
  return { ok: true, result };
527
555
  }
528
556
 
557
+ case CONTROL_FRAME_TYPES.INSTALL_AGENT_SKILL: {
558
+ const params = (frame.params ?? {}) as unknown as InstallAgentSkillParams;
559
+ if (!params.agentId) {
560
+ return {
561
+ ok: false,
562
+ error: { code: "bad_params", message: "install_agent_skill requires params.agentId" },
563
+ };
564
+ }
565
+ const channels = gateway.snapshot().channels;
566
+ if (!channels[params.agentId]) {
567
+ return {
568
+ ok: false,
569
+ error: {
570
+ code: "agent_not_loaded",
571
+ message: `agent ${params.agentId} is not loaded in daemon gateway`,
572
+ },
573
+ };
574
+ }
575
+ const skillIndexOptions = skillIndexOptionsForLoadedAgent(gateway, params.agentId);
576
+ const runtime = skillIndexOptions.runtime;
577
+ const modes = [params.manifest, params.archiveManifest, params.vercel].filter(Boolean).length;
578
+ if (modes !== 1) {
579
+ return {
580
+ ok: false,
581
+ error: {
582
+ code: "bad_params",
583
+ message: "install_agent_skill requires exactly one of manifest, archiveManifest, or vercel",
584
+ },
585
+ };
586
+ }
587
+ let result;
588
+ try {
589
+ result = params.vercel
590
+ ? await installVercelSkillsForAgent({
591
+ agentId: params.agentId,
592
+ packageSpec: params.vercel.packageSpec,
593
+ skills: params.vercel.skills,
594
+ runtime,
595
+ })
596
+ : params.archiveManifest
597
+ ? installBotLearnArchiveManifest(params.agentId, params.archiveManifest, { runtime })
598
+ : installAgentSkillManifest(params.agentId, params.manifest!, { runtime });
599
+ } catch (err) {
600
+ return {
601
+ ok: false,
602
+ error: {
603
+ code: "skill_install_failed",
604
+ message: err instanceof Error ? err.message : String(err),
605
+ },
606
+ };
607
+ }
608
+ daemonLog.debug("install_agent_skill", {
609
+ agentId: params.agentId,
610
+ runtime,
611
+ installed: result.installed.map((s) => s.name),
612
+ snapshotCount: result.snapshot.skills.length,
613
+ });
614
+ return { ok: true, result };
615
+ }
616
+
529
617
  case "wake_agent": {
530
618
  return handleWakeAgent(gateway, frame.params);
531
619
  }
@@ -653,8 +741,15 @@ async function handleWakeAgent(gateway: Gateway, raw: unknown): Promise<AckBody>
653
741
  },
654
742
  };
655
743
 
656
- await gateway.injectInbound(msg);
657
- 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 } };
658
753
  }
659
754
 
660
755
  // W8: hand-written runtime validator for the third-party gateway frame
@@ -1118,6 +1213,7 @@ async function installLocalAgent(
1118
1213
  hubUrl: credentials.hubUrl,
1119
1214
  ...(credentials.displayName ? { displayName: credentials.displayName } : {}),
1120
1215
  ...(credentials.runtime ? { runtime: credentials.runtime } : {}),
1216
+ ...(credentials.hermesProfile ? { hermesProfile: credentials.hermesProfile } : {}),
1121
1217
  });
1122
1218
  } catch (err) {
1123
1219
  // Hook misbehavior must not fail the install — the agent is already
@@ -1212,6 +1308,7 @@ async function installExistingOpenclawBinding(
1212
1308
  hubUrl: credentials.hubUrl,
1213
1309
  ...(credentials.displayName ? { displayName: credentials.displayName } : {}),
1214
1310
  ...(credentials.runtime ? { runtime: credentials.runtime } : {}),
1311
+ ...(credentials.hermesProfile ? { hermesProfile: credentials.hermesProfile } : {}),
1215
1312
  });
1216
1313
  } catch (err) {
1217
1314
  daemonLog.error("provision.onAgentInstalled threw — caches may be stale", {
@@ -68,6 +68,37 @@ const KIMI_FALLBACK_MODELS: RuntimeModelProbe[] = [
68
68
  { id: "kimi-k2-0711", displayName: "kimi-k2-0711", provider: "kimi", source: "builtin" },
69
69
  ];
70
70
 
71
+ // Gemini CLI has no `gemini models list` command — the model set is
72
+ // hard-coded inside the bundle's `VALID_GEMINI_MODELS` (see `@google/gemini-cli`
73
+ // bundle `chunk-BE42OOYM.js:275832`). We mirror the documented user-facing
74
+ // names here as a static catalog; the actual availability depends on the
75
+ // user's auth tier (gcloud ADC / Vertex / GEMINI_API_KEY) and project quota.
76
+ // The `auto` alias is the default — it lets gemini pick the best model.
77
+ const GEMINI_FALLBACK_MODELS: RuntimeModelProbe[] = [
78
+ { id: "auto", displayName: "Auto (let Gemini pick)", provider: "google", source: "builtin", isDefault: true },
79
+ { id: "pro", displayName: "Pro alias", provider: "google", source: "builtin" },
80
+ { id: "flash", displayName: "Flash alias", provider: "google", source: "builtin" },
81
+ { id: "flash-lite", displayName: "Flash Lite alias", provider: "google", source: "builtin" },
82
+ { id: "gemini-2.5-pro", displayName: "Gemini 2.5 Pro", provider: "google", source: "builtin" },
83
+ { id: "gemini-2.5-flash", displayName: "Gemini 2.5 Flash", provider: "google", source: "builtin" },
84
+ { id: "gemini-2.5-flash-lite", displayName: "Gemini 2.5 Flash Lite", provider: "google", source: "builtin" },
85
+ { id: "gemini-3-pro-preview", displayName: "Gemini 3 Pro (preview)", provider: "google", source: "builtin" },
86
+ { id: "gemini-3-flash-preview", displayName: "Gemini 3 Flash (preview)", provider: "google", source: "builtin" },
87
+ { id: "gemini-3.1-pro-preview", displayName: "Gemini 3.1 Pro (preview)", provider: "google", source: "builtin" },
88
+ {
89
+ id: "gemini-3.1-pro-preview-customtools",
90
+ displayName: "Gemini 3.1 Pro Custom Tools (preview)",
91
+ provider: "google",
92
+ source: "builtin",
93
+ },
94
+ {
95
+ id: "gemini-3.1-flash-lite-preview",
96
+ displayName: "Gemini 3.1 Flash Lite (preview)",
97
+ provider: "google",
98
+ source: "builtin",
99
+ },
100
+ ];
101
+
71
102
  export interface RuntimeModelDiscovery {
72
103
  models?: RuntimeModelProbe[];
73
104
  parameters?: RuntimeParameterProbe[];
@@ -160,6 +191,22 @@ function runtimeCatalogStrategy(entry: RuntimeProbeEntry): RuntimeCatalogStrateg
160
191
  ],
161
192
  }),
162
193
  };
194
+ case "gemini":
195
+ // Gemini CLI exposes no runtime discovery command and its thinking
196
+ // budget / level (see bundle `ThinkingLevel`, `thinkingBudget`) is
197
+ // configured per-installation in `~/.gemini/settings.json`, not via
198
+ // any CLI flag — so we ship a static model catalog with no parameter
199
+ // controls. settings.json + BOTCORD_GEMINI_BIN feed the cache key so
200
+ // user-side reconfig (e.g. switching auth type) busts the cache.
201
+ return {
202
+ id: entry.id,
203
+ contextKey: runtimeCatalogContextKey(entry, {
204
+ settings: fileStatKey(path.join(homedir(), ".gemini", "settings.json")),
205
+ env: pickEnv(["BOTCORD_GEMINI_BIN", "GEMINI_CLI_HOME"]),
206
+ }),
207
+ discoverFresh: () => ({ models: GEMINI_FALLBACK_MODELS.slice() }),
208
+ fallback: () => ({ models: GEMINI_FALLBACK_MODELS.slice() }),
209
+ };
163
210
  default:
164
211
  return null;
165
212
  }
@@ -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
+ `;