@botcord/daemon 0.2.78 → 0.2.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/attention-policy-fetcher.d.ts +14 -0
  2. package/dist/attention-policy-fetcher.js +59 -0
  3. package/dist/cloud-daemon.js +8 -0
  4. package/dist/cloud-gateway-runtime.d.ts +29 -0
  5. package/dist/cloud-gateway-runtime.js +122 -0
  6. package/dist/daemon-singleton.d.ts +13 -0
  7. package/dist/daemon-singleton.js +68 -0
  8. package/dist/daemon.js +21 -6
  9. package/dist/gateway/channels/botcord.d.ts +1 -0
  10. package/dist/gateway/channels/botcord.js +62 -17
  11. package/dist/gateway/channels/login-session.d.ts +12 -0
  12. package/dist/gateway/channels/login-session.js +20 -2
  13. package/dist/gateway/channels/sanitize.d.ts +5 -18
  14. package/dist/gateway/channels/sanitize.js +5 -54
  15. package/dist/gateway/channels/text-split.d.ts +5 -11
  16. package/dist/gateway/channels/text-split.js +5 -31
  17. package/dist/gateway/dispatcher.d.ts +7 -1
  18. package/dist/gateway/dispatcher.js +88 -8
  19. package/dist/gateway/gateway.d.ts +16 -1
  20. package/dist/gateway/gateway.js +21 -0
  21. package/dist/gateway/policy-resolver.js +17 -9
  22. package/dist/gateway/runtimes/deepseek-tui.js +56 -13
  23. package/dist/gateway/types.d.ts +12 -57
  24. package/dist/gateway-control.js +18 -9
  25. package/dist/index.js +8 -3
  26. package/dist/provision.d.ts +7 -3
  27. package/dist/provision.js +115 -8
  28. package/dist/room-recovery-context.d.ts +11 -0
  29. package/dist/room-recovery-context.js +97 -0
  30. package/dist/status-render.d.ts +4 -0
  31. package/dist/status-render.js +14 -1
  32. package/package.json +2 -2
  33. package/src/__tests__/attention-policy-fetcher.test.ts +67 -0
  34. package/src/__tests__/cloud-gateway-runtime.test.ts +127 -0
  35. package/src/__tests__/daemon-singleton.test.ts +32 -0
  36. package/src/__tests__/gateway-control.test.ts +136 -0
  37. package/src/__tests__/policy-resolver.test.ts +20 -0
  38. package/src/__tests__/provision.test.ts +65 -0
  39. package/src/__tests__/status-render.test.ts +23 -0
  40. package/src/attention-policy-fetcher.ts +87 -0
  41. package/src/cloud-daemon.ts +8 -0
  42. package/src/cloud-gateway-runtime.ts +171 -0
  43. package/src/daemon-singleton.ts +85 -0
  44. package/src/daemon.ts +23 -6
  45. package/src/gateway/__tests__/botcord-channel.test.ts +211 -5
  46. package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +263 -0
  47. package/src/gateway/__tests__/dispatcher.test.ts +56 -0
  48. package/src/gateway/channels/botcord.ts +69 -17
  49. package/src/gateway/channels/login-session.ts +20 -2
  50. package/src/gateway/channels/sanitize.ts +8 -66
  51. package/src/gateway/channels/text-split.ts +5 -27
  52. package/src/gateway/dispatcher.ts +123 -27
  53. package/src/gateway/gateway.ts +29 -0
  54. package/src/gateway/policy-resolver.ts +20 -9
  55. package/src/gateway/runtimes/deepseek-tui.ts +63 -13
  56. package/src/gateway/types.ts +31 -59
  57. package/src/gateway-control.ts +21 -9
  58. package/src/index.ts +9 -2
  59. package/src/provision.ts +133 -7
  60. package/src/room-recovery-context.ts +131 -0
  61. package/src/status-render.ts +14 -1
