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