@botcord/daemon 0.2.85 → 0.2.87

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.
@@ -5,13 +5,26 @@ export interface SoftSkillEntry {
5
5
  description?: string;
6
6
  mtimeMs: number;
7
7
  }
8
+ export interface AgentSkillSnapshotEntry {
9
+ name: string;
10
+ source: string;
11
+ description?: string;
12
+ mtimeMs: number;
13
+ }
14
+ export interface AgentSkillSnapshot {
15
+ agentId: string;
16
+ skills: AgentSkillSnapshotEntry[];
17
+ probedAt: number;
18
+ }
8
19
  export interface SkillIndexOptions {
9
20
  extraDirs?: string[];
10
21
  includeGlobal?: boolean;
22
+ runtime?: string;
11
23
  }
12
24
  export declare function defaultSkillDirs(agentId: string, opts?: SkillIndexOptions): Array<{
13
25
  dir: string;
14
26
  source: string;
15
27
  }>;
16
28
  export declare function scanSoftSkills(agentId: string, opts?: SkillIndexOptions): SoftSkillEntry[];
29
+ export declare function collectAgentSkillSnapshot(agentId: string, opts?: SkillIndexOptions): AgentSkillSnapshot;
17
30
  export declare function buildSoftSkillIndexPrompt(agentId: string, opts?: SkillIndexOptions): string | null;
@@ -7,24 +7,29 @@ const MAX_DESCRIPTION_CHARS = 260;
7
7
  const MAX_SKILL_MD_READ_CHARS = 8192;
8
8
  export function defaultSkillDirs(agentId, opts = {}) {
9
9
  const includeGlobal = opts.includeGlobal !== false;
10
- const dirs = [
11
- {
12
- dir: path.join(agentWorkspaceDir(agentId), ".claude", "skills"),
13
- source: "agent-claude",
14
- },
15
- {
16
- dir: path.join(agentCodexHomeDir(agentId), "skills"),
17
- source: "agent-codex",
18
- },
19
- ];
10
+ const agentClaude = {
11
+ dir: path.join(agentWorkspaceDir(agentId), ".claude", "skills"),
12
+ source: "agent-claude",
13
+ };
14
+ const agentCodex = {
15
+ dir: path.join(agentCodexHomeDir(agentId), "skills"),
16
+ source: "agent-codex",
17
+ };
18
+ const dirs = runtimeFamily(opts.runtime) === "codex"
19
+ ? [agentCodex, agentClaude]
20
+ : [agentClaude, agentCodex];
20
21
  if (includeGlobal) {
21
- dirs.push({ dir: path.join(homedir(), ".claude", "skills"), source: "global-claude" }, { dir: path.join(homedir(), ".codex", "skills"), source: "global-codex" });
22
+ const globalClaude = { dir: path.join(homedir(), ".claude", "skills"), source: "global-claude" };
23
+ const globalCodex = { dir: path.join(homedir(), ".codex", "skills"), source: "global-codex" };
24
+ dirs.push(...(runtimeFamily(opts.runtime) === "codex"
25
+ ? [globalCodex, globalClaude]
26
+ : [globalClaude, globalCodex]));
22
27
  }
23
28
  const envDirs = parseSkillDirsEnv(process.env.BOTCORD_SKILL_DIRS);
24
29
  for (const dir of [...envDirs, ...(opts.extraDirs ?? [])]) {
25
30
  dirs.push({ dir, source: "external" });
26
31
  }
27
- return dedupeDirs(dirs);
32
+ return dedupeDirs(expandSkillRoots(dirs));
28
33
  }
