@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,43 @@
1
+ export declare const ACTIVITY_PATH: string;
2
+ /** Max preview length per entry — keeps the digest tight + cap bytes on disk. */
3
+ export declare const ACTIVITY_PREVIEW_CHARS = 120;
4
+ export interface ActivityEntry {
5
+ agentId: string;
6
+ roomId: string;
7
+ roomName?: string;
8
+ topic: string | null;
9
+ lastActivityAt: number;
10
+ /** Sanitized snippet of the last inbound message; may be empty. */
11
+ lastInboundPreview: string;
12
+ /** What kind of peer spoke last: "agent" | "human" | "owner". */
13
+ lastSenderKind: "agent" | "human" | "owner";
14
+ lastSender: string;
15
+ }
16
+ export declare class ActivityTracker {
17
+ private data;
18
+ private loaded;
19
+ private flushScheduled;
20
+ private readonly filePath;
21
+ constructor(opts?: {
22
+ filePath?: string;
23
+ });
24
+ private load;
25
+ record(entry: Omit<ActivityEntry, "lastActivityAt"> & {
26
+ lastActivityAt?: number;
27
+ }): void;
28
+ get(agentId: string, roomId: string, topic: string | null): ActivityEntry | null;
29
+ /**
30
+ * Return entries for a given agent, ordered most-recent first and filtered
31
+ * to activity within `windowMs`. When `excludeKey` is provided, the matching
32
+ * entry (the caller's current turn) is removed.
33
+ */
34
+ listActive(opts: {
35
+ agentId: string;
36
+ windowMs?: number;
37
+ excludeKey?: string;
38
+ }): ActivityEntry[];
39
+ keyFor(agentId: string, roomId: string, topic: string | null): string;
40
+ private scheduleFlush;
41
+ /** Synchronous atomic write. Safe from signal handlers. */
42
+ flushSync(): void;
43
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Activity tracker — per-(agent, room, topic) record of the most recent
3
+ * inbound message, regardless of whether the subsequent turn succeeded.
4
+ *
5
+ * Why not reuse SessionStore? SessionStore is only written after the adapter
6
+ * returns `newSessionId`, which skips turns that errored, timed out, were
7
+ * cancelled, or ran on an adapter that doesn't do resume (Codex after 方案 A).
8
+ * The cross-room digest needs to reflect "which rooms am I actually talking
9
+ * in right now", so it reads from here instead.
10
+ *
11
+ * Stored at `<DAEMON_DIR>/activity.json`. Atomic write, 0o600.
12
+ */
13
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
14
+ import path from "node:path";
15
+ import { DAEMON_DIR_PATH } from "./config.js";
16
+ export const ACTIVITY_PATH = path.join(DAEMON_DIR_PATH, "activity.json");
17
+ /** Max preview length per entry — keeps the digest tight + cap bytes on disk. */
18
+ export const ACTIVITY_PREVIEW_CHARS = 120;
19
+ function keyOf(agentId, roomId, topic) {
20
+ return topic ? `${agentId}:${roomId}:${topic}` : `${agentId}:${roomId}`;
21
+ }
22
+ export class ActivityTracker {
23
+ data = { version: 1, entries: {} };
24
+ loaded = false;
25
+ flushScheduled = false;
26
+ filePath;
27
+ constructor(opts) {
28
+ this.filePath = opts?.filePath ?? ACTIVITY_PATH;
29
+ }
30
+ load() {
31
+ if (this.loaded)
32
+ return;
33
+ this.loaded = true;
34
+ if (!existsSync(this.filePath))
35
+ return;
36
+ try {
37
+ const raw = JSON.parse(readFileSync(this.filePath, "utf-8"));
38
+ if (raw && raw.entries && typeof raw.entries === "object") {
39
+ this.data = { version: 1, entries: raw.entries };
40
+ }
41
+ }
42
+ catch {
43
+ // Corrupt file — start fresh rather than crashing.
44
+ this.data = { version: 1, entries: {} };
45
+ }
46
+ }
47
+ record(entry) {
48
+ this.load();
49
+ const key = keyOf(entry.agentId, entry.roomId, entry.topic);
50
+ const stored = {
51
+ ...entry,
52
+ lastInboundPreview: entry.lastInboundPreview.slice(0, ACTIVITY_PREVIEW_CHARS),
53
+ lastActivityAt: entry.lastActivityAt ?? Date.now(),
54
+ };
55
+ this.data.entries[key] = stored;
56
+ this.scheduleFlush();
57
+ }
58
+ get(agentId, roomId, topic) {
59
+ this.load();
60
+ return this.data.entries[keyOf(agentId, roomId, topic)] ?? null;
61
+ }
62
+ /**
63
+ * Return entries for a given agent, ordered most-recent first and filtered
64
+ * to activity within `windowMs`. When `excludeKey` is provided, the matching
65
+ * entry (the caller's current turn) is removed.
66
+ */
67
+ listActive(opts) {
68
+ this.load();
69
+ const window = opts.windowMs ?? 2 * 60 * 60 * 1000;
70
+ const cutoff = Date.now() - window;
71
+ const out = [];
72
+ for (const [k, e] of Object.entries(this.data.entries)) {
73
+ if (e.agentId !== opts.agentId)
74
+ continue;
75
+ if (e.lastActivityAt < cutoff)
76
+ continue;
77
+ if (opts.excludeKey && k === opts.excludeKey)
78
+ continue;
79
+ out.push(e);
80
+ }
81
+ out.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
82
+ return out;
83
+ }
84
+ keyFor(agentId, roomId, topic) {
85
+ return keyOf(agentId, roomId, topic);
86
+ }
87
+ scheduleFlush() {
88
+ if (this.flushScheduled)
89
+ return;
90
+ this.flushScheduled = true;
91
+ setImmediate(() => {
92
+ this.flushScheduled = false;
93
+ this.flushSync();
94
+ });
95
+ }
96
+ /** Synchronous atomic write. Safe from signal handlers. */
97
+ flushSync() {
98
+ if (!this.loaded)
99
+ return;
100
+ try {
101
+ mkdirSync(path.dirname(this.filePath), { recursive: true, mode: 0o700 });
102
+ }
103
+ catch {
104
+ // best-effort
105
+ }
106
+ const tmp = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;
107
+ writeFileSync(tmp, JSON.stringify(this.data, null, 2), { mode: 0o600 });
108
+ renameSync(tmp, this.filePath);
109
+ }
110
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Thin pass-through to the local gateway module's runtime registry. The daemon CLI
3
+ * (`doctor`, `config`, `init`, `route`) uses these to probe, list, and
4
+ * validate adapter ids.
5
+ */
6
+ import { type RuntimeModule, type RuntimeProbeEntry as GatewayRuntimeProbeEntry } from "../gateway/index.js";
7
+ /** Lookup an adapter module by id, or null when the id is unknown. */
8
+ export declare function getAdapterModule(id: string): RuntimeModule | null;
9
+ /** All registered adapter ids in registration order. */
10
+ export declare function listAdapterIds(): string[];
11
+ /** One probe result per registered adapter, for `doctor`-style listings. */
12
+ export type RuntimeProbeEntry = GatewayRuntimeProbeEntry;
13
+ /** Probe every registered adapter and report installation status. */
14
+ export declare function detectRuntimes(): RuntimeProbeEntry[];
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Thin pass-through to the local gateway module's runtime registry. The daemon CLI
3
+ * (`doctor`, `config`, `init`, `route`) uses these to probe, list, and
4
+ * validate adapter ids.
5
+ */
6
+ import { detectRuntimes as gatewayDetectRuntimes, getRuntimeModule, listRuntimeIds, } from "../gateway/index.js";
7
+ /** Lookup an adapter module by id, or null when the id is unknown. */
8
+ export function getAdapterModule(id) {
9
+ return getRuntimeModule(id);
10
+ }
11
+ /** All registered adapter ids in registration order. */
12
+ export function listAdapterIds() {
13
+ return listRuntimeIds();
14
+ }
15
+ /** Probe every registered adapter and report installation status. */
16
+ export function detectRuntimes() {
17
+ return gatewayDetectRuntimes();
18
+ }
@@ -0,0 +1,81 @@
1
+ import { type Stats } from "node:fs";
2
+ import { type StoredBotCordCredentials } from "@botcord/protocol-core";
3
+ import type { DaemonConfig } from "./config.js";
4
+ /**
5
+ * Default location daemon looks at when discovering BotCord credentials at
6
+ * boot. Matches the path the `botcord` CLI and plugin write to.
7
+ */
8
+ export declare const DEFAULT_CREDENTIALS_DIR: string;
9
+ /**
10
+ * One local BotCord identity discovered at boot. The canonical id is the
11
+ * credential file's internal `agentId`, not the filename — a stale copy
12
+ * saved under a wrong name still binds to its true agent.
13
+ */
14
+ export interface DiscoveredAgentCredential {
15
+ agentId: string;
16
+ credentialsFile: string;
17
+ hubUrl: string;
18
+ displayName?: string;
19
+ /**
20
+ * Runtime cached in the credentials file (docs/agent-runtime-property-plan.md).
21
+ * Null for legacy bind-code credentials without the field; the daemon
22
+ * falls back to `defaultRoute` in that case.
23
+ */
24
+ runtime?: string;
25
+ /** Working directory cached alongside `runtime`. */
26
+ cwd?: string;
27
+ /** Key id from the credentials file — surfaced so boot-time workspace
28
+ * seeding (see daemon-agent-workspace-plan.md §9) can render identity.md
29
+ * without re-reading the file. */
30
+ keyId?: string;
31
+ /** ISO timestamp of when the credentials file was written. */
32
+ savedAt?: string;
33
+ }
34
+ /** Result of one discovery pass — explicit about what was dropped and why. */
35
+ export interface AgentDiscoveryResult {
36
+ agents: DiscoveredAgentCredential[];
37
+ warnings: string[];
38
+ }
39
+ /** Minimal surface the discovery module needs from `node:fs`. Injectable for tests. */
40
+ export interface DiscoveryFs {
41
+ readDir?: (dir: string) => string[];
42
+ stat?: (p: string) => Stats;
43
+ loadCredentials?: (file: string) => StoredBotCordCredentials;
44
+ }
45
+ export interface DiscoveryOptions extends DiscoveryFs {
46
+ /** Directory to scan. Defaults to {@link DEFAULT_CREDENTIALS_DIR}. */
47
+ credentialsDir?: string;
48
+ }
49
+ /**
50
+ * Scan the credentials directory and return one entry per valid BotCord
51
+ * credential file. Tolerant by design: missing directory, non-JSON files,
52
+ * unparseable JSON, credentials missing required fields, and duplicate
53
+ * `agentId` entries are all skipped with a warning — never thrown.
54
+ *
55
+ * Duplicate policy is deterministic: prefer the file with the newer
56
+ * `mtimeMs`; if equal/unavailable, prefer lexical path order. This avoids
57
+ * surprising channel selection when stale copies sit alongside fresh ones.
58
+ */
59
+ export declare function discoverAgentCredentials(opts?: DiscoveryOptions): AgentDiscoveryResult;
60
+ /** Result of composing explicit config + discovery into the final boot list. */
61
+ export interface BootAgentsResult {
62
+ /** Ordered list of agents the daemon should bind channels for. */
63
+ agents: DiscoveredAgentCredential[];
64
+ /** "config" — explicit `agents`/`agentId`; "credentials" — discovery. */
65
+ source: "config" | "credentials";
66
+ /** Resolved discovery directory (informational, for logs/status). */
67
+ credentialsDir: string;
68
+ /** Non-fatal issues surfaced by discovery, passed through for logging. */
69
+ warnings: string[];
70
+ }
71
+ /**
72
+ * Resolve the list of agents the daemon should bind at boot.
73
+ *
74
+ * Order of precedence:
75
+ * 1. `cfg.agents` / legacy `cfg.agentId` — channel credentials default to
76
+ * `~/.botcord/credentials/<agentId>.json`.
77
+ * 2. If neither is set, discover credentials from disk (unless
78
+ * `agentDiscovery.enabled === false`, in which case the caller should
79
+ * already have errored in `loadConfig`).
80
+ */
81
+ export declare function resolveBootAgents(cfg: DaemonConfig, opts?: DiscoveryOptions): BootAgentsResult;
@@ -0,0 +1,181 @@
1
+ import { readdirSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ import { defaultCredentialsFile, loadStoredCredentials, } from "@botcord/protocol-core";
5
+ import { resolveConfiguredAgentIds } from "./config.js";
6
+ import { log as daemonLog } from "./log.js";
7
+ /**
8
+ * Default location daemon looks at when discovering BotCord credentials at
9
+ * boot. Matches the path the `botcord` CLI and plugin write to.
10
+ */
11
+ export const DEFAULT_CREDENTIALS_DIR = path.join(homedir(), ".botcord", "credentials");
12
+ /**
13
+ * Scan the credentials directory and return one entry per valid BotCord
14
+ * credential file. Tolerant by design: missing directory, non-JSON files,
15
+ * unparseable JSON, credentials missing required fields, and duplicate
16
+ * `agentId` entries are all skipped with a warning — never thrown.
17
+ *
18
+ * Duplicate policy is deterministic: prefer the file with the newer
19
+ * `mtimeMs`; if equal/unavailable, prefer lexical path order. This avoids
20
+ * surprising channel selection when stale copies sit alongside fresh ones.
21
+ */
22
+ export function discoverAgentCredentials(opts = {}) {
23
+ const dir = opts.credentialsDir ?? DEFAULT_CREDENTIALS_DIR;
24
+ const readDir = opts.readDir ?? ((d) => readdirSync(d));
25
+ const stat = opts.stat ?? ((p) => statSync(p));
26
+ const loadCreds = opts.loadCredentials ?? loadStoredCredentials;
27
+ const warnings = [];
28
+ let entries;
29
+ try {
30
+ entries = readDir(dir);
31
+ }
32
+ catch (err) {
33
+ const code = err.code;
34
+ if (code === "ENOENT") {
35
+ daemonLog.debug("credentials dir missing", { dir });
36
+ return { agents: [], warnings };
37
+ }
38
+ warnings.push(`credentials dir unreadable (${dir}): ${errMsg(err)}`);
39
+ return { agents: [], warnings };
40
+ }
41
+ daemonLog.debug("credentials dir scan", { dir, entryCount: entries.length });
42
+ // Sort filenames lexically so the duplicate tie-breaker is deterministic
43
+ // regardless of filesystem ordering.
44
+ const files = entries
45
+ .filter((name) => name.endsWith(".json"))
46
+ .map((name) => path.join(dir, name))
47
+ .sort();
48
+ const byAgent = new Map();
49
+ for (const file of files) {
50
+ let mtimeMs = 0;
51
+ try {
52
+ mtimeMs = stat(file).mtimeMs;
53
+ }
54
+ catch {
55
+ // mtime is best-effort; fall back to 0 so lexical order wins ties.
56
+ }
57
+ let creds;
58
+ try {
59
+ creds = loadCreds(file);
60
+ }
61
+ catch (err) {
62
+ warnings.push(`invalid credentials at ${file}: ${errMsg(err)}`);
63
+ continue;
64
+ }
65
+ if (typeof creds.agentId !== "string" || creds.agentId.length === 0) {
66
+ warnings.push(`credentials at ${file} missing agentId; skipped`);
67
+ continue;
68
+ }
69
+ const existing = byAgent.get(creds.agentId);
70
+ if (!existing) {
71
+ byAgent.set(creds.agentId, { creds, credentialsFile: file, mtimeMs });
72
+ continue;
73
+ }
74
+ // Duplicate: pick newer mtime; ties fall through to the entry we saw
75
+ // first (lexically earlier thanks to the sort above).
76
+ if (mtimeMs > existing.mtimeMs) {
77
+ warnings.push(`duplicate agentId "${creds.agentId}": preferring ${file} over ${existing.credentialsFile} (newer mtime)`);
78
+ byAgent.set(creds.agentId, { creds, credentialsFile: file, mtimeMs });
79
+ }
80
+ else {
81
+ warnings.push(`duplicate agentId "${creds.agentId}": keeping ${existing.credentialsFile}, ignoring ${file}`);
82
+ }
83
+ }
84
+ const agents = [];
85
+ for (const { creds, credentialsFile } of byAgent.values()) {
86
+ const entry = {
87
+ agentId: creds.agentId,
88
+ credentialsFile,
89
+ hubUrl: creds.hubUrl,
90
+ };
91
+ if (creds.displayName)
92
+ entry.displayName = creds.displayName;
93
+ if (creds.runtime)
94
+ entry.runtime = creds.runtime;
95
+ if (creds.cwd)
96
+ entry.cwd = creds.cwd;
97
+ if (creds.keyId)
98
+ entry.keyId = creds.keyId;
99
+ if (creds.savedAt)
100
+ entry.savedAt = creds.savedAt;
101
+ agents.push(entry);
102
+ }
103
+ // Stable order for downstream channel creation / logs.
104
+ agents.sort((a, b) => a.agentId.localeCompare(b.agentId));
105
+ daemonLog.debug("credentials discovery done", {
106
+ dir,
107
+ agentCount: agents.length,
108
+ warningCount: warnings.length,
109
+ });
110
+ return { agents, warnings };
111
+ }
112
+ function errMsg(err) {
113
+ return err instanceof Error ? err.message : String(err);
114
+ }
115
+ /**
116
+ * Resolve the list of agents the daemon should bind at boot.
117
+ *
118
+ * Order of precedence:
119
+ * 1. `cfg.agents` / legacy `cfg.agentId` — channel credentials default to
120
+ * `~/.botcord/credentials/<agentId>.json`.
121
+ * 2. If neither is set, discover credentials from disk (unless
122
+ * `agentDiscovery.enabled === false`, in which case the caller should
123
+ * already have errored in `loadConfig`).
124
+ */
125
+ export function resolveBootAgents(cfg, opts = {}) {
126
+ const credentialsDir = opts.credentialsDir ?? cfg.agentDiscovery?.credentialsDir ?? DEFAULT_CREDENTIALS_DIR;
127
+ const explicit = resolveConfiguredAgentIds(cfg);
128
+ daemonLog.debug("resolveBootAgents", {
129
+ credentialsDir,
130
+ source: explicit ? "config" : "credentials",
131
+ explicitCount: explicit?.length ?? 0,
132
+ });
133
+ if (explicit) {
134
+ // Best-effort enrich with runtime/cwd cached in credentials. A missing
135
+ // or unreadable file is not fatal — the gateway channel will surface the
136
+ // real error at start. The fields we're after are purely for router
137
+ // fallback (docs/agent-runtime-property-plan.md §4.3).
138
+ const agents = explicit.map((agentId) => {
139
+ const credentialsFile = defaultCredentialsFile(agentId);
140
+ const entry = {
141
+ agentId,
142
+ credentialsFile,
143
+ hubUrl: "",
144
+ };
145
+ const load = opts.loadCredentials ?? loadStoredCredentials;
146
+ try {
147
+ const creds = load(credentialsFile);
148
+ if (creds.hubUrl)
149
+ entry.hubUrl = creds.hubUrl;
150
+ if (creds.displayName)
151
+ entry.displayName = creds.displayName;
152
+ if (creds.runtime)
153
+ entry.runtime = creds.runtime;
154
+ if (creds.cwd)
155
+ entry.cwd = creds.cwd;
156
+ if (creds.keyId)
157
+ entry.keyId = creds.keyId;
158
+ if (creds.savedAt)
159
+ entry.savedAt = creds.savedAt;
160
+ }
161
+ catch (err) {
162
+ // Silent on any read failure: the file may not exist yet (it gets
163
+ // written by provision flows or legacy CLI) and the gateway channel
164
+ // is the one that surfaces real errors at start. This enrichment
165
+ // is purely opportunistic — missing runtime/cwd just means the
166
+ // router falls back to `defaultRoute`, which is the pre-plan
167
+ // behavior.
168
+ void err;
169
+ }
170
+ return entry;
171
+ });
172
+ return { agents, source: "config", credentialsDir, warnings: [] };
173
+ }
174
+ const discovery = discoverAgentCredentials({ ...opts, credentialsDir });
175
+ return {
176
+ agents: discovery.agents,
177
+ source: "credentials",
178
+ credentialsDir,
179
+ warnings: discovery.warnings,
180
+ };
181
+ }
@@ -0,0 +1,31 @@
1
+ export declare function agentHomeDir(agentId: string): string;
2
+ export declare function agentWorkspaceDir(agentId: string): string;
3
+ export declare function agentStateDir(agentId: string): string;
4
+ /**
5
+ * Per-agent CODEX_HOME. The codex adapter sets the `CODEX_HOME` env var
6
+ * to this path so codex reads a daemon-managed `AGENTS.md` (written fresh
7
+ * each turn with the agent's systemContext) and stores its `sessions/`
8
+ * here — neither touching `~/.codex/` nor the agent's `workspace/` cwd.
9
+ */
10
+ export declare function agentCodexHomeDir(agentId: string): string;
11
+ export interface WorkspaceSeed {
12
+ displayName?: string;
13
+ bio?: string;
14
+ runtime?: string;
15
+ keyId?: string;
16
+ /** ISO timestamp. */
17
+ savedAt?: string;
18
+ }
19
+ /**
20
+ * Idempotently create the per-agent CODEX_HOME directory and link the
21
+ * user's codex `auth.json` into it. Does NOT write an initial `AGENTS.md`
22
+ * — the codex adapter writes it fresh per turn from `systemContext`.
23
+ */
24
+ export declare function ensureAgentCodexHome(agentId: string): string;
25
+ /**
26
+ * Idempotently create the agent's home / workspace / state directories and
27
+ * seed the workspace Markdown files. Existing files are never overwritten —
28
+ * users' edits to AGENTS.md, memory.md, etc. are preserved across calls.
29
+ * State files are not touched here; working-memory.ts owns `state/`.
30
+ */
31
+ export declare function ensureAgentWorkspace(agentId: string, seed: WorkspaceSeed): void;