@@ -313,13 +313,17 @@ export function createGatewayControl(ctx: GatewayControlContext) {
313
313
  if (!loginId) {
314
314
  return badParams("upsert_gateway: wechat requires loginId");
315
315
  }
316
- const session = sessions.get(loginId);
317
- if (!session) {
316
+ const resolved = sessions.resolve(loginId);
317
+ if (resolved.state !== "live") {
318
318
  return {
319
319
  ok: false,
320
- error: { code: "login_expired", message: `wechat login session "${loginId}" not found or expired` },
320
+ error:
321
+ resolved.state === "missing"
322
+ ? { code: "login_missing", message: `wechat login session "${loginId}" not found` }
323
+ : { code: "login_expired", message: `wechat login session "${loginId}" expired` },
321
324
  };
322
325
  }
326
+ const session = resolved.session!;
323
327
  if (session.provider !== "wechat") {
324
328
  return badParams(`upsert_gateway: login session provider "${session.provider}" != "wechat"`);
325
329
  }
@@ -347,13 +351,17 @@ export function createGatewayControl(ctx: GatewayControlContext) {
347
351
  if (!loginId) {
348
352
  return badParams("upsert_gateway: feishu requires loginId");
349
353
  }
350
- const session = sessions.get(loginId);
351
- if (!session) {
354
+ const resolved = sessions.resolve(loginId);
355
+ if (resolved.state !== "live") {
352
356
  return {
353
357
  ok: false,
354
- error: { code: "login_expired", message: `feishu login session "${loginId}" not found or expired` },
358
+ error:
359
+ resolved.state === "missing"
360
+ ? { code: "login_missing", message: `feishu login session "${loginId}" not found` }
361
+ : { code: "login_expired", message: `feishu login session "${loginId}" expired` },
355
362
  };
356
363
  }
364
+ const session = resolved.session!;
357
365
  if (session.provider !== "feishu") {
358
366
  return badParams(`upsert_gateway: login session provider "${session.provider}" != "feishu"`);
359
367
  }
@@ -869,13 +877,17 @@ export function createGatewayControl(ctx: GatewayControlContext) {
869
877
  if (!params.accountId || typeof params.accountId !== "string") {
870
878
  return badParams("gateway_recent_senders: accountId is required");
871
879
  }
872
- const session = sessions.get(params.loginId);
873
- if (!session) {
880
+ const resolved = sessions.resolve(params.loginId);
881
+ if (resolved.state !== "live") {
874
882
  return {
875
883
  ok: false,
876
- error: { code: "login_expired", message: `wechat login session "${params.loginId}" not found or expired` },
884
+ error:
885
+ resolved.state === "missing"
886
+ ? { code: "login_missing", message: `wechat login session "${params.loginId}" not found` }
887
+ : { code: "login_expired", message: `wechat login session "${params.loginId}" expired` },
877
888
  };
878
889
  }
890
+ const session = resolved.session!;
879
891
  if (session.provider !== "wechat") {
880
892
  return badParams("gateway_recent_senders: provider does not match login session");
881
893
  }
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) {
package/src/provision.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  type RuntimeProbeResult,
27
27
  type StoredBotCordCredentials,
28
28
  type UpdateAgentParams,
29
+ type GatewayInboundFrame,
29
30
  } from "@botcord/protocol-core";
30
31
  import type { Gateway } from "./gateway/index.js";
31
32
  import type { GatewayInboundMessage } from "./gateway/index.js";
@@ -77,6 +78,7 @@ import {
77
78
  buildRuntimeSelectionExtraArgs,
78
79
  mergeRuntimeExtraArgs,
79
80
  } from "./runtime-route-options.js";
81
+ import { handleCloudGatewayRuntimeInbound } from "./cloud-gateway-runtime.js";
80
82
 
81
83
  /**
82
84
  * Information passed to {@link OnAgentInstalledHook} after a successful
@@ -164,7 +166,7 @@ export function createProvisioner(opts: ProvisionerOptions): (
164
166
 
165
167
  case CONTROL_FRAME_TYPES.HELLO: {
166
168
  const params = (frame.params ?? {}) as unknown as HelloParams;
167
- const result = applyHelloIdentitySnapshot(params.agents);
169
+ const result = applyHelloIdentitySnapshot(params.agents, { gateway });
168
170
  daemonLog.debug("hello: identity snapshot applied", {
169
171
  frameId: frame.id,
170
172
  received: params.agents?.length ?? 0,
@@ -186,12 +188,19 @@ export function createProvisioner(opts: ProvisionerOptions): (
186
188
  displayName: params.displayName,
187
189
  bio: params.bio,
188
190
  });
191
+ const runtimeResult = applyAgentRuntimeSnapshot(params, { gateway });
192
+ const combined = {
193
+ changed: result.changed || runtimeResult.changed,
194
+ identity: result,
195
+ runtime: runtimeResult,
196
+ };
189
197
  daemonLog.info("update_agent applied", {
190
198
  agentId: params.agentId,
191
- changed: result.changed,
192
- skipped: result.skipped ?? null,
199
+ changed: combined.changed,
200
+ identitySkipped: result.skipped ?? null,
201
+ runtimeSkipped: runtimeResult.skipped ?? null,
193
202
  });
194
- return { ok: true, result };
203
+ return { ok: true, result: combined };
195
204
  }
196
205
 
197
206
  case CONTROL_FRAME_TYPES.PROVISION_AGENT: {
@@ -419,6 +428,31 @@ export function createProvisioner(opts: ProvisionerOptions): (
419
428
  );
420
429
  }
421
430
 
431
+ case "cloud_gateway_runtime_inbound": {
432
+ const params = (frame.params ?? {}) as { frame?: unknown };
433
+ const runtimeFrame = params.frame as GatewayInboundFrame | undefined;
434
+ if (!runtimeFrame || typeof runtimeFrame !== "object") {
435
+ return {
436
+ ok: false,
437
+ error: {
438
+ code: "bad_params",
439
+ message: "cloud_gateway_runtime_inbound requires params.frame",
440
+ },
441
+ };
442
+ }
443
+ const result = await handleCloudGatewayRuntimeInbound(gateway, runtimeFrame);
444
+ return result.accepted
445
+ ? { ok: true, result }
446
+ : {
447
+ ok: false,
448
+ result,
449
+ error: result.error ?? {
450
+ code: "runtime_inbound_rejected",
451
+ message: "cloud gateway runtime inbound was rejected",
452
+ },
453
+ };
454
+ }
455
+
422
456
  case "list_agent_files": {
423
457
  const params = (frame.params ?? {}) as unknown as ListAgentFilesParams;
424
458
  if (!params.agentId) {
@@ -2410,10 +2444,100 @@ interface HelloIdentityResult {
2410
2444
  skipped: number;
2411
2445
  }
2412
2446
 
2447
+ interface RuntimeSnapshotResult {
2448
+ changed: boolean;
2449
+ skipped?: string;
2450
+ routeUpdated?: boolean;
2451
+ }
2452
+
2453
+ interface RuntimeSnapshotCtx {
2454
+ gateway?: Gateway;
2455
+ }
2456
+
2457
+ function hasOwnField(obj: object, key: string): boolean {
2458
+ return Object.prototype.hasOwnProperty.call(obj, key);
2459
+ }
2460
+
2461
+ function cleanNullableString(value: string | null | undefined): string | undefined {
2462
+ if (typeof value !== "string") return undefined;
2463
+ const trimmed = value.trim();
2464
+ return trimmed || undefined;
2465
+ }
2466
+
2467
+ function applyAgentRuntimeSnapshot(
2468
+ snapshot: AgentIdentitySnapshot,
2469
+ ctx: RuntimeSnapshotCtx = {},
2470
+ ): RuntimeSnapshotResult {
2471
+ const hasRuntimeFields = (
2472
+ hasOwnField(snapshot, "runtime") ||
2473
+ hasOwnField(snapshot, "runtimeModel") ||
2474
+ hasOwnField(snapshot, "reasoningEffort") ||
2475
+ hasOwnField(snapshot, "thinking")
2476
+ );
2477
+ if (!hasRuntimeFields) return { changed: false, skipped: "no_runtime_fields" };
2478
+
2479
+ const credentialsFile = defaultCredentialsFile(snapshot.agentId);
2480
+ if (!existsSync(credentialsFile)) {
2481
+ return { changed: false, skipped: "credentials_missing" };
2482
+ }
2483
+
2484
+ const credentials = loadStoredCredentials(credentialsFile);
2485
+ let changed = false;
2486
+
2487
+ if (hasOwnField(snapshot, "runtime")) {
2488
+ const runtime = cleanNullableString(snapshot.runtime);
2489
+ if (runtime !== credentials.runtime) {
2490
+ if (runtime) credentials.runtime = runtime;
2491
+ else delete credentials.runtime;
2492
+ changed = true;
2493
+ }
2494
+ }
2495
+
2496
+ if (hasOwnField(snapshot, "runtimeModel")) {
2497
+ const runtimeModel = cleanNullableString(snapshot.runtimeModel);
2498
+ if (runtimeModel !== credentials.runtimeModel) {
2499
+ if (runtimeModel) credentials.runtimeModel = runtimeModel;
2500
+ else delete credentials.runtimeModel;
2501
+ changed = true;
2502
+ }
2503
+ }
2504
+
2505
+ if (hasOwnField(snapshot, "reasoningEffort")) {
2506
+ const reasoningEffort = cleanNullableString(snapshot.reasoningEffort);
2507
+ if (reasoningEffort !== credentials.reasoningEffort) {
2508
+ if (reasoningEffort) credentials.reasoningEffort = reasoningEffort;
2509
+ else delete credentials.reasoningEffort;
2510
+ changed = true;
2511
+ }
2512
+ }
2513
+
2514
+ if (hasOwnField(snapshot, "thinking")) {
2515
+ if (typeof snapshot.thinking === "boolean") {
2516
+ if (credentials.thinking !== snapshot.thinking) {
2517
+ credentials.thinking = snapshot.thinking;
2518
+ changed = true;
2519
+ }
2520
+ } else if (typeof credentials.thinking === "boolean") {
2521
+ delete credentials.thinking;
2522
+ changed = true;
2523
+ }
2524
+ }
2525
+
2526
+ if (!changed) return { changed: false };
2527
+
2528
+ writeCredentialsFile(credentialsFile, credentials);
2529
+ if (ctx.gateway) {
2530
+ upsertManagedRouteForCredentials(credentials, loadConfig(), ctx.gateway);
2531
+ return { changed: true, routeUpdated: true };
2532
+ }
2533
+ return { changed: true };
2534
+ }
2535
+
2413
2536
  /**
2414
2537
  * Reconcile every agent identity carried by the `hello.agents` snapshot
2415
- * against the on-disk `identity.md`. Best-effort: a malformed entry or a
2416
- * file-system error for one agent never aborts the rest.
2538
+ * against the on-disk `identity.md` and credentials runtime selectors.
2539
+ * Best-effort: a malformed entry or a file-system error for one agent never
2540
+ * aborts the rest.
2417
2541
  *
2418
2542
  * Identity-snapshot semantics intentionally only touch the metadata
2419
2543
  * line + Bio body — Role/Boundaries paragraphs the user authored locally
@@ -2423,6 +2547,7 @@ interface HelloIdentityResult {
2423
2547
  */
2424
2548
  export function applyHelloIdentitySnapshot(
2425
2549
  snapshot: AgentIdentitySnapshot[] | undefined,
2550
+ ctx: RuntimeSnapshotCtx = {},
2426
2551
  ): HelloIdentityResult {
2427
2552
  const out: HelloIdentityResult = { updated: 0, skipped: 0 };
2428
2553
  if (!Array.isArray(snapshot)) return out;
@@ -2436,7 +2561,8 @@ export function applyHelloIdentitySnapshot(
2436
2561
  displayName: entry.displayName,
2437
2562
  bio: entry.bio,
2438
2563
  });
2439
- if (result.changed) out.updated += 1;
2564
+ const runtimeResult = applyAgentRuntimeSnapshot(entry, ctx);
2565
+ if (result.changed || runtimeResult.changed) out.updated += 1;
2440
2566
  else out.skipped += 1;
2441
2567
  } catch (err) {
2442
2568
  out.skipped += 1;
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Build a compact, deterministic recovery block from recent Hub room messages.
3
+ * Used when a runtime-native session is discarded and the same turn is retried
4
+ * in a fresh session.
5
+ */
6
+ import { BotCordClient, loadStoredCredentials } from "@botcord/protocol-core";
7
+ import { sanitizeUntrustedContent } from "./gateway/index.js";
8
+ import type { GatewayInboundMessage } from "./gateway/index.js";
9
+
10
+ interface CachedClient {
11
+ client: BotCordClient;
12
+ credentialsPath: string;
13
+ }
14
+
15
+ export interface RecentRoomMessagesRecoveryOptions {
16
+ credentialPathByAgentId: Map<string, string>;
17
+ defaultCredentialsPath?: string;
18
+ hubBaseUrl?: string;
19
+ limit?: number;
20
+ log?: {
21
+ warn: (msg: string, meta?: Record<string, unknown>) => void;
22
+ };
23
+ }
24
+
25
+ interface RoomMessage {
26
+ from?: string;
27
+ from_name?: string;
28
+ text?: string;
29
+ type?: string;
30
+ ts?: string;
31
+ topic_id?: string | null;
32
+ topic_title?: string | null;
33
+ }
34
+
35
+ const DEFAULT_RECENT_LIMIT = 20;
36
+ const MAX_MESSAGE_TEXT_CHARS = 1200;
37
+
38
+ function stripNewlines(s: string): string {
39
+ return s.replace(/[\r\n]+/g, " ");
40
+ }
41
+
42
+ function messageLabel(m: RoomMessage): string {
43
+ const name = typeof m.from_name === "string" && m.from_name.trim()
44
+ ? m.from_name
45
+ : typeof m.from === "string" && m.from.trim()
46
+ ? m.from
47
+ : "unknown";
48
+ return sanitizeUntrustedContent(stripNewlines(name));
49
+ }
50
+
51
+ function formatRecentMessages(messages: RoomMessage[]): string {
52
+ if (messages.length === 0) return "[Recent Room Messages]\n(none)";
53
+ const chronological = [...messages].reverse();
54
+ const lines = ["[Recent Room Messages]"];
55
+ for (const m of chronological) {
56
+ const text = typeof m.text === "string" ? m.text.trim() : "";
57
+ if (!text) continue;
58
+ const ts = typeof m.ts === "string" ? m.ts : "";
59
+ const topic = typeof m.topic_title === "string" && m.topic_title.trim()
60
+ ? ` topic=${sanitizeUntrustedContent(stripNewlines(m.topic_title))}`
61
+ : typeof m.topic_id === "string" && m.topic_id
62
+ ? ` topic=${sanitizeUntrustedContent(stripNewlines(m.topic_id))}`
63
+ : "";
64
+ const safeText = sanitizeUntrustedContent(
65
+ text.length > MAX_MESSAGE_TEXT_CHARS
66
+ ? `${text.slice(0, MAX_MESSAGE_TEXT_CHARS)}...`
67
+ : text,
68
+ );
69
+ lines.push(`- ${ts ? `${ts} ` : ""}${messageLabel(m)}${topic}: ${safeText}`);
70
+ }
71
+ return lines.join("\n");
72
+ }
73
+
74
+ export function createRecentRoomMessagesRecoveryBuilder(
75
+ opts: RecentRoomMessagesRecoveryOptions,
76
+ ): (message: GatewayInboundMessage) => Promise<string | null> {
77
+ const clients = new Map<string, CachedClient>();
78
+ const limit = opts.limit ?? DEFAULT_RECENT_LIMIT;
79
+
80
+ function getClient(accountId: string): BotCordClient | null {
81
+ const existing = clients.get(accountId);
82
+ if (existing) return existing.client;
83
+
84
+ const credsPath =
85
+ opts.credentialPathByAgentId.get(accountId) ?? opts.defaultCredentialsPath;
86
+ if (!credsPath) {
87
+ opts.log?.warn("daemon.recovery-context.no-credentials", { accountId });
88
+ return null;
89
+ }
90
+
91
+ try {
92
+ const creds = loadStoredCredentials(credsPath);
93
+ const client = new BotCordClient({
94
+ hubUrl: opts.hubBaseUrl ?? creds.hubUrl,
95
+ agentId: creds.agentId,
96
+ keyId: creds.keyId,
97
+ privateKey: creds.privateKey,
98
+ ...(creds.token ? { token: creds.token } : {}),
99
+ ...(creds.tokenExpiresAt !== undefined
100
+ ? { tokenExpiresAt: creds.tokenExpiresAt }
101
+ : {}),
102
+ });
103
+ clients.set(accountId, { client, credentialsPath: credsPath });
104
+ return client;
105
+ } catch (err) {
106
+ opts.log?.warn("daemon.recovery-context.client-init-failed", {
107
+ accountId,
108
+ credsPath,
109
+ error: err instanceof Error ? err.message : String(err),
110
+ });
111
+ return null;
112
+ }
113
+ }
114
+
115
+ return async (message) => {
116
+ const client = getClient(message.accountId);
117
+ if (!client) return null;
118
+ try {
119
+ const body = await client.roomMessages(message.conversation.id, { limit });
120
+ const messages = Array.isArray(body?.messages) ? body.messages as RoomMessage[] : [];
121
+ return formatRecentMessages(messages);
122
+ } catch (err) {
123
+ opts.log?.warn("daemon.recovery-context.fetch-failed", {
124
+ accountId: message.accountId,
125
+ roomId: message.conversation.id,
126
+ error: err instanceof Error ? err.message : String(err),
127
+ });
128
+ return null;
129
+ }
130
+ };
131
+ }
@@ -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