@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.
- package/dist/activity-tracker.d.ts +43 -0
- package/dist/activity-tracker.js +110 -0
- package/dist/adapters/runtimes.d.ts +14 -0
- package/dist/adapters/runtimes.js +18 -0
- package/dist/agent-discovery.d.ts +81 -0
- package/dist/agent-discovery.js +181 -0
- package/dist/agent-workspace.d.ts +31 -0
- package/dist/agent-workspace.js +221 -0
- package/dist/config.d.ts +116 -0
- package/dist/config.js +180 -0
- package/dist/control-channel.d.ts +99 -0
- package/dist/control-channel.js +388 -0
- package/dist/cross-room.d.ts +23 -0
- package/dist/cross-room.js +55 -0
- package/dist/daemon-config-map.d.ts +61 -0
- package/dist/daemon-config-map.js +153 -0
- package/dist/daemon.d.ts +123 -0
- package/dist/daemon.js +349 -0
- package/dist/doctor.d.ts +89 -0
- package/dist/doctor.js +191 -0
- package/dist/gateway/channel-manager.d.ts +54 -0
- package/dist/gateway/channel-manager.js +292 -0
- package/dist/gateway/channels/botcord.d.ts +93 -0
- package/dist/gateway/channels/botcord.js +510 -0
- package/dist/gateway/channels/index.d.ts +2 -0
- package/dist/gateway/channels/index.js +1 -0
- package/dist/gateway/channels/sanitize.d.ts +20 -0
- package/dist/gateway/channels/sanitize.js +56 -0
- package/dist/gateway/dispatcher.d.ts +73 -0
- package/dist/gateway/dispatcher.js +431 -0
- package/dist/gateway/gateway.d.ts +87 -0
- package/dist/gateway/gateway.js +158 -0
- package/dist/gateway/index.d.ts +15 -0
- package/dist/gateway/index.js +15 -0
- package/dist/gateway/log.d.ts +9 -0
- package/dist/gateway/log.js +20 -0
- package/dist/gateway/router.d.ts +10 -0
- package/dist/gateway/router.js +48 -0
- package/dist/gateway/runtimes/claude-code.d.ts +30 -0
- package/dist/gateway/runtimes/claude-code.js +162 -0
- package/dist/gateway/runtimes/codex.d.ts +83 -0
- package/dist/gateway/runtimes/codex.js +272 -0
- package/dist/gateway/runtimes/gemini.d.ts +15 -0
- package/dist/gateway/runtimes/gemini.js +29 -0
- package/dist/gateway/runtimes/ndjson-stream.d.ts +43 -0
- package/dist/gateway/runtimes/ndjson-stream.js +169 -0
- package/dist/gateway/runtimes/probe.d.ts +17 -0
- package/dist/gateway/runtimes/probe.js +54 -0
- package/dist/gateway/runtimes/registry.d.ts +59 -0
- package/dist/gateway/runtimes/registry.js +94 -0
- package/dist/gateway/session-store.d.ts +39 -0
- package/dist/gateway/session-store.js +133 -0
- package/dist/gateway/types.d.ts +265 -0
- package/dist/gateway/types.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +854 -0
- package/dist/log.d.ts +7 -0
- package/dist/log.js +44 -0
- package/dist/provision.d.ts +88 -0
- package/dist/provision.js +749 -0
- package/dist/room-context-fetcher.d.ts +18 -0
- package/dist/room-context-fetcher.js +101 -0
- package/dist/room-context.d.ts +53 -0
- package/dist/room-context.js +112 -0
- package/dist/sender-classify.d.ts +30 -0
- package/dist/sender-classify.js +32 -0
- package/dist/snapshot-writer.d.ts +37 -0
- package/dist/snapshot-writer.js +84 -0
- package/dist/status-render.d.ts +28 -0
- package/dist/status-render.js +97 -0
- package/dist/system-context.d.ts +57 -0
- package/dist/system-context.js +91 -0
- package/dist/turn-text.d.ts +36 -0
- package/dist/turn-text.js +57 -0
- package/dist/user-auth.d.ts +75 -0
- package/dist/user-auth.js +245 -0
- package/dist/working-memory.d.ts +46 -0
- package/dist/working-memory.js +274 -0
- package/package.json +39 -0
- package/src/__tests__/activity-tracker.test.ts +130 -0
- package/src/__tests__/agent-discovery.test.ts +191 -0
- package/src/__tests__/agent-workspace.test.ts +147 -0
- package/src/__tests__/control-channel.test.ts +327 -0
- package/src/__tests__/cross-room.test.ts +116 -0
- package/src/__tests__/daemon-config-map.test.ts +416 -0
- package/src/__tests__/daemon.test.ts +300 -0
- package/src/__tests__/device-code.test.ts +152 -0
- package/src/__tests__/doctor.test.ts +218 -0
- package/src/__tests__/protocol-core-reexport.test.ts +24 -0
- package/src/__tests__/provision.test.ts +922 -0
- package/src/__tests__/room-context.test.ts +233 -0
- package/src/__tests__/runtime-discovery.test.ts +173 -0
- package/src/__tests__/snapshot-writer.test.ts +141 -0
- package/src/__tests__/status-render.test.ts +137 -0
- package/src/__tests__/system-context.test.ts +315 -0
- package/src/__tests__/turn-text.test.ts +116 -0
- package/src/__tests__/user-auth.test.ts +125 -0
- package/src/__tests__/working-memory.test.ts +240 -0
- package/src/activity-tracker.ts +140 -0
- package/src/adapters/runtimes.ts +30 -0
- package/src/agent-discovery.ts +262 -0
- package/src/agent-workspace.ts +247 -0
- package/src/config.ts +290 -0
- package/src/control-channel.ts +455 -0
- package/src/cross-room.ts +89 -0
- package/src/daemon-config-map.ts +200 -0
- package/src/daemon.ts +478 -0
- package/src/doctor.ts +282 -0
- package/src/gateway/__tests__/.gitkeep +0 -0
- package/src/gateway/__tests__/botcord-channel.test.ts +480 -0
- package/src/gateway/__tests__/channel-manager.test.ts +475 -0
- package/src/gateway/__tests__/claude-code-adapter.test.ts +318 -0
- package/src/gateway/__tests__/codex-adapter.test.ts +350 -0
- package/src/gateway/__tests__/dispatcher.test.ts +1159 -0
- package/src/gateway/__tests__/gateway-add-channel.test.ts +180 -0
- package/src/gateway/__tests__/gateway-managed-routes.test.ts +181 -0
- package/src/gateway/__tests__/gateway.test.ts +222 -0
- package/src/gateway/__tests__/router.test.ts +247 -0
- package/src/gateway/__tests__/sanitize.test.ts +193 -0
- package/src/gateway/__tests__/session-store.test.ts +235 -0
- package/src/gateway/channel-manager.ts +349 -0
- package/src/gateway/channels/botcord.ts +605 -0
- package/src/gateway/channels/index.ts +6 -0
- package/src/gateway/channels/sanitize.ts +68 -0
- package/src/gateway/dispatcher.ts +554 -0
- package/src/gateway/gateway.ts +211 -0
- package/src/gateway/index.ts +29 -0
- package/src/gateway/log.ts +30 -0
- package/src/gateway/router.ts +60 -0
- package/src/gateway/runtimes/claude-code.ts +180 -0
- package/src/gateway/runtimes/codex.ts +312 -0
- package/src/gateway/runtimes/gemini.ts +43 -0
- package/src/gateway/runtimes/ndjson-stream.ts +225 -0
- package/src/gateway/runtimes/probe.ts +73 -0
- package/src/gateway/runtimes/registry.ts +143 -0
- package/src/gateway/session-store.ts +157 -0
- package/src/gateway/types.ts +325 -0
- package/src/index.ts +961 -0
- package/src/log.ts +47 -0
- package/src/provision.ts +879 -0
- package/src/room-context-fetcher.ts +124 -0
- package/src/room-context.ts +167 -0
- package/src/sender-classify.ts +46 -0
- package/src/snapshot-writer.ts +103 -0
- package/src/status-render.ts +132 -0
- package/src/system-context.ts +162 -0
- package/src/turn-text.ts +93 -0
- package/src/user-auth.ts +295 -0
- package/src/working-memory.ts +352 -0
package/dist/daemon.d.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { type GatewayInboundMessage, type GatewayLogger, type GatewayRuntimeSnapshot } from "./gateway/index.js";
|
|
2
|
+
import type { DaemonConfig } from "./config.js";
|
|
3
|
+
import { type BootAgentsResult } from "./agent-discovery.js";
|
|
4
|
+
import { ensureAgentWorkspace } from "./agent-workspace.js";
|
|
5
|
+
import { UserAuthManager } from "./user-auth.js";
|
|
6
|
+
import { classifyActivitySender } from "./sender-classify.js";
|
|
7
|
+
export { classifyActivitySender };
|
|
8
|
+
/** Minimal activity-tracker surface the inbound observer uses. */
|
|
9
|
+
interface ActivityRecorderTarget {
|
|
10
|
+
record: (entry: {
|
|
11
|
+
agentId: string;
|
|
12
|
+
roomId: string;
|
|
13
|
+
roomName?: string;
|
|
14
|
+
topic: string | null;
|
|
15
|
+
lastInboundPreview: string;
|
|
16
|
+
lastSenderKind: "agent" | "human" | "owner";
|
|
17
|
+
lastSender: string;
|
|
18
|
+
}) => void;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Build the `onInbound` observer wired to the given activity tracker.
|
|
22
|
+
* Exported for tests.
|
|
23
|
+
*
|
|
24
|
+
* The recorded `agentId` is taken from the inbound message's `accountId`
|
|
25
|
+
* — so a multi-agent daemon files activity under whichever configured
|
|
26
|
+
* agent actually received the message. An optional `fallbackAgentId`
|
|
27
|
+
* covers pathological inputs where `accountId` is empty (should never
|
|
28
|
+
* happen from the gateway, but defensive).
|
|
29
|
+
*/
|
|
30
|
+
export declare function createActivityRecorder(opts: {
|
|
31
|
+
activityTracker: ActivityRecorderTarget;
|
|
32
|
+
fallbackAgentId?: string;
|
|
33
|
+
}): (msg: GatewayInboundMessage) => void;
|
|
34
|
+
/**
|
|
35
|
+
* Minimal send-capable surface used by {@link pushRuntimeSnapshot}.
|
|
36
|
+
* Exists so the helper is trivially mockable from unit tests without needing
|
|
37
|
+
* a full `ControlChannel` + user-auth harness.
|
|
38
|
+
*/
|
|
39
|
+
export interface RuntimeSnapshotSink {
|
|
40
|
+
send: (frame: {
|
|
41
|
+
id: string;
|
|
42
|
+
type: string;
|
|
43
|
+
params?: Record<string, unknown>;
|
|
44
|
+
ts?: number;
|
|
45
|
+
}) => boolean;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Emit one `runtime_snapshot` event frame on the control channel. Plan §8.5
|
|
49
|
+
* P0: first-connect push only — reconnect-push and diffing are P1. A send
|
|
50
|
+
* failure is non-fatal (the Hub will re-query via `list_runtimes` on demand
|
|
51
|
+
* or wait for the next daemon restart). Exported for unit tests.
|
|
52
|
+
*/
|
|
53
|
+
export declare function pushRuntimeSnapshot(sink: RuntimeSnapshotSink): boolean;
|
|
54
|
+
/** Options accepted by {@link startDaemon} — the P0.5 compatibility shim. */
|
|
55
|
+
export interface DaemonRuntimeOptions {
|
|
56
|
+
config: DaemonConfig;
|
|
57
|
+
/** Informational only; surfaced in startup logs. */
|
|
58
|
+
configPath: string;
|
|
59
|
+
/** Override the JSON session store location; defaults to `~/.botcord/daemon/sessions.json`. */
|
|
60
|
+
sessionStorePath?: string;
|
|
61
|
+
/** Override the snapshot file path; defaults to `~/.botcord/daemon/snapshot.json`. */
|
|
62
|
+
snapshotPath?: string;
|
|
63
|
+
/** Override snapshot write cadence in ms; defaults to 5s or `BOTCORD_DAEMON_SNAPSHOT_INTERVAL_MS`. */
|
|
64
|
+
snapshotIntervalMs?: number;
|
|
65
|
+
log?: GatewayLogger;
|
|
66
|
+
/** Override Hub base URL; defaults to the one stored in credentials. */
|
|
67
|
+
hubBaseUrl?: string;
|
|
68
|
+
/** Override credentials JSON path; defaults to `~/.botcord/credentials/<agentId>.json`. */
|
|
69
|
+
credentialsPath?: string;
|
|
70
|
+
/**
|
|
71
|
+
* Inject a pre-resolved boot-agent list (e.g. from tests). When omitted,
|
|
72
|
+
* `startDaemon` resolves boot agents from `config.agents`/`config.agentId`
|
|
73
|
+
* or falls back to credential discovery.
|
|
74
|
+
*/
|
|
75
|
+
bootAgents?: BootAgentsResult;
|
|
76
|
+
/**
|
|
77
|
+
* Inject a pre-built user-auth manager. Typically only set by tests; in
|
|
78
|
+
* production the daemon calls `UserAuthManager.load()` internally, and
|
|
79
|
+
* only starts the control channel when a user-auth record exists.
|
|
80
|
+
*/
|
|
81
|
+
userAuth?: UserAuthManager | null;
|
|
82
|
+
/** Skip the control channel even when user-auth is available. Test hook. */
|
|
83
|
+
disableControlChannel?: boolean;
|
|
84
|
+
}
|
|
85
|
+
/** Handle returned by {@link startDaemon}. */
|
|
86
|
+
export interface DaemonHandle {
|
|
87
|
+
/** Graceful shutdown — idempotent. */
|
|
88
|
+
stop: (reason?: string) => Promise<void>;
|
|
89
|
+
/** Channel + turn status snapshot, straight from `Gateway.snapshot()`. */
|
|
90
|
+
snapshot: () => GatewayRuntimeSnapshot;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Boot the gateway using a daemon-shaped config. This is the P0.5 compat
|
|
94
|
+
* entry point: the on-disk config at `~/.botcord/daemon/config.json` keeps
|
|
95
|
+
* its existing shape, and `Gateway` handles channels/dispatch/sessions
|
|
96
|
+
* under the hood.
|
|
97
|
+
*
|
|
98
|
+
* Only the BotCord channel is supported today; `channels[]` in the
|
|
99
|
+
* translated gateway config has exactly one entry (`botcord-main`).
|
|
100
|
+
*/
|
|
101
|
+
export declare function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHandle>;
|
|
102
|
+
/**
|
|
103
|
+
* Result of {@link backfillBootAgents}: the maps the boot flow needs to
|
|
104
|
+
* plumb into `toGatewayConfig` + the channel factory.
|
|
105
|
+
*/
|
|
106
|
+
export interface BootBackfillResult {
|
|
107
|
+
credentialPathByAgentId: Map<string, string>;
|
|
108
|
+
agentRuntimes: Record<string, {
|
|
109
|
+
runtime?: string;
|
|
110
|
+
cwd?: string;
|
|
111
|
+
}>;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Walk the boot-agent list and (a) populate the credential-path + runtime
|
|
115
|
+
* caches used downstream, and (b) idempotently create each agent's on-disk
|
|
116
|
+
* workspace tree (plan §9). One agent's failing workspace must not block
|
|
117
|
+
* the others — errors are warned and swallowed per agent. Exported for
|
|
118
|
+
* unit tests; `startDaemon` calls this inline.
|
|
119
|
+
*/
|
|
120
|
+
export declare function backfillBootAgents(agents: BootAgentsResult["agents"], opts: {
|
|
121
|
+
logger: GatewayLogger;
|
|
122
|
+
ensure?: typeof ensureAgentWorkspace;
|
|
123
|
+
}): BootBackfillResult;
|
package/dist/daemon.js
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { CONTROL_FRAME_TYPES } from "@botcord/protocol-core";
|
|
2
|
+
import { Gateway, createBotCordChannel, sanitizeUntrustedContent, } from "./gateway/index.js";
|
|
3
|
+
import { ActivityTracker } from "./activity-tracker.js";
|
|
4
|
+
import { SESSIONS_PATH, SNAPSHOT_PATH } from "./config.js";
|
|
5
|
+
import { resolveBootAgents } from "./agent-discovery.js";
|
|
6
|
+
import { ensureAgentWorkspace } from "./agent-workspace.js";
|
|
7
|
+
import { ControlChannel } from "./control-channel.js";
|
|
8
|
+
import { toGatewayConfig } from "./daemon-config-map.js";
|
|
9
|
+
import { log as daemonLog } from "./log.js";
|
|
10
|
+
import { collectRuntimeSnapshot, createProvisioner } from "./provision.js";
|
|
11
|
+
import { SnapshotWriter } from "./snapshot-writer.js";
|
|
12
|
+
import { createDaemonSystemContextBuilder } from "./system-context.js";
|
|
13
|
+
import { createRoomStaticContextBuilder } from "./room-context.js";
|
|
14
|
+
import { createRoomContextFetcher } from "./room-context-fetcher.js";
|
|
15
|
+
import { composeBotCordUserTurn } from "./turn-text.js";
|
|
16
|
+
import { UserAuthManager } from "./user-auth.js";
|
|
17
|
+
/**
|
|
18
|
+
* Matches the 10-minute turn timeout the legacy daemon dispatcher used, so
|
|
19
|
+
* long-running CLI turns behave the same way under the gateway core.
|
|
20
|
+
*/
|
|
21
|
+
const DEFAULT_TURN_TIMEOUT_MS = 10 * 60 * 1000;
|
|
22
|
+
/**
|
|
23
|
+
* Default cadence for writing `gateway.snapshot()` to disk. Override via
|
|
24
|
+
* `BOTCORD_DAEMON_SNAPSHOT_INTERVAL_MS`.
|
|
25
|
+
*/
|
|
26
|
+
const DEFAULT_SNAPSHOT_INTERVAL_MS = 5_000;
|
|
27
|
+
function resolveSnapshotIntervalMs() {
|
|
28
|
+
const raw = process.env.BOTCORD_DAEMON_SNAPSHOT_INTERVAL_MS;
|
|
29
|
+
if (!raw)
|
|
30
|
+
return DEFAULT_SNAPSHOT_INTERVAL_MS;
|
|
31
|
+
const n = Number(raw);
|
|
32
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
33
|
+
return DEFAULT_SNAPSHOT_INTERVAL_MS;
|
|
34
|
+
return n;
|
|
35
|
+
}
|
|
36
|
+
// Sender classification lives in `./sender-classify.ts` so it can be shared
|
|
37
|
+
// with the user-turn composer without a daemon.ts ↔ turn-text.ts cycle.
|
|
38
|
+
import { classifyActivitySender } from "./sender-classify.js";
|
|
39
|
+
export { classifyActivitySender };
|
|
40
|
+
/**
|
|
41
|
+
* Build the `onInbound` observer wired to the given activity tracker.
|
|
42
|
+
* Exported for tests.
|
|
43
|
+
*
|
|
44
|
+
* The recorded `agentId` is taken from the inbound message's `accountId`
|
|
45
|
+
* — so a multi-agent daemon files activity under whichever configured
|
|
46
|
+
* agent actually received the message. An optional `fallbackAgentId`
|
|
47
|
+
* covers pathological inputs where `accountId` is empty (should never
|
|
48
|
+
* happen from the gateway, but defensive).
|
|
49
|
+
*/
|
|
50
|
+
export function createActivityRecorder(opts) {
|
|
51
|
+
return (msg) => {
|
|
52
|
+
const { kind, label } = classifyActivitySender(msg);
|
|
53
|
+
const rawText = typeof msg.text === "string" ? msg.text : "";
|
|
54
|
+
// Owner text passes through verbatim; everything else gets the same
|
|
55
|
+
// sanitization the legacy dispatcher applied before recording a preview.
|
|
56
|
+
const preview = kind === "owner" ? rawText : sanitizeUntrustedContent(rawText);
|
|
57
|
+
const agentId = msg.accountId || opts.fallbackAgentId || "";
|
|
58
|
+
opts.activityTracker.record({
|
|
59
|
+
agentId,
|
|
60
|
+
roomId: msg.conversation.id,
|
|
61
|
+
roomName: msg.conversation.title,
|
|
62
|
+
topic: msg.conversation.threadId ?? null,
|
|
63
|
+
lastInboundPreview: preview,
|
|
64
|
+
lastSenderKind: kind,
|
|
65
|
+
lastSender: label,
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Emit one `runtime_snapshot` event frame on the control channel. Plan §8.5
|
|
71
|
+
* P0: first-connect push only — reconnect-push and diffing are P1. A send
|
|
72
|
+
* failure is non-fatal (the Hub will re-query via `list_runtimes` on demand
|
|
73
|
+
* or wait for the next daemon restart). Exported for unit tests.
|
|
74
|
+
*/
|
|
75
|
+
export function pushRuntimeSnapshot(sink) {
|
|
76
|
+
const snap = collectRuntimeSnapshot();
|
|
77
|
+
const ok = sink.send({
|
|
78
|
+
id: `rt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
79
|
+
type: CONTROL_FRAME_TYPES.RUNTIME_SNAPSHOT,
|
|
80
|
+
params: snap,
|
|
81
|
+
ts: Date.now(),
|
|
82
|
+
});
|
|
83
|
+
if (!ok) {
|
|
84
|
+
daemonLog.warn("runtime-snapshot: control-channel send returned false", {
|
|
85
|
+
runtimes: snap.runtimes.length,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return ok;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Adapt daemon's file-based `log` module into the gateway logger contract.
|
|
92
|
+
* Writes go to `~/.botcord/logs/daemon.log` + stderr, preserving the format
|
|
93
|
+
* existing `logs -f` watchers rely on. Debug lines stay gated by
|
|
94
|
+
* `BOTCORD_DAEMON_DEBUG`, mirroring pre-migration behavior.
|
|
95
|
+
*/
|
|
96
|
+
function buildDaemonLogger() {
|
|
97
|
+
return {
|
|
98
|
+
info: (msg, meta) => daemonLog.info(msg, meta),
|
|
99
|
+
warn: (msg, meta) => daemonLog.warn(msg, meta),
|
|
100
|
+
error: (msg, meta) => daemonLog.error(msg, meta),
|
|
101
|
+
debug: (msg, meta) => daemonLog.debug(msg, meta),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Boot the gateway using a daemon-shaped config. This is the P0.5 compat
|
|
106
|
+
* entry point: the on-disk config at `~/.botcord/daemon/config.json` keeps
|
|
107
|
+
* its existing shape, and `Gateway` handles channels/dispatch/sessions
|
|
108
|
+
* under the hood.
|
|
109
|
+
*
|
|
110
|
+
* Only the BotCord channel is supported today; `channels[]` in the
|
|
111
|
+
* translated gateway config has exactly one entry (`botcord-main`).
|
|
112
|
+
*/
|
|
113
|
+
export async function startDaemon(opts) {
|
|
114
|
+
const logger = opts.log ?? buildDaemonLogger();
|
|
115
|
+
// Resolve boot agents: explicit `agents` config wins; otherwise scan the
|
|
116
|
+
// credentials directory. A zero-agent result is valid in P1 — the daemon
|
|
117
|
+
// still starts with zero channels so operators can drop credentials in
|
|
118
|
+
// and restart without re-running `init`.
|
|
119
|
+
const boot = opts.bootAgents ?? resolveBootAgents(opts.config);
|
|
120
|
+
for (const w of boot.warnings) {
|
|
121
|
+
logger.warn("daemon.discovery.warning", { message: w });
|
|
122
|
+
}
|
|
123
|
+
const agentIds = boot.agents.map((a) => a.agentId);
|
|
124
|
+
const { credentialPathByAgentId, agentRuntimes } = backfillBootAgents(boot.agents, { logger });
|
|
125
|
+
const gwConfig = toGatewayConfig(opts.config, { agentIds, agentRuntimes });
|
|
126
|
+
// ActivityTracker lives at the daemon layer (not the gateway core). We
|
|
127
|
+
// expose it to the gateway via (a) the `buildSystemContext` hook so the
|
|
128
|
+
// cross-room digest reflects current activity, and (b) the `onInbound`
|
|
129
|
+
// observer so incoming messages get recorded before the turn runs —
|
|
130
|
+
// mirroring the pre-P0.5 dispatcher's "record-before-adapter-run" ordering.
|
|
131
|
+
const activityTracker = new ActivityTracker();
|
|
132
|
+
// Shared room-context fetcher — one BotCordClient per accountId, created
|
|
133
|
+
// lazily and reused across turns so JWT refreshes amortize. The builder
|
|
134
|
+
// wrapping it adds a TTL cache on top so group rooms don't hit Hub every
|
|
135
|
+
// turn.
|
|
136
|
+
const roomContextFetcher = createRoomContextFetcher({
|
|
137
|
+
credentialPathByAgentId,
|
|
138
|
+
...(opts.credentialsPath ? { defaultCredentialsPath: opts.credentialsPath } : {}),
|
|
139
|
+
...(opts.hubBaseUrl ? { hubBaseUrl: opts.hubBaseUrl } : {}),
|
|
140
|
+
log: logger,
|
|
141
|
+
});
|
|
142
|
+
const roomContextBuilder = createRoomStaticContextBuilder({
|
|
143
|
+
fetchRoomInfo: roomContextFetcher,
|
|
144
|
+
log: logger,
|
|
145
|
+
});
|
|
146
|
+
const scBuilders = new Map();
|
|
147
|
+
for (const aid of agentIds) {
|
|
148
|
+
scBuilders.set(aid, createDaemonSystemContextBuilder({
|
|
149
|
+
agentId: aid,
|
|
150
|
+
activityTracker,
|
|
151
|
+
roomContextBuilder,
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
const buildSystemContext = (message) => {
|
|
155
|
+
const b = scBuilders.get(message.accountId);
|
|
156
|
+
if (b)
|
|
157
|
+
return b(message);
|
|
158
|
+
// Unknown accountId (shouldn't happen in practice): fall back to the
|
|
159
|
+
// first configured agent so we still emit *something* rather than
|
|
160
|
+
// silently dropping the context block. When no agents are bound the
|
|
161
|
+
// daemon has no context to surface — return undefined.
|
|
162
|
+
const first = agentIds[0];
|
|
163
|
+
if (!first)
|
|
164
|
+
return undefined;
|
|
165
|
+
const fallback = scBuilders.get(first);
|
|
166
|
+
return fallback ? fallback(message) : undefined;
|
|
167
|
+
};
|
|
168
|
+
// Observer runs after ack + before runtime.run. Keeping the side effect
|
|
169
|
+
// outside the system-context builder (option A) means the builder stays
|
|
170
|
+
// pure — a cleaner contract the gateway can also expose to non-daemon
|
|
171
|
+
// callers in the future.
|
|
172
|
+
const onInbound = createActivityRecorder({
|
|
173
|
+
activityTracker,
|
|
174
|
+
...(agentIds[0] ? { fallbackAgentId: agentIds[0] } : {}),
|
|
175
|
+
});
|
|
176
|
+
const gateway = new Gateway({
|
|
177
|
+
config: gwConfig,
|
|
178
|
+
sessionStorePath: opts.sessionStorePath ?? SESSIONS_PATH,
|
|
179
|
+
createChannel: (chCfg) => {
|
|
180
|
+
const agentId = typeof chCfg.agentId === "string" ? chCfg.agentId : chCfg.accountId;
|
|
181
|
+
return createBotCordChannel({
|
|
182
|
+
id: chCfg.id,
|
|
183
|
+
accountId: chCfg.accountId,
|
|
184
|
+
agentId,
|
|
185
|
+
credentialsPath: credentialPathByAgentId.get(agentId) ?? opts.credentialsPath,
|
|
186
|
+
hubBaseUrl: opts.hubBaseUrl,
|
|
187
|
+
});
|
|
188
|
+
},
|
|
189
|
+
log: logger,
|
|
190
|
+
turnTimeoutMs: DEFAULT_TURN_TIMEOUT_MS,
|
|
191
|
+
buildSystemContext,
|
|
192
|
+
onInbound,
|
|
193
|
+
composeUserTurn: composeBotCordUserTurn,
|
|
194
|
+
});
|
|
195
|
+
logger.info("daemon starting", {
|
|
196
|
+
agents: agentIds,
|
|
197
|
+
source: boot.source,
|
|
198
|
+
credentialsDir: boot.credentialsDir,
|
|
199
|
+
configPath: opts.configPath,
|
|
200
|
+
sessionsPath: opts.sessionStorePath ?? SESSIONS_PATH,
|
|
201
|
+
channels: gwConfig.channels.map((c) => c.id),
|
|
202
|
+
routeCount: gwConfig.routes?.length ?? 0,
|
|
203
|
+
});
|
|
204
|
+
if (agentIds.length === 0) {
|
|
205
|
+
logger.warn("daemon starting with no channels", {
|
|
206
|
+
source: boot.source,
|
|
207
|
+
credentialsDir: boot.credentialsDir,
|
|
208
|
+
hint: "drop a credentials JSON in the discovery dir and restart, or run `botcord-daemon init --agent <ag_xxx>`",
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
await gateway.start();
|
|
212
|
+
logger.info("daemon started", { agents: agentIds });
|
|
213
|
+
// Control channel is optional — daemon still runs (data-plane only)
|
|
214
|
+
// when user-auth hasn't been set up yet. Operators can `login` later
|
|
215
|
+
// without restarting, but for P0 we require a restart to pick it up.
|
|
216
|
+
let controlChannel = null;
|
|
217
|
+
const userAuth = opts.userAuth === undefined
|
|
218
|
+
? tryLoadUserAuth(logger)
|
|
219
|
+
: opts.userAuth;
|
|
220
|
+
if (userAuth?.current && !opts.disableControlChannel) {
|
|
221
|
+
logger.info("control-channel: enabling", {
|
|
222
|
+
userId: userAuth.current.userId,
|
|
223
|
+
hubUrl: userAuth.current.hubUrl,
|
|
224
|
+
});
|
|
225
|
+
const provisioner = createProvisioner({ gateway });
|
|
226
|
+
controlChannel = new ControlChannel({
|
|
227
|
+
auth: userAuth,
|
|
228
|
+
handle: provisioner,
|
|
229
|
+
});
|
|
230
|
+
try {
|
|
231
|
+
await controlChannel.start();
|
|
232
|
+
// Plan §8.5 P0 — push one runtime snapshot immediately after connect
|
|
233
|
+
// so Hub's `daemon_instances.runtimes_json` is populated for the
|
|
234
|
+
// dashboard even before any user action. No periodic refresh in P0.
|
|
235
|
+
const pushed = pushRuntimeSnapshot(controlChannel);
|
|
236
|
+
logger.info("control-channel: initial runtime_snapshot push", {
|
|
237
|
+
ok: pushed,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
logger.warn("control-channel failed to start; continuing without it", {
|
|
242
|
+
error: err instanceof Error ? err.message : String(err),
|
|
243
|
+
});
|
|
244
|
+
// start() schedules its own reconnect; we swallow the initial
|
|
245
|
+
// failure so the daemon boots either way.
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
else if (!userAuth?.current) {
|
|
249
|
+
logger.info("control-channel skipped: no user-auth record", {
|
|
250
|
+
hint: "run `botcord-daemon start` to enable Hub control plane (device-code login)",
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
const snapshotWriter = new SnapshotWriter({
|
|
254
|
+
path: opts.snapshotPath ?? SNAPSHOT_PATH,
|
|
255
|
+
intervalMs: opts.snapshotIntervalMs ?? resolveSnapshotIntervalMs(),
|
|
256
|
+
snapshot: () => gateway.snapshot(),
|
|
257
|
+
log: logger,
|
|
258
|
+
});
|
|
259
|
+
snapshotWriter.start();
|
|
260
|
+
let stopping = null;
|
|
261
|
+
const stop = (reason) => {
|
|
262
|
+
if (stopping)
|
|
263
|
+
return stopping;
|
|
264
|
+
logger.info("daemon stopping", { reason: reason ?? null });
|
|
265
|
+
snapshotWriter.stop();
|
|
266
|
+
// Write one final snapshot so `status` doesn't briefly see stale data,
|
|
267
|
+
// then delete the file on the way out.
|
|
268
|
+
snapshotWriter.writeFinal();
|
|
269
|
+
const controlStopP = controlChannel
|
|
270
|
+
? controlChannel.stop().catch(() => undefined)
|
|
271
|
+
: Promise.resolve();
|
|
272
|
+
stopping = Promise.all([controlStopP, gateway.stop(reason)]).then(() => undefined).finally(() => {
|
|
273
|
+
snapshotWriter.remove();
|
|
274
|
+
logger.info("daemon stopped", { reason: reason ?? null });
|
|
275
|
+
});
|
|
276
|
+
return stopping;
|
|
277
|
+
};
|
|
278
|
+
return {
|
|
279
|
+
stop,
|
|
280
|
+
snapshot: () => gateway.snapshot(),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Walk the boot-agent list and (a) populate the credential-path + runtime
|
|
285
|
+
* caches used downstream, and (b) idempotently create each agent's on-disk
|
|
286
|
+
* workspace tree (plan §9). One agent's failing workspace must not block
|
|
287
|
+
* the others — errors are warned and swallowed per agent. Exported for
|
|
288
|
+
* unit tests; `startDaemon` calls this inline.
|
|
289
|
+
*/
|
|
290
|
+
export function backfillBootAgents(agents, opts) {
|
|
291
|
+
const ensure = opts.ensure ?? ensureAgentWorkspace;
|
|
292
|
+
const credentialPathByAgentId = new Map();
|
|
293
|
+
const agentRuntimes = {};
|
|
294
|
+
const failed = [];
|
|
295
|
+
for (const a of agents) {
|
|
296
|
+
if (a.credentialsFile)
|
|
297
|
+
credentialPathByAgentId.set(a.agentId, a.credentialsFile);
|
|
298
|
+
if (a.runtime || a.cwd) {
|
|
299
|
+
agentRuntimes[a.agentId] = {
|
|
300
|
+
...(a.runtime ? { runtime: a.runtime } : {}),
|
|
301
|
+
...(a.cwd ? { cwd: a.cwd } : {}),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
// Seed files are written only when missing (see `ensureAgentWorkspace`),
|
|
305
|
+
// so a legacy agent whose workspace dir doesn't exist yet gets one on
|
|
306
|
+
// the next boot — with zero risk of overwriting the user's edits.
|
|
307
|
+
try {
|
|
308
|
+
ensure(a.agentId, {
|
|
309
|
+
...(a.displayName ? { displayName: a.displayName } : {}),
|
|
310
|
+
...(a.runtime ? { runtime: a.runtime } : {}),
|
|
311
|
+
...(a.keyId ? { keyId: a.keyId } : {}),
|
|
312
|
+
...(a.savedAt ? { savedAt: a.savedAt } : {}),
|
|
313
|
+
// `bio` is not surfaced on BootAgent — identity.md renders a
|
|
314
|
+
// placeholder the user can fill in.
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
failed.push(a.agentId);
|
|
319
|
+
opts.logger.warn("ensureAgentWorkspace failed at boot; continuing", {
|
|
320
|
+
agentId: a.agentId,
|
|
321
|
+
error: err instanceof Error ? err.message : String(err),
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (failed.length > 0) {
|
|
326
|
+
opts.logger.warn("ensureAgentWorkspace: boot backfill incomplete", {
|
|
327
|
+
count: failed.length,
|
|
328
|
+
agentIds: failed,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
return { credentialPathByAgentId, agentRuntimes };
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Load the user-auth record if present, swallowing "file missing" as the
|
|
335
|
+
* expected not-logged-in state. A parse / permission error is logged and
|
|
336
|
+
* treated as "no record" so a broken user-auth.json can't block the
|
|
337
|
+
* data-plane from coming up.
|
|
338
|
+
*/
|
|
339
|
+
function tryLoadUserAuth(logger) {
|
|
340
|
+
try {
|
|
341
|
+
return UserAuthManager.load();
|
|
342
|
+
}
|
|
343
|
+
catch (err) {
|
|
344
|
+
logger.warn("failed to load user-auth", {
|
|
345
|
+
error: err instanceof Error ? err.message : String(err),
|
|
346
|
+
});
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
}
|
package/dist/doctor.d.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { RuntimeProbeEntry } from "./adapters/runtimes.js";
|
|
2
|
+
import type { DaemonConfig } from "./config.js";
|
|
3
|
+
/** Summary of a single channel's readiness, printable by the doctor command. */
|
|
4
|
+
export interface ChannelProbeResult {
|
|
5
|
+
id: string;
|
|
6
|
+
type: string;
|
|
7
|
+
accountId: string;
|
|
8
|
+
credentialsOk: boolean;
|
|
9
|
+
credentialsMessage: string;
|
|
10
|
+
hubUrl: string | null;
|
|
11
|
+
hubOk: boolean;
|
|
12
|
+
hubMessage: string;
|
|
13
|
+
}
|
|
14
|
+
/** Minimal filesystem surface needed by {@link probeChannel}; injectable for tests. */
|
|
15
|
+
export interface DoctorFileReader {
|
|
16
|
+
readFile(path: string): string | null;
|
|
17
|
+
}
|
|
18
|
+
/** HTTP GET surface needed by {@link probeChannel}; injectable for tests. */
|
|
19
|
+
export interface DoctorHttpFetcher {
|
|
20
|
+
(url: string, timeoutMs: number): Promise<DoctorHttpResult>;
|
|
21
|
+
}
|
|
22
|
+
/** Response shape returned by {@link DoctorHttpFetcher}. */
|
|
23
|
+
export interface DoctorHttpResult {
|
|
24
|
+
ok: boolean;
|
|
25
|
+
status?: number;
|
|
26
|
+
error?: string;
|
|
27
|
+
}
|
|
28
|
+
/** Input for the rendered doctor output. */
|
|
29
|
+
export interface DoctorInput {
|
|
30
|
+
runtimes: RuntimeProbeEntry[];
|
|
31
|
+
channels: ChannelProbeResult[];
|
|
32
|
+
}
|
|
33
|
+
/** Per-channel config entry accepted by {@link probeChannel}. */
|
|
34
|
+
export interface ChannelProbeConfig {
|
|
35
|
+
id: string;
|
|
36
|
+
type: string;
|
|
37
|
+
accountId: string;
|
|
38
|
+
/**
|
|
39
|
+
* Optional explicit credential file path. When set, wins over the
|
|
40
|
+
* default `~/.botcord/credentials/<accountId>.json` used by
|
|
41
|
+
* `probeChannel`'s fallback. Populated by discovery to surface the
|
|
42
|
+
* exact file that would be loaded at start.
|
|
43
|
+
*/
|
|
44
|
+
credentialsFile?: string;
|
|
45
|
+
}
|
|
46
|
+
/** Top-level options for {@link probeChannels}. */
|
|
47
|
+
export interface ProbeChannelsOptions {
|
|
48
|
+
channels: ChannelProbeConfig[];
|
|
49
|
+
credentialsPath: (accountId: string) => string;
|
|
50
|
+
fileReader: DoctorFileReader;
|
|
51
|
+
fetcher: DoctorHttpFetcher;
|
|
52
|
+
timeoutMs?: number;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Build the implicit channel list for a daemon config. One channel per
|
|
56
|
+
* configured or discovered agent, keyed by agentId (matches
|
|
57
|
+
* `toGatewayConfig`). Mirrors the daemon's boot-agent resolution so
|
|
58
|
+
* `doctor` reports channels even when the config file omits `agents`.
|
|
59
|
+
*/
|
|
60
|
+
export declare function channelsFromDaemonConfig(cfg: DaemonConfig): ChannelProbeConfig[];
|
|
61
|
+
/**
|
|
62
|
+
* Inspect credentials + Hub reachability for one channel. Pure modulo the
|
|
63
|
+
* injected file reader and fetcher.
|
|
64
|
+
*/
|
|
65
|
+
export declare function probeChannel(ch: ChannelProbeConfig, opts: {
|
|
66
|
+
credentialsPath: (accountId: string) => string;
|
|
67
|
+
fileReader: DoctorFileReader;
|
|
68
|
+
fetcher: DoctorHttpFetcher;
|
|
69
|
+
timeoutMs: number;
|
|
70
|
+
}): Promise<ChannelProbeResult>;
|
|
71
|
+
/** Probe a list of channels sequentially. Sequential keeps output stable. */
|
|
72
|
+
export declare function probeChannels(opts: ProbeChannelsOptions): Promise<ChannelProbeResult[]>;
|
|
73
|
+
/** Default HTTP fetcher using `fetch` + `AbortController` timeout. */
|
|
74
|
+
export declare const defaultHttpFetcher: DoctorHttpFetcher;
|
|
75
|
+
/**
|
|
76
|
+
* Render runtime + channel probe output. Pure — all IO happened already.
|
|
77
|
+
* Used by the CLI `doctor` command and by unit tests.
|
|
78
|
+
*/
|
|
79
|
+
export declare function renderDoctor(input: DoctorInput): string;
|
|
80
|
+
/**
|
|
81
|
+
* Thin orchestrator: runs runtime + channel probes and returns the rendered
|
|
82
|
+
* text. Keeps `index.ts` free of probe wiring.
|
|
83
|
+
*/
|
|
84
|
+
export declare function runDoctor(runtimes: RuntimeProbeEntry[], channels: ChannelProbeConfig[], opts: {
|
|
85
|
+
credentialsPath: (accountId: string) => string;
|
|
86
|
+
fileReader: DoctorFileReader;
|
|
87
|
+
fetcher: DoctorHttpFetcher;
|
|
88
|
+
timeoutMs?: number;
|
|
89
|
+
}): Promise<DoctorInput>;
|