@botcord/daemon 0.2.85 → 0.2.86

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.85",
3
+ "version": "0.2.86",
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 });
@@ -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 = {
@@ -0,0 +1,91 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import {
6
+ agentCodexHomeDir,
7
+ agentWorkspaceDir,
8
+ } from "../agent-workspace.js";
9
+ import {
10
+ buildSoftSkillIndexPrompt,
11
+ collectAgentSkillSnapshot,
12
+ scanSoftSkills,
13
+ } from "../skill-index.js";
14
+
15
+ let tmpDir = "";
16
+ let prevHome: string | undefined;
17
+
18
+ function writeSkill(dir: string, name: string, description: string): void {
19
+ const skillDir = path.join(dir, name);
20
+ mkdirSync(skillDir, { recursive: true });
21
+ writeFileSync(
22
+ path.join(skillDir, "SKILL.md"),
23
+ `---\nname: ${name}\ndescription: "${description}"\n---\n\n# ${name}\n`,
24
+ );
25
+ }
26
+
27
+ beforeEach(() => {
28
+ tmpDir = mkdtempSync(path.join(tmpdir(), "skill-index-"));
29
+ prevHome = process.env.HOME;
30
+ process.env.HOME = tmpDir;
31
+ });
32
+
33
+ afterEach(() => {
34
+ if (prevHome === undefined) delete process.env.HOME;
35
+ else process.env.HOME = prevHome;
36
+ rmSync(tmpDir, { recursive: true, force: true });
37
+ });
38
+
39
+ describe("skill snapshots", () => {
40
+ it("scans agent workspace/runtime-global skills and maps UI source buckets", () => {
41
+ const agentId = "ag_skilltest";
42
+ writeSkill(path.join(agentWorkspaceDir(agentId), ".claude", "skills"), "workspace-skill", "Workspace skill");
43
+ writeSkill(path.join(agentCodexHomeDir(agentId), "skills"), "codex-skill", "Codex skill");
44
+ writeSkill(path.join(tmpDir, ".codex", "skills"), "global-skill", "Global skill");
45
+
46
+ const scanned = scanSoftSkills(agentId);
47
+ expect(scanned.map((s) => s.name).sort()).toEqual([
48
+ "codex-skill",
49
+ "global-skill",
50
+ "workspace-skill",
51
+ ]);
52
+
53
+ const snapshot = collectAgentSkillSnapshot(agentId);
54
+ expect(snapshot.agentId).toBe(agentId);
55
+ expect(snapshot.skills).toHaveLength(3);
56
+ expect(snapshot.skills.find((s) => s.name === "workspace-skill")?.source)
57
+ .toBe("workspace");
58
+ expect(snapshot.skills.find((s) => s.name === "codex-skill")?.source)
59
+ .toBe("workspace");
60
+ expect(snapshot.skills.find((s) => s.name === "global-skill")?.source)
61
+ .toBe("runtime-global");
62
+ expect(snapshot.probedAt).toBeGreaterThan(0);
63
+ });
64
+
65
+ it("returns complete snapshots while keeping the prompt soft index capped", () => {
66
+ const agentId = "ag_manyskills";
67
+ const workspaceSkills = path.join(agentWorkspaceDir(agentId), ".claude", "skills");
68
+ for (let i = 0; i < 30; i += 1) {
69
+ const name = `skill-${String(i).padStart(2, "0")}`;
70
+ writeSkill(workspaceSkills, name, `Skill ${i}`);
71
+ }
72
+
73
+ const scanned = scanSoftSkills(agentId);
74
+ expect(scanned).toHaveLength(30);
75
+ expect(scanned.map((s) => s.name)).toEqual(
76
+ Array.from({ length: 30 }, (_, i) => `skill-${String(i).padStart(2, "0")}`),
77
+ );
78
+
79
+ const snapshot = collectAgentSkillSnapshot(agentId);
80
+ expect(snapshot.skills).toHaveLength(30);
81
+
82
+ const prompt = buildSoftSkillIndexPrompt(agentId);
83
+ expect(prompt).not.toBeNull();
84
+ const skillLines = prompt
85
+ ?.split("\n")
86
+ .filter((line) => line.startsWith("- skill-"));
87
+ expect(skillLines).toHaveLength(24);
88
+ expect(skillLines?.at(0)).toContain("skill-00");
89
+ expect(skillLines?.at(-1)).toContain("skill-23");
90
+ });
91
+ });
@@ -27,7 +27,7 @@ import { ControlChannel } from "./control-channel.js";
27
27
  import { toGatewayConfig } from "./daemon-config-map.js";
28
28
  import { log as daemonLog } from "./log.js";
29
29
  import { createProvisioner } from "./provision.js";
30
- import { createDaemonChannel, pushRuntimeSnapshot } from "./daemon.js";
30
+ import { createDaemonChannel, pushAgentSkillSnapshot, pushRuntimeSnapshot } from "./daemon.js";
31
31
  import { SnapshotWriter } from "./snapshot-writer.js";
32
32
  import { createDaemonSystemContextBuilder } from "./system-context.js";
33
33
  import { readWorkingMemorySnapshot } from "./working-memory.js";
@@ -234,7 +234,20 @@ export async function startCloudDaemon(
234
234
  });
