@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/LICENSE +21 -0
- package/dist/cloud-daemon.js +18 -2
- package/dist/daemon-singleton.d.ts +12 -0
- package/dist/daemon-singleton.js +83 -7
- package/dist/daemon.d.ts +1 -0
- package/dist/daemon.js +24 -0
- package/dist/gateway/channels/botcord.js +72 -29
- package/dist/gateway/runtimes/deepseek-tui.js +5 -1
- package/dist/index.js +27 -7
- package/dist/provision.js +30 -3
- package/dist/skill-index.d.ts +12 -0
- package/dist/skill-index.js +15 -4
- package/package.json +10 -11
- package/src/__tests__/cloud-daemon.test.ts +79 -0
- package/src/__tests__/daemon-singleton.test.ts +59 -1
- package/src/__tests__/provision.test.ts +42 -0
- package/src/__tests__/runtime-discovery.test.ts +17 -1
- package/src/__tests__/skill-index.test.ts +91 -0
- package/src/cloud-daemon.ts +18 -2
- package/src/daemon-singleton.ts +98 -6
- package/src/daemon.ts +28 -0
- package/src/gateway/__tests__/botcord-channel.test.ts +79 -0
- package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +49 -0
- package/src/gateway/channels/botcord.ts +81 -33
- package/src/gateway/runtimes/deepseek-tui.ts +7 -1
- package/src/index.ts +25 -6
- package/src/provision.ts +34 -1
- package/src/skill-index.ts +32 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botcord/daemon",
|
|
3
|
-
"version": "0.2.
|
|
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
|
+
});
|
package/src/cloud-daemon.ts
CHANGED
|
@@ -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),
|
package/src/daemon-singleton.ts
CHANGED
|
@@ -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
|
-
|
|
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),
|