@botcord/daemon 0.1.1

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 (149) hide show
  1. package/dist/activity-tracker.d.ts +43 -0
  2. package/dist/activity-tracker.js +110 -0
  3. package/dist/adapters/runtimes.d.ts +14 -0
  4. package/dist/adapters/runtimes.js +18 -0
  5. package/dist/agent-discovery.d.ts +81 -0
  6. package/dist/agent-discovery.js +181 -0
  7. package/dist/agent-workspace.d.ts +31 -0
  8. package/dist/agent-workspace.js +221 -0
  9. package/dist/config.d.ts +116 -0
  10. package/dist/config.js +180 -0
  11. package/dist/control-channel.d.ts +99 -0
  12. package/dist/control-channel.js +388 -0
  13. package/dist/cross-room.d.ts +23 -0
  14. package/dist/cross-room.js +55 -0
  15. package/dist/daemon-config-map.d.ts +61 -0
  16. package/dist/daemon-config-map.js +153 -0
  17. package/dist/daemon.d.ts +123 -0
  18. package/dist/daemon.js +349 -0
  19. package/dist/doctor.d.ts +89 -0
  20. package/dist/doctor.js +191 -0
  21. package/dist/gateway/channel-manager.d.ts +54 -0
  22. package/dist/gateway/channel-manager.js +292 -0
  23. package/dist/gateway/channels/botcord.d.ts +93 -0
  24. package/dist/gateway/channels/botcord.js +510 -0
  25. package/dist/gateway/channels/index.d.ts +2 -0
  26. package/dist/gateway/channels/index.js +1 -0
  27. package/dist/gateway/channels/sanitize.d.ts +20 -0
  28. package/dist/gateway/channels/sanitize.js +56 -0
  29. package/dist/gateway/dispatcher.d.ts +73 -0
  30. package/dist/gateway/dispatcher.js +431 -0
  31. package/dist/gateway/gateway.d.ts +87 -0
  32. package/dist/gateway/gateway.js +158 -0
  33. package/dist/gateway/index.d.ts +15 -0
  34. package/dist/gateway/index.js +15 -0
  35. package/dist/gateway/log.d.ts +9 -0
  36. package/dist/gateway/log.js +20 -0
  37. package/dist/gateway/router.d.ts +10 -0
  38. package/dist/gateway/router.js +48 -0
  39. package/dist/gateway/runtimes/claude-code.d.ts +30 -0
  40. package/dist/gateway/runtimes/claude-code.js +162 -0
  41. package/dist/gateway/runtimes/codex.d.ts +83 -0
  42. package/dist/gateway/runtimes/codex.js +272 -0
  43. package/dist/gateway/runtimes/gemini.d.ts +15 -0
  44. package/dist/gateway/runtimes/gemini.js +29 -0
  45. package/dist/gateway/runtimes/ndjson-stream.d.ts +43 -0
  46. package/dist/gateway/runtimes/ndjson-stream.js +169 -0
  47. package/dist/gateway/runtimes/probe.d.ts +17 -0
  48. package/dist/gateway/runtimes/probe.js +54 -0
  49. package/dist/gateway/runtimes/registry.d.ts +59 -0
  50. package/dist/gateway/runtimes/registry.js +94 -0
  51. package/dist/gateway/session-store.d.ts +39 -0
  52. package/dist/gateway/session-store.js +133 -0
  53. package/dist/gateway/types.d.ts +265 -0
  54. package/dist/gateway/types.js +1 -0
  55. package/dist/index.d.ts +2 -0
  56. package/dist/index.js +854 -0
  57. package/dist/log.d.ts +7 -0
  58. package/dist/log.js +44 -0
  59. package/dist/provision.d.ts +88 -0
  60. package/dist/provision.js +749 -0
  61. package/dist/room-context-fetcher.d.ts +18 -0
  62. package/dist/room-context-fetcher.js +101 -0
  63. package/dist/room-context.d.ts +53 -0
  64. package/dist/room-context.js +112 -0
  65. package/dist/sender-classify.d.ts +30 -0
  66. package/dist/sender-classify.js +32 -0
  67. package/dist/snapshot-writer.d.ts +37 -0
  68. package/dist/snapshot-writer.js +84 -0
  69. package/dist/status-render.d.ts +28 -0
  70. package/dist/status-render.js +97 -0
  71. package/dist/system-context.d.ts +57 -0
  72. package/dist/system-context.js +91 -0
  73. package/dist/turn-text.d.ts +36 -0
  74. package/dist/turn-text.js +57 -0
  75. package/dist/user-auth.d.ts +75 -0
  76. package/dist/user-auth.js +245 -0
  77. package/dist/working-memory.d.ts +46 -0
  78. package/dist/working-memory.js +274 -0
  79. package/package.json +39 -0
  80. package/src/__tests__/activity-tracker.test.ts +130 -0
  81. package/src/__tests__/agent-discovery.test.ts +191 -0
  82. package/src/__tests__/agent-workspace.test.ts +147 -0
  83. package/src/__tests__/control-channel.test.ts +327 -0
  84. package/src/__tests__/cross-room.test.ts +116 -0
  85. package/src/__tests__/daemon-config-map.test.ts +416 -0
  86. package/src/__tests__/daemon.test.ts +300 -0
  87. package/src/__tests__/device-code.test.ts +152 -0
  88. package/src/__tests__/doctor.test.ts +218 -0
  89. package/src/__tests__/protocol-core-reexport.test.ts +24 -0
  90. package/src/__tests__/provision.test.ts +922 -0
  91. package/src/__tests__/room-context.test.ts +233 -0
  92. package/src/__tests__/runtime-discovery.test.ts +173 -0
  93. package/src/__tests__/snapshot-writer.test.ts +141 -0
  94. package/src/__tests__/status-render.test.ts +137 -0
  95. package/src/__tests__/system-context.test.ts +315 -0
  96. package/src/__tests__/turn-text.test.ts +116 -0
  97. package/src/__tests__/user-auth.test.ts +125 -0
  98. package/src/__tests__/working-memory.test.ts +240 -0
  99. package/src/activity-tracker.ts +140 -0
  100. package/src/adapters/runtimes.ts +30 -0
  101. package/src/agent-discovery.ts +262 -0
  102. package/src/agent-workspace.ts +247 -0
  103. package/src/config.ts +290 -0
  104. package/src/control-channel.ts +455 -0
  105. package/src/cross-room.ts +89 -0
  106. package/src/daemon-config-map.ts +200 -0
  107. package/src/daemon.ts +478 -0
  108. package/src/doctor.ts +282 -0
  109. package/src/gateway/__tests__/.gitkeep +0 -0
  110. package/src/gateway/__tests__/botcord-channel.test.ts +480 -0
  111. package/src/gateway/__tests__/channel-manager.test.ts +475 -0
  112. package/src/gateway/__tests__/claude-code-adapter.test.ts +318 -0
  113. package/src/gateway/__tests__/codex-adapter.test.ts +350 -0
  114. package/src/gateway/__tests__/dispatcher.test.ts +1159 -0
  115. package/src/gateway/__tests__/gateway-add-channel.test.ts +180 -0
  116. package/src/gateway/__tests__/gateway-managed-routes.test.ts +181 -0
  117. package/src/gateway/__tests__/gateway.test.ts +222 -0
  118. package/src/gateway/__tests__/router.test.ts +247 -0
  119. package/src/gateway/__tests__/sanitize.test.ts +193 -0
  120. package/src/gateway/__tests__/session-store.test.ts +235 -0
  121. package/src/gateway/channel-manager.ts +349 -0
  122. package/src/gateway/channels/botcord.ts +605 -0
  123. package/src/gateway/channels/index.ts +6 -0
  124. package/src/gateway/channels/sanitize.ts +68 -0
  125. package/src/gateway/dispatcher.ts +554 -0
  126. package/src/gateway/gateway.ts +211 -0
  127. package/src/gateway/index.ts +29 -0
  128. package/src/gateway/log.ts +30 -0
  129. package/src/gateway/router.ts +60 -0
  130. package/src/gateway/runtimes/claude-code.ts +180 -0
  131. package/src/gateway/runtimes/codex.ts +312 -0
  132. package/src/gateway/runtimes/gemini.ts +43 -0
  133. package/src/gateway/runtimes/ndjson-stream.ts +225 -0
  134. package/src/gateway/runtimes/probe.ts +73 -0
  135. package/src/gateway/runtimes/registry.ts +143 -0
  136. package/src/gateway/session-store.ts +157 -0
  137. package/src/gateway/types.ts +325 -0
  138. package/src/index.ts +961 -0
  139. package/src/log.ts +47 -0
  140. package/src/provision.ts +879 -0
  141. package/src/room-context-fetcher.ts +124 -0
  142. package/src/room-context.ts +167 -0
  143. package/src/sender-classify.ts +46 -0
  144. package/src/snapshot-writer.ts +103 -0
  145. package/src/status-render.ts +132 -0
  146. package/src/system-context.ts +162 -0
  147. package/src/turn-text.ts +93 -0
  148. package/src/user-auth.ts +295 -0
  149. package/src/working-memory.ts +352 -0
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Hub-backed implementation of `RoomContextFetcher`.
3
+ *
4
+ * Maintains a per-`accountId` `BotCordClient` (so token refreshes amortize
5
+ * across turns) and translates the shared `/hub/rooms/:id` response into the
6
+ * `{ room, members }` shape the builder expects. A single GET is enough —
7
+ * Hub returns both the room record and its member list in one payload.
8
+ */
9
+ import { BotCordClient, loadStoredCredentials } from "@botcord/protocol-core";
10
+ import type { RoomContextFetcher } from "./room-context.js";
11
+
12
+ interface CachedClient {
13
+ client: BotCordClient;
14
+ credentialsPath: string;
15
+ }
16
+
17
+ export interface RoomContextFetcherOptions {
18
+ /** agentId → credentials JSON path. Populated by `resolveBootAgents`. */
19
+ credentialPathByAgentId: Map<string, string>;
20
+ /** Default creds path when an agent isn't in the map (rare). */
21
+ defaultCredentialsPath?: string;
22
+ /** Hub base URL override; when set, wins over the URL stored in credentials. */
23
+ hubBaseUrl?: string;
24
+ log?: {
25
+ warn: (msg: string, meta?: Record<string, unknown>) => void;
26
+ };
27
+ }
28
+
29
+ /**
30
+ * Build a {@link RoomContextFetcher} that resolves against Hub. Returns
31
+ * `null` on any error (missing creds, network, non-JSON, etc.) so the
32
+ * system-context builder can skip the block without blocking the turn.
33
+ */
34
+ export function createRoomContextFetcher(
35
+ opts: RoomContextFetcherOptions,
36
+ ): RoomContextFetcher {
37
+ const clients = new Map<string, CachedClient>();
38
+
39
+ function getClient(accountId: string): BotCordClient | null {
40
+ const existing = clients.get(accountId);
41
+ if (existing) return existing.client;
42
+
43
+ const credsPath =
44
+ opts.credentialPathByAgentId.get(accountId) ?? opts.defaultCredentialsPath;
45
+ if (!credsPath) {
46
+ opts.log?.warn("daemon.room-context.no-credentials", { accountId });
47
+ return null;
48
+ }
49
+
50
+ try {
51
+ const creds = loadStoredCredentials(credsPath);
52
+ const client = new BotCordClient({
53
+ hubUrl: opts.hubBaseUrl ?? creds.hubUrl,
54
+ agentId: creds.agentId,
55
+ keyId: creds.keyId,
56
+ privateKey: creds.privateKey,
57
+ ...(creds.token ? { token: creds.token } : {}),
58
+ ...(creds.tokenExpiresAt !== undefined
59
+ ? { tokenExpiresAt: creds.tokenExpiresAt }
60
+ : {}),
61
+ });
62
+ clients.set(accountId, { client, credentialsPath: credsPath });
63
+ return client;
64
+ } catch (err) {
65
+ opts.log?.warn("daemon.room-context.client-init-failed", {
66
+ accountId,
67
+ credsPath,
68
+ error: err instanceof Error ? err.message : String(err),
69
+ });
70
+ return null;
71
+ }
72
+ }
73
+
74
+ return async ({ accountId, roomId }) => {
75
+ const client = getClient(accountId);
76
+ if (!client) return null;
77
+ try {
78
+ // Hub returns `{ room_id, name, description, rule, visibility,
79
+ // join_policy, member_count, members: [...], ... }` in a single
80
+ // `/hub/rooms/:id` response. Use the raw value so we don't pay for
81
+ // the typed cast that drops `members`.
82
+ const room = (await client.getRoomInfo(roomId)) as Record<string, unknown>;
83
+ const members = Array.isArray((room as { members?: unknown[] }).members)
84
+ ? ((room as { members: unknown[] }).members as Array<Record<string, unknown>>)
85
+ : [];
86
+ return {
87
+ room: {
88
+ room_id:
89
+ typeof room.room_id === "string" ? room.room_id : roomId,
90
+ ...(typeof room.name === "string" ? { name: room.name } : {}),
91
+ ...(typeof room.description === "string"
92
+ ? { description: room.description }
93
+ : {}),
94
+ ...(typeof room.rule === "string" || room.rule === null
95
+ ? { rule: (room.rule as string | null) ?? null }
96
+ : {}),
97
+ ...(typeof room.visibility === "string"
98
+ ? { visibility: room.visibility }
99
+ : {}),
100
+ ...(typeof room.join_policy === "string"
101
+ ? { join_policy: room.join_policy }
102
+ : {}),
103
+ ...(typeof room.member_count === "number"
104
+ ? { member_count: room.member_count }
105
+ : {}),
106
+ },
107
+ members: members.map((m) => ({
108
+ agent_id: typeof m.agent_id === "string" ? m.agent_id : "unknown",
109
+ ...(typeof m.display_name === "string"
110
+ ? { display_name: m.display_name }
111
+ : {}),
112
+ ...(typeof m.role === "string" ? { role: m.role } : {}),
113
+ })),
114
+ };
115
+ } catch (err) {
116
+ opts.log?.warn("daemon.room-context.fetch-failed", {
117
+ accountId,
118
+ roomId,
119
+ error: err instanceof Error ? err.message : String(err),
120
+ });
121
+ return null;
122
+ }
123
+ };
124
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Room static context builder — injects room name, description, rule, and
3
+ * member list into the system prompt for group conversations. Mirrors
4
+ * `plugin/src/room-context.ts#buildRoomStaticContext` so Claude Code in
5
+ * daemon-mode carries the same awareness as when hosted by OpenClaw.
6
+ *
7
+ * Scope:
8
+ * - Group rooms only. DMs (`rm_dm_`) and owner-chat (`rm_oc_`) rooms skip
9
+ * the block — DMs don't need it and owner-chat already has a scene
10
+ * prompt from system-context.ts.
11
+ * - Cached per `accountId:roomId` with a 5-minute TTL to keep Hub load
12
+ * bounded. Fetch failures are NOT cached so the next turn retries.
13
+ * - Concurrent fetches are de-duplicated via an in-flight promise slot.
14
+ */
15
+ import { sanitizeUntrustedContent } from "./gateway/index.js";
16
+ import type { GatewayInboundMessage } from "./gateway/index.js";
17
+
18
+ /** Subset of Hub `/hub/rooms/:id` needed to render the block. */
19
+ export interface RoomInfoSnapshot {
20
+ room_id: string;
21
+ name?: string;
22
+ description?: string;
23
+ rule?: string | null;
24
+ visibility?: string;
25
+ join_policy?: string;
26
+ member_count?: number;
27
+ }
28
+
29
+ /** Subset of a room-member record needed to render the block. */
30
+ export interface RoomMemberSnapshot {
31
+ agent_id: string;
32
+ display_name?: string;
33
+ role?: string;
34
+ }
35
+
36
+ /** Combined result returned by the injected fetcher. */
37
+ export interface RoomContextFetchResult {
38
+ room: RoomInfoSnapshot;
39
+ members: RoomMemberSnapshot[];
40
+ }
41
+
42
+ /** Injected fetcher — daemon wraps a `BotCordClient` behind this contract. */
43
+ export type RoomContextFetcher = (params: {
44
+ accountId: string;
45
+ roomId: string;
46
+ }) => Promise<RoomContextFetchResult | null>;
47
+
48
+ /** Minimal logger surface — matches the daemon/gateway logger shape. */
49
+ interface CtxLogger {
50
+ warn: (msg: string, meta?: Record<string, unknown>) => void;
51
+ }
52
+
53
+ export interface RoomContextBuilderOptions {
54
+ fetchRoomInfo: RoomContextFetcher;
55
+ /** Cache TTL in ms. Defaults to 5 minutes to match the plugin. */
56
+ ttlMs?: number;
57
+ /** Clock override for tests. */
58
+ now?: () => number;
59
+ log?: CtxLogger;
60
+ }
61
+
62
+ interface CacheEntry {
63
+ blockText: string | null;
64
+ fetchedAt: number;
65
+ }
66
+
67
+ const DEFAULT_TTL_MS = 5 * 60 * 1000;
68
+
69
+ /** Strip CR/LF so tenant-controlled values can't reshape the prompt header. */
70
+ function stripNewlines(s: string): string {
71
+ return s.replace(/[\r\n]+/g, " ");
72
+ }
73
+
74
+ /** Render the block. Exported for tests; production callers go through the builder. */
75
+ export function renderRoomContextBlock(
76
+ room: RoomInfoSnapshot,
77
+ members: RoomMemberSnapshot[],
78
+ ): string {
79
+ const safeName = sanitizeUntrustedContent(stripNewlines(room.name ?? ""));
80
+ const lines: string[] = [
81
+ "[BotCord Room Context]",
82
+ `Room: ${safeName || "(unnamed)"} (${room.room_id})`,
83
+ ];
84
+ if (room.description) {
85
+ lines.push(`Description: ${sanitizeUntrustedContent(room.description)}`);
86
+ }
87
+ if (room.rule) {
88
+ lines.push(`Rule: ${sanitizeUntrustedContent(room.rule)}`);
89
+ }
90
+ if (room.visibility || room.join_policy) {
91
+ const visibility = room.visibility ?? "unknown";
92
+ const joinPolicy = room.join_policy ?? "unknown";
93
+ lines.push(`Visibility: ${visibility}, Join: ${joinPolicy}`);
94
+ }
95
+ if (members.length > 0) {
96
+ const list = members
97
+ .map((m) => {
98
+ const raw = m.display_name || m.agent_id;
99
+ const safe = sanitizeUntrustedContent(stripNewlines(raw));
100
+ return m.role && m.role !== "member" ? `${safe} (${m.role})` : safe;
101
+ })
102
+ .join(", ");
103
+ lines.push(`Members (${members.length}): ${list}`);
104
+ }
105
+ return lines.join("\n");
106
+ }
107
+
108
+ /**
109
+ * Return `true` if the inbound message is eligible for a room-context block.
110
+ * Exported for tests + reuse by the system-context builder.
111
+ */
112
+ export function shouldInjectRoomContext(message: GatewayInboundMessage): boolean {
113
+ if (message.conversation.kind !== "group") return false;
114
+ const id = message.conversation.id;
115
+ if (id.startsWith("rm_dm_")) return false;
116
+ if (id.startsWith("rm_oc_")) return false;
117
+ return true;
118
+ }
119
+
120
+ /**
121
+ * Create a per-turn builder: `(msg) => Promise<string | null>`. The returned
122
+ * function honors TTL, dedupes concurrent fetches, and tolerates fetcher
123
+ * failures (logs + returns null so the turn is never blocked).
124
+ */
125
+ export function createRoomStaticContextBuilder(
126
+ opts: RoomContextBuilderOptions,
127
+ ): (message: GatewayInboundMessage) => Promise<string | null> {
128
+ const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
129
+ const now = opts.now ?? Date.now;
130
+ const cache = new Map<string, CacheEntry>();
131
+ const inflight = new Map<string, Promise<string | null>>();
132
+
133
+ return async function getBlock(message) {
134
+ if (!shouldInjectRoomContext(message)) return null;
135
+ const accountId = message.accountId;
136
+ const roomId = message.conversation.id;
137
+ const key = `${accountId}:${roomId}`;
138
+
139
+ const hit = cache.get(key);
140
+ if (hit && now() - hit.fetchedAt < ttl) return hit.blockText;
141
+
142
+ const existing = inflight.get(key);
143
+ if (existing) return existing;
144
+
145
+ const p = (async () => {
146
+ try {
147
+ const result = await opts.fetchRoomInfo({ accountId, roomId });
148
+ if (!result) return null;
149
+ const blockText = renderRoomContextBlock(result.room, result.members);
150
+ cache.set(key, { blockText, fetchedAt: now() });
151
+ return blockText;
152
+ } catch (err) {
153
+ opts.log?.warn("daemon.room-context.fetch-failed", {
154
+ accountId,
155
+ roomId,
156
+ error: err instanceof Error ? err.message : String(err),
157
+ });
158
+ // Don't poison the cache — next turn will retry.
159
+ return null;
160
+ } finally {
161
+ inflight.delete(key);
162
+ }
163
+ })();
164
+ inflight.set(key, p);
165
+ return p;
166
+ };
167
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Sender classification helper — shared between the daemon's activity
3
+ * recorder (daemon.ts) and the user-turn composer (turn-text.ts).
4
+ *
5
+ * Lives in its own module so both callers can import it without forming a
6
+ * dependency cycle through daemon.ts.
7
+ */
8
+ import type { GatewayInboundMessage } from "./gateway/index.js";
9
+
10
+ /**
11
+ * BotCord owner-chat room prefix. Rooms with this prefix are direct-message
12
+ * rooms between an operator and their own agent; turns here are treated as
13
+ * owner-trust by the daemon's trust classifier.
14
+ */
15
+ export const OWNER_CHAT_PREFIX = "rm_oc_";
16
+
17
+ /**
18
+ * Map a gateway inbound message to a sender label + kind.
19
+ *
20
+ * The gateway BotCord channel collapses two distinct owner-trust cases
21
+ * (`rm_oc_` rooms AND `source_type === "dashboard_user_chat"`) into a single
22
+ * `sender.kind === "user"` marker — which also covers `dashboard_human_room`
23
+ * humans. We need them separated here so callers can distinguish "owner"
24
+ * (admin, fully trusted) from "human Alice" (a regular human in a normal
25
+ * room). Falling back to just the `rm_oc_` prefix when `raw` is an
26
+ * unexpected shape keeps the classifier working even if a non-BotCord
27
+ * channel is later plugged in.
28
+ */
29
+ export function classifyActivitySender(
30
+ msg: GatewayInboundMessage,
31
+ ): { kind: "agent" | "human" | "owner"; label: string } {
32
+ const sourceType =
33
+ msg.raw && typeof msg.raw === "object" && "source_type" in msg.raw
34
+ ? (msg.raw as { source_type?: unknown }).source_type
35
+ : undefined;
36
+ const isOwner =
37
+ msg.conversation.id.startsWith(OWNER_CHAT_PREFIX) ||
38
+ sourceType === "dashboard_user_chat";
39
+ if (isOwner) {
40
+ return { kind: "owner", label: msg.sender.name || msg.sender.id || "owner" };
41
+ }
42
+ if (msg.sender.kind === "user") {
43
+ return { kind: "human", label: msg.sender.name || msg.sender.id || "user" };
44
+ }
45
+ return { kind: "agent", label: msg.sender.id || "unknown" };
46
+ }
@@ -0,0 +1,103 @@
1
+ import { mkdirSync, renameSync, writeFileSync, unlinkSync } from "node:fs";
2
+ import path from "node:path";
3
+ import type { GatewayLogger, GatewayRuntimeSnapshot } from "./gateway/index.js";
4
+
5
+ /** Envelope written to the snapshot file; `version` lets readers guard shape. */
6
+ export interface SnapshotFile {
7
+ version: 1;
8
+ writtenAt: number;
9
+ snapshot: GatewayRuntimeSnapshot;
10
+ }
11
+
12
+ /** Options for {@link SnapshotWriter}. */
13
+ export interface SnapshotWriterOptions {
14
+ path: string;
15
+ intervalMs: number;
16
+ snapshot: () => GatewayRuntimeSnapshot;
17
+ log?: GatewayLogger;
18
+ /** Injection point for tests. Defaults to `Date.now`. */
19
+ now?: () => number;
20
+ }
21
+
22
+ /**
23
+ * Periodically writes `gateway.snapshot()` to a file so out-of-process CLI
24
+ * commands can read daemon state. Writes are atomic (tmp + rename) and
25
+ * failures are logged, never thrown.
26
+ */
27
+ export class SnapshotWriter {
28
+ private readonly opts: SnapshotWriterOptions;
29
+ private timer: NodeJS.Timeout | null = null;
30
+ private stopped = false;
31
+
32
+ constructor(opts: SnapshotWriterOptions) {
33
+ this.opts = opts;
34
+ }
35
+
36
+ /** Begin periodic writes; performs one write immediately. */
37
+ start(): void {
38
+ if (this.timer || this.stopped) return;
39
+ this.writeOnce();
40
+ this.timer = setInterval(() => this.writeOnce(), this.opts.intervalMs);
41
+ // Don't keep the event loop alive just for status writes.
42
+ if (typeof this.timer.unref === "function") this.timer.unref();
43
+ }
44
+
45
+ /** Stop the interval. Does not delete the file — call {@link writeFinal} / {@link remove} as needed. */
46
+ stop(): void {
47
+ this.stopped = true;
48
+ if (this.timer) {
49
+ clearInterval(this.timer);
50
+ this.timer = null;
51
+ }
52
+ }
53
+
54
+ /** Write one synchronous snapshot immediately; swallows errors. */
55
+ writeOnce(): void {
56
+ let snap: GatewayRuntimeSnapshot;
57
+ try {
58
+ snap = this.opts.snapshot();
59
+ } catch (err) {
60
+ this.opts.log?.warn("daemon.snapshot-writer.snapshot-fn-threw", {
61
+ error: (err as Error).message,
62
+ });
63
+ return;
64
+ }
65
+ const now = (this.opts.now ?? Date.now)();
66
+ const payload: SnapshotFile = {
67
+ version: 1,
68
+ writtenAt: now,
69
+ snapshot: snap,
70
+ };
71
+ try {
72
+ const dir = path.dirname(this.opts.path);
73
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
74
+ const tmp = `${this.opts.path}.${process.pid}.tmp`;
75
+ writeFileSync(tmp, JSON.stringify(payload, null, 2), { mode: 0o600 });
76
+ renameSync(tmp, this.opts.path);
77
+ } catch (err) {
78
+ this.opts.log?.warn("daemon.snapshot-writer.write-failed", {
79
+ path: this.opts.path,
80
+ error: (err as Error).message,
81
+ });
82
+ }
83
+ }
84
+
85
+ /** Write one last snapshot — used right before {@link remove}. */
86
+ writeFinal(): void {
87
+ this.writeOnce();
88
+ }
89
+
90
+ /** Best-effort delete of the snapshot file; swallows + logs on failure. */
91
+ remove(): void {
92
+ try {
93
+ unlinkSync(this.opts.path);
94
+ } catch (err) {
95
+ const code = (err as NodeJS.ErrnoException).code;
96
+ if (code === "ENOENT") return;
97
+ this.opts.log?.warn("daemon.snapshot-writer.remove-failed", {
98
+ path: this.opts.path,
99
+ error: (err as Error).message,
100
+ });
101
+ }
102
+ }
103
+ }
@@ -0,0 +1,132 @@
1
+ import type { GatewayRuntimeSnapshot } from "./gateway/index.js";
2
+
3
+ /** Threshold after which a snapshot is flagged `⚠ stale` in rendered output. */
4
+ export const STALE_THRESHOLD_MS = 30_000;
5
+
6
+ /** Input bundle for {@link renderStatus}. */
7
+ export interface StatusRenderInput {
8
+ pid: number | null;
9
+ alive: boolean;
10
+ /**
11
+ * Effective list of agent ids the daemon is bound to. Single-agent installs
12
+ * show one entry; multi-agent configs show all. `agentId` (scalar) is kept
13
+ * for backward-compat callers and, when provided alone, rendered the same
14
+ * way.
15
+ */
16
+ agents?: string[] | null;
17
+ /** @deprecated prefer `agents`. */
18
+ agentId?: string | null;
19
+ /** "config" — explicit list; "credentials" — auto-discovered. */
20
+ agentsSource?: "config" | "credentials" | null;
21
+ configPath?: string | null;
22
+ snapshot?: GatewayRuntimeSnapshot | null;
23
+ /** `writtenAt` age in ms. Undefined when no snapshot is available. */
24
+ snapshotAgeMs?: number | null;
25
+ }
26
+
27
+ function pad(s: string, n: number): string {
28
+ return s + " ".repeat(Math.max(0, n - s.length));
29
+ }
30
+
31
+ function relTime(ms: number): string {
32
+ if (!Number.isFinite(ms) || ms < 0) return "—";
33
+ const s = Math.round(ms / 1000);
34
+ if (s < 60) return `${s}s ago`;
35
+ const m = Math.round(s / 60);
36
+ if (m < 60) return `${m}m ago`;
37
+ const h = Math.round(m / 60);
38
+ return `${h}h ago`;
39
+ }
40
+
41
+ function renderChannels(snap: GatewayRuntimeSnapshot): string[] {
42
+ const entries = Object.values(snap.channels);
43
+ if (entries.length === 0) return ["Channels:", " (none)"];
44
+ const idW = Math.max(2, ...entries.map((c) => c.channel.length));
45
+ const accW = Math.max(7, ...entries.map((c) => c.accountId.length));
46
+ const out: string[] = ["Channels:"];
47
+ out.push(
48
+ ` ${pad("ID", idW)} ${pad("ACCOUNT", accW)} RUNNING CONNECTED RETRIES RESTART LAST ERROR`,
49
+ );
50
+ for (const c of entries) {
51
+ const running = c.running ? "yes" : "no";
52
+ const connected =
53
+ c.connected === undefined ? "—" : c.connected ? "yes" : "no";
54
+ const retries =
55
+ c.reconnectAttempts === undefined ? "—" : String(c.reconnectAttempts);
56
+ const restart = c.restartPending ? "yes" : "no";
57
+ const err = c.lastError ?? "—";
58
+ out.push(
59
+ ` ${pad(c.channel, idW)} ${pad(c.accountId, accW)} ${pad(running, 7)} ${pad(connected, 9)} ${pad(retries, 7)} ${pad(restart, 7)} ${err}`,
60
+ );
61
+ }
62
+ return out;
63
+ }
64
+
65
+ function renderTurns(
66
+ snap: GatewayRuntimeSnapshot,
67
+ now: number,
68
+ ): string[] {
69
+ const entries = Object.values(snap.turns);
70
+ if (entries.length === 0) return ["In-flight turns:", " (none)"];
71
+ const out: string[] = ["In-flight turns:"];
72
+ const keyW = Math.max(3, ...entries.map((t) => t.key.length));
73
+ const chW = Math.max(7, ...entries.map((t) => t.channel.length));
74
+ const convW = Math.max(14, ...entries.map((t) => t.conversationId.length));
75
+ const rtW = Math.max(7, ...entries.map((t) => t.runtime.length));
76
+ out.push(
77
+ ` ${pad("KEY", keyW)} ${pad("CHANNEL", chW)} ${pad("CONVERSATION", convW)} ${pad("RUNTIME", rtW)} STARTED CWD`,
78
+ );
79
+ for (const t of entries) {
80
+ const started = relTime(now - t.startedAt);
81
+ out.push(
82
+ ` ${pad(t.key, keyW)} ${pad(t.channel, chW)} ${pad(t.conversationId, convW)} ${pad(t.runtime, rtW)} ${pad(started, 16)} ${t.cwd}`,
83
+ );
84
+ }
85
+ return out;
86
+ }
87
+
88
+ /**
89
+ * Format a human-readable status block. Kept pure so it can be unit-tested
90
+ * without touching disk or spawning a daemon.
91
+ */
92
+ export function renderStatus(input: StatusRenderInput, now: number = Date.now()): string {
93
+ const lines: string[] = [];
94
+ if (input.pid === null) {
95
+ lines.push("daemon: stopped");
96
+ return lines.join("\n");
97
+ }
98
+ lines.push(`daemon: pid ${input.pid} (${input.alive ? "alive" : "not alive"})`);
99
+ const agents =
100
+ input.agents && input.agents.length > 0
101
+ ? input.agents
102
+ : input.agentId
103
+ ? [input.agentId]
104
+ : [];
105
+ const sourceTag =
106
+ input.agentsSource === "credentials"
107
+ ? " (discovered)"
108
+ : input.agentsSource === "config"
109
+ ? ""
110
+ : "";
111
+ if (agents.length === 1) {
112
+ lines.push(`agent: ${agents[0]}${sourceTag}`);
113
+ } else if (agents.length > 1) {
114
+ lines.push(`agents: ${agents.join(", ")}${sourceTag}`);
115
+ } else if (input.agentsSource === "credentials") {
116
+ lines.push(`agents: (none discovered; drop credentials in ~/.botcord/credentials)`);
117
+ }
118
+ if (input.configPath) lines.push(`config: ${input.configPath}`);
119
+
120
+ if (input.snapshot) {
121
+ const age = input.snapshotAgeMs ?? 0;
122
+ const stale = age > STALE_THRESHOLD_MS ? " ⚠ stale" : "";
123
+ lines.push(`snapshot: ${relTime(age)}${stale}`);
124
+ lines.push("");
125
+ lines.push(...renderChannels(input.snapshot));
126
+ lines.push("");
127
+ lines.push(...renderTurns(input.snapshot, now));
128
+ } else if (input.alive) {
129
+ lines.push("snapshot: unavailable (daemon running but no snapshot file found)");
130
+ }
131
+ return lines.join("\n");
132
+ }