@botcord/daemon 0.2.79 → 0.2.81

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.
@@ -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;
@@ -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
  }
@@ -133,4 +133,5 @@ declare function normalizeBlockForHub(block: {
133
133
  kind: string;
134
134
  seq: number;
135
135
  payload: Record<string, unknown>;
136
+ raw?: unknown;
136
137
  };
@@ -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") ?? stringField(item, "type") ?? stringField(inner, "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
- ...(runResult.error ? { error: runResult.error } : {}),
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" && (!!payload?.tool || payload?.item?.kind === "tool_call")) ||
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() + 500;
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 500ms (expected child pid ${child.pid})`);
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) {
@@ -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
@@ -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
- lines.push("daemon: stopped");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.79",
3
+ "version": "0.2.81",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@botcord/cli": "^0.1.7",
31
- "@botcord/protocol-core": "^0.2.10",
31
+ "@botcord/protocol-core": "^0.2.11",
32
32
  "@larksuiteoapi/node-sdk": "^1.63.1",
33
33
  "ws": "^8.20.1"
34
34
  },
@@ -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
  {
@@ -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
- ).toEqual({
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
- ).toEqual({
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
- ).toEqual({
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
- ).toEqual({
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
- ).toEqual({
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
- ).toEqual({
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).toEqual({
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: stringField(item, "name") ?? stringField(item, "type") ?? stringField(inner, "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
- ...(runResult.error ? { error: runResult.error } : {}),
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" && (!!payload?.tool || payload?.item?.kind === "tool_call")) ||
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() + 500;
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 500ms (expected child pid ${child.pid})`);
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) {
@@ -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
- lines.push("daemon: stopped");
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