@botcord/daemon 0.2.84 → 0.2.86

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 BotLearn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -17,7 +17,7 @@ import { ControlChannel } from "./control-channel.js";
17
17
  import { toGatewayConfig } from "./daemon-config-map.js";
18
18
  import { log as daemonLog } from "./log.js";
19
19
  import { createProvisioner } from "./provision.js";
20
- import { createDaemonChannel, pushRuntimeSnapshot } from "./daemon.js";
20
+ import { createDaemonChannel, pushAgentSkillSnapshot, pushRuntimeSnapshot } from "./daemon.js";
21
21
  import { SnapshotWriter } from "./snapshot-writer.js";
22
22
  import { createDaemonSystemContextBuilder } from "./system-context.js";
23
23
  import { readWorkingMemorySnapshot } from "./working-memory.js";
@@ -157,7 +157,20 @@ export async function startCloudDaemon(opts) {
157
157
  text: msg.text,
158
158
  });
159
159
  };
160
+ const installedAgentIds = new Set();
161
+ let controlChannel = null;
162
+ const pushInstalledAgentSkillSnapshot = (agentId, reason) => {
163
+ if (!controlChannel)
164
+ return;
165
+ const pushed = pushAgentSkillSnapshot(controlChannel, agentId);
166
+ logger.info("cloud control-channel: agent_skill_snapshot pushed", {
167
+ agentId,
168
+ reason,
169
+ ok: pushed,
170
+ });
171
+ };
160
172
  const onAgentInstalled = (info) => {
173
+ installedAgentIds.add(info.agentId);
161
174
  credentialPathByAgentId.set(info.agentId, info.credentialsFile);
162
175
  if (info.hubUrl)
163
176
  hubUrlByAgentId.set(info.agentId, info.hubUrl);
@@ -173,6 +186,7 @@ export async function startCloudDaemon(opts) {
173
186
  loopRiskBuilder: () => null,
174
187
  }));
175
188
  }
189
+ pushInstalledAgentSkillSnapshot(info.agentId, "agent_installed");
176
190
  };
