@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
|
@@ -0,0 +1,130 @@
|
|
|
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("scans Codex .system skills and prefers Codex copies for Codex agents", () => {
|
|
66
|
+
const agentId = "ag_codex_system";
|
|
67
|
+
writeSkill(path.join(agentWorkspaceDir(agentId), ".claude", "skills"), "shared", "Claude copy");
|
|
68
|
+
writeSkill(path.join(agentCodexHomeDir(agentId), "skills"), "shared", "Codex copy");
|
|
69
|
+
writeSkill(
|
|
70
|
+
path.join(agentCodexHomeDir(agentId), "skills", ".system"),
|
|
71
|
+
"agent-system",
|
|
72
|
+
"Agent Codex system skill",
|
|
73
|
+
);
|
|
74
|
+
writeSkill(
|
|
75
|
+
path.join(tmpDir, ".codex", "skills", ".system"),
|
|
76
|
+
"imagegen",
|
|
77
|
+
"Codex global system skill",
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const codexScanned = scanSoftSkills(agentId, { runtime: "codex" });
|
|
81
|
+
expect(codexScanned.map((s) => s.name).sort()).toEqual([
|
|
82
|
+
"agent-system",
|
|
83
|
+
"imagegen",
|
|
84
|
+
"shared",
|
|
85
|
+
]);
|
|
86
|
+
expect(codexScanned.find((s) => s.name === "shared")).toMatchObject({
|
|
87
|
+
source: "agent-codex",
|
|
88
|
+
description: "Codex copy",
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const claudeScanned = scanSoftSkills(agentId, { runtime: "claude-code" });
|
|
92
|
+
expect(claudeScanned.find((s) => s.name === "shared")).toMatchObject({
|
|
93
|
+
source: "agent-claude",
|
|
94
|
+
description: "Claude copy",
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const snapshot = collectAgentSkillSnapshot(agentId, { runtime: "codex" });
|
|
98
|
+
expect(snapshot.skills.find((s) => s.name === "agent-system")?.source)
|
|
99
|
+
.toBe("workspace");
|
|
100
|
+
expect(snapshot.skills.find((s) => s.name === "imagegen")?.source)
|
|
101
|
+
.toBe("runtime-global");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("returns complete snapshots while keeping the prompt soft index capped", () => {
|
|
105
|
+
const agentId = "ag_manyskills";
|
|
106
|
+
const workspaceSkills = path.join(agentWorkspaceDir(agentId), ".claude", "skills");
|
|
107
|
+
for (let i = 0; i < 30; i += 1) {
|
|
108
|
+
const name = `skill-${String(i).padStart(2, "0")}`;
|
|
109
|
+
writeSkill(workspaceSkills, name, `Skill ${i}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const scanned = scanSoftSkills(agentId);
|
|
113
|
+
expect(scanned).toHaveLength(30);
|
|
114
|
+
expect(scanned.map((s) => s.name)).toEqual(
|
|
115
|
+
Array.from({ length: 30 }, (_, i) => `skill-${String(i).padStart(2, "0")}`),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const snapshot = collectAgentSkillSnapshot(agentId);
|
|
119
|
+
expect(snapshot.skills).toHaveLength(30);
|
|
120
|
+
|
|
121
|
+
const prompt = buildSoftSkillIndexPrompt(agentId);
|
|
122
|
+
expect(prompt).not.toBeNull();
|
|
123
|
+
const skillLines = prompt
|
|
124
|
+
?.split("\n")
|
|
125
|
+
.filter((line) => line.startsWith("- skill-"));
|
|
126
|
+
expect(skillLines).toHaveLength(24);
|
|
127
|
+
expect(skillLines?.at(0)).toContain("skill-00");
|
|
128
|
+
expect(skillLines?.at(-1)).toContain("skill-23");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -370,3 +370,124 @@ describe("composeBotCordUserTurn", () => {
|
|
|
370
370
|
expect(headerLines.length).toBe(1);
|
|
371
371
|
});
|
|
372
372
|
});
|
|
373
|
+
|
|
374
|
+
describe("composeBotCordUserTurn quote-reply", () => {
|
|
375
|
+
it("inserts a [quoting …] line above the body when reply_preview is present", () => {
|
|
376
|
+
const out = composeBotCordUserTurn(
|
|
377
|
+
makeMessage({
|
|
378
|
+
text: "agreed, ship it",
|
|
379
|
+
sender: { id: "ag_alice", name: "Alice", kind: "agent" },
|
|
380
|
+
raw: {
|
|
381
|
+
reply_preview: {
|
|
382
|
+
msg_id: "h_orig",
|
|
383
|
+
sender_id: "ag_bob",
|
|
384
|
+
sender_display_name: "Bob",
|
|
385
|
+
text_preview: "We should ship the feature next sprint",
|
|
386
|
+
topic_id: null,
|
|
387
|
+
deleted: false,
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
}),
|
|
391
|
+
);
|
|
392
|
+
expect(out).toContain('<agent-message sender="ag_alice" sender_kind="agent">');
|
|
393
|
+
expect(out).toContain('[quoting Bob: "We should ship the feature next sprint"]');
|
|
394
|
+
expect(out).toContain("agreed, ship it");
|
|
395
|
+
// Quote line precedes body inside the tag block.
|
|
396
|
+
const quoteIdx = out.indexOf("[quoting Bob");
|
|
397
|
+
const bodyIdx = out.indexOf("agreed, ship it");
|
|
398
|
+
expect(quoteIdx).toBeGreaterThan(-1);
|
|
399
|
+
expect(quoteIdx).toBeLessThan(bodyIdx);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("renders a tombstone line when the quote target was deleted", () => {
|
|
403
|
+
const out = composeBotCordUserTurn(
|
|
404
|
+
makeMessage({
|
|
405
|
+
text: "RE: that thing",
|
|
406
|
+
sender: { id: "ag_alice", kind: "agent" },
|
|
407
|
+
raw: {
|
|
408
|
+
reply_preview: {
|
|
409
|
+
msg_id: "h_gone",
|
|
410
|
+
sender_id: null,
|
|
411
|
+
sender_display_name: null,
|
|
412
|
+
text_preview: null,
|
|
413
|
+
topic_id: null,
|
|
414
|
+
deleted: true,
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
}),
|
|
418
|
+
);
|
|
419
|
+
expect(out).toContain("[quoting (deleted message)]");
|
|
420
|
+
expect(out).toContain("RE: that thing");
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("falls back to sender_id when display name is missing", () => {
|
|
424
|
+
const out = composeBotCordUserTurn(
|
|
425
|
+
makeMessage({
|
|
426
|
+
text: "ack",
|
|
427
|
+
sender: { id: "ag_alice", kind: "agent" },
|
|
428
|
+
raw: {
|
|
429
|
+
reply_preview: {
|
|
430
|
+
msg_id: "h_orig",
|
|
431
|
+
sender_id: "ag_bob",
|
|
432
|
+
sender_display_name: null,
|
|
433
|
+
text_preview: "hi",
|
|
434
|
+
topic_id: null,
|
|
435
|
+
deleted: false,
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
}),
|
|
439
|
+
);
|
|
440
|
+
expect(out).toContain('[quoting ag_bob: "hi"]');
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("emits no quote line when reply_preview is absent (regression guard)", () => {
|
|
444
|
+
const out = composeBotCordUserTurn(
|
|
445
|
+
makeMessage({
|
|
446
|
+
text: "just a normal message",
|
|
447
|
+
sender: { id: "ag_alice", kind: "agent" },
|
|
448
|
+
}),
|
|
449
|
+
);
|
|
450
|
+
expect(out).not.toContain("[quoting");
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("renders per-entry quote lines in a batched turn", () => {
|
|
454
|
+
const batchedRaw = {
|
|
455
|
+
batch: [
|
|
456
|
+
{
|
|
457
|
+
hub_msg_id: "h_1",
|
|
458
|
+
text: "first reply",
|
|
459
|
+
envelope: { from: "ag_alice", type: "message" },
|
|
460
|
+
source_type: "agent",
|
|
461
|
+
reply_preview: {
|
|
462
|
+
msg_id: "h_orig1",
|
|
463
|
+
sender_id: "ag_bob",
|
|
464
|
+
sender_display_name: "Bob",
|
|
465
|
+
text_preview: "the plan",
|
|
466
|
+
topic_id: null,
|
|
467
|
+
deleted: false,
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
hub_msg_id: "h_2",
|
|
472
|
+
text: "second reply (no quote)",
|
|
473
|
+
envelope: { from: "ag_alice", type: "message" },
|
|
474
|
+
source_type: "agent",
|
|
475
|
+
},
|
|
476
|
+
],
|
|
477
|
+
};
|
|
478
|
+
const out = composeBotCordUserTurn(
|
|
479
|
+
makeMessage({
|
|
480
|
+
text: "ignored — batch path reads raw.batch",
|
|
481
|
+
sender: { id: "ag_alice", kind: "agent" },
|
|
482
|
+
raw: batchedRaw,
|
|
483
|
+
}),
|
|
484
|
+
);
|
|
485
|
+
expect(out).toContain("[BotCord Messages (2 new)]");
|
|
486
|
+
expect(out).toContain('[quoting Bob: "the plan"]');
|
|
487
|
+
expect(out).toContain("first reply");
|
|
488
|
+
expect(out).toContain("second reply (no quote)");
|
|
489
|
+
// The second entry has no quote line.
|
|
490
|
+
const quoteCount = (out.match(/\[quoting /g) || []).length;
|
|
491
|
+
expect(quoteCount).toBe(1);
|
|
492
|
+
});
|
|
493
|
+
});
|
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,24 @@ export async function startCloudDaemon(
|
|
|
234
234
|
});
|
|
235
235
|
};
|
|
236
236
|
|
|
237
|
+
const installedAgentIds = new Set<string>();
|
|
238
|
+
const runtimeByAgentId = new Map<string, string>();
|
|
239
|
+
let controlChannel: ControlChannel | null = null;
|
|
240
|
+
const pushInstalledAgentSkillSnapshot = (agentId: string, reason: string): void => {
|
|
241
|
+
if (!controlChannel) return;
|
|
242
|
+
const runtime = runtimeByAgentId.get(agentId) ?? opts.config.defaultRoute.adapter;
|
|
243
|
+
const pushed = pushAgentSkillSnapshot(controlChannel, agentId, { runtime });
|
|
244
|
+
logger.info("cloud control-channel: agent_skill_snapshot pushed", {
|
|
245
|
+
agentId,
|
|
246
|
+
runtime,
|
|
247
|
+
reason,
|
|
248
|
+
ok: pushed,
|
|
249
|
+
});
|
|
250
|
+
};
|
|
251
|
+
|
|
237
252
|
const onAgentInstalled: OnAgentInstalledHook = (info: InstalledAgentInfo) => {
|
|
253
|
+
installedAgentIds.add(info.agentId);
|
|
254
|
+
if (info.runtime) runtimeByAgentId.set(info.agentId, info.runtime);
|
|
238
255
|
credentialPathByAgentId.set(info.agentId, info.credentialsFile);
|
|
239
256
|
if (info.hubUrl) hubUrlByAgentId.set(info.agentId, info.hubUrl);
|
|
240
257
|
if (info.displayName) displayNameByAgent.set(info.agentId, info.displayName);
|
|
@@ -251,6 +268,7 @@ export async function startCloudDaemon(
|
|
|
251
268
|
}),
|
|
252
269
|
);
|
|
253
270
|
}
|
|
271
|
+
pushInstalledAgentSkillSnapshot(info.agentId, "agent_installed");
|
|
254
272
|
};
|
|
255
273
|
|
|
256
274
|
const gateway = new Gateway({
|
|
@@ -281,7 +299,6 @@ export async function startCloudDaemon(
|
|
|
281
299
|
await gateway.start();
|
|
282
300
|
logger.info("cloud daemon gateway started (zero agents at boot)");
|
|
283
301
|
|
|
284
|
-
let controlChannel: ControlChannel | null = null;
|
|
285
302
|
if (!opts.disableControlChannel) {
|
|
286
303
|
const auth = asUserAuthManager(new CloudAuthManager(cloudCfg));
|
|
287
304
|
const provisionerFactory = opts.provisionerFactory ?? createProvisioner;
|
|
@@ -330,6 +347,9 @@ export async function startCloudDaemon(
|
|
|
330
347
|
logger.info("cloud control-channel started; runtime_snapshot pushed", {
|
|
331
348
|
ok: pushed,
|
|
332
349
|
});
|
|
350
|
+
for (const agentId of installedAgentIds) {
|
|
351
|
+
pushInstalledAgentSkillSnapshot(agentId, "control_channel_started");
|
|
352
|
+
}
|
|
333
353
|
} catch (err) {
|
|
334
354
|
logger.warn("cloud control-channel start failed; daemon will retry", {
|
|
335
355
|
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,27 @@ export function pushRuntimeSnapshot(
|
|
|
245
246
|
return ok;
|
|
246
247
|
}
|
|
247
248
|
|
|
249
|
+
export function pushAgentSkillSnapshot(
|
|
250
|
+
sink: RuntimeSnapshotSink,
|
|
251
|
+
agentId: string,
|
|
252
|
+
opts: { runtime?: string } = {},
|
|
253
|
+
): boolean {
|
|
254
|
+
const snap = collectAgentSkillSnapshot(agentId, opts);
|
|
255
|
+
const ok = sink.send({
|
|
256
|
+
id: `skill_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
257
|
+
type: "agent_skill_snapshot",
|
|
258
|
+
params: snap as unknown as Record<string, unknown>,
|
|
259
|
+
ts: Date.now(),
|
|
260
|
+
});
|
|
261
|
+
if (!ok) {
|
|
262
|
+
daemonLog.warn("agent-skill-snapshot: control-channel send returned false", {
|
|
263
|
+
agentId,
|
|
264
|
+
skills: snap.skills.length,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
return ok;
|
|
268
|
+
}
|
|
269
|
+
|
|
248
270
|
/** Options accepted by {@link startDaemon} — the P0.5 compatibility shim. */
|
|
249
271
|
export interface DaemonRuntimeOptions {
|
|
250
272
|
config: DaemonConfig;
|
|
@@ -508,6 +530,12 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
508
530
|
// next room-context fetch re-loads the BotCordClient against the new
|
|
509
531
|
// credential file.
|
|
510
532
|
credentialPathByAgentId.set(info.agentId, info.credentialsFile);
|
|
533
|
+
if (info.runtime) {
|
|
534
|
+
agentRuntimes[info.agentId] = {
|
|
535
|
+
...(agentRuntimes[info.agentId] ?? {}),
|
|
536
|
+
runtime: info.runtime,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
511
539
|
if (info.hubUrl) hubUrlByAgentId.set(info.agentId, info.hubUrl);
|
|
512
540
|
if (info.displayName) displayNameByAgent.set(info.agentId, info.displayName);
|
|
513
541
|
if (!scBuilders.has(info.agentId)) {
|
|
@@ -648,6 +676,15 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
648
676
|
logger.info("control-channel: initial runtime_snapshot push", {
|
|
649
677
|
ok: pushed,
|
|
650
678
|
});
|
|
679
|
+
for (const agentId of agentIds) {
|
|
680
|
+
const runtime = agentRuntimes[agentId]?.runtime ?? opts.config.defaultRoute.adapter;
|
|
681
|
+
const skillsPushed = pushAgentSkillSnapshot(controlChannel, agentId, { runtime });
|
|
682
|
+
logger.info("control-channel: initial agent_skill_snapshot push", {
|
|
683
|
+
agentId,
|
|
684
|
+
runtime,
|
|
685
|
+
ok: skillsPushed,
|
|
686
|
+
});
|
|
687
|
+
}
|
|
651
688
|
} catch (err) {
|
|
652
689
|
logger.warn("control-channel failed to start; continuing without it", {
|
|
653
690
|
error: err instanceof Error ? err.message : String(err),
|
|
@@ -793,6 +793,85 @@ describe("createBotCordChannel — streamBlock()", () => {
|
|
|
793
793
|
});
|
|
794
794
|
});
|
|
795
795
|
|
|
796
|
+
it("normalizes wrapped DeepSeek item.started tool input from the runtime event stream", () => {
|
|
797
|
+
expect(
|
|
798
|
+
__normalizeBlockForHubForTests(
|
|
799
|
+
{
|
|
800
|
+
kind: "tool_use",
|
|
801
|
+
seq: 5,
|
|
802
|
+
raw: {
|
|
803
|
+
event: "item.started",
|
|
804
|
+
payload: {
|
|
805
|
+
seq: 922,
|
|
806
|
+
thread_id: "thr_test",
|
|
807
|
+
turn_id: "turn_test",
|
|
808
|
+
item_id: "item_exec",
|
|
809
|
+
event: "item.started",
|
|
810
|
+
payload: {
|
|
811
|
+
item: {
|
|
812
|
+
id: "item_exec",
|
|
813
|
+
kind: "tool_call",
|
|
814
|
+
status: "in_progress",
|
|
815
|
+
summary: "exec_shell started",
|
|
816
|
+
detail: "{\"cmd\":\"botcord-daemon status\"}",
|
|
817
|
+
},
|
|
818
|
+
},
|
|
819
|
+
},
|
|
820
|
+
},
|
|
821
|
+
},
|
|
822
|
+
5,
|
|
823
|
+
),
|
|
824
|
+
).toMatchObject({
|
|
825
|
+
kind: "tool_call",
|
|
826
|
+
seq: 5,
|
|
827
|
+
payload: {
|
|
828
|
+
id: "item_exec",
|
|
829
|
+
name: "exec_shell",
|
|
830
|
+
params: { cmd: "botcord-daemon status" },
|
|
831
|
+
status: "in_progress",
|
|
832
|
+
},
|
|
833
|
+
});
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
it("normalizes wrapped DeepSeek item.completed output without showing the event envelope", () => {
|
|
837
|
+
expect(
|
|
838
|
+
__normalizeBlockForHubForTests(
|
|
839
|
+
{
|
|
840
|
+
kind: "tool_result",
|
|
841
|
+
seq: 6,
|
|
842
|
+
raw: {
|
|
843
|
+
event: "item.completed",
|
|
844
|
+
payload: {
|
|
845
|
+
seq: 955,
|
|
846
|
+
thread_id: "thr_test",
|
|
847
|
+
turn_id: "turn_test",
|
|
848
|
+
item_id: "item_exec",
|
|
849
|
+
event: "item.completed",
|
|
850
|
+
payload: {
|
|
851
|
+
item: {
|
|
852
|
+
id: "item_exec",
|
|
853
|
+
kind: "command_execution",
|
|
854
|
+
status: "completed",
|
|
855
|
+
summary: "exec_shell: daemon: pid 49616",
|
|
856
|
+
detail: "daemon: pid 49616 (alive)",
|
|
857
|
+
},
|
|
858
|
+
},
|
|
859
|
+
},
|
|
860
|
+
},
|
|
861
|
+
},
|
|
862
|
+
6,
|
|
863
|
+
),
|
|
864
|
+
).toMatchObject({
|
|
865
|
+
kind: "tool_result",
|
|
866
|
+
seq: 6,
|
|
867
|
+
payload: {
|
|
868
|
+
name: "exec_shell",
|
|
869
|
+
result: "daemon: pid 49616 (alive)",
|
|
870
|
+
tool_use_id: "item_exec",
|
|
871
|
+
},
|
|
872
|
+
});
|
|
873
|
+
});
|
|
874
|
+
|
|
796
875
|
it("POSTs to /hub/stream-block with the right trace_id + block", async () => {
|
|
797
876
|
const fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
|
|
798
877
|
const realFetch = globalThis.fetch;
|
|
@@ -353,6 +353,55 @@ describe("DeepseekTuiAdapter", () => {
|
|
|
353
353
|
}
|
|
354
354
|
});
|
|
355
355
|
|
|
356
|
+
it("treats DeepSeek command_execution item.started events as tool blocks", async () => {
|
|
357
|
+
const server = await startMockDeepseekServer({
|
|
358
|
+
events: [
|
|
359
|
+
{
|
|
360
|
+
event: "turn.started",
|
|
361
|
+
data: { thread_id: "thr_test", turn_id: "turn_test", event: "turn.started" },
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
event: "item.started",
|
|
365
|
+
data: {
|
|
366
|
+
thread_id: "thr_test",
|
|
367
|
+
turn_id: "turn_test",
|
|
368
|
+
event: "item.started",
|
|
369
|
+
payload: {
|
|
370
|
+
item: {
|
|
371
|
+
id: "item_exec",
|
|
372
|
+
kind: "command_execution",
|
|
373
|
+
status: "in_progress",
|
|
374
|
+
summary: "exec_shell started",
|
|
375
|
+
detail: "{\"cmd\":\"date\"}",
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
event: "item.delta",
|
|
382
|
+
data: {
|
|
383
|
+
thread_id: "thr_test",
|
|
384
|
+
turn_id: "turn_test",
|
|
385
|
+
event: "item.delta",
|
|
386
|
+
payload: { kind: "agent_message", delta: "done" },
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
event: "turn.completed",
|
|
391
|
+
data: { thread_id: "thr_test", turn_id: "turn_test", event: "turn.completed" },
|
|
392
|
+
},
|
|
393
|
+
],
|
|
394
|
+
});
|
|
395
|
+
try {
|
|
396
|
+
const { result, blocks, status } = runAdapter(server.baseUrl, server.token);
|
|
397
|
+
await expect(result).resolves.toMatchObject({ text: "done" });
|
|
398
|
+
expect(blocks).toEqual(expect.arrayContaining(["tool_use", "assistant_text"]));
|
|
399
|
+
expect(status).toContainEqual({ phase: "updated", label: "exec_shell" });
|
|
400
|
+
} finally {
|
|
401
|
+
await server.close();
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
|
|
356
405
|
it("emits current DeepSeek agent_reasoning completions as thinking blocks", async () => {
|
|
357
406
|
const server = await startMockDeepseekServer({
|
|
358
407
|
events: [
|