@botcord/daemon 0.2.90 → 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.
@@ -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(() => "");
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.91",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,7 @@
24
24
  "@larksuiteoapi/node-sdk": "^1.63.1",
25
25
  "ws": "^8.20.1",
26
26
  "@botcord/protocol-core": "^0.2.13",
27
- "@botcord/cli": "^0.1.18"
27
+ "@botcord/cli": "^0.1.19"
28
28
  },
29
29
  "overrides": {
30
30
  "axios": "^1.15.2"
@@ -336,6 +336,29 @@ describe("wake_agent handler", () => {
336
336
  });
337
337
  });
338
338
 
339
+ it("acks wake_agent after queueing instead of waiting for the turn to finish", async () => {
340
+ const gw = makeFakeGateway(["ag_wake"]);
341
+ gw.injectInbound.mockImplementation(() => new Promise(() => undefined));
342
+ const handler = createProvisioner({ gateway: gw as any });
343
+
344
+ const res = await Promise.race([
345
+ handler({
346
+ id: "req_wake_pending",
347
+ type: "wake_agent",
348
+ params: {
349
+ agent_id: "ag_wake",
350
+ message: "tick",
351
+ run_id: "sr_pending",
352
+ },
353
+ }),
354
+ new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 20)),
355
+ ]);
356
+
357
+ expect(res).not.toBe("timeout");
358
+ expect(res).toMatchObject({ ok: true, result: { agent_id: "ag_wake", queued: true } });
359
+ expect(gw.injectInbound).toHaveBeenCalledTimes(1);
360
+ });
361
+
339
362
  it("rejects wake_agent for an unloaded agent", async () => {
340
363
  const gw = makeFakeGateway(["ag_loaded"]);
341
364
  const handler = createProvisioner({ gateway: gw as any });
@@ -0,0 +1,57 @@
1
+ import {
2
+ mkdirSync,
3
+ mkdtempSync,
4
+ realpathSync,
5
+ rmSync,
6
+ symlinkSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
12
+ import { findDaemonInstallPrefix } from "../self-restart.js";
13
+
14
+ describe("self restart install prefix detection", () => {
15
+ let tmpDir: string;
16
+
17
+ beforeEach(() => {
18
+ tmpDir = mkdtempSync(path.join(os.tmpdir(), "botcord-self-restart-"));
19
+ });
20
+
21
+ afterEach(() => {
22
+ rmSync(tmpDir, { recursive: true, force: true });
23
+ });
24
+
25
+ it("finds the npm install prefix for a managed @botcord/daemon entrypoint", () => {
26
+ const prefix = path.join(tmpDir, ".botcord", "daemon");
27
+ const packageRoot = path.join(prefix, "node_modules", "@botcord", "daemon");
28
+ const entrypoint = path.join(packageRoot, "dist", "index.js");
29
+ mkdirSync(path.dirname(entrypoint), { recursive: true });
30
+ writeFileSync(path.join(packageRoot, "package.json"), '{"name":"@botcord/daemon"}');
31
+ writeFileSync(entrypoint, "");
32
+
33
+ expect(findDaemonInstallPrefix(entrypoint)).toBe(prefix);
34
+ });
35
+
36
+ it("resolves npm .bin symlinks before looking for the package root", () => {
37
+ const prefix = path.join(tmpDir, ".botcord", "daemon");
38
+ const packageRoot = path.join(prefix, "node_modules", "@botcord", "daemon");
39
+ const entrypoint = path.join(packageRoot, "dist", "index.js");
40
+ const bin = path.join(prefix, "node_modules", ".bin", "botcord-daemon");
41
+ mkdirSync(path.dirname(entrypoint), { recursive: true });
42
+ mkdirSync(path.dirname(bin), { recursive: true });
43
+ writeFileSync(path.join(packageRoot, "package.json"), '{"name":"@botcord/daemon"}');
44
+ writeFileSync(entrypoint, "");
45
+ symlinkSync(entrypoint, bin);
46
+
47
+ expect(findDaemonInstallPrefix(bin)).toBe(realpathSync(prefix));
48
+ });
49
+
50
+ it("does not treat a monorepo development entrypoint as self-updatable", () => {
51
+ const entrypoint = path.join(tmpDir, "packages", "daemon", "dist", "index.js");
52
+ mkdirSync(path.dirname(entrypoint), { recursive: true });
53
+ writeFileSync(entrypoint, "");
54
+
55
+ expect(findDaemonInstallPrefix(entrypoint)).toBeNull();
56
+ });
57
+ });
@@ -149,6 +149,47 @@ describe("skill snapshots", () => {
149
149
  });
150
150
  });
151
151
 
152
+ it("scans Gemini workspace and user skill roots without mixing Claude or Codex dirs", () => {
153
+ const agentId = "ag_gemini_skills";
154
+ const workspaceGemini = path.join(agentWorkspaceDir(agentId), ".gemini", "skills");
155
+ const workspaceAgents = path.join(agentWorkspaceDir(agentId), ".agents", "skills");
156
+ writeSkill(workspaceGemini, "workspace-gemini", "Workspace Gemini skill");
157
+ writeSkill(workspaceAgents, "workspace-agent", "Workspace shared-agent skill");
158
+ writeSkill(path.join(tmpDir, ".gemini", "skills"), "global-gemini", "Global Gemini skill");
159
+ writeSkill(path.join(tmpDir, ".agents", "skills"), "global-agent", "Global shared-agent skill");
160
+ writeSkill(path.join(agentWorkspaceDir(agentId), ".claude", "skills"), "claude-only", "Claude only");
161
+ writeSkill(path.join(agentCodexHomeDir(agentId), "skills"), "codex-only", "Codex only");
162
+
163
+ const geminiScanned = scanSoftSkills(agentId, { runtime: "gemini" });
164
+ expect(geminiScanned.map((s) => s.name)).toEqual([
165
+ "global-agent",
166
+ "global-gemini",
167
+ "workspace-agent",
168
+ "workspace-gemini",
169
+ ]);
170
+ expect(geminiScanned.every((s) => s.runtime === "gemini")).toBe(true);
171
+
172
+ const snapshot = collectAgentSkillSnapshot(agentId, { runtime: "gemini" });
173
+ expect(snapshot.runtime).toBe("gemini");
174
+ expect(snapshot.skills.find((s) => s.name === "workspace-gemini"))
175
+ .toMatchObject({
176
+ source: "workspace",
177
+ sourceDetail: "agent-gemini",
178
+ runtime: "gemini",
179
+ path: path.join(workspaceGemini, "workspace-gemini", "SKILL.md"),
180
+ });
181
+ expect(snapshot.skills.find((s) => s.name === "workspace-agent"))
182
+ .toMatchObject({
183
+ source: "workspace",
184
+ sourceDetail: "agent-agents",
185
+ });
186
+ expect(snapshot.skills.find((s) => s.name === "global-agent"))
187
+ .toMatchObject({
188
+ source: "runtime-global",
189
+ sourceDetail: "global-agents",
190
+ });
191
+ });
192
+
152
193
  it("keeps same-device workspace skills scoped by agent id", () => {
153
194
  writeSkill(
154
195
  path.join(agentWorkspaceDir("ag_workspace_a"), ".claude", "skills"),
@@ -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
@@ -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" {