@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.
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { PID_PATH } from "./config.js";
5
5
 
@@ -17,6 +17,18 @@ const noopLogger: SingletonLogger = {
17
17
  },
18
18
  };
19
19
 
20
+ const DEFAULT_LOCK_WAIT_MS = 15_000;
21
+ const DEFAULT_LOCK_RETRY_MS = 50;
22
+
23
+ export interface DaemonSingletonLock {
24
+ lockPath: string;
25
+ release(): void;
26
+ }
27
+
28
+ export function defaultLockPath(pidPath = PID_PATH): string {
29
+ return `${pidPath}.lock`;
30
+ }
31
+
20
32
  export function readPid(pidPath = PID_PATH): number | null {
21
33
  if (!existsSync(pidPath)) return null;
22
34
  const raw = readFileSync(pidPath, "utf8").trim();
@@ -24,6 +36,10 @@ export function readPid(pidPath = PID_PATH): number | null {
24
36
  return Number.isFinite(pid) && pid > 0 ? pid : null;
25
37
  }
26
38
 
39
+ function readLockOwner(lockPath: string): number | null {
40
+ return readPid(path.join(lockPath, "owner.pid"));
41
+ }
42
+
27
43
  export function pidAlive(pid: number): boolean {
28
44
  try {
29
45
  process.kill(pid, 0);
@@ -127,6 +143,78 @@ export async function stopDaemonFromPidFileForRestart(
127
143
  }
128
144
  }
129
145
 
146
+ export async function acquireDaemonSingletonLock(
147
+ opts: {
148
+ lockPath?: string;
149
+ pidPath?: string;
150
+ currentPid?: number;
151
+ logger?: SingletonLogger;
152
+ timeoutMs?: number;
153
+ } = {},
154
+ ): Promise<DaemonSingletonLock> {
155
+ const pidPath = opts.pidPath ?? PID_PATH;
156
+ const lockPath = opts.lockPath ?? defaultLockPath(pidPath);
157
+ const currentPid = opts.currentPid ?? process.pid;
158
+ const logger = opts.logger ?? noopLogger;
159
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_LOCK_WAIT_MS;
160
+ const deadline = Date.now() + timeoutMs;
161
+
162
+ ensureParentDir(lockPath);
163
+ while (true) {
164
+ try {
165
+ mkdirSync(lockPath, { mode: 0o700 });
166
+ writeFileSync(path.join(lockPath, "owner.pid"), String(currentPid), { mode: 0o600 });
167
+ return {
168
+ lockPath,
169
+ release() {
170
+ const owner = readLockOwner(lockPath);
171
+ if (owner !== null && owner !== currentPid) return;
172
+ try {
173
+ rmSync(lockPath, { recursive: true, force: true });
174
+ } catch {
175
+ // ignore
176
+ }
177
+ },
178
+ };
179
+ } catch (err) {
180
+ const code = (err as NodeJS.ErrnoException).code;
181
+ if (code !== "EEXIST") throw err;
182
+ }
183
+
184
+ const owner = readLockOwner(lockPath);
185
+ if (owner === currentPid) {
186
+ return {
187
+ lockPath,
188
+ release() {
189
+ try {
190
+ rmSync(lockPath, { recursive: true, force: true });
191
+ } catch {
192
+ // ignore
193
+ }
194
+ },
195
+ };
196
+ }
197
+ if (owner !== null && pidAlive(owner)) {
198
+ logger.info("daemon singleton lock owner found; restarting", { pid: owner });
199
+ await stopExistingDaemonForRestart(owner, { pidPath, currentPid, logger });
200
+ }
201
+
202
+ const refreshedOwner = readLockOwner(lockPath);
203
+ if (refreshedOwner === null || !pidAlive(refreshedOwner)) {
204
+ try {
205
+ rmSync(lockPath, { recursive: true, force: true });
206
+ } catch {
207
+ // another starter may have removed/recreated it
208
+ }
209
+ }
210
+
211
+ if (Date.now() >= deadline) {
212
+ throw new Error(`timed out acquiring daemon singleton lock at ${lockPath}`);
213
+ }
214
+ await delay(DEFAULT_LOCK_RETRY_MS);
215
+ }
216
+ }
217
+
130
218
  export async function stopOtherDaemonProcessesForRestart(
131
219
  opts: {
132
220
  currentPid?: number;
@@ -187,11 +275,7 @@ export function writeCurrentPid(
187
275
  // Cloud-mode startup writes the PID file before `saveConfig` runs, so
188
276
  // the daemon dir may not exist yet. mkdir its parent (0700) so the
189
277
  // first write doesn't crash with ENOENT.
190
- try {
191
- mkdirSync(path.dirname(pidPath), { recursive: true, mode: 0o700 });
192
- } catch {
193
- // best-effort — writeFileSync below will surface the real error
194
- }
278
+ ensureParentDir(pidPath);
195
279
  writeFileSync(pidPath, String(opts.currentPid ?? process.pid), { mode: 0o600 });
196
280
  }
197
281
 
@@ -231,3 +315,11 @@ export function isBotCordDaemonStartCommand(command: string): boolean {
231
315
  function delay(ms: number): Promise<void> {
232
316
  return new Promise((resolve) => setTimeout(resolve, ms));
233
317
  }
318
+
319
+ function ensureParentDir(filePath: string): void {
320
+ try {
321
+ mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
322
+ } catch {
323
+ // best-effort — the next filesystem operation will surface real errors
324
+ }
325
+ }
package/src/daemon.ts CHANGED
@@ -52,6 +52,7 @@ import { PolicyResolver, type DaemonAttentionPolicy } from "./gateway/policy-res
52
52
  import { scanMention } from "./mention-scan.js";
53
53
  import { createDiagnosticBundle, uploadDiagnosticBundle } from "./diagnostics.js";
54
54
  import { createAttentionPolicyFetcher } from "./attention-policy-fetcher.js";
55
+ import { collectAgentSkillSnapshot } from "./skill-index.js";
55
56
 
56
57
  /**
57
58
  * Default hard cap for a single runtime turn. Long-running coding/research
@@ -245,6 +246,26 @@ export function pushRuntimeSnapshot(
245
246
  return ok;
246
247
  }
247
248
 
249
+ export function pushAgentSkillSnapshot(
250
+ sink: RuntimeSnapshotSink,
251
+ agentId: string,
252
+ ): boolean {
253
+ const snap = collectAgentSkillSnapshot(agentId);
254
+ const ok = sink.send({
255
+ id: `skill_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
256
+ type: "agent_skill_snapshot",
257
+ params: snap as unknown as Record<string, unknown>,
258
+ ts: Date.now(),
259
+ });
260
+ if (!ok) {
261
+ daemonLog.warn("agent-skill-snapshot: control-channel send returned false", {
262
+ agentId,
263
+ skills: snap.skills.length,
264
+ });
265
+ }
266
+ return ok;
267
+ }
268
+
248
269
  /** Options accepted by {@link startDaemon} — the P0.5 compatibility shim. */
249
270
  export interface DaemonRuntimeOptions {
250
271
  config: DaemonConfig;
@@ -648,6 +669,13 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
648
669
  logger.info("control-channel: initial runtime_snapshot push", {
649
670
  ok: pushed,
650
671
  });
672
+ for (const agentId of agentIds) {
673
+ const skillsPushed = pushAgentSkillSnapshot(controlChannel, agentId);
674
+ logger.info("control-channel: initial agent_skill_snapshot push", {
675
+ agentId,
676
+ ok: skillsPushed,
677
+ });
678
+ }
651
679
  } catch (err) {
652
680
  logger.warn("control-channel failed to start; continuing without it", {
653
681
  error: err instanceof Error ? err.message : String(err),
@@ -793,6 +793,85 @@ describe("createBotCordChannel — streamBlock()", () => {
793
793
  });
794
794
  });
795
795
 
796
+ it("normalizes wrapped DeepSeek item.started tool input from the runtime event stream", () => {
797
+ expect(
798
+ __normalizeBlockForHubForTests(
799
+ {
800
+ kind: "tool_use",
801
+ seq: 5,
802
+ raw: {
803
+ event: "item.started",
804
+ payload: {
805
+ seq: 922,
806
+ thread_id: "thr_test",
807
+ turn_id: "turn_test",
808
+ item_id: "item_exec",
809
+ event: "item.started",
810
+ payload: {
811
+ item: {
812
+ id: "item_exec",
813
+ kind: "tool_call",
814
+ status: "in_progress",
815
+ summary: "exec_shell started",
816
+ detail: "{\"cmd\":\"botcord-daemon status\"}",
817
+ },
818
+ },
819
+ },
820
+ },
821
+ },
822
+ 5,
823
+ ),
824
+ ).toMatchObject({
825
+ kind: "tool_call",
826
+ seq: 5,
827
+ payload: {
828
+ id: "item_exec",
829
+ name: "exec_shell",
830
+ params: { cmd: "botcord-daemon status" },
831
+ status: "in_progress",
832
+ },
833
+ });
834
+ });
835
+
836
+ it("normalizes wrapped DeepSeek item.completed output without showing the event envelope", () => {
837
+ expect(
838
+ __normalizeBlockForHubForTests(
839
+ {
840
+ kind: "tool_result",
841
+ seq: 6,
842
+ raw: {
843
+ event: "item.completed",
844
+ payload: {
845
+ seq: 955,
846
+ thread_id: "thr_test",
847
+ turn_id: "turn_test",
848
+ item_id: "item_exec",
849
+ event: "item.completed",
850
+ payload: {
851
+ item: {
852
+ id: "item_exec",
853
+ kind: "command_execution",
854
+ status: "completed",
855
+ summary: "exec_shell: daemon: pid 49616",
856
+ detail: "daemon: pid 49616 (alive)",
857
+ },
858
+ },
859
+ },
860
+ },
861
+ },
862
+ 6,
863
+ ),
864
+ ).toMatchObject({
865
+ kind: "tool_result",
866
+ seq: 6,
867
+ payload: {
868
+ name: "exec_shell",
869
+ result: "daemon: pid 49616 (alive)",
870
+ tool_use_id: "item_exec",
871
+ },
872
+ });
873
+ });
874
+
796
875
  it("POSTs to /hub/stream-block with the right trace_id + block", async () => {
797
876
  const fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
798
877
  const realFetch = globalThis.fetch;
@@ -353,6 +353,55 @@ describe("DeepseekTuiAdapter", () => {
353
353
  }
354
354
  });
355
355
 
356
+ it("treats DeepSeek command_execution item.started events as tool blocks", async () => {
357
+ const server = await startMockDeepseekServer({
358
+ events: [
359
+ {
360
+ event: "turn.started",
361
+ data: { thread_id: "thr_test", turn_id: "turn_test", event: "turn.started" },
362
+ },
363
+ {
364
+ event: "item.started",
365
+ data: {
366
+ thread_id: "thr_test",
367
+ turn_id: "turn_test",
368
+ event: "item.started",
369
+ payload: {
370
+ item: {
371
+ id: "item_exec",
372
+ kind: "command_execution",
373
+ status: "in_progress",
374
+ summary: "exec_shell started",
375
+ detail: "{\"cmd\":\"date\"}",
376
+ },
377
+ },
378
+ },
379
+ },
380
+ {
381
+ event: "item.delta",
382
+ data: {
383
+ thread_id: "thr_test",
384
+ turn_id: "turn_test",
385
+ event: "item.delta",
386
+ payload: { kind: "agent_message", delta: "done" },
387
+ },
388
+ },
389
+ {
390
+ event: "turn.completed",
391
+ data: { thread_id: "thr_test", turn_id: "turn_test", event: "turn.completed" },
392
+ },
393
+ ],
394
+ });
395
+ try {
396
+ const { result, blocks, status } = runAdapter(server.baseUrl, server.token);
397
+ await expect(result).resolves.toMatchObject({ text: "done" });
398
+ expect(blocks).toEqual(expect.arrayContaining(["tool_use", "assistant_text"]));
399
+ expect(status).toContainEqual({ phase: "updated", label: "exec_shell" });
400
+ } finally {
401
+ await server.close();
402
+ }
403
+ });
404
+
356
405
  it("emits current DeepSeek agent_reasoning completions as thinking blocks", async () => {
357
406
  const server = await startMockDeepseekServer({
358
407
  events: [
@@ -1196,27 +1196,46 @@ function extractToolResult(raw: any): { name?: string; result: string; id?: stri
1196
1196
  function extractDeepseekToolCall(raw: any): { name: string; params?: unknown; id?: string; status?: string } | null {
1197
1197
  const payload = raw?.payload;
1198
1198
  if (!payload || typeof payload !== "object") return null;
1199
+ const innerPayload = unwrapDeepseekPayload(raw);
1200
+ const event = stringField(raw, "event") ?? stringField(payload, "event");
1199
1201
 
1200
- if (raw?.event === "tool.started") {
1201
- const tool = payload.tool && typeof payload.tool === "object" ? payload.tool : undefined;
1202
+ if (event === "tool.started") {
1203
+ const tool = innerPayload?.tool && typeof innerPayload.tool === "object" ? innerPayload.tool : undefined;
1202
1204
  return {
1203
- name: stringField(payload, "name") ?? stringField(tool, "name") ?? "tool",
1204
- params: parseMaybeJson(payload.input ?? payload.arguments ?? payload.params ?? tool?.input ?? tool?.rawInput),
1205
- id: stringField(payload, "id") ?? stringField(tool, "id"),
1206
- status: stringField(payload, "status") ?? stringField(tool, "status"),
1205
+ name: stringField(innerPayload, "name") ?? stringField(tool, "name") ?? "tool",
1206
+ params: parseMaybeJson(
1207
+ innerPayload?.input ??
1208
+ innerPayload?.arguments ??
1209
+ innerPayload?.params ??
1210
+ tool?.input ??
1211
+ tool?.rawInput ??
1212
+ tool?.arguments ??
1213
+ tool?.params,
1214
+ ),
1215
+ id: stringField(innerPayload, "id") ?? stringField(tool, "id"),
1216
+ status: stringField(innerPayload, "status") ?? stringField(tool, "status"),
1207
1217
  };
1208
1218
  }
1209
1219
 
1210
- if (raw?.event === "item.started" || payload.event === "item.started") {
1211
- const inner =
1212
- raw?.event === "item.started"
1213
- ? payload
1214
- : payload.payload && typeof payload.payload === "object"
1215
- ? payload.payload
1216
- : {};
1220
+ if (event === "item.started") {
1221
+ const inner = innerPayload ?? {};
1217
1222
  const item = inner.item && typeof inner.item === "object" ? inner.item : undefined;
1218
1223
  const tool = inner.tool && typeof inner.tool === "object" ? inner.tool : item?.tool;
1219
- const itemParams = parseMaybeJson(item?.input ?? item?.arguments ?? item?.detail);
1224
+ const metadata = item?.metadata && typeof item.metadata === "object" ? item.metadata : undefined;
1225
+ const metadataCommand =
1226
+ metadata && (metadata.command ?? metadata.cmd)
1227
+ ? { [metadata.command ? "command" : "cmd"]: metadata.command ?? metadata.cmd }
1228
+ : undefined;
1229
+ const itemParams = parseMaybeJson(
1230
+ item?.input ??
1231
+ item?.arguments ??
1232
+ item?.params ??
1233
+ metadata?.input ??
1234
+ metadata?.arguments ??
1235
+ metadata?.params ??
1236
+ metadataCommand ??
1237
+ item?.detail,
1238
+ );
1220
1239
  const detailParams =
1221
1240
  itemParams !== undefined
1222
1241
  ? itemParams
@@ -1240,9 +1259,18 @@ function extractDeepseekToolCall(raw: any): { name: string; params?: unknown; id
1240
1259
  inner.arguments ??
1241
1260
  inner.params ??
1242
1261
  item?.input ??
1243
- item?.arguments,
1262
+ item?.arguments ??
1263
+ item?.params ??
1264
+ metadata?.input ??
1265
+ metadata?.arguments ??
1266
+ metadata?.params ??
1267
+ metadataCommand,
1244
1268
  ) ?? detailParams ?? tool ?? item,
1245
- id: stringField(tool, "id") ?? stringField(inner, "id") ?? stringField(item, "id"),
1269
+ id:
1270
+ stringField(tool, "id") ??
1271
+ stringField(inner, "id") ??
1272
+ stringField(item, "id") ??
1273
+ stringField(payload, "item_id"),
1246
1274
  status: stringField(tool, "status") ?? stringField(inner, "status") ?? stringField(item, "status"),
1247
1275
  };
1248
1276
  }
@@ -1253,28 +1281,26 @@ function extractDeepseekToolCall(raw: any): { name: string; params?: unknown; id
1253
1281
  function extractDeepseekToolResult(raw: any): { name?: string; result: string; id?: string } | null {
1254
1282
  const payload = raw?.payload;
1255
1283
  if (!payload || typeof payload !== "object") return null;
1284
+ const innerPayload = unwrapDeepseekPayload(raw);
1285
+ const event = stringField(raw, "event") ?? stringField(payload, "event");
1256
1286
 
1257
- if (raw?.event === "tool.completed") {
1258
- const result = payload.output ?? payload.result ?? payload.content ?? payload.error ?? payload;
1287
+ if (event === "tool.completed") {
1288
+ const result =
1289
+ innerPayload?.output ??
1290
+ innerPayload?.result ??
1291
+ innerPayload?.content ??
1292
+ innerPayload?.error ??
1293
+ innerPayload ??
1294
+ payload;
1259
1295
  return {
1260
- name: stringField(payload, "name"),
1296
+ name: stringField(innerPayload, "name"),
1261
1297
  result: stringifyToolResult(result),
1262
- id: stringField(payload, "id"),
1298
+ id: stringField(innerPayload, "id"),
1263
1299
  };
1264
1300
  }
1265
1301
 
1266
- if (
1267
- raw?.event === "item.completed" ||
1268
- raw?.event === "item.failed" ||
1269
- payload.event === "item.completed" ||
1270
- payload.event === "item.failed"
1271
- ) {
1272
- const inner =
1273
- raw?.event === "item.completed" || raw?.event === "item.failed"
1274
- ? payload
1275
- : payload.payload && typeof payload.payload === "object"
1276
- ? payload.payload
1277
- : {};
1302
+ if (event === "item.completed" || event === "item.failed") {
1303
+ const inner = innerPayload ?? {};
1278
1304
  const item = inner.item && typeof inner.item === "object" ? inner.item : undefined;
1279
1305
  const result =
1280
1306
  item?.output ??
@@ -1295,13 +1321,35 @@ function extractDeepseekToolResult(raw: any): { name?: string; result: string; i
1295
1321
  stringField(inner, "name") ??
1296
1322
  stringField(item, "type"),
1297
1323
  result: stringifyToolResult(result),
1298
- id: stringField(item, "id") ?? stringField(inner, "id"),
1324
+ id: stringField(item, "id") ?? stringField(inner, "id") ?? stringField(payload, "item_id"),
1299
1325
  };
1300
1326
  }
1301
1327
 
1302
1328
  return null;
1303
1329
  }
1304
1330
 
1331
+ function unwrapDeepseekPayload(raw: any): any {
1332
+ const payload = raw?.payload;
1333
+ if (!payload || typeof payload !== "object") return undefined;
1334
+ const nested = payload.payload;
1335
+ if (nested && typeof nested === "object") {
1336
+ const outerEvent = stringField(payload, "event");
1337
+ if (
1338
+ outerEvent ||
1339
+ nested.item ||
1340
+ nested.tool ||
1341
+ nested.turn ||
1342
+ nested.kind ||
1343
+ nested.output ||
1344
+ nested.result ||
1345
+ nested.error
1346
+ ) {
1347
+ return nested;
1348
+ }
1349
+ }
1350
+ return payload;
1351
+ }
1352
+
1305
1353
  function formatBlockDetails(raw: unknown): string {
1306
1354
  if (!raw || typeof raw !== "object") return "";
1307
1355
  const r = raw as any;
@@ -497,9 +497,15 @@ function isDeepseekTerminalEvent(eventName: string, payload: any): boolean {
497
497
  }
498
498
 
499
499
  function isToolStarted(eventName: string, payload: any): boolean {
500
+ const itemKind = payload?.payload?.item?.kind ?? payload?.item?.kind;
500
501
  return (
501
502
  (eventName === "item.started" &&
502
- (!!payload?.tool || payload?.item?.kind === "tool_call" || payload?.payload?.item?.kind === "tool_call")) ||
503
+ (
504
+ !!payload?.tool ||
505
+ itemKind === "tool_call" ||
506
+ itemKind === "command_execution" ||
507
+ itemKind === "file_change"
508
+ )) ||
503
509
  (payload?.event === "item.started" && !!payload?.payload?.tool)
504
510
  );
505
511
  }
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  type RouteRuleMatch,
18
18
  } from "./config.js";
19
19
  import {
20
+ acquireDaemonSingletonLock,
20
21
  ensureNoOtherDaemonFromPidFile,
21
22
  findOtherDaemonProcesses,
22
23
  pidAlive,
@@ -625,13 +626,22 @@ async function cmdStart(args: ParsedArgs): Promise<void> {
625
626
  }
626
627
 
627
628
  // Foreground: we ARE the daemon.
629
+ const singletonLock = await acquireDaemonSingletonLock({ logger: log });
628
630
  writeCurrentPid();
629
- const handle = await startDaemon({ config: cfg, configPath: CONFIG_FILE_PATH });
631
+ let handle: Awaited<ReturnType<typeof startDaemon>>;
632
+ try {
633
+ handle = await startDaemon({ config: cfg, configPath: CONFIG_FILE_PATH });
634
+ } catch (err) {
635
+ removePidFile();
636
+ singletonLock.release();
637
+ throw err;
638
+ }
630
639
 
631
640
  const shutdown = async (sig: string) => {
632
641
  log.info("signal received", { sig });
633
642
  await handle.stop(sig);
634
643
  removePidFile();
644
+ singletonLock.release();
635
645
  process.exit(0);
636
646
  };
637
647
  process.on("SIGTERM", () => shutdown("SIGTERM"));
@@ -661,6 +671,7 @@ async function cmdStartCloud(_args: ParsedArgs): Promise<void> {
661
671
  daemonInstanceId: cloudConfig.daemonInstanceId,
662
672
  hubUrl: cloudConfig.hubUrl,
663
673
  });
674
+ const singletonLock = await acquireDaemonSingletonLock({ logger: log });
664
675
  await stopDaemonFromPidFileForRestart({ logger: log });
665
676
  await stopOtherDaemonProcessesForRestart({ logger: log });
666
677
  writeCurrentPid();
@@ -676,16 +687,24 @@ async function cmdStartCloud(_args: ParsedArgs): Promise<void> {
676
687
  saveConfig(cfg);
677
688
  log.info("cloud mode config initialized", { configPath: CONFIG_FILE_PATH });
678
689
 
679
- const handle = await startCloudDaemon({
680
- cloudConfig,
681
- config: cfg,
682
- configPath: CONFIG_FILE_PATH,
683
- });
690
+ let handle: Awaited<ReturnType<typeof startCloudDaemon>>;
691
+ try {
692
+ handle = await startCloudDaemon({
693
+ cloudConfig,
694
+ config: cfg,
695
+ configPath: CONFIG_FILE_PATH,
696
+ });
697
+ } catch (err) {
698
+ removePidFile();
699
+ singletonLock.release();
700
+ throw err;
701
+ }
684
702
 
685
703
  const shutdown = async (sig: string): Promise<void> => {
686
704
  log.info("signal received", { sig });
687
705
  await handle.stop(sig);
688
706
  removePidFile();
707
+ singletonLock.release();
689
708
  process.exit(0);
690
709
  };
691
710
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
package/src/provision.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  defaultCredentialsFile,
14
14
  derivePublicKey,
15
15
  loadStoredCredentials,
16
+ normalizeTokenExpiresAt,
16
17
  writeCredentialsFile,
17
18
  type AgentIdentitySnapshot,
18
19
  type ControlAck,
@@ -74,6 +75,7 @@ import { log as daemonLog } from "./log.js";
74
75
  import { discoverAgentCredentials } from "./agent-discovery.js";
75
76
  import { resolveMemoryDir } from "./working-memory.js";
76
77
  import { discoverRuntimeModelCatalog } from "./runtime-models.js";
78
+ import { collectAgentSkillSnapshot } from "./skill-index.js";
77
79
  import {
78
80
  buildRuntimeSelectionExtraArgs,
79
81
  mergeRuntimeExtraArgs,
@@ -83,6 +85,10 @@ import {
83
85
  type CloudGatewayTypingEmitter,
84
86
  } from "./cloud-gateway-runtime.js";
85
87
 
88
+ interface ListAgentSkillsParams {
89
+ agentId: string;
90
+ }
91
+
86
92
  /**
87
93
  * Information passed to {@link OnAgentInstalledHook} after a successful
88
94
  * provision. Mirrors the credential fields the daemon's per-agent caches
@@ -486,6 +492,32 @@ export function createProvisioner(opts: ProvisionerOptions): (
486
492
  return { ok: true, result };
487
493
  }
488
494
 
495
+ case "list_agent_skills": {
496
+ const params = (frame.params ?? {}) as unknown as ListAgentSkillsParams;
497
+ if (!params.agentId) {
498
+ return {
499
+ ok: false,
500
+ error: { code: "bad_params", message: "list_agent_skills requires params.agentId" },
501
+ };
502
+ }
503
+ const channels = gateway.snapshot().channels;
504
+ if (!channels[params.agentId]) {
505
+ return {
506
+ ok: false,
507
+ error: {
508
+ code: "agent_not_loaded",
509
+ message: `agent ${params.agentId} is not loaded in daemon gateway`,
510
+ },
511
+ };
512
+ }
513
+ const result = collectAgentSkillSnapshot(params.agentId);
514
+ daemonLog.debug("list_agent_skills", {
515
+ agentId: params.agentId,
516
+ count: result.skills.length,
517
+ });
518
+ return { ok: true, result };
519
+ }
520
+
489
521
  case "wake_agent": {
490
522
  return handleWakeAgent(gateway, frame.params);
491
523
  }
@@ -1227,7 +1259,8 @@ async function materializeCredentials(
1227
1259
  };
1228
1260
  if (c.displayName) record.displayName = c.displayName;
1229
1261
  if (c.token) record.token = c.token;
1230
- if (typeof c.tokenExpiresAt === "number") record.tokenExpiresAt = c.tokenExpiresAt;
1262
+ const tokenExpiresAt = normalizeTokenExpiresAt(c.tokenExpiresAt);
1263
+ if (tokenExpiresAt !== undefined) record.tokenExpiresAt = tokenExpiresAt;
1231
1264
  if (runtime) record.runtime = runtime;
1232
1265
  const runtimeSelection = pickRuntimeSelection(params);
1233
1266
  if (runtimeSelection.runtimeModel) record.runtimeModel = runtimeSelection.runtimeModel;