@botcord/daemon 0.2.79 → 0.2.80
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/dist/daemon-singleton.d.ts +13 -0
- package/dist/daemon-singleton.js +68 -0
- package/dist/gateway/channels/botcord.d.ts +1 -0
- package/dist/gateway/channels/botcord.js +33 -8
- package/dist/gateway/runtimes/deepseek-tui.js +26 -2
- package/dist/index.js +8 -3
- package/dist/status-render.d.ts +4 -0
- package/dist/status-render.js +14 -1
- package/package.json +1 -1
- package/src/__tests__/daemon-singleton.test.ts +32 -0
- package/src/__tests__/status-render.test.ts +23 -0
- package/src/daemon-singleton.ts +85 -0
- package/src/gateway/__tests__/botcord-channel.test.ts +116 -7
- package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +86 -0
- package/src/gateway/channels/botcord.ts +37 -9
- package/src/gateway/runtimes/deepseek-tui.ts +27 -2
- package/src/index.ts +9 -2
- package/src/status-render.ts +14 -1
|
@@ -4,6 +4,14 @@ export interface SingletonLogger {
|
|
|
4
4
|
}
|
|
5
5
|
export declare function readPid(pidPath?: string): number | null;
|
|
6
6
|
export declare function pidAlive(pid: number): boolean;
|
|
7
|
+
export interface DaemonProcessInfo {
|
|
8
|
+
pid: number;
|
|
9
|
+
command: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function parseDaemonProcesses(psOutput: string, currentPid?: number): DaemonProcessInfo[];
|
|
12
|
+
export declare function findOtherDaemonProcesses(opts?: {
|
|
13
|
+
currentPid?: number;
|
|
14
|
+
}): DaemonProcessInfo[];
|
|
7
15
|
export declare function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean>;
|
|
8
16
|
export declare function stopExistingDaemonForRestart(pid: number, opts?: {
|
|
9
17
|
pidPath?: string;
|
|
@@ -15,6 +23,11 @@ export declare function stopDaemonFromPidFileForRestart(opts?: {
|
|
|
15
23
|
currentPid?: number;
|
|
16
24
|
logger?: SingletonLogger;
|
|
17
25
|
}): Promise<void>;
|
|
26
|
+
export declare function stopOtherDaemonProcessesForRestart(opts?: {
|
|
27
|
+
currentPid?: number;
|
|
28
|
+
logger?: SingletonLogger;
|
|
29
|
+
processes?: DaemonProcessInfo[];
|
|
30
|
+
}): Promise<DaemonProcessInfo[]>;
|
|
18
31
|
export declare function ensureNoOtherDaemonFromPidFile(opts?: {
|
|
19
32
|
pidPath?: string;
|
|
20
33
|
currentPid?: number;
|
package/dist/daemon-singleton.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
1
2
|
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
3
|
import { PID_PATH } from "./config.js";
|
|
3
4
|
const noopLogger = {
|
|
@@ -24,6 +25,36 @@ export function pidAlive(pid) {
|
|
|
24
25
|
return false;
|
|
25
26
|
}
|
|
26
27
|
}
|
|
28
|
+
export function parseDaemonProcesses(psOutput, currentPid = process.pid) {
|
|
29
|
+
const out = [];
|
|
30
|
+
for (const line of psOutput.split(/\r?\n/)) {
|
|
31
|
+
const trimmed = line.trim();
|
|
32
|
+
const match = /^(\d+)\s+(.+)$/.exec(trimmed);
|
|
33
|
+
if (!match)
|
|
34
|
+
continue;
|
|
35
|
+
const pid = Number(match[1]);
|
|
36
|
+
if (!Number.isFinite(pid) || pid <= 0 || pid === currentPid)
|
|
37
|
+
continue;
|
|
38
|
+
const command = match[2] ?? "";
|
|
39
|
+
if (!isBotCordDaemonStartCommand(command))
|
|
40
|
+
continue;
|
|
41
|
+
out.push({ pid, command });
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
export function findOtherDaemonProcesses(opts = {}) {
|
|
46
|
+
const currentPid = opts.currentPid ?? process.pid;
|
|
47
|
+
try {
|
|
48
|
+
const output = execFileSync("ps", ["-axo", "pid=,command="], {
|
|
49
|
+
encoding: "utf8",
|
|
50
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
51
|
+
});
|
|
52
|
+
return parseDaemonProcesses(output, currentPid).filter((p) => pidAlive(p.pid));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
27
58
|
export async function waitForPidExit(pid, timeoutMs) {
|
|
28
59
|
const deadline = Date.now() + timeoutMs;
|
|
29
60
|
while (Date.now() < deadline) {
|
|
@@ -66,6 +97,36 @@ export async function stopDaemonFromPidFileForRestart(opts = {}) {
|
|
|
66
97
|
await stopExistingDaemonForRestart(existing, opts);
|
|
67
98
|
}
|
|
68
99
|
}
|
|
100
|
+
export async function stopOtherDaemonProcessesForRestart(opts = {}) {
|
|
101
|
+
const currentPid = opts.currentPid ?? process.pid;
|
|
102
|
+
const logger = opts.logger ?? noopLogger;
|
|
103
|
+
const processes = opts.processes ?? findOtherDaemonProcesses({ currentPid });
|
|
104
|
+
for (const proc of processes) {
|
|
105
|
+
logger.info("additional daemon process found; restarting", {
|
|
106
|
+
pid: proc.pid,
|
|
107
|
+
command: proc.command,
|
|
108
|
+
});
|
|
109
|
+
try {
|
|
110
|
+
process.kill(proc.pid, "SIGTERM");
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (!(await waitForPidExit(proc.pid, 5_000))) {
|
|
116
|
+
logger.warn("additional daemon did not stop after SIGTERM; sending SIGKILL", {
|
|
117
|
+
pid: proc.pid,
|
|
118
|
+
});
|
|
119
|
+
try {
|
|
120
|
+
process.kill(proc.pid, "SIGKILL");
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// ignore
|
|
124
|
+
}
|
|
125
|
+
await waitForPidExit(proc.pid, 2_000);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return processes;
|
|
129
|
+
}
|
|
69
130
|
export function ensureNoOtherDaemonFromPidFile(opts = {}) {
|
|
70
131
|
const pidPath = opts.pidPath ?? PID_PATH;
|
|
71
132
|
const currentPid = opts.currentPid ?? process.pid;
|
|
@@ -86,6 +147,13 @@ export function removePidFile(pidPath = PID_PATH) {
|
|
|
86
147
|
// ignore
|
|
87
148
|
}
|
|
88
149
|
}
|
|
150
|
+
function isBotCordDaemonStartCommand(command) {
|
|
151
|
+
if (!/\bstart\b/.test(command))
|
|
152
|
+
return false;
|
|
153
|
+
return (command.includes("botcord-daemon") ||
|
|
154
|
+
/(?:^|\s)\S*botcord\S*\/daemon\/dist\/index\.js(?:\s|$)/.test(command) ||
|
|
155
|
+
/(?:^|\s)\S*packages\/daemon\/dist\/index\.js(?:\s|$)/.test(command));
|
|
156
|
+
}
|
|
89
157
|
function delay(ms) {
|
|
90
158
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
91
159
|
}
|
|
@@ -838,6 +838,7 @@ function normalizeBlockForHub(block, seq) {
|
|
|
838
838
|
const raw = (block?.raw ?? {});
|
|
839
839
|
const kind = block?.kind ?? "other";
|
|
840
840
|
const payload = {};
|
|
841
|
+
const withRaw = (out) => (block && "raw" in block ? { ...out, raw: block.raw } : out);
|
|
841
842
|
if (kind === "assistant_text") {
|
|
842
843
|
// Claude Code: {type:"assistant", message:{content:[{type:"text",text}]}}
|
|
843
844
|
// Codex: {type:"item.completed", item:{type:"agent_message", text}}
|
|
@@ -880,7 +881,7 @@ function normalizeBlockForHub(block, seq) {
|
|
|
880
881
|
if (call.status)
|
|
881
882
|
payload.status = call.status;
|
|
882
883
|
}
|
|
883
|
-
return { kind: "tool_call", seq, payload };
|
|
884
|
+
return withRaw({ kind: "tool_call", seq, payload });
|
|
884
885
|
}
|
|
885
886
|
if (kind === "tool_result") {
|
|
886
887
|
const result = extractToolResult(raw);
|
|
@@ -891,7 +892,7 @@ function normalizeBlockForHub(block, seq) {
|
|
|
891
892
|
if (result.id)
|
|
892
893
|
payload.tool_use_id = result.id;
|
|
893
894
|
}
|
|
894
|
-
return { kind: "tool_result", seq, payload };
|
|
895
|
+
return withRaw({ kind: "tool_result", seq, payload });
|
|
895
896
|
}
|
|
896
897
|
if (kind === "system") {
|
|
897
898
|
if (typeof raw?.subtype === "string")
|
|
@@ -901,7 +902,7 @@ function normalizeBlockForHub(block, seq) {
|
|
|
901
902
|
if (typeof raw?.model === "string")
|
|
902
903
|
payload.model = raw.model;
|
|
903
904
|
payload.details = formatBlockDetails(raw);
|
|
904
|
-
return { kind: "system", seq, payload };
|
|
905
|
+
return withRaw({ kind: "system", seq, payload });
|
|
905
906
|
}
|
|
906
907
|
if (kind === "thinking") {
|
|
907
908
|
// Daemon-synthesized lifecycle marker. `raw` carries `{ phase, label?, source? }`
|
|
@@ -915,7 +916,7 @@ function normalizeBlockForHub(block, seq) {
|
|
|
915
916
|
if (typeof raw?.source === "string")
|
|
916
917
|
payload.source = raw.source;
|
|
917
918
|
payload.details = formatBlockDetails(raw);
|
|
918
|
-
return { kind: "thinking", seq, payload };
|
|
919
|
+
return withRaw({ kind: "thinking", seq, payload });
|
|
919
920
|
}
|
|
920
921
|
// "other" — e.g. Claude Code `type:"result"` end-of-turn summary.
|
|
921
922
|
if (isTerminalRuntimeBlock(raw)) {
|
|
@@ -925,7 +926,7 @@ function normalizeBlockForHub(block, seq) {
|
|
|
925
926
|
const embedded = typeof raw?.payload?.event === "string" ? raw.payload.event : undefined;
|
|
926
927
|
if (event || embedded)
|
|
927
928
|
payload.event = event ?? embedded;
|
|
928
|
-
return { kind: "other", seq, payload };
|
|
929
|
+
return withRaw({ kind: "other", seq, payload });
|
|
929
930
|
}
|
|
930
931
|
if (raw?.type === "result") {
|
|
931
932
|
if (typeof raw.result === "string")
|
|
@@ -935,7 +936,7 @@ function normalizeBlockForHub(block, seq) {
|
|
|
935
936
|
if (typeof raw.total_cost_usd === "number")
|
|
936
937
|
payload.total_cost_usd = raw.total_cost_usd;
|
|
937
938
|
}
|
|
938
|
-
return { kind: "other", seq, payload };
|
|
939
|
+
return withRaw({ kind: "other", seq, payload });
|
|
939
940
|
}
|
|
940
941
|
function isTerminalRuntimeBlock(raw) {
|
|
941
942
|
const event = typeof raw?.event === "string" ? raw.event : undefined;
|
|
@@ -1060,18 +1061,28 @@ function extractDeepseekToolCall(raw) {
|
|
|
1060
1061
|
: {};
|
|
1061
1062
|
const item = inner.item && typeof inner.item === "object" ? inner.item : undefined;
|
|
1062
1063
|
const tool = inner.tool && typeof inner.tool === "object" ? inner.tool : item?.tool;
|
|
1064
|
+
const itemParams = parseMaybeJson(item?.input ?? item?.arguments ?? item?.detail);
|
|
1065
|
+
const detailParams = itemParams !== undefined
|
|
1066
|
+
? itemParams
|
|
1067
|
+
: typeof item?.detail === "string" && item.detail.trim()
|
|
1068
|
+
? item.detail.trim()
|
|
1069
|
+
: undefined;
|
|
1063
1070
|
return {
|
|
1064
1071
|
name: stringField(tool, "name") ??
|
|
1065
1072
|
stringField(inner, "name") ??
|
|
1066
1073
|
stringField(item, "name") ??
|
|
1074
|
+
inferDeepseekToolName(item) ??
|
|
1067
1075
|
stringField(item, "type") ??
|
|
1068
1076
|
"tool",
|
|
1069
1077
|
params: parseMaybeJson(tool?.input ??
|
|
1070
1078
|
tool?.rawInput ??
|
|
1071
1079
|
tool?.arguments ??
|
|
1080
|
+
tool?.params ??
|
|
1072
1081
|
inner.input ??
|
|
1082
|
+
inner.arguments ??
|
|
1083
|
+
inner.params ??
|
|
1073
1084
|
item?.input ??
|
|
1074
|
-
item?.arguments) ?? tool ?? item,
|
|
1085
|
+
item?.arguments) ?? detailParams ?? tool ?? item,
|
|
1075
1086
|
id: stringField(tool, "id") ?? stringField(inner, "id") ?? stringField(item, "id"),
|
|
1076
1087
|
status: stringField(tool, "status") ?? stringField(inner, "status") ?? stringField(item, "status"),
|
|
1077
1088
|
};
|
|
@@ -1112,7 +1123,10 @@ function extractDeepseekToolResult(raw) {
|
|
|
1112
1123
|
item ??
|
|
1113
1124
|
inner;
|
|
1114
1125
|
return {
|
|
1115
|
-
name: stringField(item, "name") ??
|
|
1126
|
+
name: stringField(item, "name") ??
|
|
1127
|
+
inferDeepseekToolName(item) ??
|
|
1128
|
+
stringField(inner, "name") ??
|
|
1129
|
+
stringField(item, "type"),
|
|
1116
1130
|
result: stringifyToolResult(result),
|
|
1117
1131
|
id: stringField(item, "id") ?? stringField(inner, "id"),
|
|
1118
1132
|
};
|
|
@@ -1232,6 +1246,17 @@ function parseMaybeJson(value) {
|
|
|
1232
1246
|
return value;
|
|
1233
1247
|
}
|
|
1234
1248
|
}
|
|
1249
|
+
function inferDeepseekToolName(item) {
|
|
1250
|
+
const candidates = [stringField(item, "summary"), stringField(item, "detail")];
|
|
1251
|
+
for (const candidate of candidates) {
|
|
1252
|
+
if (!candidate)
|
|
1253
|
+
continue;
|
|
1254
|
+
const match = candidate.match(/^([A-Za-z0-9_.:-]+)\s*(?:started|completed|failed|returned|:)/);
|
|
1255
|
+
if (match?.[1] && match[1] !== "tool_call")
|
|
1256
|
+
return match[1];
|
|
1257
|
+
}
|
|
1258
|
+
return undefined;
|
|
1259
|
+
}
|
|
1235
1260
|
function isEmptyRecord(value) {
|
|
1236
1261
|
return !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0;
|
|
1237
1262
|
}
|
|
@@ -97,10 +97,12 @@ export class DeepseekTuiAdapter {
|
|
|
97
97
|
signal: turnAbort.signal,
|
|
98
98
|
});
|
|
99
99
|
const text = runResult.text;
|
|
100
|
+
const error = runResult.error ??
|
|
101
|
+
(text === "" ? emptyCompletionError(handle.stderrTail) : undefined);
|
|
100
102
|
return {
|
|
101
103
|
text,
|
|
102
104
|
newSessionId: threadId,
|
|
103
|
-
...(
|
|
105
|
+
...(error ? { error } : {}),
|
|
104
106
|
};
|
|
105
107
|
}
|
|
106
108
|
catch (err) {
|
|
@@ -326,6 +328,7 @@ export class DeepseekTuiAdapter {
|
|
|
326
328
|
const label = stringField(payload, "name") ??
|
|
327
329
|
stringField(payload?.tool, "name") ??
|
|
328
330
|
stringField(payload?.payload?.tool, "name") ??
|
|
331
|
+
inferDeepseekToolName(payload?.item ?? payload?.payload?.item) ??
|
|
329
332
|
"tool";
|
|
330
333
|
opts.onStatus?.({ kind: "thinking", phase: "updated", label });
|
|
331
334
|
}
|
|
@@ -423,7 +426,8 @@ function isDeepseekTerminalEvent(eventName, payload) {
|
|
|
423
426
|
embedded === "done");
|
|
424
427
|
}
|
|
425
428
|
function isToolStarted(eventName, payload) {
|
|
426
|
-
return ((eventName === "item.started" &&
|
|
429
|
+
return ((eventName === "item.started" &&
|
|
430
|
+
(!!payload?.tool || payload?.item?.kind === "tool_call" || payload?.payload?.item?.kind === "tool_call")) ||
|
|
427
431
|
(payload?.event === "item.started" && !!payload?.payload?.tool));
|
|
428
432
|
}
|
|
429
433
|
function isToolCompleted(eventName, payload) {
|
|
@@ -443,6 +447,26 @@ function isAgentReasoningItem(payload) {
|
|
|
443
447
|
function extractDeepseekDelta(payload) {
|
|
444
448
|
return stringField(payload, "delta") ?? stringField(payload?.payload, "delta") ?? "";
|
|
445
449
|
}
|
|
450
|
+
function inferDeepseekToolName(item) {
|
|
451
|
+
const candidates = [stringField(item, "summary"), stringField(item, "detail")];
|
|
452
|
+
for (const candidate of candidates) {
|
|
453
|
+
if (!candidate)
|
|
454
|
+
continue;
|
|
455
|
+
const match = candidate.match(/^([A-Za-z0-9_.:-]+)\s*(?:started|completed|failed|returned|:)/);
|
|
456
|
+
if (match?.[1] && match[1] !== "tool_call")
|
|
457
|
+
return match[1];
|
|
458
|
+
}
|
|
459
|
+
return undefined;
|
|
460
|
+
}
|
|
461
|
+
function emptyCompletionError(stderrTail) {
|
|
462
|
+
const tail = stderrTail.trim();
|
|
463
|
+
if (!tail) {
|
|
464
|
+
return "deepseek runtime completed with no assistant_message (check DEEPSEEK_API_KEY / model availability)";
|
|
465
|
+
}
|
|
466
|
+
const lines = tail.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
|
467
|
+
const lastLines = lines.slice(-5).join("\n").slice(-500);
|
|
468
|
+
return `deepseek runtime completed with no assistant_message; stderr tail: ${lastLines}`;
|
|
469
|
+
}
|
|
446
470
|
function extractDeepseekError(eventName, payload) {
|
|
447
471
|
if (eventName === "error") {
|
|
448
472
|
return (stringField(payload, "message") ??
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import { homedir, hostname } from "node:os";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { augmentProcessPath } from "./path-env.js";
|
|
7
7
|
import { loadConfig, saveConfig, initDefaultConfig, resolveConfiguredAgentIds, SNAPSHOT_PATH, CONFIG_FILE_PATH, CONFIG_MISSING, } from "./config.js";
|
|
8
|
-
import { ensureNoOtherDaemonFromPidFile, pidAlive, readPid, removePidFile, stopDaemonFromPidFileForRestart, writeCurrentPid, } from "./daemon-singleton.js";
|
|
8
|
+
import { ensureNoOtherDaemonFromPidFile, findOtherDaemonProcesses, pidAlive, readPid, removePidFile, stopDaemonFromPidFileForRestart, stopOtherDaemonProcessesForRestart, writeCurrentPid, } from "./daemon-singleton.js";
|
|
9
9
|
import { resolveBootAgents } from "./agent-discovery.js";
|
|
10
10
|
import { defaultTranscriptRoot, resolveTranscriptEnabled, transcriptAgentRoot, transcriptFilePath, } from "./gateway/index.js";
|
|
11
11
|
import { startDaemon } from "./daemon.js";
|
|
@@ -469,6 +469,7 @@ async function cmdStart(args) {
|
|
|
469
469
|
if (process.env.BOTCORD_DAEMON_CHILD !== "1") {
|
|
470
470
|
await ensureUserAuthForStart(args);
|
|
471
471
|
await stopDaemonFromPidFileForRestart({ logger: log });
|
|
472
|
+
await stopOtherDaemonProcessesForRestart({ logger: log });
|
|
472
473
|
}
|
|
473
474
|
else {
|
|
474
475
|
const existing = ensureNoOtherDaemonFromPidFile();
|
|
@@ -487,7 +488,7 @@ async function cmdStart(args) {
|
|
|
487
488
|
env: { ...process.env, BOTCORD_DAEMON_CHILD: "1" },
|
|
488
489
|
});
|
|
489
490
|
child.unref();
|
|
490
|
-
const deadline = Date.now() +
|
|
491
|
+
const deadline = Date.now() + 5_000;
|
|
491
492
|
let observed = null;
|
|
492
493
|
while (Date.now() < deadline) {
|
|
493
494
|
const p = readPid();
|
|
@@ -498,7 +499,7 @@ async function cmdStart(args) {
|
|
|
498
499
|
await new Promise((r) => setTimeout(r, 50));
|
|
499
500
|
}
|
|
500
501
|
if (!observed) {
|
|
501
|
-
console.error(`daemon did not record pid within
|
|
502
|
+
console.error(`daemon did not record pid within 5000ms (expected child pid ${child.pid})`);
|
|
502
503
|
process.exit(1);
|
|
503
504
|
}
|
|
504
505
|
console.log(`daemon started (pid ${observed})`);
|
|
@@ -539,6 +540,7 @@ async function cmdStartCloud(_args) {
|
|
|
539
540
|
hubUrl: cloudConfig.hubUrl,
|
|
540
541
|
});
|
|
541
542
|
await stopDaemonFromPidFileForRestart({ logger: log });
|
|
543
|
+
await stopOtherDaemonProcessesForRestart({ logger: log });
|
|
542
544
|
writeCurrentPid();
|
|
543
545
|
// Cloud daemons always start with an empty in-memory config — every
|
|
544
546
|
// agent + route arrives over the control plane. We synthesize the
|
|
@@ -628,6 +630,7 @@ async function cmdStatus(args) {
|
|
|
628
630
|
const file = readSnapshotFile();
|
|
629
631
|
const now = Date.now();
|
|
630
632
|
const snapshotAgeMs = file ? now - file.writtenAt : null;
|
|
633
|
+
const daemonProcesses = findOtherDaemonProcesses().filter((p) => p.pid !== pid);
|
|
631
634
|
if (args.flags.json === true) {
|
|
632
635
|
const payload = {
|
|
633
636
|
pid,
|
|
@@ -652,6 +655,7 @@ async function cmdStatus(args) {
|
|
|
652
655
|
snapshotWrittenAt: file?.writtenAt ?? null,
|
|
653
656
|
snapshotAgeMs,
|
|
654
657
|
snapshotPath: SNAPSHOT_PATH,
|
|
658
|
+
daemonProcesses,
|
|
655
659
|
};
|
|
656
660
|
console.log(JSON.stringify(payload, null, 2));
|
|
657
661
|
return;
|
|
@@ -664,6 +668,7 @@ async function cmdStatus(args) {
|
|
|
664
668
|
configPath,
|
|
665
669
|
snapshot: file?.snapshot ?? null,
|
|
666
670
|
snapshotAgeMs,
|
|
671
|
+
daemonProcesses,
|
|
667
672
|
};
|
|
668
673
|
console.log(renderStatus(input, now));
|
|
669
674
|
if (userAuth) {
|
package/dist/status-render.d.ts
CHANGED
|
@@ -5,6 +5,10 @@ export declare const STALE_THRESHOLD_MS = 30000;
|
|
|
5
5
|
export interface StatusRenderInput {
|
|
6
6
|
pid: number | null;
|
|
7
7
|
alive: boolean;
|
|
8
|
+
daemonProcesses?: Array<{
|
|
9
|
+
pid: number;
|
|
10
|
+
command?: string;
|
|
11
|
+
}> | null;
|
|
8
12
|
/**
|
|
9
13
|
* Effective list of agent ids the daemon is bound to. Single-agent installs
|
|
10
14
|
* show one entry; multi-agent configs show all. `agentId` (scalar) is kept
|
package/dist/status-render.js
CHANGED
|
@@ -71,10 +71,23 @@ function renderRuntimeCircuitBreakers(snap, now) {
|
|
|
71
71
|
export function renderStatus(input, now = Date.now()) {
|
|
72
72
|
const lines = [];
|
|
73
73
|
if (input.pid === null) {
|
|
74
|
-
|
|
74
|
+
const extras = input.daemonProcesses ?? [];
|
|
75
|
+
if (extras.length > 0) {
|
|
76
|
+
lines.push("daemon: no pid file");
|
|
77
|
+
lines.push(`warning: ${extras.length} daemon process${extras.length === 1 ? "" : "es"} detected without pid file`);
|
|
78
|
+
lines.push(`pids: ${extras.map((p) => p.pid).join(", ")}`);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
lines.push("daemon: stopped");
|
|
82
|
+
}
|
|
75
83
|
return lines.join("\n");
|
|
76
84
|
}
|
|
77
85
|
lines.push(`daemon: pid ${input.pid} (${input.alive ? "alive" : "not alive"})`);
|
|
86
|
+
const extras = (input.daemonProcesses ?? []).filter((p) => p.pid !== input.pid);
|
|
87
|
+
if (extras.length > 0) {
|
|
88
|
+
lines.push(`warning: ${extras.length} additional daemon process${extras.length === 1 ? "" : "es"} detected`);
|
|
89
|
+
lines.push(`extra pids: ${extras.map((p) => p.pid).join(", ")}`);
|
|
90
|
+
}
|
|
78
91
|
const agents = input.agents && input.agents.length > 0
|
|
79
92
|
? input.agents
|
|
80
93
|
: input.agentId
|
package/package.json
CHANGED
|
@@ -5,8 +5,10 @@ import path from "node:path";
|
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
6
|
import {
|
|
7
7
|
ensureNoOtherDaemonFromPidFile,
|
|
8
|
+
parseDaemonProcesses,
|
|
8
9
|
readPid,
|
|
9
10
|
removePidFile,
|
|
11
|
+
stopOtherDaemonProcessesForRestart,
|
|
10
12
|
stopDaemonFromPidFileForRestart,
|
|
11
13
|
writeCurrentPid,
|
|
12
14
|
} from "../daemon-singleton.js";
|
|
@@ -73,6 +75,36 @@ describe("daemon singleton pid helpers", () => {
|
|
|
73
75
|
|
|
74
76
|
expect(readPid(pidPath)).toBeNull();
|
|
75
77
|
});
|
|
78
|
+
|
|
79
|
+
it("finds botcord daemon start commands in ps output", () => {
|
|
80
|
+
const out = parseDaemonProcesses(
|
|
81
|
+
[
|
|
82
|
+
" 111 node /Users/me/.botcord/daemon/node_modules/.bin/botcord-daemon start --foreground",
|
|
83
|
+
" 222 node /opt/botcord/daemon/dist/index.js start --foreground",
|
|
84
|
+
" 333 node /tmp/other.js",
|
|
85
|
+
` ${process.pid} node /Users/me/.botcord/daemon/node_modules/.bin/botcord-daemon start --foreground`,
|
|
86
|
+
].join("\n"),
|
|
87
|
+
process.pid,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
expect(out.map((p) => p.pid)).toEqual([111, 222]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("terminates extra daemon processes discovered outside the pid file", async () => {
|
|
94
|
+
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
|
|
95
|
+
stdio: "ignore",
|
|
96
|
+
});
|
|
97
|
+
children.push(child);
|
|
98
|
+
await waitForPid(child);
|
|
99
|
+
|
|
100
|
+
await stopOtherDaemonProcessesForRestart({
|
|
101
|
+
currentPid: process.pid,
|
|
102
|
+
processes: [{ pid: child.pid!, command: "node /opt/botcord/daemon/dist/index.js start --foreground" }],
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await waitForExit(child);
|
|
106
|
+
expect(child.exitCode === null && child.signalCode === null).toBe(false);
|
|
107
|
+
});
|
|
76
108
|
});
|
|
77
109
|
|
|
78
110
|
async function waitForPid(child: ChildProcess): Promise<void> {
|
|
@@ -18,6 +18,18 @@ describe("renderStatus", () => {
|
|
|
18
18
|
expect(out).toContain("stopped");
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
+
it("reports daemon processes even when the pid file is missing", () => {
|
|
22
|
+
const out = renderStatus({
|
|
23
|
+
pid: null,
|
|
24
|
+
alive: false,
|
|
25
|
+
daemonProcesses: [{ pid: 1342 }, { pid: 1527 }],
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(out).toContain("daemon: no pid file");
|
|
29
|
+
expect(out).toContain("warning: 2 daemon processes detected without pid file");
|
|
30
|
+
expect(out).toContain("pids: 1342, 1527");
|
|
31
|
+
});
|
|
32
|
+
|
|
21
33
|
it("prints pid + agent + config when only PID state is known (no snapshot)", () => {
|
|
22
34
|
const out = renderStatus(
|
|
23
35
|
{
|
|
@@ -95,6 +107,17 @@ describe("renderStatus", () => {
|
|
|
95
107
|
expect(out).toContain("agent: ag_solo");
|
|
96
108
|
});
|
|
97
109
|
|
|
110
|
+
it("warns when extra daemon processes are detected", () => {
|
|
111
|
+
const out = renderStatus({
|
|
112
|
+
pid: 42,
|
|
113
|
+
alive: true,
|
|
114
|
+
daemonProcesses: [{ pid: 1001 }, { pid: 1002 }],
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(out).toContain("warning: 2 additional daemon processes detected");
|
|
118
|
+
expect(out).toContain("extra pids: 1001, 1002");
|
|
119
|
+
});
|
|
120
|
+
|
|
98
121
|
it("surfaces ⚠ stale when snapshotAgeMs exceeds the threshold", () => {
|
|
99
122
|
const out = renderStatus(
|
|
100
123
|
{
|
package/src/daemon-singleton.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
1
2
|
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
3
|
import { PID_PATH } from "./config.js";
|
|
3
4
|
|
|
@@ -31,6 +32,46 @@ export function pidAlive(pid: number): boolean {
|
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
export interface DaemonProcessInfo {
|
|
36
|
+
pid: number;
|
|
37
|
+
command: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function parseDaemonProcesses(
|
|
41
|
+
psOutput: string,
|
|
42
|
+
currentPid: number = process.pid,
|
|
43
|
+
): DaemonProcessInfo[] {
|
|
44
|
+
const out: DaemonProcessInfo[] = [];
|
|
45
|
+
for (const line of psOutput.split(/\r?\n/)) {
|
|
46
|
+
const trimmed = line.trim();
|
|
47
|
+
const match = /^(\d+)\s+(.+)$/.exec(trimmed);
|
|
48
|
+
if (!match) continue;
|
|
49
|
+
const pid = Number(match[1]);
|
|
50
|
+
if (!Number.isFinite(pid) || pid <= 0 || pid === currentPid) continue;
|
|
51
|
+
const command = match[2] ?? "";
|
|
52
|
+
if (!isBotCordDaemonStartCommand(command)) continue;
|
|
53
|
+
out.push({ pid, command });
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function findOtherDaemonProcesses(
|
|
59
|
+
opts: {
|
|
60
|
+
currentPid?: number;
|
|
61
|
+
} = {},
|
|
62
|
+
): DaemonProcessInfo[] {
|
|
63
|
+
const currentPid = opts.currentPid ?? process.pid;
|
|
64
|
+
try {
|
|
65
|
+
const output = execFileSync("ps", ["-axo", "pid=,command="], {
|
|
66
|
+
encoding: "utf8",
|
|
67
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
68
|
+
});
|
|
69
|
+
return parseDaemonProcesses(output, currentPid).filter((p) => pidAlive(p.pid));
|
|
70
|
+
} catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
34
75
|
export async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean> {
|
|
35
76
|
const deadline = Date.now() + timeoutMs;
|
|
36
77
|
while (Date.now() < deadline) {
|
|
@@ -85,6 +126,41 @@ export async function stopDaemonFromPidFileForRestart(
|
|
|
85
126
|
}
|
|
86
127
|
}
|
|
87
128
|
|
|
129
|
+
export async function stopOtherDaemonProcessesForRestart(
|
|
130
|
+
opts: {
|
|
131
|
+
currentPid?: number;
|
|
132
|
+
logger?: SingletonLogger;
|
|
133
|
+
processes?: DaemonProcessInfo[];
|
|
134
|
+
} = {},
|
|
135
|
+
): Promise<DaemonProcessInfo[]> {
|
|
136
|
+
const currentPid = opts.currentPid ?? process.pid;
|
|
137
|
+
const logger = opts.logger ?? noopLogger;
|
|
138
|
+
const processes = opts.processes ?? findOtherDaemonProcesses({ currentPid });
|
|
139
|
+
for (const proc of processes) {
|
|
140
|
+
logger.info("additional daemon process found; restarting", {
|
|
141
|
+
pid: proc.pid,
|
|
142
|
+
command: proc.command,
|
|
143
|
+
});
|
|
144
|
+
try {
|
|
145
|
+
process.kill(proc.pid, "SIGTERM");
|
|
146
|
+
} catch {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (!(await waitForPidExit(proc.pid, 5_000))) {
|
|
150
|
+
logger.warn("additional daemon did not stop after SIGTERM; sending SIGKILL", {
|
|
151
|
+
pid: proc.pid,
|
|
152
|
+
});
|
|
153
|
+
try {
|
|
154
|
+
process.kill(proc.pid, "SIGKILL");
|
|
155
|
+
} catch {
|
|
156
|
+
// ignore
|
|
157
|
+
}
|
|
158
|
+
await waitForPidExit(proc.pid, 2_000);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return processes;
|
|
162
|
+
}
|
|
163
|
+
|
|
88
164
|
export function ensureNoOtherDaemonFromPidFile(
|
|
89
165
|
opts: {
|
|
90
166
|
pidPath?: string;
|
|
@@ -117,6 +193,15 @@ export function removePidFile(pidPath = PID_PATH): void {
|
|
|
117
193
|
}
|
|
118
194
|
}
|
|
119
195
|
|
|
196
|
+
function isBotCordDaemonStartCommand(command: string): boolean {
|
|
197
|
+
if (!/\bstart\b/.test(command)) return false;
|
|
198
|
+
return (
|
|
199
|
+
command.includes("botcord-daemon") ||
|
|
200
|
+
/(?:^|\s)\S*botcord\S*\/daemon\/dist\/index\.js(?:\s|$)/.test(command) ||
|
|
201
|
+
/(?:^|\s)\S*packages\/daemon\/dist\/index\.js(?:\s|$)/.test(command)
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
120
205
|
function delay(ms: number): Promise<void> {
|
|
121
206
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
122
207
|
}
|
|
@@ -699,7 +699,7 @@ describe("createBotCordChannel — streamBlock()", () => {
|
|
|
699
699
|
},
|
|
700
700
|
1,
|
|
701
701
|
),
|
|
702
|
-
).
|
|
702
|
+
).toMatchObject({
|
|
703
703
|
kind: "tool_call",
|
|
704
704
|
seq: 1,
|
|
705
705
|
payload: {
|
|
@@ -725,7 +725,7 @@ describe("createBotCordChannel — streamBlock()", () => {
|
|
|
725
725
|
},
|
|
726
726
|
2,
|
|
727
727
|
),
|
|
728
|
-
).
|
|
728
|
+
).toMatchObject({
|
|
729
729
|
kind: "tool_call",
|
|
730
730
|
seq: 2,
|
|
731
731
|
payload: {
|
|
@@ -758,7 +758,7 @@ describe("createBotCordChannel — streamBlock()", () => {
|
|
|
758
758
|
},
|
|
759
759
|
3,
|
|
760
760
|
),
|
|
761
|
-
).
|
|
761
|
+
).toMatchObject({
|
|
762
762
|
kind: "tool_result",
|
|
763
763
|
seq: 3,
|
|
764
764
|
payload: {
|
|
@@ -782,7 +782,7 @@ describe("createBotCordChannel — streamBlock()", () => {
|
|
|
782
782
|
},
|
|
783
783
|
4,
|
|
784
784
|
),
|
|
785
|
-
).
|
|
785
|
+
).toMatchObject({
|
|
786
786
|
kind: "tool_call",
|
|
787
787
|
seq: 4,
|
|
788
788
|
payload: {
|
|
@@ -982,7 +982,7 @@ describe("createBotCordChannel — streamBlock()", () => {
|
|
|
982
982
|
},
|
|
983
983
|
7,
|
|
984
984
|
),
|
|
985
|
-
).
|
|
985
|
+
).toMatchObject({
|
|
986
986
|
kind: "tool_call",
|
|
987
987
|
seq: 7,
|
|
988
988
|
payload: {
|
|
@@ -991,6 +991,102 @@ describe("createBotCordChannel — streamBlock()", () => {
|
|
|
991
991
|
params: { query: "上海天气" },
|
|
992
992
|
status: "in_progress",
|
|
993
993
|
},
|
|
994
|
+
raw: {
|
|
995
|
+
event: "item.started",
|
|
996
|
+
payload: {
|
|
997
|
+
item: { id: "item_tool", kind: "tool_call", status: "in_progress" },
|
|
998
|
+
tool: { id: "call_1", name: "web_search", input: { query: "上海天气" } },
|
|
999
|
+
},
|
|
1000
|
+
},
|
|
1001
|
+
});
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
it("infers current DeepSeek tool input from item summary and detail", () => {
|
|
1005
|
+
expect(
|
|
1006
|
+
__normalizeBlockForHubForTests(
|
|
1007
|
+
{
|
|
1008
|
+
kind: "tool_use",
|
|
1009
|
+
seq: 8,
|
|
1010
|
+
raw: {
|
|
1011
|
+
event: "item.started",
|
|
1012
|
+
payload: {
|
|
1013
|
+
item: {
|
|
1014
|
+
id: "item_exec",
|
|
1015
|
+
kind: "tool_call",
|
|
1016
|
+
status: "in_progress",
|
|
1017
|
+
summary: "exec_shell started",
|
|
1018
|
+
detail: "{\"cmd\":\"botcord-daemon --version\"}",
|
|
1019
|
+
},
|
|
1020
|
+
},
|
|
1021
|
+
},
|
|
1022
|
+
},
|
|
1023
|
+
8,
|
|
1024
|
+
),
|
|
1025
|
+
).toMatchObject({
|
|
1026
|
+
kind: "tool_call",
|
|
1027
|
+
seq: 8,
|
|
1028
|
+
payload: {
|
|
1029
|
+
id: "item_exec",
|
|
1030
|
+
name: "exec_shell",
|
|
1031
|
+
params: { cmd: "botcord-daemon --version" },
|
|
1032
|
+
status: "in_progress",
|
|
1033
|
+
},
|
|
1034
|
+
raw: {
|
|
1035
|
+
event: "item.started",
|
|
1036
|
+
payload: {
|
|
1037
|
+
item: {
|
|
1038
|
+
id: "item_exec",
|
|
1039
|
+
kind: "tool_call",
|
|
1040
|
+
status: "in_progress",
|
|
1041
|
+
summary: "exec_shell started",
|
|
1042
|
+
detail: "{\"cmd\":\"botcord-daemon --version\"}",
|
|
1043
|
+
},
|
|
1044
|
+
},
|
|
1045
|
+
},
|
|
1046
|
+
});
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
it("infers current DeepSeek tool result name from item summary", () => {
|
|
1050
|
+
expect(
|
|
1051
|
+
__normalizeBlockForHubForTests(
|
|
1052
|
+
{
|
|
1053
|
+
kind: "tool_result",
|
|
1054
|
+
seq: 9,
|
|
1055
|
+
raw: {
|
|
1056
|
+
event: "item.completed",
|
|
1057
|
+
payload: {
|
|
1058
|
+
item: {
|
|
1059
|
+
id: "item_exec",
|
|
1060
|
+
kind: "tool_call",
|
|
1061
|
+
status: "completed",
|
|
1062
|
+
summary: "exec_shell: botcord-daemon 0.2.78",
|
|
1063
|
+
detail: "botcord-daemon 0.2.78",
|
|
1064
|
+
},
|
|
1065
|
+
},
|
|
1066
|
+
},
|
|
1067
|
+
},
|
|
1068
|
+
9,
|
|
1069
|
+
),
|
|
1070
|
+
).toMatchObject({
|
|
1071
|
+
kind: "tool_result",
|
|
1072
|
+
seq: 9,
|
|
1073
|
+
payload: {
|
|
1074
|
+
name: "exec_shell",
|
|
1075
|
+
result: "botcord-daemon 0.2.78",
|
|
1076
|
+
tool_use_id: "item_exec",
|
|
1077
|
+
},
|
|
1078
|
+
raw: {
|
|
1079
|
+
event: "item.completed",
|
|
1080
|
+
payload: {
|
|
1081
|
+
item: {
|
|
1082
|
+
id: "item_exec",
|
|
1083
|
+
kind: "tool_call",
|
|
1084
|
+
status: "completed",
|
|
1085
|
+
summary: "exec_shell: botcord-daemon 0.2.78",
|
|
1086
|
+
detail: "botcord-daemon 0.2.78",
|
|
1087
|
+
},
|
|
1088
|
+
},
|
|
1089
|
+
},
|
|
994
1090
|
});
|
|
995
1091
|
});
|
|
996
1092
|
|
|
@@ -1015,10 +1111,22 @@ describe("createBotCordChannel — streamBlock()", () => {
|
|
|
1015
1111
|
},
|
|
1016
1112
|
8,
|
|
1017
1113
|
),
|
|
1018
|
-
).
|
|
1114
|
+
).toMatchObject({
|
|
1019
1115
|
kind: "thinking",
|
|
1020
1116
|
seq: 8,
|
|
1021
1117
|
payload: { details: "I should answer briefly." },
|
|
1118
|
+
raw: {
|
|
1119
|
+
event: "item.completed",
|
|
1120
|
+
payload: {
|
|
1121
|
+
item: {
|
|
1122
|
+
id: "item_reasoning",
|
|
1123
|
+
kind: "agent_reasoning",
|
|
1124
|
+
status: "completed",
|
|
1125
|
+
summary: "I should answer briefly.",
|
|
1126
|
+
detail: "I should answer briefly.",
|
|
1127
|
+
},
|
|
1128
|
+
},
|
|
1129
|
+
},
|
|
1022
1130
|
});
|
|
1023
1131
|
});
|
|
1024
1132
|
|
|
@@ -1090,10 +1198,11 @@ describe("createBotCordChannel — streamBlock()", () => {
|
|
|
1090
1198
|
log: silentLog,
|
|
1091
1199
|
});
|
|
1092
1200
|
const body = JSON.parse(fetchSpy.mock.calls[0][1].body as string);
|
|
1093
|
-
expect(body.block).
|
|
1201
|
+
expect(body.block).toMatchObject({
|
|
1094
1202
|
kind: "thinking",
|
|
1095
1203
|
seq: 7,
|
|
1096
1204
|
payload: { phase: "updated", label: "Searching web", source: "runtime", details: "Searching web" },
|
|
1205
|
+
raw: { phase: "updated", label: "Searching web", source: "runtime" },
|
|
1097
1206
|
});
|
|
1098
1207
|
} finally {
|
|
1099
1208
|
globalThis.fetch = realFetch;
|
|
@@ -304,6 +304,55 @@ describe("DeepseekTuiAdapter", () => {
|
|
|
304
304
|
}
|
|
305
305
|
});
|
|
306
306
|
|
|
307
|
+
it("infers current DeepSeek tool status labels without a payload.tool object", async () => {
|
|
308
|
+
const server = await startMockDeepseekServer({
|
|
309
|
+
events: [
|
|
310
|
+
{
|
|
311
|
+
event: "turn.started",
|
|
312
|
+
data: { thread_id: "thr_test", turn_id: "turn_test", event: "turn.started" },
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
event: "item.started",
|
|
316
|
+
data: {
|
|
317
|
+
thread_id: "thr_test",
|
|
318
|
+
turn_id: "turn_test",
|
|
319
|
+
event: "item.started",
|
|
320
|
+
payload: {
|
|
321
|
+
item: {
|
|
322
|
+
id: "item_exec",
|
|
323
|
+
kind: "tool_call",
|
|
324
|
+
status: "in_progress",
|
|
325
|
+
summary: "exec_shell started",
|
|
326
|
+
detail: "{\"cmd\":\"botcord-daemon --version\"}",
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
event: "item.delta",
|
|
333
|
+
data: {
|
|
334
|
+
thread_id: "thr_test",
|
|
335
|
+
turn_id: "turn_test",
|
|
336
|
+
event: "item.delta",
|
|
337
|
+
payload: { kind: "agent_message", delta: "done" },
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
event: "turn.completed",
|
|
342
|
+
data: { thread_id: "thr_test", turn_id: "turn_test", event: "turn.completed" },
|
|
343
|
+
},
|
|
344
|
+
],
|
|
345
|
+
});
|
|
346
|
+
try {
|
|
347
|
+
const { result, blocks, status } = runAdapter(server.baseUrl, server.token);
|
|
348
|
+
await expect(result).resolves.toMatchObject({ text: "done" });
|
|
349
|
+
expect(blocks).toEqual(expect.arrayContaining(["tool_use", "assistant_text"]));
|
|
350
|
+
expect(status).toContainEqual({ phase: "updated", label: "exec_shell" });
|
|
351
|
+
} finally {
|
|
352
|
+
await server.close();
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
307
356
|
it("emits current DeepSeek agent_reasoning completions as thinking blocks", async () => {
|
|
308
357
|
const server = await startMockDeepseekServer({
|
|
309
358
|
events: [
|
|
@@ -410,6 +459,43 @@ describe("DeepseekTuiAdapter", () => {
|
|
|
410
459
|
}
|
|
411
460
|
});
|
|
412
461
|
|
|
462
|
+
it("surfaces a diagnostic error when DeepSeek completes a turn with no assistant_message", async () => {
|
|
463
|
+
const server = await startMockDeepseekServer({
|
|
464
|
+
events: [
|
|
465
|
+
{ event: "turn.started", data: { thread_id: "thr_test", turn_id: "turn_test" } },
|
|
466
|
+
{
|
|
467
|
+
event: "item.completed",
|
|
468
|
+
data: {
|
|
469
|
+
thread_id: "thr_test",
|
|
470
|
+
turn_id: "turn_test",
|
|
471
|
+
event: "item.completed",
|
|
472
|
+
payload: {
|
|
473
|
+
item: {
|
|
474
|
+
id: "item_reasoning",
|
|
475
|
+
kind: "agent_reasoning",
|
|
476
|
+
status: "completed",
|
|
477
|
+
summary: "thinking...",
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
event: "turn.completed",
|
|
484
|
+
data: { thread_id: "thr_test", turn_id: "turn_test", payload: { turn: { status: "completed" } } },
|
|
485
|
+
},
|
|
486
|
+
],
|
|
487
|
+
});
|
|
488
|
+
try {
|
|
489
|
+
const { result } = runAdapter(server.baseUrl, server.token);
|
|
490
|
+
const res = await result;
|
|
491
|
+
expect(res.text).toBe("");
|
|
492
|
+
expect(res.newSessionId).toBe("thr_test");
|
|
493
|
+
expect(res.error).toMatch(/no assistant_message/);
|
|
494
|
+
} finally {
|
|
495
|
+
await server.close();
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
|
|
413
499
|
it("returns a runtime error when DeepSeek completes the turn as failed", async () => {
|
|
414
500
|
const server = await startMockDeepseekServer({
|
|
415
501
|
events: [
|
|
@@ -977,10 +977,13 @@ export { normalizeBlockForHub as __normalizeBlockForHubForTests };
|
|
|
977
977
|
function normalizeBlockForHub(
|
|
978
978
|
block: { raw?: unknown; kind?: string; seq?: number } | undefined,
|
|
979
979
|
seq: number,
|
|
980
|
-
): { kind: string; seq: number; payload: Record<string, unknown
|
|
980
|
+
): { kind: string; seq: number; payload: Record<string, unknown>; raw?: unknown } {
|
|
981
981
|
const raw = (block?.raw ?? {}) as any;
|
|
982
982
|
const kind = block?.kind ?? "other";
|
|
983
983
|
const payload: Record<string, unknown> = {};
|
|
984
|
+
const withRaw = (out: { kind: string; seq: number; payload: Record<string, unknown> }) => (
|
|
985
|
+
block && "raw" in block ? { ...out, raw: block.raw } : out
|
|
986
|
+
);
|
|
984
987
|
|
|
985
988
|
if (kind === "assistant_text") {
|
|
986
989
|
// Claude Code: {type:"assistant", message:{content:[{type:"text",text}]}}
|
|
@@ -1022,7 +1025,7 @@ function normalizeBlockForHub(
|
|
|
1022
1025
|
if (call.id) payload.id = call.id;
|
|
1023
1026
|
if (call.status) payload.status = call.status;
|
|
1024
1027
|
}
|
|
1025
|
-
return { kind: "tool_call", seq, payload };
|
|
1028
|
+
return withRaw({ kind: "tool_call", seq, payload });
|
|
1026
1029
|
}
|
|
1027
1030
|
|
|
1028
1031
|
if (kind === "tool_result") {
|
|
@@ -1032,7 +1035,7 @@ function normalizeBlockForHub(
|
|
|
1032
1035
|
payload.result = result.result;
|
|
1033
1036
|
if (result.id) payload.tool_use_id = result.id;
|
|
1034
1037
|
}
|
|
1035
|
-
return { kind: "tool_result", seq, payload };
|
|
1038
|
+
return withRaw({ kind: "tool_result", seq, payload });
|
|
1036
1039
|
}
|
|
1037
1040
|
|
|
1038
1041
|
if (kind === "system") {
|
|
@@ -1040,7 +1043,7 @@ function normalizeBlockForHub(
|
|
|
1040
1043
|
if (typeof raw?.session_id === "string") payload.session_id = raw.session_id;
|
|
1041
1044
|
if (typeof raw?.model === "string") payload.model = raw.model;
|
|
1042
1045
|
payload.details = formatBlockDetails(raw);
|
|
1043
|
-
return { kind: "system", seq, payload };
|
|
1046
|
+
return withRaw({ kind: "system", seq, payload });
|
|
1044
1047
|
}
|
|
1045
1048
|
|
|
1046
1049
|
if (kind === "thinking") {
|
|
@@ -1052,7 +1055,7 @@ function normalizeBlockForHub(
|
|
|
1052
1055
|
if (typeof raw?.label === "string") payload.label = raw.label;
|
|
1053
1056
|
if (typeof raw?.source === "string") payload.source = raw.source;
|
|
1054
1057
|
payload.details = formatBlockDetails(raw);
|
|
1055
|
-
return { kind: "thinking", seq, payload };
|
|
1058
|
+
return withRaw({ kind: "thinking", seq, payload });
|
|
1056
1059
|
}
|
|
1057
1060
|
|
|
1058
1061
|
// "other" — e.g. Claude Code `type:"result"` end-of-turn summary.
|
|
@@ -1062,14 +1065,14 @@ function normalizeBlockForHub(
|
|
|
1062
1065
|
const event = typeof raw?.event === "string" ? raw.event : undefined;
|
|
1063
1066
|
const embedded = typeof raw?.payload?.event === "string" ? raw.payload.event : undefined;
|
|
1064
1067
|
if (event || embedded) payload.event = event ?? embedded;
|
|
1065
|
-
return { kind: "other", seq, payload };
|
|
1068
|
+
return withRaw({ kind: "other", seq, payload });
|
|
1066
1069
|
}
|
|
1067
1070
|
if (raw?.type === "result") {
|
|
1068
1071
|
if (typeof raw.result === "string") payload.text = raw.result;
|
|
1069
1072
|
if (typeof raw.subtype === "string") payload.subtype = raw.subtype;
|
|
1070
1073
|
if (typeof raw.total_cost_usd === "number") payload.total_cost_usd = raw.total_cost_usd;
|
|
1071
1074
|
}
|
|
1072
|
-
return { kind: "other", seq, payload };
|
|
1075
|
+
return withRaw({ kind: "other", seq, payload });
|
|
1073
1076
|
}
|
|
1074
1077
|
|
|
1075
1078
|
function isTerminalRuntimeBlock(raw: any): boolean {
|
|
@@ -1213,21 +1216,32 @@ function extractDeepseekToolCall(raw: any): { name: string; params?: unknown; id
|
|
|
1213
1216
|
: {};
|
|
1214
1217
|
const item = inner.item && typeof inner.item === "object" ? inner.item : undefined;
|
|
1215
1218
|
const tool = inner.tool && typeof inner.tool === "object" ? inner.tool : item?.tool;
|
|
1219
|
+
const itemParams = parseMaybeJson(item?.input ?? item?.arguments ?? item?.detail);
|
|
1220
|
+
const detailParams =
|
|
1221
|
+
itemParams !== undefined
|
|
1222
|
+
? itemParams
|
|
1223
|
+
: typeof item?.detail === "string" && item.detail.trim()
|
|
1224
|
+
? item.detail.trim()
|
|
1225
|
+
: undefined;
|
|
1216
1226
|
return {
|
|
1217
1227
|
name:
|
|
1218
1228
|
stringField(tool, "name") ??
|
|
1219
1229
|
stringField(inner, "name") ??
|
|
1220
1230
|
stringField(item, "name") ??
|
|
1231
|
+
inferDeepseekToolName(item) ??
|
|
1221
1232
|
stringField(item, "type") ??
|
|
1222
1233
|
"tool",
|
|
1223
1234
|
params: parseMaybeJson(
|
|
1224
1235
|
tool?.input ??
|
|
1225
1236
|
tool?.rawInput ??
|
|
1226
1237
|
tool?.arguments ??
|
|
1238
|
+
tool?.params ??
|
|
1227
1239
|
inner.input ??
|
|
1240
|
+
inner.arguments ??
|
|
1241
|
+
inner.params ??
|
|
1228
1242
|
item?.input ??
|
|
1229
1243
|
item?.arguments,
|
|
1230
|
-
) ?? tool ?? item,
|
|
1244
|
+
) ?? detailParams ?? tool ?? item,
|
|
1231
1245
|
id: stringField(tool, "id") ?? stringField(inner, "id") ?? stringField(item, "id"),
|
|
1232
1246
|
status: stringField(tool, "status") ?? stringField(inner, "status") ?? stringField(item, "status"),
|
|
1233
1247
|
};
|
|
@@ -1275,7 +1289,11 @@ function extractDeepseekToolResult(raw: any): { name?: string; result: string; i
|
|
|
1275
1289
|
item ??
|
|
1276
1290
|
inner;
|
|
1277
1291
|
return {
|
|
1278
|
-
name:
|
|
1292
|
+
name:
|
|
1293
|
+
stringField(item, "name") ??
|
|
1294
|
+
inferDeepseekToolName(item) ??
|
|
1295
|
+
stringField(inner, "name") ??
|
|
1296
|
+
stringField(item, "type"),
|
|
1279
1297
|
result: stringifyToolResult(result),
|
|
1280
1298
|
id: stringField(item, "id") ?? stringField(inner, "id"),
|
|
1281
1299
|
};
|
|
@@ -1392,6 +1410,16 @@ function parseMaybeJson(value: unknown): unknown {
|
|
|
1392
1410
|
}
|
|
1393
1411
|
}
|
|
1394
1412
|
|
|
1413
|
+
function inferDeepseekToolName(item: any): string | undefined {
|
|
1414
|
+
const candidates = [stringField(item, "summary"), stringField(item, "detail")];
|
|
1415
|
+
for (const candidate of candidates) {
|
|
1416
|
+
if (!candidate) continue;
|
|
1417
|
+
const match = candidate.match(/^([A-Za-z0-9_.:-]+)\s*(?:started|completed|failed|returned|:)/);
|
|
1418
|
+
if (match?.[1] && match[1] !== "tool_call") return match[1];
|
|
1419
|
+
}
|
|
1420
|
+
return undefined;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1395
1423
|
function isEmptyRecord(value: unknown): boolean {
|
|
1396
1424
|
return !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0;
|
|
1397
1425
|
}
|
|
@@ -143,11 +143,14 @@ export class DeepseekTuiAdapter implements RuntimeAdapter {
|
|
|
143
143
|
signal: turnAbort.signal,
|
|
144
144
|
});
|
|
145
145
|
const text = runResult.text;
|
|
146
|
+
const error =
|
|
147
|
+
runResult.error ??
|
|
148
|
+
(text === "" ? emptyCompletionError(handle.stderrTail) : undefined);
|
|
146
149
|
|
|
147
150
|
return {
|
|
148
151
|
text,
|
|
149
152
|
newSessionId: threadId,
|
|
150
|
-
...(
|
|
153
|
+
...(error ? { error } : {}),
|
|
151
154
|
};
|
|
152
155
|
} catch (err) {
|
|
153
156
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -393,6 +396,7 @@ export class DeepseekTuiAdapter implements RuntimeAdapter {
|
|
|
393
396
|
stringField(payload, "name") ??
|
|
394
397
|
stringField(payload?.tool, "name") ??
|
|
395
398
|
stringField(payload?.payload?.tool, "name") ??
|
|
399
|
+
inferDeepseekToolName(payload?.item ?? payload?.payload?.item) ??
|
|
396
400
|
"tool";
|
|
397
401
|
opts.onStatus?.({ kind: "thinking", phase: "updated", label });
|
|
398
402
|
} else if (isDeepseekTerminalEvent(eventName, payload)) {
|
|
@@ -494,7 +498,8 @@ function isDeepseekTerminalEvent(eventName: string, payload: any): boolean {
|
|
|
494
498
|
|
|
495
499
|
function isToolStarted(eventName: string, payload: any): boolean {
|
|
496
500
|
return (
|
|
497
|
-
(eventName === "item.started" &&
|
|
501
|
+
(eventName === "item.started" &&
|
|
502
|
+
(!!payload?.tool || payload?.item?.kind === "tool_call" || payload?.payload?.item?.kind === "tool_call")) ||
|
|
498
503
|
(payload?.event === "item.started" && !!payload?.payload?.tool)
|
|
499
504
|
);
|
|
500
505
|
}
|
|
@@ -522,6 +527,26 @@ function extractDeepseekDelta(payload: any): string {
|
|
|
522
527
|
return stringField(payload, "delta") ?? stringField(payload?.payload, "delta") ?? "";
|
|
523
528
|
}
|
|
524
529
|
|
|
530
|
+
function inferDeepseekToolName(item: any): string | undefined {
|
|
531
|
+
const candidates = [stringField(item, "summary"), stringField(item, "detail")];
|
|
532
|
+
for (const candidate of candidates) {
|
|
533
|
+
if (!candidate) continue;
|
|
534
|
+
const match = candidate.match(/^([A-Za-z0-9_.:-]+)\s*(?:started|completed|failed|returned|:)/);
|
|
535
|
+
if (match?.[1] && match[1] !== "tool_call") return match[1];
|
|
536
|
+
}
|
|
537
|
+
return undefined;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function emptyCompletionError(stderrTail: string): string {
|
|
541
|
+
const tail = stderrTail.trim();
|
|
542
|
+
if (!tail) {
|
|
543
|
+
return "deepseek runtime completed with no assistant_message (check DEEPSEEK_API_KEY / model availability)";
|
|
544
|
+
}
|
|
545
|
+
const lines = tail.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
|
546
|
+
const lastLines = lines.slice(-5).join("\n").slice(-500);
|
|
547
|
+
return `deepseek runtime completed with no assistant_message; stderr tail: ${lastLines}`;
|
|
548
|
+
}
|
|
549
|
+
|
|
525
550
|
function extractDeepseekError(eventName: string, payload: any): string | undefined {
|
|
526
551
|
if (eventName === "error") {
|
|
527
552
|
return (
|
package/src/index.ts
CHANGED
|
@@ -18,10 +18,12 @@ import {
|
|
|
18
18
|
} from "./config.js";
|
|
19
19
|
import {
|
|
20
20
|
ensureNoOtherDaemonFromPidFile,
|
|
21
|
+
findOtherDaemonProcesses,
|
|
21
22
|
pidAlive,
|
|
22
23
|
readPid,
|
|
23
24
|
removePidFile,
|
|
24
25
|
stopDaemonFromPidFileForRestart,
|
|
26
|
+
stopOtherDaemonProcessesForRestart,
|
|
25
27
|
writeCurrentPid,
|
|
26
28
|
} from "./daemon-singleton.js";
|
|
27
29
|
import { resolveBootAgents } from "./agent-discovery.js";
|
|
@@ -585,6 +587,7 @@ async function cmdStart(args: ParsedArgs): Promise<void> {
|
|
|
585
587
|
if (process.env.BOTCORD_DAEMON_CHILD !== "1") {
|
|
586
588
|
await ensureUserAuthForStart(args);
|
|
587
589
|
await stopDaemonFromPidFileForRestart({ logger: log });
|
|
590
|
+
await stopOtherDaemonProcessesForRestart({ logger: log });
|
|
588
591
|
} else {
|
|
589
592
|
const existing = ensureNoOtherDaemonFromPidFile();
|
|
590
593
|
if (existing) {
|
|
@@ -603,7 +606,7 @@ async function cmdStart(args: ParsedArgs): Promise<void> {
|
|
|
603
606
|
env: { ...process.env, BOTCORD_DAEMON_CHILD: "1" },
|
|
604
607
|
});
|
|
605
608
|
child.unref();
|
|
606
|
-
const deadline = Date.now() +
|
|
609
|
+
const deadline = Date.now() + 5_000;
|
|
607
610
|
let observed: number | null = null;
|
|
608
611
|
while (Date.now() < deadline) {
|
|
609
612
|
const p = readPid();
|
|
@@ -614,7 +617,7 @@ async function cmdStart(args: ParsedArgs): Promise<void> {
|
|
|
614
617
|
await new Promise((r) => setTimeout(r, 50));
|
|
615
618
|
}
|
|
616
619
|
if (!observed) {
|
|
617
|
-
console.error(`daemon did not record pid within
|
|
620
|
+
console.error(`daemon did not record pid within 5000ms (expected child pid ${child.pid})`);
|
|
618
621
|
process.exit(1);
|
|
619
622
|
}
|
|
620
623
|
console.log(`daemon started (pid ${observed})`);
|
|
@@ -659,6 +662,7 @@ async function cmdStartCloud(_args: ParsedArgs): Promise<void> {
|
|
|
659
662
|
hubUrl: cloudConfig.hubUrl,
|
|
660
663
|
});
|
|
661
664
|
await stopDaemonFromPidFileForRestart({ logger: log });
|
|
665
|
+
await stopOtherDaemonProcessesForRestart({ logger: log });
|
|
662
666
|
writeCurrentPid();
|
|
663
667
|
|
|
664
668
|
// Cloud daemons always start with an empty in-memory config — every
|
|
@@ -755,6 +759,7 @@ async function cmdStatus(args: ParsedArgs): Promise<void> {
|
|
|
755
759
|
const file = readSnapshotFile();
|
|
756
760
|
const now = Date.now();
|
|
757
761
|
const snapshotAgeMs = file ? now - file.writtenAt : null;
|
|
762
|
+
const daemonProcesses = findOtherDaemonProcesses().filter((p) => p.pid !== pid);
|
|
758
763
|
|
|
759
764
|
if (args.flags.json === true) {
|
|
760
765
|
const payload = {
|
|
@@ -780,6 +785,7 @@ async function cmdStatus(args: ParsedArgs): Promise<void> {
|
|
|
780
785
|
snapshotWrittenAt: file?.writtenAt ?? null,
|
|
781
786
|
snapshotAgeMs,
|
|
782
787
|
snapshotPath: SNAPSHOT_PATH,
|
|
788
|
+
daemonProcesses,
|
|
783
789
|
};
|
|
784
790
|
console.log(JSON.stringify(payload, null, 2));
|
|
785
791
|
return;
|
|
@@ -793,6 +799,7 @@ async function cmdStatus(args: ParsedArgs): Promise<void> {
|
|
|
793
799
|
configPath,
|
|
794
800
|
snapshot: file?.snapshot ?? null,
|
|
795
801
|
snapshotAgeMs,
|
|
802
|
+
daemonProcesses,
|
|
796
803
|
};
|
|
797
804
|
console.log(renderStatus(input, now));
|
|
798
805
|
if (userAuth) {
|
package/src/status-render.ts
CHANGED
|
@@ -7,6 +7,7 @@ export const STALE_THRESHOLD_MS = 30_000;
|
|
|
7
7
|
export interface StatusRenderInput {
|
|
8
8
|
pid: number | null;
|
|
9
9
|
alive: boolean;
|
|
10
|
+
daemonProcesses?: Array<{ pid: number; command?: string }> | null;
|
|
10
11
|
/**
|
|
11
12
|
* Effective list of agent ids the daemon is bound to. Single-agent installs
|
|
12
13
|
* show one entry; multi-agent configs show all. `agentId` (scalar) is kept
|
|
@@ -114,10 +115,22 @@ function renderRuntimeCircuitBreakers(
|
|
|
114
115
|
export function renderStatus(input: StatusRenderInput, now: number = Date.now()): string {
|
|
115
116
|
const lines: string[] = [];
|
|
116
117
|
if (input.pid === null) {
|
|
117
|
-
|
|
118
|
+
const extras = input.daemonProcesses ?? [];
|
|
119
|
+
if (extras.length > 0) {
|
|
120
|
+
lines.push("daemon: no pid file");
|
|
121
|
+
lines.push(`warning: ${extras.length} daemon process${extras.length === 1 ? "" : "es"} detected without pid file`);
|
|
122
|
+
lines.push(`pids: ${extras.map((p) => p.pid).join(", ")}`);
|
|
123
|
+
} else {
|
|
124
|
+
lines.push("daemon: stopped");
|
|
125
|
+
}
|
|
118
126
|
return lines.join("\n");
|
|
119
127
|
}
|
|
120
128
|
lines.push(`daemon: pid ${input.pid} (${input.alive ? "alive" : "not alive"})`);
|
|
129
|
+
const extras = (input.daemonProcesses ?? []).filter((p) => p.pid !== input.pid);
|
|
130
|
+
if (extras.length > 0) {
|
|
131
|
+
lines.push(`warning: ${extras.length} additional daemon process${extras.length === 1 ? "" : "es"} detected`);
|
|
132
|
+
lines.push(`extra pids: ${extras.map((p) => p.pid).join(", ")}`);
|
|
133
|
+
}
|
|
121
134
|
const agents =
|
|
122
135
|
input.agents && input.agents.length > 0
|
|
123
136
|
? input.agents
|