177
191
  const gateway = new Gateway({
178
192
  config: gwConfig,
@@ -197,7 +211,6 @@ export async function startCloudDaemon(opts) {
197
211
  });
198
212
  await gateway.start();
199
213
  logger.info("cloud daemon gateway started (zero agents at boot)");
200
- let controlChannel = null;
201
214
  if (!opts.disableControlChannel) {
202
215
  const auth = asUserAuthManager(new CloudAuthManager(cloudCfg));
203
216
  const provisionerFactory = opts.provisionerFactory ?? createProvisioner;
@@ -247,6 +260,9 @@ export async function startCloudDaemon(opts) {
247
260
  logger.info("cloud control-channel started; runtime_snapshot pushed", {
248
261
  ok: pushed,
249
262
  });
263
+ for (const agentId of installedAgentIds) {
264
+ pushInstalledAgentSkillSnapshot(agentId, "control_channel_started");
265
+ }
250
266
  }
251
267
  catch (err) {
252
268
  logger.warn("cloud control-channel start failed; daemon will retry", {
@@ -2,6 +2,11 @@ export interface SingletonLogger {
2
2
  info(message: string, meta?: Record<string, unknown>): void;
3
3
  warn(message: string, meta?: Record<string, unknown>): void;
4
4
  }
5
+ export interface DaemonSingletonLock {
6
+ lockPath: string;
7
+ release(): void;
8
+ }
9
+ export declare function defaultLockPath(pidPath?: string): string;
5
10
  export declare function readPid(pidPath?: string): number | null;
6
11
  export declare function pidAlive(pid: number): boolean;
7
12
  export interface DaemonProcessInfo {
@@ -23,6 +28,13 @@ export declare function stopDaemonFromPidFileForRestart(opts?: {
23
28
  currentPid?: number;
24
29
  logger?: SingletonLogger;
25
30
  }): Promise<void>;
31
+ export declare function acquireDaemonSingletonLock(opts?: {
32
+ lockPath?: string;
33
+ pidPath?: string;
34
+ currentPid?: number;
35
+ logger?: SingletonLogger;
36
+ timeoutMs?: number;
37
+ }): Promise<DaemonSingletonLock>;
26
38
  export declare function stopOtherDaemonProcessesForRestart(opts?: {
27
39
  currentPid?: number;
28
40
  logger?: SingletonLogger;
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { PID_PATH } from "./config.js";
5
5
  const noopLogger = {
@@ -10,6 +10,11 @@ const noopLogger = {
10
10
  // noop
11
11
  },
12
12
  };
13
+ const DEFAULT_LOCK_WAIT_MS = 15_000;
14
+ const DEFAULT_LOCK_RETRY_MS = 50;
15
+ export function defaultLockPath(pidPath = PID_PATH) {
16
+ return `${pidPath}.lock`;
17
+ }
13
18
  export function readPid(pidPath = PID_PATH) {
14
19
  if (!existsSync(pidPath))
15
20
  return null;
@@ -17,6 +22,9 @@ export function readPid(pidPath = PID_PATH) {
17
22
  const pid = Number(raw);
18
23
  return Number.isFinite(pid) && pid > 0 ? pid : null;
19
24
  }
25
+ function readLockOwner(lockPath) {
26
+ return readPid(path.join(lockPath, "owner.pid"));
27
+ }
20
28
  export function pidAlive(pid) {
21
29
  try {
22
30
  process.kill(pid, 0);
@@ -98,6 +106,71 @@ export async function stopDaemonFromPidFileForRestart(opts = {}) {
98
106
  await stopExistingDaemonForRestart(existing, opts);
99
107
  }
100
108
  }
109
+ export async function acquireDaemonSingletonLock(opts = {}) {
110
+ const pidPath = opts.pidPath ?? PID_PATH;
111
+ const lockPath = opts.lockPath ?? defaultLockPath(pidPath);
112
+ const currentPid = opts.currentPid ?? process.pid;
113
+ const logger = opts.logger ?? noopLogger;
114
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_LOCK_WAIT_MS;
115
+ const deadline = Date.now() + timeoutMs;
116
+ ensureParentDir(lockPath);
117
+ while (true) {
118
+ try {
119
+ mkdirSync(lockPath, { mode: 0o700 });
120
+ writeFileSync(path.join(lockPath, "owner.pid"), String(currentPid), { mode: 0o600 });
121
+ return {
122
+ lockPath,
123
+ release() {
124
+ const owner = readLockOwner(lockPath);
125
+ if (owner !== null && owner !== currentPid)
126
+ return;
127
+ try {
128
+ rmSync(lockPath, { recursive: true, force: true });
129
+ }
130
+ catch {
131
+ // ignore
132
+ }
133
+ },
134
+ };
135
+ }
136
+ catch (err) {
137
+ const code = err.code;
138
+ if (code !== "EEXIST")
139
+ throw err;
140
+ }
141
+ const owner = readLockOwner(lockPath);
142
+ if (owner === currentPid) {
143
+ return {
144
+ lockPath,
145
+ release() {
146
+ try {
147
+ rmSync(lockPath, { recursive: true, force: true });
148
+ }
149
+ catch {
150
+ // ignore
151
+ }
152
+ },
153
+ };
154
+ }
155
+ if (owner !== null && pidAlive(owner)) {
156
+ logger.info("daemon singleton lock owner found; restarting", { pid: owner });
157
+ await stopExistingDaemonForRestart(owner, { pidPath, currentPid, logger });
158
+ }
159
+ const refreshedOwner = readLockOwner(lockPath);
160
+ if (refreshedOwner === null || !pidAlive(refreshedOwner)) {
161
+ try {
162
+ rmSync(lockPath, { recursive: true, force: true });
163
+ }
164
+ catch {
165
+ // another starter may have removed/recreated it
166
+ }
167
+ }
168
+ if (Date.now() >= deadline) {
169
+ throw new Error(`timed out acquiring daemon singleton lock at ${lockPath}`);
170
+ }
171
+ await delay(DEFAULT_LOCK_RETRY_MS);
172
+ }
173
+ }
101
174
  export async function stopOtherDaemonProcessesForRestart(opts = {}) {
102
175
  const currentPid = opts.currentPid ?? process.pid;
103
176
  const logger = opts.logger ?? noopLogger;
@@ -142,12 +215,7 @@ export function writeCurrentPid(opts = {}) {
142
215
  // Cloud-mode startup writes the PID file before `saveConfig` runs, so
143
216
  // the daemon dir may not exist yet. mkdir its parent (0700) so the
144
217
  // first write doesn't crash with ENOENT.
145
- try {
146
- mkdirSync(path.dirname(pidPath), { recursive: true, mode: 0o700 });
147
- }
148
- catch {
149
- // best-effort — writeFileSync below will surface the real error
150
- }
218
+ ensureParentDir(pidPath);
151
219
  writeFileSync(pidPath, String(opts.currentPid ?? process.pid), { mode: 0o600 });
152
220
  }
153
221
  export function removePidFile(pidPath = PID_PATH) {
@@ -186,3 +254,11 @@ export function isBotCordDaemonStartCommand(command) {
186
254
  function delay(ms) {
187
255
  return new Promise((resolve) => setTimeout(resolve, ms));
188
256
  }
257
+ function ensureParentDir(filePath) {
258
+ try {
259
+ mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
260
+ }
261
+ catch {
262
+ // best-effort — the next filesystem operation will surface real errors
263
+ }
264
+ }
package/dist/daemon.d.ts CHANGED
@@ -65,6 +65,7 @@ export interface RuntimeSnapshotSink {
65
65
  * or wait for the next daemon restart). Exported for unit tests.
66
66
  */
67
67
  export declare function pushRuntimeSnapshot(sink: RuntimeSnapshotSink, liveSnapshot?: GatewayRuntimeSnapshot): boolean;
68
+ export declare function pushAgentSkillSnapshot(sink: RuntimeSnapshotSink, agentId: string): boolean;
68
69
  /** Options accepted by {@link startDaemon} — the P0.5 compatibility shim. */
69
70
  export interface DaemonRuntimeOptions {
70
71
  config: DaemonConfig;
package/dist/daemon.js CHANGED
@@ -22,6 +22,7 @@ import { PolicyResolver } from "./gateway/policy-resolver.js";
22
22
  import { scanMention } from "./mention-scan.js";
23
23
  import { createDiagnosticBundle, uploadDiagnosticBundle } from "./diagnostics.js";
24
24
  import { createAttentionPolicyFetcher } from "./attention-policy-fetcher.js";
25
+ import { collectAgentSkillSnapshot } from "./skill-index.js";
25
26
  /**
26
27
  * Default hard cap for a single runtime turn. Long-running coding/research
27
28
  * tasks routinely exceed 10 minutes, so daemon-hosted agents get a larger
@@ -164,6 +165,22 @@ export function pushRuntimeSnapshot(sink, liveSnapshot) {
164
165
  }
165
166
  return ok;
166
167
  }
168
+ export function pushAgentSkillSnapshot(sink, agentId) {
169
+ const snap = collectAgentSkillSnapshot(agentId);
170
+ const ok = sink.send({
171
+ id: `skill_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
172
+ type: "agent_skill_snapshot",
173
+ params: snap,
174
+ ts: Date.now(),
175
+ });
176
+ if (!ok) {
177
+ daemonLog.warn("agent-skill-snapshot: control-channel send returned false", {
178
+ agentId,
179
+ skills: snap.skills.length,
180
+ });
181
+ }
182
+ return ok;
183
+ }
167
184
  /**
168
185
  * Adapt daemon's file-based `log` module into the gateway logger contract.
169
186
  * Writes go to `~/.botcord/logs/daemon.log` + stderr, preserving the format
@@ -483,6 +500,13 @@ export async function startDaemon(opts) {
483
500
  logger.info("control-channel: initial runtime_snapshot push", {
484
501
  ok: pushed,
485
502
  });
503
+ for (const agentId of agentIds) {
504
+ const skillsPushed = pushAgentSkillSnapshot(controlChannel, agentId);
505
+ logger.info("control-channel: initial agent_skill_snapshot push", {
506
+ agentId,
507
+ ok: skillsPushed,
508
+ });
509
+ }
486
510
  }
487
511
  catch (err) {
488
512
  logger.warn("control-channel failed to start; continuing without it", {
@@ -1044,24 +1044,39 @@ function extractDeepseekToolCall(raw) {
1044
1044
  const payload = raw?.payload;
1045
1045
  if (!payload || typeof payload !== "object")
1046
1046
  return null;
1047
- if (raw?.event === "tool.started") {
1048
- const tool = payload.tool && typeof payload.tool === "object" ? payload.tool : undefined;
1047
+ const innerPayload = unwrapDeepseekPayload(raw);
1048
+ const event = stringField(raw, "event") ?? stringField(payload, "event");
1049
+ if (event === "tool.started") {
1050
+ const tool = innerPayload?.tool && typeof innerPayload.tool === "object" ? innerPayload.tool : undefined;
1049
1051
  return {
1050
- name: stringField(payload, "name") ?? stringField(tool, "name") ?? "tool",
1051
- params: parseMaybeJson(payload.input ?? payload.arguments ?? payload.params ?? tool?.input ?? tool?.rawInput),
1052
- id: stringField(payload, "id") ?? stringField(tool, "id"),
1053
- status: stringField(payload, "status") ?? stringField(tool, "status"),
1052
+ name: stringField(innerPayload, "name") ?? stringField(tool, "name") ?? "tool",
1053
+ params: parseMaybeJson(innerPayload?.input ??
1054
+ innerPayload?.arguments ??
1055
+ innerPayload?.params ??
1056
+ tool?.input ??
1057
+ tool?.rawInput ??
1058
+ tool?.arguments ??
1059
+ tool?.params),
1060
+ id: stringField(innerPayload, "id") ?? stringField(tool, "id"),
1061
+ status: stringField(innerPayload, "status") ?? stringField(tool, "status"),
1054
1062
  };
1055
1063
  }
1056
- if (raw?.event === "item.started" || payload.event === "item.started") {
1057
- const inner = raw?.event === "item.started"
1058
- ? payload
1059
- : payload.payload && typeof payload.payload === "object"
1060
- ? payload.payload
1061
- : {};
1064
+ if (event === "item.started") {
1065
+ const inner = innerPayload ?? {};
1062
1066
  const item = inner.item && typeof inner.item === "object" ? inner.item : undefined;
1063
1067
  const tool = inner.tool && typeof inner.tool === "object" ? inner.tool : item?.tool;
1064
- const itemParams = parseMaybeJson(item?.input ?? item?.arguments ?? item?.detail);
1068
+ const metadata = item?.metadata && typeof item.metadata === "object" ? item.metadata : undefined;
1069
+ const metadataCommand = metadata && (metadata.command ?? metadata.cmd)
1070
+ ? { [metadata.command ? "command" : "cmd"]: metadata.command ?? metadata.cmd }
1071
+ : undefined;
1072
+ const itemParams = parseMaybeJson(item?.input ??
1073
+ item?.arguments ??
1074
+ item?.params ??
1075
+ metadata?.input ??
1076
+ metadata?.arguments ??
1077
+ metadata?.params ??
1078
+ metadataCommand ??
1079
+ item?.detail);
1065
1080
  const detailParams = itemParams !== undefined
1066
1081
  ? itemParams
1067
1082
  : typeof item?.detail === "string" && item.detail.trim()
@@ -1082,8 +1097,16 @@ function extractDeepseekToolCall(raw) {
1082
1097
  inner.arguments ??
1083
1098
  inner.params ??
1084
1099
  item?.input ??
1085
- item?.arguments) ?? detailParams ?? tool ?? item,
1086
- id: stringField(tool, "id") ?? stringField(inner, "id") ?? stringField(item, "id"),
1100
+ item?.arguments ??
1101
+ item?.params ??
1102
+ metadata?.input ??
1103
+ metadata?.arguments ??
1104
+ metadata?.params ??
1105
+ metadataCommand) ?? detailParams ?? tool ?? item,
1106
+ id: stringField(tool, "id") ??
1107
+ stringField(inner, "id") ??
1108
+ stringField(item, "id") ??
1109
+ stringField(payload, "item_id"),
1087
1110
  status: stringField(tool, "status") ?? stringField(inner, "status") ?? stringField(item, "status"),
1088
1111
  };
1089
1112
  }
@@ -1093,23 +1116,23 @@ function extractDeepseekToolResult(raw) {
1093
1116
  const payload = raw?.payload;
1094
1117
  if (!payload || typeof payload !== "object")
1095
1118
  return null;
1096
- if (raw?.event === "tool.completed") {
1097
- const result = payload.output ?? payload.result ?? payload.content ?? payload.error ?? payload;
1119
+ const innerPayload = unwrapDeepseekPayload(raw);
1120
+ const event = stringField(raw, "event") ?? stringField(payload, "event");
1121
+ if (event === "tool.completed") {
1122
+ const result = innerPayload?.output ??
1123
+ innerPayload?.result ??
1124
+ innerPayload?.content ??
1125
+ innerPayload?.error ??
1126
+ innerPayload ??
1127
+ payload;
1098
1128
  return {
1099
- name: stringField(payload, "name"),
1129
+ name: stringField(innerPayload, "name"),
1100
1130
  result: stringifyToolResult(result),
1101
- id: stringField(payload, "id"),
1131
+ id: stringField(innerPayload, "id"),
1102
1132
  };
1103
1133
  }
1104
- if (raw?.event === "item.completed" ||
1105
- raw?.event === "item.failed" ||
1106
- payload.event === "item.completed" ||
1107
- payload.event === "item.failed") {
1108
- const inner = raw?.event === "item.completed" || raw?.event === "item.failed"
1109
- ? payload
1110
- : payload.payload && typeof payload.payload === "object"
1111
- ? payload.payload
1112
- : {};
1134
+ if (event === "item.completed" || event === "item.failed") {
1135
+ const inner = innerPayload ?? {};
1113
1136
  const item = inner.item && typeof inner.item === "object" ? inner.item : undefined;
1114
1137
  const result = item?.output ??
1115
1138
  item?.result ??
@@ -1128,11 +1151,31 @@ function extractDeepseekToolResult(raw) {
1128
1151
  stringField(inner, "name") ??
1129
1152
  stringField(item, "type"),
1130
1153
  result: stringifyToolResult(result),
1131
- id: stringField(item, "id") ?? stringField(inner, "id"),
1154
+ id: stringField(item, "id") ?? stringField(inner, "id") ?? stringField(payload, "item_id"),
1132
1155
  };
1133
1156
  }
1134
1157
  return null;
1135
1158
  }
1159
+ function unwrapDeepseekPayload(raw) {
1160
+ const payload = raw?.payload;
1161
+ if (!payload || typeof payload !== "object")
1162
+ return undefined;
1163
+ const nested = payload.payload;
1164
+ if (nested && typeof nested === "object") {
1165
+ const outerEvent = stringField(payload, "event");
1166
+ if (outerEvent ||
1167
+ nested.item ||
1168
+ nested.tool ||
1169
+ nested.turn ||
1170
+ nested.kind ||
1171
+ nested.output ||
1172
+ nested.result ||
1173
+ nested.error) {
1174
+ return nested;
1175
+ }
1176
+ }
1177
+ return payload;
1178
+ }
1136
1179
  function formatBlockDetails(raw) {
1137
1180
  if (!raw || typeof raw !== "object")
1138
1181
  return "";
@@ -426,8 +426,12 @@ function isDeepseekTerminalEvent(eventName, payload) {
426
426
  embedded === "done");
427
427
  }
428
428
  function isToolStarted(eventName, payload) {
429
+ const itemKind = payload?.payload?.item?.kind ?? payload?.item?.kind;
429
430
  return ((eventName === "item.started" &&
430
- (!!payload?.tool || payload?.item?.kind === "tool_call" || payload?.payload?.item?.kind === "tool_call")) ||
431
+ (!!payload?.tool ||
432
+ itemKind === "tool_call" ||
433
+ itemKind === "command_execution" ||
434
+ itemKind === "file_change")) ||
431
435
  (payload?.event === "item.started" && !!payload?.payload?.tool));
432
436
  }
433
437
  function isToolCompleted(eventName, payload) {
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, findOtherDaemonProcesses, pidAlive, readPid, removePidFile, stopDaemonFromPidFileForRestart, stopOtherDaemonProcessesForRestart, writeCurrentPid, } from "./daemon-singleton.js";
8
+ import { acquireDaemonSingletonLock, 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";
@@ -506,12 +506,22 @@ async function cmdStart(args) {
506
506
  return;
507
507
  }
508
508
  // Foreground: we ARE the daemon.
509
+ const singletonLock = await acquireDaemonSingletonLock({ logger: log });
509
510
  writeCurrentPid();
510
- const handle = await startDaemon({ config: cfg, configPath: CONFIG_FILE_PATH });
511
+ let handle;
512
+ try {
513
+ handle = await startDaemon({ config: cfg, configPath: CONFIG_FILE_PATH });
514
+ }
515
+ catch (err) {
516
+ removePidFile();
517
+ singletonLock.release();
518
+ throw err;
519
+ }
511
520
  const shutdown = async (sig) => {
512
521
  log.info("signal received", { sig });
513
522
  await handle.stop(sig);
514
523
  removePidFile();
524
+ singletonLock.release();
515
525
  process.exit(0);
516
526
  };
517
527
  process.on("SIGTERM", () => shutdown("SIGTERM"));
@@ -539,6 +549,7 @@ async function cmdStartCloud(_args) {
539
549
  daemonInstanceId: cloudConfig.daemonInstanceId,
540
550
  hubUrl: cloudConfig.hubUrl,
541
551
  });
552
+ const singletonLock = await acquireDaemonSingletonLock({ logger: log });
542
553
  await stopDaemonFromPidFileForRestart({ logger: log });
543
554
  await stopOtherDaemonProcessesForRestart({ logger: log });
544
555
  writeCurrentPid();
@@ -552,15 +563,24 @@ async function cmdStartCloud(_args) {
552
563
  };
553
564
  saveConfig(cfg);
554
565
  log.info("cloud mode config initialized", { configPath: CONFIG_FILE_PATH });
555
- const handle = await startCloudDaemon({
556
- cloudConfig,
557
- config: cfg,
558
- configPath: CONFIG_FILE_PATH,
559
- });
566
+ let handle;
567
+ try {
568
+ handle = await startCloudDaemon({
569
+ cloudConfig,
570
+ config: cfg,
571
+ configPath: CONFIG_FILE_PATH,
572
+ });
573
+ }
574
+ catch (err) {
575
+ removePidFile();
576
+ singletonLock.release();
577
+ throw err;
578
+ }
560
579
  const shutdown = async (sig) => {
561
580
  log.info("signal received", { sig });
562
581
  await handle.stop(sig);
563
582
  removePidFile();
583
+ singletonLock.release();
564
584
  process.exit(0);
565
585
  };
566
586
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
package/dist/provision.js CHANGED
@@ -7,7 +7,7 @@
7
7
  import { existsSync, lstatSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
9
  import path from "node:path";
10
- import { BotCordClient, CONTROL_FRAME_TYPES, defaultCredentialsFile, derivePublicKey, loadStoredCredentials, writeCredentialsFile, } from "@botcord/protocol-core";
10
+ import { BotCordClient, CONTROL_FRAME_TYPES, defaultCredentialsFile, derivePublicKey, loadStoredCredentials, normalizeTokenExpiresAt, writeCredentialsFile, } from "@botcord/protocol-core";
11
11
  import { loadConfig, resolveConfiguredAgentIds, saveConfig, } from "./config.js";
12
12
  import { BOTCORD_CHANNEL_TYPE, buildManagedRoutes, prepareGatewayProfile, } from "./daemon-config-map.js";
13
13
  import { discoverLocalOpenclawGateways, mergeOpenclawGateways, openclawDiscoveryConfigEnabled, } from "./openclaw-discovery.js";
@@ -19,6 +19,7 @@ import { log as daemonLog } from "./log.js";
19
19
  import { discoverAgentCredentials } from "./agent-discovery.js";
20
20
  import { resolveMemoryDir } from "./working-memory.js";
21
21
  import { discoverRuntimeModelCatalog } from "./runtime-models.js";
22
+ import { collectAgentSkillSnapshot } from "./skill-index.js";
22
23
  import { buildRuntimeSelectionExtraArgs, mergeRuntimeExtraArgs, } from "./runtime-route-options.js";
23
24
  import { handleCloudGatewayRuntimeInbound, } from "./cloud-gateway-runtime.js";
24
25
  /**
@@ -315,6 +316,31 @@ export function createProvisioner(opts) {
315
316
  });
316
317
  return { ok: true, result };
317
318
  }
319
+ case "list_agent_skills": {
320
+ const params = (frame.params ?? {});
321
+ if (!params.agentId) {
322
+ return {
323
+ ok: false,
324
+ error: { code: "bad_params", message: "list_agent_skills requires params.agentId" },
325
+ };
326
+ }
327
+ const channels = gateway.snapshot().channels;
328
+ if (!channels[params.agentId]) {
329
+ return {
330
+ ok: false,
331
+ error: {
332
+ code: "agent_not_loaded",
333
+ message: `agent ${params.agentId} is not loaded in daemon gateway`,
334
+ },
335
+ };
336
+ }
337
+ const result = collectAgentSkillSnapshot(params.agentId);
338
+ daemonLog.debug("list_agent_skills", {
339
+ agentId: params.agentId,
340
+ count: result.skills.length,
341
+ });
342
+ return { ok: true, result };
343
+ }
318
344
  case "wake_agent": {
319
345
  return handleWakeAgent(gateway, frame.params);
320
346
  }
@@ -921,8 +947,9 @@ async function materializeCredentials(params, cfg, ctx, explicitCwd) {
921
947
  record.displayName = c.displayName;
922
948
  if (c.token)
923
949
  record.token = c.token;
924
- if (typeof c.tokenExpiresAt === "number")
925
- record.tokenExpiresAt = c.tokenExpiresAt;
950
+ const tokenExpiresAt = normalizeTokenExpiresAt(c.tokenExpiresAt);
951
+ if (tokenExpiresAt !== undefined)
952
+ record.tokenExpiresAt = tokenExpiresAt;
926
953
  if (runtime)
927
954
  record.runtime = runtime;
928
955
  const runtimeSelection = pickRuntimeSelection(params);
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
4
4
  import path from "node:path";
5
5
  const MODEL_LIST_TIMEOUT_MS = 5000;
6
6
  const MODEL_LIST_MAX_BUFFER = 16 * 1024 * 1024;
7
- const RUNTIME_CATALOG_CACHE_VERSION = 1;
7
+ const RUNTIME_CATALOG_CACHE_VERSION = 2;
8
8
  const RUNTIME_CATALOG_CACHE_FRESH_MS = 10 * 60 * 1000;
9
9
  const DEFAULT_RUNTIME_CATALOG_CACHE_DIR = path.join(homedir(), ".botcord", "daemon", "runtime-catalog-cache");
10
10
  const CLAUDE_ALIAS_MODELS = [
@@ -102,7 +102,7 @@ function runtimeCatalogStrategy(entry) {
102
102
  discoverFresh: () => discoverDeepseekCatalog(entry.result.path),
103
103
  fallback: () => ({
104
104
  models: DEEPSEEK_FALLBACK_MODELS.slice(),
105
- parameters: discoverDeepseekParameters(),
105
+ parameters: discoverDeepseekParameters(entry.result.path),
106
106
  }),
107
107
  };
108
108
  case "kimi-cli":
@@ -392,7 +392,7 @@ function discoverCodexParameters(rawCatalog) {
392
392
  function discoverDeepseekCatalog(command) {
393
393
  return {
394
394
  models: discoverDeepseekModels(command),
395
- parameters: discoverDeepseekParameters(),
395
+ parameters: discoverDeepseekParameters(command),
396
396
  };
397
397
  }
398
398
  export function discoverDeepseekModels(command) {
@@ -416,8 +416,9 @@ export function parseDeepseekModelList(raw) {
416
416
  }
417
417
  return out.length ? out : undefined;
418
418
  }
419
- function discoverDeepseekParameters() {
419
+ function discoverDeepseekParameters(command) {
420
420
  const config = readConfigScalars(path.join(homedir(), ".deepseek", "config.toml"));
421
+ const reasoningEffortValues = discoverDeepseekReasoningEffortValues(command);
421
422
  return [
422
423
  compactParameter({
423
424
  id: "model",
@@ -439,8 +440,9 @@ function discoverDeepseekParameters() {
439
440
  compactParameter({
440
441
  id: "reasoning_effort",
441
442
  displayName: "Reasoning effort",
442
- type: "string",
443
- flag: "reasoning_effort",
443
+ type: reasoningEffortValues.length > 0 ? "enum" : "string",
444
+ flag: "--reasoning-effort",
445
+ values: reasoningEffortValues.length > 0 ? reasoningEffortValues : undefined,
444
446
  defaultValue: config.reasoning_effort,
445
447
  source: config.reasoning_effort ? "config" : "cli",
446
448
  }),
@@ -462,6 +464,46 @@ function discoverDeepseekParameters() {
462
464
  }),
463
465
  ];
464
466
  }
467
+ function discoverDeepseekReasoningEffortValues(command) {
468
+ const candidates = deepseekRuntimeTemplateCandidates(command);
469
+ const values = new Set();
470
+ for (const candidate of candidates) {
471
+ try {
472
+ const raw = readFileSync(candidate)
473
+ .toString("latin1")
474
+ .replace(/[^\x20-\x7E]+/g, "\n");
475
+ const templateRe = /Thinking mode \(DeepSeek V4 reasoning effort\):[\s\S]{0,256}?#\s*((?:"[^"]+"\s*(?:\|\s*)?)+)/g;
476
+ for (const match of raw.matchAll(templateRe)) {
477
+ const line = match[1] ?? "";
478
+ for (const valueMatch of line.matchAll(/"([^"]+)"/g)) {
479
+ const value = valueMatch[1]?.trim();
480
+ if (value && /^[A-Za-z0-9_.-]+$/.test(value))
481
+ values.add(value);
482
+ }
483
+ }
484
+ }
485
+ catch {
486
+ // Try the next candidate; runtime discovery should stay best-effort.
487
+ }
488
+ }
489
+ return Array.from(values);
490
+ }
491
+ function deepseekRuntimeTemplateCandidates(command) {
492
+ if (!command)
493
+ return [];
494
+ const candidates = new Set();
495
+ if (existsSync(command))
496
+ candidates.add(command);
497
+ const dir = path.dirname(command);
498
+ for (const candidate of [
499
+ path.join(dir, "deepseek-tui"),
500
+ path.join(dir, "downloads", "deepseek-tui"),
501
+ ]) {
502
+ if (existsSync(candidate))
503
+ candidates.add(candidate);
504
+ }
505
+ return Array.from(candidates);
506
+ }
465
507
  function discoverKimiCatalog() {
466
508
  const configPath = path.join(homedir(), ".kimi", "config.toml");
467
509
  if (!existsSync(configPath))