29
34
  export function scanSoftSkills(agentId, opts = {}) {
30
35
  const byName = new Map();
@@ -34,7 +39,7 @@ export function scanSoftSkills(agentId, opts = {}) {
34
39
  continue;
35
40
  let children;
36
41
  try {
37
- children = readdirSync(root.dir);
42
+ children = readdirSync(root.dir).sort((a, b) => a.localeCompare(b));
38
43
  }
39
44
  catch {
40
45
  continue;
@@ -63,17 +68,28 @@ export function scanSoftSkills(agentId, opts = {}) {
63
68
  description: parsed.description,
64
69
  mtimeMs: st.mtimeMs,
65
70
  };
66
- if (!existing || priority(root.source) < priority(existing.source)) {
71
+ if (!existing || priority(root.source, opts.runtime) < priority(existing.source, opts.runtime)) {
67
72
  byName.set(entry.name, entry);
68
73
  }
69
74
  }
70
75
  }
71
76
  return Array.from(byName.values())
72
- .sort((a, b) => a.name.localeCompare(b.name))
73
- .slice(0, MAX_SKILLS);
77
+ .sort((a, b) => a.name.localeCompare(b.name));
78
+ }
79
+ export function collectAgentSkillSnapshot(agentId, opts = {}) {
80
+ return {
81
+ agentId,
82
+ skills: scanSoftSkills(agentId, opts).map((skill) => ({
83
+ name: skill.name,
84
+ source: skill.source.startsWith("agent-") ? "workspace" : "runtime-global",
85
+ ...(skill.description ? { description: skill.description } : {}),
86
+ mtimeMs: skill.mtimeMs,
87
+ })),
88
+ probedAt: Date.now(),
89
+ };
74
90
  }
75
91
  export function buildSoftSkillIndexPrompt(agentId, opts = {}) {
76
- const skills = scanSoftSkills(agentId, opts);
92
+ const skills = scanSoftSkills(agentId, opts).slice(0, MAX_SKILLS);
77
93
  if (skills.length === 0)
78
94
  return null;
79
95
  const lines = [
@@ -145,7 +161,38 @@ function dedupeDirs(dirs) {
145
161
  }
146
162
  return out;
147
163
  }
148
- function priority(source) {
164
+ function expandSkillRoots(dirs) {
165
+ const out = [];
166
+ for (const entry of dirs) {
167
+ out.push(entry);
168
+ if (entry.source.includes("codex")) {
169
+ out.push({ dir: path.join(entry.dir, ".system"), source: entry.source });
170
+ }
171
+ }
172
+ return out;
173
+ }
174
+ function runtimeFamily(runtime) {
175
+ if (runtime === "codex")
176
+ return "codex";
177
+ if (runtime === "claude-code")
178
+ return "claude";
179
+ return "other";
180
+ }
181
+ function priority(source, runtime) {
182
+ if (runtimeFamily(runtime) === "codex") {
183
+ switch (source) {
184
+ case "agent-codex":
185
+ return 0;
186
+ case "global-codex":
187
+ return 1;
188
+ case "agent-claude":
189
+ return 2;
190
+ case "global-claude":
191
+ return 3;
192
+ default:
193
+ return 4;
194
+ }
195
+ }
149
196
  switch (source) {
150
197
  case "agent-claude":
151
198
  return 0;
package/dist/turn-text.js CHANGED
@@ -118,6 +118,39 @@ function entryText(e) {
118
118
  return e.envelope.payload.text;
119
119
  return "";
120
120
  }
121
+ /**
122
+ * Format the inline quote-reply context line that prefixes a message body
123
+ * when the inbound envelope replies to another message. Single layer — we
124
+ * never render a quote-of-a-quote chain. Returns `null` when the source
125
+ * carries no reply_preview, so the caller can skip emitting an empty line.
126
+ *
127
+ * Both sender label and preview body are sanitized; the preview is hard-
128
+ * capped at 120 chars to mirror the backend truncation.
129
+ */
130
+ function formatReplyQuoteLine(raw) {
131
+ if (!raw || typeof raw !== "object")
132
+ return null;
133
+ const rp = raw.reply_preview;
134
+ if (!rp || typeof rp !== "object")
135
+ return null;
136
+ if (rp.deleted === true) {
137
+ return "[quoting (deleted message)]";
138
+ }
139
+ const senderRaw = typeof rp.sender_display_name === "string" && rp.sender_display_name
140
+ ? rp.sender_display_name
141
+ : typeof rp.sender_id === "string" && rp.sender_id
142
+ ? rp.sender_id
143
+ : "unknown";
144
+ const sender = sanitizeSenderName(senderRaw);
145
+ const previewRaw = typeof rp.text_preview === "string" ? rp.text_preview : "";
146
+ const previewClean = sanitizeUntrustedContent(previewRaw)
147
+ .replace(/[\r\n]+/g, " ")
148
+ .slice(0, 120);
149
+ if (!previewClean) {
150
+ return `[quoting ${sender}]`;
151
+ }
152
+ return `[quoting ${sender}: "${previewClean}"]`;
153
+ }
121
154
  function formatRoomContext(raw, fallback) {
122
155
  const r = raw && typeof raw === "object" ? raw : {};
123
156
  const roomId = typeof r.room_id === "string" && r.room_id ? r.room_id : fallback.id;
@@ -206,11 +239,13 @@ export function composeBotCordUserTurn(msg) {
206
239
  "they can decide whether to accept or reject it. Include the sender's " +
207
240
  "agent ID and any message they attached.]"
208
241
  : null;
242
+ const quoteLine = formatReplyQuoteLine(msg.raw);
209
243
  const lines = [
210
244
  headerFields.join(" | "),
211
245
  ...formatScheduleContext(msg.raw),
212
246
  ...(isGroup ? formatRoomContext(msg.raw, { id: conversation.id, title: roomTitle }) : []),
213
247
  `<${tag} sender="${sanitizedSenderLabel}" sender_kind="${senderKindAttr}">`,
248
+ ...(quoteLine ? [quoteLine] : []),
214
249
  trimmed,
215
250
  `</${tag}>`,
216
251
  "",
@@ -256,7 +291,9 @@ function composeBatchedTurn(msg, batch) {
256
291
  // non-owner. Still sanitize defensively.
257
292
  const safeBody = sanitizeUntrustedContent(raw);
258
293
  const tag = kind === "human" ? "human-message" : "agent-message";
259
- blocks.push(`<${tag} sender="${safeLabel}" sender_kind="${kind}">\n${safeBody}\n</${tag}>`);
294
+ const quoteLine = formatReplyQuoteLine(entry);
295
+ const inner = quoteLine ? `${quoteLine}\n${safeBody}` : safeBody;
296
+ blocks.push(`<${tag} sender="${safeLabel}" sender_kind="${kind}">\n${inner}\n</${tag}>`);
260
297
  if (envelopeType === "contact_request") {
261
298
  contactRequestSenders.push(safeLabel);
262
299
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.85",
3
+ "version": "0.2.87",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,12 +8,6 @@
8
8
  },
9
9
  "main": "./dist/index.js",
10
10
  "types": "./dist/index.d.ts",
11
- "scripts": {
12
- "build": "tsc -p tsconfig.build.json",
13
- "prepublishOnly": "tsc -p tsconfig.build.json",
14
- "test": "vitest run",
15
- "test:watch": "vitest"
16
- },
17
11
  "files": [
18
12
  "dist/",
19
13
  "src/",
@@ -27,10 +21,10 @@
27
21
  "access": "public"
28
22
  },
29
23
  "dependencies": {
30
- "@botcord/cli": "^0.1.7",
31
- "@botcord/protocol-core": "^0.2.12",
32
24
  "@larksuiteoapi/node-sdk": "^1.63.1",
33
- "ws": "^8.20.1"
25
+ "ws": "^8.20.1",
26
+ "@botcord/cli": "^0.1.18",
27
+ "@botcord/protocol-core": "^0.2.13"
34
28
  },
35
29
  "overrides": {
36
30
  "axios": "^1.15.2"
@@ -40,5 +34,10 @@
40
34
  "@types/ws": "^8.5.0",
41
35
  "typescript": "^5.4.0",
42
36
  "vitest": "^4.0.18"
37
+ },
38
+ "scripts": {
39
+ "build": "tsc -p tsconfig.build.json",
40
+ "test": "vitest run",
41
+ "test:watch": "vitest"
43
42
  }
44
- }
43
+ }
@@ -20,6 +20,7 @@ import type { CloudModeConfig } from "../cloud-mode.js";
20
20
  import { ControlChannel } from "../control-channel.js";
21
21
  import type { DaemonConfig } from "../config.js";
22
22
  import type { Gateway, GatewayChannelConfig } from "../gateway/index.js";
23
+ import type { OnAgentInstalledHook } from "../provision.js";
23
24
 
24
25
  class FakeWebSocket extends EventEmitter {
25
26
  public readyState = 0;
@@ -75,6 +76,10 @@ function makeDaemonCfg(): DaemonConfig {
75
76
  };
76
77
  }
77
78
 
79
+ function sentFrames(ws: FakeWebSocket): Array<{ type?: string; params?: any }> {
80
+ return ws.sent.map((raw) => JSON.parse(raw));
81
+ }
82
+
78
83
  describe("startCloudDaemon", () => {
79
84
  let tmpDir: string;
80
85
 
@@ -173,6 +178,80 @@ describe("startCloudDaemon", () => {
173
178
  }
174
179
  });
175
180
 
181
+ it("pushes skill snapshots for agents installed before control-channel startup completes", async () => {
182
+ const ctor = makeFakeCtor();
183
+ class TestControlChannel extends ControlChannel {
184
+ constructor(opts: ConstructorParameters<typeof ControlChannel>[0]) {
185
+ super({ ...opts, webSocketCtor: ctor, hubPublicKey: null });
186
+ }
187
+ }
188
+ const handle = await startCloudDaemon({
189
+ cloudConfig: makeCfg(),
190
+ config: makeDaemonCfg(),
191
+ configPath: "(cloud-mode)",
192
+ controlChannelFactory: TestControlChannel as unknown as typeof ControlChannel,
193
+ provisionerFactory: ((args: { onAgentInstalled: OnAgentInstalledHook }) => {
194
+ args.onAgentInstalled({
195
+ agentId: "ag_preinstalled",
196
+ credentialsFile: path.join(tmpDir, "ag_preinstalled.json"),
197
+ hubUrl: "http://localhost:9000",
198
+ });
199
+ return vi.fn();
200
+ }) as unknown as typeof import("../provision.js").createProvisioner,
201
+ sessionStorePath: path.join(tmpDir, "sessions.json"),
202
+ snapshotPath: path.join(tmpDir, "snapshot.json"),
203
+ snapshotIntervalMs: 60_000,
204
+ });
205
+ try {
206
+ await new Promise((r) => setImmediate(r));
207
+ const ws = FakeWebSocket.instances[0]!;
208
+ const skillFrames = sentFrames(ws).filter((frame) => frame.type === "agent_skill_snapshot");
209
+ expect(skillFrames).toHaveLength(1);
210
+ expect(skillFrames[0]!.params.agentId).toBe("ag_preinstalled");
211
+ expect(Array.isArray(skillFrames[0]!.params.skills)).toBe(true);
212
+ } finally {
213
+ await handle.stop("test");
214
+ }
215
+ });
216
+
217
+ it("pushes a skill snapshot when a cloud agent is installed after startup", async () => {
218
+ let onAgentInstalled: OnAgentInstalledHook | undefined;
219
+ const ctor = makeFakeCtor();
220
+ class TestControlChannel extends ControlChannel {
221
+ constructor(opts: ConstructorParameters<typeof ControlChannel>[0]) {
222
+ super({ ...opts, webSocketCtor: ctor, hubPublicKey: null });
223
+ }
224
+ }
225
+ const handle = await startCloudDaemon({
226
+ cloudConfig: makeCfg(),
227
+ config: makeDaemonCfg(),
228
+ configPath: "(cloud-mode)",
229
+ controlChannelFactory: TestControlChannel as unknown as typeof ControlChannel,
230
+ provisionerFactory: ((args: { onAgentInstalled: OnAgentInstalledHook }) => {
231
+ onAgentInstalled = args.onAgentInstalled;
232
+ return vi.fn();
233
+ }) as unknown as typeof import("../provision.js").createProvisioner,
234
+ sessionStorePath: path.join(tmpDir, "sessions.json"),
235
+ snapshotPath: path.join(tmpDir, "snapshot.json"),
236
+ snapshotIntervalMs: 60_000,
237
+ });
238
+ try {
239
+ await new Promise((r) => setImmediate(r));
240
+ onAgentInstalled!({
241
+ agentId: "ag_hot_installed",
242
+ credentialsFile: path.join(tmpDir, "ag_hot_installed.json"),
243
+ hubUrl: "http://localhost:9000",
244
+ });
245
+ const ws = FakeWebSocket.instances[0]!;
246
+ const skillFrames = sentFrames(ws).filter((frame) => frame.type === "agent_skill_snapshot");
247
+ expect(skillFrames).toHaveLength(1);
248
+ expect(skillFrames[0]!.params.agentId).toBe("ag_hot_installed");
249
+ expect(Array.isArray(skillFrames[0]!.params.skills)).toBe(true);
250
+ } finally {
251
+ await handle.stop("test");
252
+ }
253
+ });
254
+
176
255
  it.each(["telegram", "wechat", "feishu"] as const)(
177
256
  "allows %s gateway channels to be hot-plugged in cloud mode",
178
257
  async (type) => {
@@ -1,9 +1,10 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
- import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
6
  import {
7
+ acquireDaemonSingletonLock,
7
8
  ensureNoOtherDaemonFromPidFile,
8
9
  isBotCordDaemonStartCommand,
9
10
  parseDaemonProcesses,
@@ -79,6 +80,63 @@ describe("daemon singleton pid helpers", () => {
79
80
  expect(child.exitCode === null && child.signalCode === null).toBe(false);
80
81
  });
81
82
 
83
+ it("holds an atomic singleton lock until release", async () => {
84
+ const pidPath = path.join(tmpDir, "daemon.pid");
85
+ const lockPath = `${pidPath}.lock`;
86
+
87
+ const lock = await acquireDaemonSingletonLock({
88
+ pidPath,
89
+ lockPath,
90
+ currentPid: process.pid,
91
+ });
92
+
93
+ expect(existsSync(lockPath)).toBe(true);
94
+ expect(readPid(path.join(lockPath, "owner.pid"))).toBe(process.pid);
95
+
96
+ lock.release();
97
+
98
+ expect(existsSync(lockPath)).toBe(false);
99
+ });
100
+
101
+ it("removes stale singleton locks", async () => {
102
+ const pidPath = path.join(tmpDir, "daemon.pid");
103
+ const lockPath = `${pidPath}.lock`;
104
+ mkdirSync(lockPath, { recursive: true });
105
+ writeCurrentPid({ pidPath: path.join(lockPath, "owner.pid"), currentPid: 99999999 });
106
+
107
+ const lock = await acquireDaemonSingletonLock({
108
+ pidPath,
109
+ lockPath,
110
+ currentPid: process.pid,
111
+ });
112
+
113
+ expect(readPid(path.join(lockPath, "owner.pid"))).toBe(process.pid);
114
+ lock.release();
115
+ });
116
+
117
+ it("terminates a live singleton lock owner before acquiring", async () => {
118
+ const pidPath = path.join(tmpDir, "daemon.pid");
119
+ const lockPath = `${pidPath}.lock`;
120
+ const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
121
+ stdio: "ignore",
122
+ });
123
+ children.push(child);
124
+ await waitForPid(child);
125
+ mkdirSync(lockPath, { recursive: true });
126
+ writeCurrentPid({ pidPath: path.join(lockPath, "owner.pid"), currentPid: child.pid! });
127
+
128
+ const lock = await acquireDaemonSingletonLock({
129
+ pidPath,
130
+ lockPath,
131
+ currentPid: process.pid,
132
+ });
133
+
134
+ await waitForExit(child);
135
+ expect(child.exitCode === null && child.signalCode === null).toBe(false);
136
+ expect(readPid(path.join(lockPath, "owner.pid"))).toBe(process.pid);
137
+ lock.release();
138
+ });
139
+
82
140
  it("removes stale pid files", () => {
83
141
  const pidPath = path.join(tmpDir, "daemon.pid");
84
142
  writeCurrentPid({ pidPath, currentPid: 99999999 });
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { pickReplyToTarget } from "../gateway/dispatcher.js";
3
+ import type { GatewayInboundMessage } from "../gateway/index.js";
4
+
5
+ function makeMsg(partial: Partial<GatewayInboundMessage> = {}): GatewayInboundMessage {
6
+ return {
7
+ id: partial.id ?? "h_abc123",
8
+ channel: partial.channel ?? "botcord",
9
+ accountId: partial.accountId ?? "ag_me",
10
+ conversation: partial.conversation ?? { id: "rm_room", kind: "group" },
11
+ sender: partial.sender ?? { id: "ag_alice", kind: "agent" },
12
+ text: partial.text ?? "hi",
13
+ raw: partial.raw ?? null,
14
+ receivedAt: partial.receivedAt ?? Date.now(),
15
+ mentioned: partial.mentioned ?? false,
16
+ replyTo: partial.replyTo ?? null,
17
+ };
18
+ }
19
+
20
+ describe("pickReplyToTarget", () => {
21
+ it("returns msg.replyTo when the inbound was already a reply (chain semantics)", () => {
22
+ const result = pickReplyToTarget(
23
+ makeMsg({
24
+ replyTo: "11111111-2222-3333-4444-555555555555",
25
+ id: "h_inbound",
26
+ raw: { envelope: { msg_id: "ignored-because-chain-takes-priority" } },
27
+ }),
28
+ );
29
+ expect(result).toBe("11111111-2222-3333-4444-555555555555");
30
+ });
31
+
32
+ it("returns envelope.msg_id (canonical UUID) when present and not chained", () => {
33
+ const result = pickReplyToTarget(
34
+ makeMsg({
35
+ id: "h_inbound",
36
+ raw: { envelope: { msg_id: "11111111-2222-3333-4444-555555555555" } },
37
+ }),
38
+ );
39
+ expect(result).toBe("11111111-2222-3333-4444-555555555555");
40
+ expect(result).not.toMatch(/^h_/);
41
+ });
42
+
43
+ it("falls back to hub_msg_id when envelope.msg_id is missing", () => {
44
+ const result = pickReplyToTarget(
45
+ makeMsg({ id: "h_inbound", raw: null }),
46
+ );
47
+ // Hub is lenient (accepts h_* via _load_reply_target), so this is still
48
+ // resolvable on the wire — but the helper should clearly mark the fallback.
49
+ expect(result).toBe("h_inbound");
50
+ });
51
+
52
+ it("ignores non-string envelope.msg_id and falls back to hub_msg_id", () => {
53
+ const result = pickReplyToTarget(
54
+ makeMsg({
55
+ id: "h_inbound",
56
+ raw: { envelope: { msg_id: 42 as unknown as string } },
57
+ }),
58
+ );
59
+ expect(result).toBe("h_inbound");
60
+ });
61
+ });
@@ -719,6 +719,48 @@ describe("provision_agent handler writes runtime + cwd", () => {
719
719
  }
720
720
  });
721
721
 
722
+ it("normalizes Hub-supplied millisecond tokenExpiresAt before writing credentials", async () => {
723
+ const os = await import("node:os");
724
+ const fs = await import("node:fs");
725
+ const nodePath = await import("node:path");
726
+
727
+ const tmp = fs.mkdtempSync(nodePath.join(os.tmpdir(), "daemon-provision-"));
728
+ const prevHome = process.env.HOME;
729
+ process.env.HOME = tmp;
730
+ try {
731
+ const gw = makeFakeGateway();
732
+ const provisioner = createProvisioner({
733
+ gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
734
+ });
735
+ const privateKey = Buffer.alloc(32, 9).toString("base64");
736
+
737
+ const ack = await provisioner({
738
+ id: "req_token_expiry",
739
+ type: CONTROL_FRAME_TYPES.PROVISION_AGENT,
740
+ params: {
741
+ runtime: "codex",
742
+ credentials: {
743
+ agentId: "ag_token_expiry",
744
+ keyId: "k_token_expiry",
745
+ privateKey,
746
+ hubUrl: "https://hub.example",
747
+ token: "agent-token",
748
+ tokenExpiresAt: 1_779_856_985_546,
749
+ },
750
+ },
751
+ });
752
+
753
+ expect(ack.ok).toBe(true);
754
+ const credFile = nodePath.join(tmp, ".botcord", "credentials", "ag_token_expiry.json");
755
+ const saved = JSON.parse(fs.readFileSync(credFile, "utf8")) as Record<string, unknown>;
756
+ expect(saved.tokenExpiresAt).toBe(1_779_856_985);
757
+ } finally {
758
+ if (prevHome === undefined) delete process.env.HOME;
759
+ else process.env.HOME = prevHome;
760
+ fs.rmSync(tmp, { recursive: true, force: true });
761
+ }
762
+ });
763
+
722
764
  it("rejects unknown runtime ids before touching disk", async () => {
723
765
  const gw = makeFakeGateway();
724
766
  const provisioner = createProvisioner({
@@ -40,7 +40,7 @@ beforeEach(() => {
40
40
  // mocked runtime list between cases, so reset before each.
41
41
  clearRuntimeProbeCache();
42
42
  });
43
- const { pushRuntimeSnapshot } = await import("../daemon.js");
43
+ const { pushAgentSkillSnapshot, pushRuntimeSnapshot } = await import("../daemon.js");
44
44
  const { CONTROL_FRAME_TYPES } = await import("@botcord/protocol-core");
45
45
  import type { GatewayChannelConfig, GatewayRuntimeSnapshot } from "../gateway/index.js";
46
46
 
@@ -474,6 +474,22 @@ describe("pushRuntimeSnapshot (first-connect push)", () => {
474
474
  });
475
475
  });
476
476
 
477
+ describe("pushAgentSkillSnapshot", () => {
478
+ it("sends an agent_skill_snapshot frame", () => {
479
+ const frames: any[] = [];
480
+ const ok = pushAgentSkillSnapshot(
481
+ { send: (frame) => { frames.push(frame); return true; } },
482
+ "ag_skills",
483
+ );
484
+ expect(ok).toBe(true);
485
+ expect(frames).toHaveLength(1);
486
+ expect(frames[0].type).toBe("agent_skill_snapshot");
487
+ expect(frames[0].params.agentId).toBe("ag_skills");
488
+ expect(Array.isArray(frames[0].params.skills)).toBe(true);
489
+ expect(typeof frames[0].params.probedAt).toBe("number");
490
+ });
491
+ });
492
+
477
493
  describe("attachRuntimeHealth", () => {
478
494
  it("groups live circuit breakers onto matching runtime entries", () => {
479
495
  const snap = {