@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,879 @@
1
+ /**
2
+ * Business logic triggered by control-plane frames. The channel dispatches
3
+ * to this module with the parsed {@link ControlFrame}; we execute the
4
+ * side effects (register agent, write credentials, load route, add/remove
5
+ * gateway channel) and return an ack payload.
6
+ *
7
+ * See `docs/daemon-control-plane-plan.md` §4.3, §5.3, §8.
8
+ */
9
+ import { existsSync, rmSync, unlinkSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import path from "node:path";
12
+ import {
13
+ BotCordClient,
14
+ CONTROL_FRAME_TYPES,
15
+ defaultCredentialsFile,
16
+ derivePublicKey,
17
+ loadStoredCredentials,
18
+ writeCredentialsFile,
19
+ type ControlAck,
20
+ type ControlFrame,
21
+ type ListRuntimesResult,
22
+ type ProvisionAgentParams,
23
+ type RevokeAgentParams,
24
+ type RevokeAgentResult,
25
+ type RuntimeProbeResult,
26
+ type StoredBotCordCredentials,
27
+ } from "@botcord/protocol-core";
28
+ import type { Gateway } from "./gateway/index.js";
29
+ import type {
30
+ GatewayChannelConfig,
31
+ GatewayRuntimeSnapshot,
32
+ } from "./gateway/index.js";
33
+ import {
34
+ loadConfig,
35
+ resolveConfiguredAgentIds,
36
+ saveConfig,
37
+ type DaemonConfig,
38
+ type RouteRule,
39
+ type RouteRuleMatch,
40
+ } from "./config.js";
41
+ import { BOTCORD_CHANNEL_TYPE, buildManagedRoutes } from "./daemon-config-map.js";
42
+ import {
43
+ agentHomeDir,
44
+ agentStateDir,
45
+ agentWorkspaceDir,
46
+ ensureAgentWorkspace,
47
+ } from "./agent-workspace.js";
48
+ import { detectRuntimes, getAdapterModule } from "./adapters/runtimes.js";
49
+ import { log as daemonLog } from "./log.js";
50
+
51
+ /** Options accepted by {@link createProvisioner}. */
52
+ export interface ProvisionerOptions {
53
+ /** Live gateway handle used to hot-plug channels. */
54
+ gateway: Gateway;
55
+ /**
56
+ * Override for `BotCordClient.register` — tests inject a stub so they can
57
+ * run without a real Hub.
58
+ */
59
+ register?: typeof BotCordClient.register;
60
+ }
61
+
62
+ /** The value a frame handler returns (minus the `id` which the channel fills in). */
63
+ type AckBody = Omit<ControlAck, "id">;
64
+
65
+ /**
66
+ * Build a dispatcher function that routes a `ControlFrame` to the right
67
+ * handler. Returned function signature matches
68
+ * `ControlChannelOptions.handle`.
69
+ */
70
+ export function createProvisioner(opts: ProvisionerOptions): (
71
+ frame: ControlFrame,
72
+ ) => Promise<AckBody> {
73
+ const gateway = opts.gateway;
74
+ const register = opts.register ?? BotCordClient.register;
75
+
76
+ return async (frame: ControlFrame): Promise<AckBody> => {
77
+ daemonLog.debug("provision.dispatch", { type: frame.type, id: frame.id });
78
+ switch (frame.type) {
79
+ case CONTROL_FRAME_TYPES.PING:
80
+ return { ok: true, result: { pong: true, ts: Date.now() } };
81
+
82
+ case CONTROL_FRAME_TYPES.PROVISION_AGENT: {
83
+ const params = (frame.params ?? {}) as unknown as ProvisionAgentParams;
84
+ daemonLog.info("provision_agent: start", {
85
+ frameId: frame.id,
86
+ hasCredentials: !!params.credentials,
87
+ runtime: pickRuntime(params) ?? null,
88
+ name: params.name ?? null,
89
+ });
90
+ const agent = await provisionAgent(params, { gateway, register });
91
+ return {
92
+ ok: true,
93
+ result: {
94
+ agentId: agent.agentId,
95
+ hubUrl: agent.hubUrl,
96
+ credentialsFile: agent.credentialsFile,
97
+ },
98
+ };
99
+ }
100
+
101
+ case CONTROL_FRAME_TYPES.REVOKE_AGENT: {
102
+ const params = (frame.params ?? {}) as unknown as RevokeAgentParams;
103
+ daemonLog.info("revoke_agent: start", {
104
+ frameId: frame.id,
105
+ agentId: params.agentId,
106
+ deleteCredentials: params.deleteCredentials !== false,
107
+ });
108
+ const res = await revokeAgent(params, { gateway });
109
+ return { ok: true, result: res };
110
+ }
111
+
112
+ case CONTROL_FRAME_TYPES.LIST_AGENTS: {
113
+ const agents = listAgentsFromGateway(gateway);
114
+ daemonLog.debug("list_agents", { count: agents.length });
115
+ return { ok: true, result: { agents } };
116
+ }
117
+
118
+ case CONTROL_FRAME_TYPES.RELOAD_CONFIG: {
119
+ daemonLog.info("reload_config: start", { frameId: frame.id });
120
+ const res = await reloadConfig({ gateway });
121
+ return { ok: true, result: res };
122
+ }
123
+
124
+ case CONTROL_FRAME_TYPES.SET_ROUTE: {
125
+ daemonLog.info("set_route: start", { frameId: frame.id });
126
+ const res = setRoute(frame.params ?? {});
127
+ return { ok: true, result: res };
128
+ }
129
+
130
+ case CONTROL_FRAME_TYPES.LIST_RUNTIMES: {
131
+ const snapshot = collectRuntimeSnapshot();
132
+ daemonLog.debug("list_runtimes", { count: snapshot.runtimes.length });
133
+ return { ok: true, result: snapshot };
134
+ }
135
+
136
+ default:
137
+ daemonLog.warn("provision.dispatch: unknown frame type", {
138
+ type: frame.type,
139
+ id: frame.id,
140
+ });
141
+ return {
142
+ ok: false,
143
+ error: { code: "unknown_type", message: `unknown control frame type "${frame.type}"` },
144
+ };
145
+ }
146
+ };
147
+ }
148
+
149
+ interface ProvisionedAgent {
150
+ agentId: string;
151
+ hubUrl: string;
152
+ credentialsFile: string;
153
+ }
154
+
155
+ interface ProvisionCtx {
156
+ gateway: Gateway;
157
+ register: typeof BotCordClient.register;
158
+ }
159
+
160
+ async function provisionAgent(
161
+ params: ProvisionAgentParams,
162
+ ctx: ProvisionCtx,
163
+ ): Promise<ProvisionedAgent> {
164
+ // Validate both caller-supplied cwd sources up front. Previously only
165
+ // `params.cwd` was checked, so `params.credentials.cwd` could smuggle an
166
+ // arbitrary path (e.g. `/etc`) into the credentials file; plan §7 closes
167
+ // that hole by moving the check to the union of both.
168
+ const explicitCwd = params.credentials?.cwd ?? params.cwd;
169
+ assertSafeCwd(explicitCwd);
170
+
171
+ const cfg = loadConfig();
172
+ const credentials = await materializeCredentials(params, cfg, ctx, explicitCwd);
173
+ daemonLog.debug("provision: credentials materialized", {
174
+ agentId: credentials.agentId,
175
+ hubUrl: credentials.hubUrl,
176
+ runtime: credentials.runtime ?? null,
177
+ source: params.credentials ? "hub-supplied" : "registered",
178
+ });
179
+
180
+ const credentialsFile = writeCredentialsFile(
181
+ defaultCredentialsFile(credentials.agentId),
182
+ credentials,
183
+ );
184
+
185
+ // Seed the per-agent workspace directory. On failure, unlink the fresh
186
+ // credentials file but do NOT `rm -rf` the agent dir — partial contents
187
+ // may belong to a pre-existing workspace we must not touch.
188
+ try {
189
+ ensureAgentWorkspace(credentials.agentId, {
190
+ displayName: credentials.displayName,
191
+ bio: params.bio,
192
+ runtime: credentials.runtime,
193
+ keyId: credentials.keyId,
194
+ savedAt: credentials.savedAt,
195
+ });
196
+ } catch (err) {
197
+ try {
198
+ unlinkSync(credentialsFile);
199
+ } catch {
200
+ // best-effort
201
+ }
202
+ throw err;
203
+ }
204
+
205
+ try {
206
+ const updated = addAgentToConfig(cfg, credentials.agentId);
207
+ if (updated) saveConfig(updated);
208
+ } catch (err) {
209
+ // Rollback the credentials file if we can't persist config — the
210
+ // daemon should stay in sync or not at all. `addChannel` below would
211
+ // otherwise succeed against a config that doesn't list the agent.
212
+ try {
213
+ unlinkSync(credentialsFile);
214
+ } catch {
215
+ // best-effort
216
+ }
217
+ throw err;
218
+ }
219
+
220
+ try {
221
+ await ctx.gateway.addChannel({
222
+ id: credentials.agentId,
223
+ type: BOTCORD_CHANNEL_TYPE,
224
+ accountId: credentials.agentId,
225
+ agentId: credentials.agentId,
226
+ });
227
+ } catch (err) {
228
+ // Best-effort rollback: drop the new agent from config and remove the
229
+ // credentials file. Log loudly so operators notice the partial state.
230
+ daemonLog.error("provision.addChannel failed, rolling back", {
231
+ agentId: credentials.agentId,
232
+ error: err instanceof Error ? err.message : String(err),
233
+ });
234
+ try {
235
+ const revertCfg = removeAgentFromConfig(loadConfig(), credentials.agentId);
236
+ if (revertCfg) saveConfig(revertCfg);
237
+ } catch {
238
+ // ignore
239
+ }
240
+ try {
241
+ unlinkSync(credentialsFile);
242
+ } catch {
243
+ // ignore
244
+ }
245
+ throw err;
246
+ }
247
+
248
+ // Hot-add the synthesized per-agent managed route so the next turn picks
249
+ // the agent's runtime + workspace cwd without waiting for reload_config.
250
+ try {
251
+ ctx.gateway.upsertManagedRoute(credentials.agentId, {
252
+ match: { accountId: credentials.agentId },
253
+ runtime: credentials.runtime ?? cfg.defaultRoute.adapter,
254
+ cwd: credentials.cwd ?? agentWorkspaceDir(credentials.agentId),
255
+ });
256
+ } catch (err) {
257
+ // Rollback the channel + config + credentials on managed-route failure
258
+ // (shouldn't happen — pure map op — but keeps the invariant tight).
259
+ daemonLog.error("provision.upsertManagedRoute failed, rolling back", {
260
+ agentId: credentials.agentId,
261
+ error: err instanceof Error ? err.message : String(err),
262
+ });
263
+ try {
264
+ await ctx.gateway.removeChannel(credentials.agentId, "provision rollback");
265
+ } catch {
266
+ // ignore
267
+ }
268
+ try {
269
+ const revertCfg = removeAgentFromConfig(loadConfig(), credentials.agentId);
270
+ if (revertCfg) saveConfig(revertCfg);
271
+ } catch {
272
+ // ignore
273
+ }
274
+ try {
275
+ unlinkSync(credentialsFile);
276
+ } catch {
277
+ // ignore
278
+ }
279
+ throw err;
280
+ }
281
+
282
+ daemonLog.info("agent provisioned", {
283
+ agentId: credentials.agentId,
284
+ credentialsFile,
285
+ });
286
+
287
+ return {
288
+ agentId: credentials.agentId,
289
+ hubUrl: credentials.hubUrl,
290
+ credentialsFile,
291
+ };
292
+ }
293
+
294
+ async function materializeCredentials(
295
+ params: ProvisionAgentParams,
296
+ cfg: DaemonConfig,
297
+ ctx: ProvisionCtx,
298
+ explicitCwd: string | undefined,
299
+ ): Promise<StoredBotCordCredentials> {
300
+ // Runtime is an agent property (docs/agent-runtime-property-plan.md §4.1).
301
+ // Hub is authoritative; top-level `runtime` wins, `adapter` is a one-release
302
+ // alias, and `credentials.runtime` is the per-agent cached copy.
303
+ const runtime = pickRuntime(params);
304
+ if (runtime) assertKnownRuntime(runtime);
305
+
306
+ // Fast path: Hub handed us the credential envelope directly.
307
+ if (params.credentials) {
308
+ const c = params.credentials;
309
+ if (!c.agentId || !c.keyId || !c.privateKey) {
310
+ throw new Error(
311
+ "provision_agent.credentials missing required fields (agentId, keyId, privateKey)",
312
+ );
313
+ }
314
+ const derivedPub = derivePublicKey(c.privateKey);
315
+ if (c.publicKey && c.publicKey !== derivedPub) {
316
+ throw new Error("provision_agent.credentials publicKey does not match privateKey");
317
+ }
318
+ const hubUrl = c.hubUrl;
319
+ if (!hubUrl) {
320
+ throw new Error("provision_agent.credentials missing hubUrl");
321
+ }
322
+ const cwd = explicitCwd ?? agentWorkspaceDir(c.agentId);
323
+ const record: StoredBotCordCredentials = {
324
+ version: 1,
325
+ hubUrl,
326
+ agentId: c.agentId,
327
+ keyId: c.keyId,
328
+ privateKey: c.privateKey,
329
+ publicKey: c.publicKey ?? derivedPub,
330
+ savedAt: new Date().toISOString(),
331
+ };
332
+ if (c.displayName) record.displayName = c.displayName;
333
+ if (c.token) record.token = c.token;
334
+ if (typeof c.tokenExpiresAt === "number") record.tokenExpiresAt = c.tokenExpiresAt;
335
+ if (runtime) record.runtime = runtime;
336
+ record.cwd = cwd;
337
+ return record;
338
+ }
339
+
340
+ // Slow path: daemon registers a fresh identity against Hub. We need a
341
+ // hubUrl — but `DaemonConfig` doesn't persist one, so fall back to a
342
+ // sibling credentials file if any agent is already bound.
343
+ const hubUrl = inferHubUrl(cfg);
344
+ if (!hubUrl) {
345
+ throw new Error(
346
+ "provision_agent: cannot register without a known hubUrl — include `credentials.hubUrl` in the frame",
347
+ );
348
+ }
349
+ const name = params.name || `agent-${Date.now()}`;
350
+ const reg = await ctx.register(hubUrl, name, params.bio);
351
+ const cwd = explicitCwd ?? agentWorkspaceDir(reg.agentId);
352
+ const record: StoredBotCordCredentials = {
353
+ version: 1,
354
+ hubUrl: reg.hubUrl,
355
+ agentId: reg.agentId,
356
+ keyId: reg.keyId,
357
+ privateKey: reg.privateKey,
358
+ publicKey: reg.publicKey,
359
+ savedAt: new Date().toISOString(),
360
+ displayName: name,
361
+ token: reg.token,
362
+ tokenExpiresAt: reg.expiresAt,
363
+ };
364
+ if (runtime) record.runtime = runtime;
365
+ record.cwd = cwd;
366
+ return record;
367
+ }
368
+
369
+ async function revokeAgent(
370
+ params: RevokeAgentParams,
371
+ ctx: { gateway: Gateway },
372
+ ): Promise<RevokeAgentResult> {
373
+ if (!params.agentId) {
374
+ throw new Error("revoke_agent requires params.agentId");
375
+ }
376
+ const agentId = params.agentId;
377
+ const deleteCreds = params.deleteCredentials !== false;
378
+ // `deleteState` defaults to whatever `deleteCredentials` resolves to —
379
+ // vanilla revoke wipes runtime state, but explicit `deleteCredentials:false`
380
+ // (keep-creds) also implies keep-state unless the caller says otherwise.
381
+ const deleteState = params.deleteState ?? deleteCreds;
382
+ // Workspace is precious (user-authored memory/notes); require explicit opt-in.
383
+ const deleteWorkspace = params.deleteWorkspace === true;
384
+
385
+ // In-memory gateway ops run first so any in-flight turn is aborted before
386
+ // disk state changes. Both run unconditionally — the channel is revoked
387
+ // regardless of whether disk state survives, and the synthesized managed
388
+ // route is now dangling.
389
+ try {
390
+ await ctx.gateway.removeChannel(agentId, "revoked by hub");
391
+ } catch (err) {
392
+ daemonLog.warn("revoke.removeChannel failed", {
393
+ agentId,
394
+ error: err instanceof Error ? err.message : String(err),
395
+ });
396
+ }
397
+ try {
398
+ ctx.gateway.removeManagedRoute(agentId);
399
+ } catch (err) {
400
+ daemonLog.warn("revoke.removeManagedRoute failed", {
401
+ agentId,
402
+ error: err instanceof Error ? err.message : String(err),
403
+ });
404
+ }
405
+
406
+ let removed = false;
407
+ try {
408
+ const cfg = loadConfig();
409
+ const next = removeAgentFromConfig(cfg, agentId);
410
+ if (next) {
411
+ saveConfig(next);
412
+ removed = true;
413
+ }
414
+ } catch (err) {
415
+ daemonLog.warn("revoke.saveConfig failed", {
416
+ agentId,
417
+ error: err instanceof Error ? err.message : String(err),
418
+ });
419
+ }
420
+
421
+ let credentialsDeleted = false;
422
+ if (deleteCreds) {
423
+ const file = defaultCredentialsFile(agentId);
424
+ try {
425
+ if (existsSync(file)) {
426
+ unlinkSync(file);
427
+ credentialsDeleted = true;
428
+ }
429
+ } catch (err) {
430
+ daemonLog.warn("revoke.unlink failed", {
431
+ agentId,
432
+ file,
433
+ error: err instanceof Error ? err.message : String(err),
434
+ });
435
+ }
436
+ }
437
+
438
+ // Disk steps are independent and best-effort: a failure at one step logs a
439
+ // warning but does not prevent the next (matches `deleteCredentials`).
440
+ let stateDeleted = false;
441
+ let workspaceDeleted = false;
442
+ if (deleteWorkspace) {
443
+ // Workspace deletion subsumes state — remove the whole agent home.
444
+ const home = agentHomeDir(agentId);
445
+ try {
446
+ if (existsSync(home)) {
447
+ rmSync(home, { recursive: true, force: true });
448
+ workspaceDeleted = true;
449
+ stateDeleted = true;
450
+ }
451
+ } catch (err) {
452
+ daemonLog.warn("revoke.rmWorkspace failed", {
453
+ agentId,
454
+ path: home,
455
+ error: err instanceof Error ? err.message : String(err),
456
+ });
457
+ }
458
+ } else if (deleteState) {
459
+ const state = agentStateDir(agentId);
460
+ try {
461
+ if (existsSync(state)) {
462
+ rmSync(state, { recursive: true, force: true });
463
+ stateDeleted = true;
464
+ }
465
+ } catch (err) {
466
+ daemonLog.warn("revoke.rmState failed", {
467
+ agentId,
468
+ path: state,
469
+ error: err instanceof Error ? err.message : String(err),
470
+ });
471
+ }
472
+ }
473
+
474
+ daemonLog.info("agent revoked", {
475
+ agentId,
476
+ removed,
477
+ credentialsDeleted,
478
+ stateDeleted,
479
+ workspaceDeleted,
480
+ });
481
+ return { agentId, removed, credentialsDeleted, stateDeleted, workspaceDeleted };
482
+ }
483
+
484
+ /** Reject paths outside the operator's home directory (plan §8.3). */
485
+ function assertSafeCwd(cwd: string | undefined): void {
486
+ if (!cwd) return;
487
+ const home = homedir();
488
+ const abs = path.resolve(cwd);
489
+ const rel = path.relative(home, abs);
490
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
491
+ throw new Error(`provision_agent.cwd "${cwd}" is outside the user home directory`);
492
+ }
493
+ }
494
+
495
+ /**
496
+ * Append `agentId` to the daemon config if not already present. Returns a
497
+ * new config object or `null` if nothing changed (so callers can skip the
498
+ * disk write).
499
+ */
500
+ export function addAgentToConfig(cfg: DaemonConfig, agentId: string): DaemonConfig | null {
501
+ const list = Array.isArray(cfg.agents) ? cfg.agents.slice() : [];
502
+ if (cfg.agentId && !list.includes(cfg.agentId)) list.push(cfg.agentId);
503
+ if (list.includes(agentId)) return null;
504
+ list.push(agentId);
505
+ const next: DaemonConfig = { ...cfg, agents: list };
506
+ // Once `agents` exists explicitly, the legacy scalar becomes redundant.
507
+ delete next.agentId;
508
+ return next;
509
+ }
510
+
511
+ /** Inverse of {@link addAgentToConfig}. Returns `null` on no-op. */
512
+ export function removeAgentFromConfig(
513
+ cfg: DaemonConfig,
514
+ agentId: string,
515
+ ): DaemonConfig | null {
516
+ const list = Array.isArray(cfg.agents) ? cfg.agents.slice() : [];
517
+ if (cfg.agentId && !list.includes(cfg.agentId)) list.push(cfg.agentId);
518
+ const before = list.length;
519
+ const filtered = list.filter((a) => a !== agentId);
520
+ const legacyMatched = cfg.agentId === agentId;
521
+ if (filtered.length === before && !legacyMatched) return null;
522
+ const next: DaemonConfig = { ...cfg, agents: filtered };
523
+ if (legacyMatched) delete next.agentId;
524
+ return next;
525
+ }
526
+
527
+ // ---------------------------------------------------------------------------
528
+ // runtime-discovery snapshot (plan §8.5)
529
+ // ---------------------------------------------------------------------------
530
+
531
+ /**
532
+ * Probe every registered adapter and shape the result as the wire-level
533
+ * {@link ListRuntimesResult} — used by both the `list_runtimes` ack path and
534
+ * the daemon-side first-connect `runtime_snapshot` push in `daemon.ts`.
535
+ *
536
+ * Kept pure: the only side effects are `detectRuntimes()` itself (which the
537
+ * gateway already isolates from throwing) and reading the wall clock.
538
+ */
539
+ export function collectRuntimeSnapshot(): ListRuntimesResult {
540
+ const entries = detectRuntimes();
541
+ const runtimes: RuntimeProbeResult[] = entries.map((entry) => {
542
+ const record: RuntimeProbeResult = {
543
+ id: entry.id,
544
+ available: entry.result.available,
545
+ };
546
+ // Only attach optional fields when present so the wire frame doesn't
547
+ // carry explicit `undefined`s — mirrors the credential-materialization
548
+ // style used above.
549
+ if (entry.result.version) record.version = entry.result.version;
550
+ if (entry.result.path) record.path = entry.result.path;
551
+ // Gateway's probe surface doesn't expose an `error` string today — it
552
+ // already swallows throws into `{available: false}`. We leave the wire
553
+ // field blank in that case and let callers treat `!available` as reason
554
+ // enough; filling a synthetic message would be misleading.
555
+ return record;
556
+ });
557
+ return { runtimes, probedAt: Date.now() };
558
+ }
559
+
560
+ // ---------------------------------------------------------------------------
561
+ // reload_config / list_agents / set_route handlers (P3)
562
+ // ---------------------------------------------------------------------------
563
+
564
+ interface ReloadResult {
565
+ reloaded: true;
566
+ added: string[];
567
+ removed: string[];
568
+ }
569
+
570
+ /**
571
+ * Re-read `config.json` and reconcile the running gateway against it. New
572
+ * agents in config but not in gateway snapshot → `addChannel`; agents in
573
+ * gateway but no longer in config → `removeChannel`. The agent's
574
+ * credentials must already exist on disk; we don't re-register identities
575
+ * here (that's `provision_agent`'s job).
576
+ */
577
+ export async function reloadConfig(ctx: { gateway: Gateway }): Promise<ReloadResult> {
578
+ const cfg = loadConfig();
579
+ const desired = new Set(resolveConfiguredAgentIds(cfg) ?? []);
580
+ const current = new Set(Object.keys(ctx.gateway.snapshot().channels));
581
+
582
+ const added: string[] = [];
583
+ const removed: string[] = [];
584
+
585
+ for (const id of desired) {
586
+ if (current.has(id)) continue;
587
+ const channelCfg: GatewayChannelConfig = {
588
+ id,
589
+ type: BOTCORD_CHANNEL_TYPE,
590
+ accountId: id,
591
+ agentId: id,
592
+ };
593
+ try {
594
+ await ctx.gateway.addChannel(channelCfg);
595
+ added.push(id);
596
+ } catch (err) {
597
+ daemonLog.warn("reload_config.addChannel failed", {
598
+ agentId: id,
599
+ error: err instanceof Error ? err.message : String(err),
600
+ });
601
+ }
602
+ }
603
+ for (const id of current) {
604
+ if (desired.has(id)) continue;
605
+ try {
606
+ await ctx.gateway.removeChannel(id, "reload_config");
607
+ removed.push(id);
608
+ } catch (err) {
609
+ daemonLog.warn("reload_config.removeChannel failed", {
610
+ agentId: id,
611
+ error: err instanceof Error ? err.message : String(err),
612
+ });
613
+ }
614
+ }
615
+
616
+ // Re-synthesize managed routes so `set_route` + `reload_config` actually
617
+ // applies at runtime (plan §10.5). User-authored `cfg.routes[]` lives in a
618
+ // different bucket and is unaffected.
619
+ try {
620
+ const freshCfg = loadConfig();
621
+ const freshAgents = resolveConfiguredAgentIds(freshCfg) ?? [];
622
+ const agentRuntimes = readAgentRuntimesFromCredentials(freshAgents);
623
+ const freshDefault = {
624
+ runtime: freshCfg.defaultRoute.adapter,
625
+ cwd: freshCfg.defaultRoute.cwd,
626
+ };
627
+ const managed = buildManagedRoutes(freshAgents, agentRuntimes, freshDefault);
628
+ ctx.gateway.replaceManagedRoutes(managed);
629
+ } catch (err) {
630
+ daemonLog.warn("reload_config.replaceManagedRoutes failed", {
631
+ error: err instanceof Error ? err.message : String(err),
632
+ });
633
+ }
634
+
635
+ daemonLog.info("config reloaded", { added, removed });
636
+ return { reloaded: true, added, removed };
637
+ }
638
+
639
+ /**
640
+ * Read cached `runtime`/`cwd` from each agent's credentials file. Missing
641
+ * files or malformed entries are skipped silently — callers fall back to
642
+ * the daemon's `defaultRoute` for those agents.
643
+ */
644
+ function readAgentRuntimesFromCredentials(
645
+ agentIds: string[],
646
+ ): Record<string, { runtime?: string; cwd?: string }> {
647
+ const out: Record<string, { runtime?: string; cwd?: string }> = {};
648
+ for (const id of agentIds) {
649
+ const file = defaultCredentialsFile(id);
650
+ try {
651
+ if (!existsSync(file)) continue;
652
+ const creds = loadStoredCredentials(file);
653
+ const entry: { runtime?: string; cwd?: string } = {};
654
+ if (creds.runtime) entry.runtime = creds.runtime;
655
+ if (creds.cwd) entry.cwd = creds.cwd;
656
+ if (entry.runtime || entry.cwd) out[id] = entry;
657
+ } catch {
658
+ // best-effort — skip agents with unreadable credentials
659
+ }
660
+ }
661
+ return out;
662
+ }
663
+
664
+ /**
665
+ * Per-agent entry returned by `list_agents`. Shape follows
666
+ * `docs/daemon-control-plane-api-contract.md` §3.2 — `{id, name, online}`.
667
+ * `status` and `lastMessageAt` are extra daemon-only fields the dashboard
668
+ * may ignore; kept so future contract revisions can promote them without
669
+ * breaking the wire.
670
+ */
671
+ export interface AgentListEntry {
672
+ id: string;
673
+ /** Display name from credentials, when known. Falls back to the agent id. */
674
+ name: string;
675
+ /** True when the gateway channel is currently running + connected. */
676
+ online: boolean;
677
+ status: "running" | "stopped" | "unknown";
678
+ lastMessageAt?: number;
679
+ }
680
+
681
+ function listAgentsFromGateway(gateway: Gateway): AgentListEntry[] {
682
+ const snap: GatewayRuntimeSnapshot = gateway.snapshot();
683
+ // Include any configured agents that the gateway may not have a status for
684
+ // yet (e.g. initial boot before first reconcile).
685
+ let configuredIds: string[] = [];
686
+ try {
687
+ configuredIds = resolveConfiguredAgentIds(loadConfig()) ?? [];
688
+ } catch {
689
+ configuredIds = [];
690
+ }
691
+ const ids = new Set<string>([...Object.keys(snap.channels), ...configuredIds]);
692
+ const out: AgentListEntry[] = [];
693
+ for (const id of ids) {
694
+ const ch = snap.channels[id];
695
+ let name = id;
696
+ try {
697
+ const file = defaultCredentialsFile(id);
698
+ if (existsSync(file)) {
699
+ const c = loadStoredCredentials(file);
700
+ if (c.displayName) name = c.displayName;
701
+ }
702
+ } catch {
703
+ // ignore — fall back to the id
704
+ }
705
+ const online = !!(ch && ch.running && ch.connected !== false);
706
+ const entry: AgentListEntry = {
707
+ id,
708
+ name,
709
+ online,
710
+ status: ch ? (ch.running ? "running" : "stopped") : "unknown",
711
+ };
712
+ if (ch?.lastStartAt) entry.lastMessageAt = ch.lastStartAt;
713
+ out.push(entry);
714
+ }
715
+ return out;
716
+ }
717
+
718
+ interface SetRouteResult {
719
+ ok: true;
720
+ agentId: string;
721
+ routeIndex: number;
722
+ inserted: boolean;
723
+ }
724
+
725
+ interface SetRouteParams {
726
+ agentId?: string;
727
+ /**
728
+ * Contract shape (`docs/daemon-control-plane-api-contract.md` §3.2):
729
+ * `{pattern, agentId}`. `pattern` is treated as a conversation-id prefix
730
+ * (`rm_oc_*` etc.). When `route` is omitted, we synthesize a sensible
731
+ * default route record using the daemon's existing default adapter+cwd.
732
+ */
733
+ pattern?: string;
734
+ /**
735
+ * Daemon-richer shape (back-compat). When provided, takes precedence
736
+ * over `pattern` since it can express more than just a prefix.
737
+ */
738
+ route?: {
739
+ adapter?: string;
740
+ cwd?: string;
741
+ extraArgs?: string[];
742
+ match?: RouteRuleMatch;
743
+ };
744
+ }
745
+
746
+ /**
747
+ * Persist a route in `config.json` for the given agent. If a route already
748
+ * matches `agentId` exactly (single-key match), it is replaced; otherwise a
749
+ * new entry is appended. `match.accountId` is forced to `agentId` so the
750
+ * route is always agent-scoped. The change is applied at next
751
+ * `reload_config` — it does not mutate the live router immediately.
752
+ *
753
+ * Accepts the contract's `{pattern, agentId}` shape (treats `pattern` as a
754
+ * conversation-id prefix) AND the richer `{agentId, route: {...}}` shape
755
+ * for daemon-side callers that need to set adapter/cwd explicitly.
756
+ */
757
+ export function setRoute(params: unknown): SetRouteResult {
758
+ const p = (params ?? {}) as SetRouteParams;
759
+ const agentId = p.agentId;
760
+ if (!agentId || typeof agentId !== "string") {
761
+ throw new Error("set_route requires params.agentId");
762
+ }
763
+ const route = p.route;
764
+ if (!route && (!p.pattern || typeof p.pattern !== "string")) {
765
+ throw new Error("set_route requires either params.route or params.pattern");
766
+ }
767
+
768
+ // Defaults used when only `pattern` is supplied.
769
+ const cfg = loadConfig();
770
+ const adapter = route?.adapter ?? cfg.defaultRoute.adapter;
771
+ if (!getAdapterModule(adapter)) {
772
+ throw new Error(`set_route: unknown adapter "${adapter}"`);
773
+ }
774
+ const cwd = route?.cwd ?? cfg.defaultRoute.cwd;
775
+ if (!cwd || typeof cwd !== "string") {
776
+ throw new Error("set_route: route.cwd is required");
777
+ }
778
+ assertSafeCwd(cwd);
779
+
780
+ // Build the canonical match — always pin accountId so the route can't
781
+ // accidentally bleed across agents.
782
+ const incomingMatch = (route?.match ?? {}) as RouteRuleMatch;
783
+ const match: RouteRuleMatch = { ...incomingMatch, accountId: agentId };
784
+ if (p.pattern && typeof p.pattern === "string" && !match.conversationPrefix) {
785
+ match.conversationPrefix = p.pattern;
786
+ }
787
+
788
+ const newRule: RouteRule = {
789
+ match,
790
+ adapter,
791
+ cwd,
792
+ ...(Array.isArray(route?.extraArgs) ? { extraArgs: route!.extraArgs!.slice() } : {}),
793
+ };
794
+
795
+ const routes = Array.isArray(cfg.routes) ? cfg.routes.slice() : [];
796
+ // Replace an existing matching rule. We use the canonical signature
797
+ // (accountId + conversationPrefix or accountId-only) so successive
798
+ // `set_route` calls for the same agent+pattern overwrite in place.
799
+ const sameSignature = (m: RouteRuleMatch | undefined): boolean => {
800
+ if (!m) return false;
801
+ if (m.accountId !== agentId) return false;
802
+ const incomingPrefix = match.conversationPrefix ?? null;
803
+ const existingPrefix = m.conversationPrefix ?? m.roomPrefix ?? null;
804
+ if (incomingPrefix !== existingPrefix) return false;
805
+ if (incomingPrefix === null && hasNonAccountSelector(m)) return false;
806
+ return true;
807
+ };
808
+ const existingIdx = routes.findIndex((r) => sameSignature(r.match));
809
+
810
+ let inserted = false;
811
+ let routeIndex: number;
812
+ if (existingIdx >= 0) {
813
+ routes[existingIdx] = newRule;
814
+ routeIndex = existingIdx;
815
+ } else {
816
+ routes.push(newRule);
817
+ routeIndex = routes.length - 1;
818
+ inserted = true;
819
+ }
820
+
821
+ const next: DaemonConfig = { ...cfg, routes };
822
+ saveConfig(next);
823
+ daemonLog.info("route set", { agentId, routeIndex, inserted });
824
+ return { ok: true, agentId, routeIndex, inserted };
825
+ }
826
+
827
+ function hasNonAccountSelector(m: RouteRuleMatch | undefined): boolean {
828
+ if (!m) return false;
829
+ return !!(
830
+ m.channel ||
831
+ m.conversationId ||
832
+ m.conversationPrefix ||
833
+ m.conversationKind ||
834
+ m.senderId ||
835
+ m.roomId ||
836
+ m.roomPrefix ||
837
+ typeof m.mentioned === "boolean"
838
+ );
839
+ }
840
+
841
+ /**
842
+ * Resolve the runtime id the frame asks for. Prefers the canonical
843
+ * `runtime` field; falls back to the deprecated `adapter` alias and finally
844
+ * to `credentials.runtime` for Hub builds that ship the envelope-only form.
845
+ */
846
+ function pickRuntime(params: ProvisionAgentParams): string | undefined {
847
+ const candidates = [params.runtime, params.adapter, params.credentials?.runtime];
848
+ for (const c of candidates) {
849
+ if (typeof c === "string" && c.length > 0) return c;
850
+ }
851
+ return undefined;
852
+ }
853
+
854
+ function assertKnownRuntime(runtime: string): void {
855
+ const mod = getAdapterModule(runtime);
856
+ if (!mod) {
857
+ throw new Error(`provision_agent: unknown runtime "${runtime}"`);
858
+ }
859
+ }
860
+
861
+ /**
862
+ * Pull a hubUrl out of an existing credentials file, if the daemon is
863
+ * already bound to at least one agent. Used as a fallback when
864
+ * `provision_agent` doesn't carry an explicit `credentials.hubUrl`.
865
+ */
866
+ function inferHubUrl(cfg: DaemonConfig): string | null {
867
+ const ids = resolveConfiguredAgentIds(cfg) ?? [];
868
+ for (const id of ids) {
869
+ const file = defaultCredentialsFile(id);
870
+ try {
871
+ if (!existsSync(file)) continue;
872
+ const creds = loadStoredCredentials(file);
873
+ if (creds.hubUrl) return creds.hubUrl;
874
+ } catch {
875
+ // skip
876
+ }
877
+ }
878
+ return null;
879
+ }