235
235
  };
236
236
 
237
+ const installedAgentIds = new Set<string>();
238
+ let controlChannel: ControlChannel | null = null;
239
+ const pushInstalledAgentSkillSnapshot = (agentId: string, reason: string): void => {
240
+ if (!controlChannel) return;
241
+ const pushed = pushAgentSkillSnapshot(controlChannel, agentId);
242
+ logger.info("cloud control-channel: agent_skill_snapshot pushed", {
243
+ agentId,
244
+ reason,
245
+ ok: pushed,
246
+ });
247
+ };
248
+
237
249
  const onAgentInstalled: OnAgentInstalledHook = (info: InstalledAgentInfo) => {
250
+ installedAgentIds.add(info.agentId);
238
251
  credentialPathByAgentId.set(info.agentId, info.credentialsFile);
239
252
  if (info.hubUrl) hubUrlByAgentId.set(info.agentId, info.hubUrl);
240
253
  if (info.displayName) displayNameByAgent.set(info.agentId, info.displayName);
@@ -251,6 +264,7 @@ export async function startCloudDaemon(
251
264
  }),
252
265
  );
253
266
  }
267
+ pushInstalledAgentSkillSnapshot(info.agentId, "agent_installed");
254
268
  };
255
269
 
256
270
  const gateway = new Gateway({
@@ -281,7 +295,6 @@ export async function startCloudDaemon(
281
295
  await gateway.start();
282
296
  logger.info("cloud daemon gateway started (zero agents at boot)");
283
297
 
284
- let controlChannel: ControlChannel | null = null;
285
298
  if (!opts.disableControlChannel) {
286
299
  const auth = asUserAuthManager(new CloudAuthManager(cloudCfg));
287
300
  const provisionerFactory = opts.provisionerFactory ?? createProvisioner;
@@ -330,6 +343,9 @@ export async function startCloudDaemon(
330
343
  logger.info("cloud control-channel started; runtime_snapshot pushed", {
331
344
  ok: pushed,
332
345
  });
346
+ for (const agentId of installedAgentIds) {
347
+ pushInstalledAgentSkillSnapshot(agentId, "control_channel_started");
348
+ }
333
349
  } catch (err) {
334
350
  logger.warn("cloud control-channel start failed; daemon will retry", {
335
351
  error: err instanceof Error ? err.message : String(err),
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { PID_PATH } from "./config.js";
5
5
 
@@ -17,6 +17,18 @@ const noopLogger: SingletonLogger = {
17
17
  },
18
18
  };
19
19
 
20
+ const DEFAULT_LOCK_WAIT_MS = 15_000;
21
+ const DEFAULT_LOCK_RETRY_MS = 50;
22
+
23
+ export interface DaemonSingletonLock {
24
+ lockPath: string;
25
+ release(): void;
26
+ }
27
+
28
+ export function defaultLockPath(pidPath = PID_PATH): string {
29
+ return `${pidPath}.lock`;
30
+ }
31
+
20
32
  export function readPid(pidPath = PID_PATH): number | null {
21
33
  if (!existsSync(pidPath)) return null;
22
34
  const raw = readFileSync(pidPath, "utf8").trim();
@@ -24,6 +36,10 @@ export function readPid(pidPath = PID_PATH): number | null {
24
36
  return Number.isFinite(pid) && pid > 0 ? pid : null;
25
37
  }
26
38
 
39
+ function readLockOwner(lockPath: string): number | null {
40
+ return readPid(path.join(lockPath, "owner.pid"));
41
+ }
42
+
27
43
  export function pidAlive(pid: number): boolean {
28
44
  try {
29
45
  process.kill(pid, 0);
@@ -127,6 +143,78 @@ export async function stopDaemonFromPidFileForRestart(
127
143
  }
128
144
  }
129
145
 
146
+ export async function acquireDaemonSingletonLock(
147
+ opts: {
148
+ lockPath?: string;
149
+ pidPath?: string;
150
+ currentPid?: number;
151
+ logger?: SingletonLogger;
152
+ timeoutMs?: number;
153
+ } = {},
154
+ ): Promise<DaemonSingletonLock> {
155
+ const pidPath = opts.pidPath ?? PID_PATH;
156
+ const lockPath = opts.lockPath ?? defaultLockPath(pidPath);
157
+ const currentPid = opts.currentPid ?? process.pid;
158
+ const logger = opts.logger ?? noopLogger;
159
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_LOCK_WAIT_MS;
160
+ const deadline = Date.now() + timeoutMs;
161
+
162
+ ensureParentDir(lockPath);
163
+ while (true) {
164
+ try {
165
+ mkdirSync(lockPath, { mode: 0o700 });
166
+ writeFileSync(path.join(lockPath, "owner.pid"), String(currentPid), { mode: 0o600 });
167
+ return {
168
+ lockPath,
169
+ release() {
170
+ const owner = readLockOwner(lockPath);
171
+ if (owner !== null && owner !== currentPid) return;
172
+ try {
173
+ rmSync(lockPath, { recursive: true, force: true });
174
+ } catch {
175
+ // ignore
176
+ }
177
+ },
178
+ };
179
+ } catch (err) {
180
+ const code = (err as NodeJS.ErrnoException).code;
181
+ if (code !== "EEXIST") throw err;
182
+ }
183
+
184
+ const owner = readLockOwner(lockPath);
185
+ if (owner === currentPid) {
186
+ return {
187
+ lockPath,
188
+ release() {
189
+ try {
190
+ rmSync(lockPath, { recursive: true, force: true });
191
+ } catch {
192
+ // ignore
193
+ }
194
+ },
195
+ };
196
+ }
197
+ if (owner !== null && pidAlive(owner)) {
198
+ logger.info("daemon singleton lock owner found; restarting", { pid: owner });
199
+ await stopExistingDaemonForRestart(owner, { pidPath, currentPid, logger });
200
+ }
201
+
202
+ const refreshedOwner = readLockOwner(lockPath);
203
+ if (refreshedOwner === null || !pidAlive(refreshedOwner)) {
204
+ try {
205
+ rmSync(lockPath, { recursive: true, force: true });
206
+ } catch {
207
+ // another starter may have removed/recreated it
208
+ }
209
+ }
210
+
211
+ if (Date.now() >= deadline) {
212
+ throw new Error(`timed out acquiring daemon singleton lock at ${lockPath}`);
213
+ }
214
+ await delay(DEFAULT_LOCK_RETRY_MS);
215
+ }
216
+ }
217
+
130
218
  export async function stopOtherDaemonProcessesForRestart(
131
219
  opts: {
132
220
  currentPid?: number;
@@ -187,11 +275,7 @@ export function writeCurrentPid(
187
275
  // Cloud-mode startup writes the PID file before `saveConfig` runs, so
188
276
  // the daemon dir may not exist yet. mkdir its parent (0700) so the
189
277
  // first write doesn't crash with ENOENT.
190
- try {
191
- mkdirSync(path.dirname(pidPath), { recursive: true, mode: 0o700 });
192
- } catch {
193
- // best-effort — writeFileSync below will surface the real error
194
- }
278
+ ensureParentDir(pidPath);
195
279
  writeFileSync(pidPath, String(opts.currentPid ?? process.pid), { mode: 0o600 });
196
280
  }
197
281
 
@@ -231,3 +315,11 @@ export function isBotCordDaemonStartCommand(command: string): boolean {
231
315
  function delay(ms: number): Promise<void> {
232
316
  return new Promise((resolve) => setTimeout(resolve, ms));
233
317
  }
318
+
319
+ function ensureParentDir(filePath: string): void {
320
+ try {
321
+ mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
322
+ } catch {
323
+ // best-effort — the next filesystem operation will surface real errors
324
+ }
325
+ }
package/src/daemon.ts CHANGED
@@ -52,6 +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
56
 
56
57
  /**
57
58
  * Default hard cap for a single runtime turn. Long-running coding/research
@@ -245,6 +246,26 @@ export function pushRuntimeSnapshot(
245
246
  return ok;
246
247
  }
247
248
 
249
+ export function pushAgentSkillSnapshot(
250
+ sink: RuntimeSnapshotSink,
251
+ agentId: string,
252
+ ): boolean {
253
+ const snap = collectAgentSkillSnapshot(agentId);
254
+ const ok = sink.send({
255
+ id: `skill_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
256
+ type: "agent_skill_snapshot",
257
+ params: snap as unknown as Record<string, unknown>,
258
+ ts: Date.now(),
259
+ });
260
+ if (!ok) {
261
+ daemonLog.warn("agent-skill-snapshot: control-channel send returned false", {
262
+ agentId,
263
+ skills: snap.skills.length,
264
+ });
265
+ }
266
+ return ok;
267
+ }
268
+
248
269
  /** Options accepted by {@link startDaemon} — the P0.5 compatibility shim. */
249
270
  export interface DaemonRuntimeOptions {
250
271
  config: DaemonConfig;
@@ -648,6 +669,13 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
648
669
  logger.info("control-channel: initial runtime_snapshot push", {
649
670
  ok: pushed,
650
671
  });
672
+ for (const agentId of agentIds) {
673
+ const skillsPushed = pushAgentSkillSnapshot(controlChannel, agentId);
674
+ logger.info("control-channel: initial agent_skill_snapshot push", {
675
+ agentId,
676
+ ok: skillsPushed,
677
+ });
678
+ }
651
679
  } catch (err) {
652
680
  logger.warn("control-channel failed to start; continuing without it", {
653
681
  error: err instanceof Error ? err.message : String(err),