@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.
- package/LICENSE +21 -0
- package/dist/cloud-daemon.js +23 -2
- package/dist/daemon-singleton.d.ts +12 -0
- package/dist/daemon-singleton.js +83 -7
- package/dist/daemon.d.ts +3 -0
- package/dist/daemon.js +32 -0
- package/dist/gateway/channels/botcord.js +72 -29
- package/dist/gateway/dispatcher.d.ts +16 -0
- package/dist/gateway/dispatcher.js +25 -1
- package/dist/gateway/runtimes/deepseek-tui.js +5 -1
- package/dist/index.js +27 -7
- package/dist/provision.js +37 -3
- package/dist/skill-index.d.ts +13 -0
- package/dist/skill-index.js +65 -18
- package/dist/turn-text.js +38 -1
- 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__/dispatcher-reply-to.test.ts +61 -0
- package/src/__tests__/provision.test.ts +42 -0
- package/src/__tests__/runtime-discovery.test.ts +17 -1
- package/src/__tests__/skill-index.test.ts +130 -0
- package/src/__tests__/turn-text.test.ts +121 -0
- package/src/cloud-daemon.ts +22 -2
- package/src/daemon-singleton.ts +98 -6
- package/src/daemon.ts +37 -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/dispatcher.ts +26 -1
- package/src/gateway/runtimes/deepseek-tui.ts +7 -1
- package/src/index.ts +25 -6
- package/src/provision.ts +42 -1
- package/src/skill-index.ts +87 -19
- package/src/turn-text.ts +51 -1
package/dist/skill-index.d.ts
CHANGED
|
@@ -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;
|
package/dist/skill-index.js
CHANGED
|
@@ -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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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 = {
|