@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
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { ChannelManager, type ChannelBackoffOptions } from "./channel-manager.js";
|
|
2
|
+
import { Dispatcher, type RuntimeFactory } from "./dispatcher.js";
|
|
3
|
+
import { consoleLogger, type GatewayLogger } from "./log.js";
|
|
4
|
+
import { createRuntime } from "./runtimes/registry.js";
|
|
5
|
+
import { DEFAULT_SESSION_STORE_MAX_ENTRY_AGE_MS, SessionStore } from "./session-store.js";
|
|
6
|
+
import type {
|
|
7
|
+
ChannelAdapter,
|
|
8
|
+
GatewayChannelConfig,
|
|
9
|
+
GatewayConfig,
|
|
10
|
+
GatewayRoute,
|
|
11
|
+
GatewayRuntimeSnapshot,
|
|
12
|
+
InboundObserver,
|
|
13
|
+
SystemContextBuilder,
|
|
14
|
+
UserTurnBuilder,
|
|
15
|
+
} from "./types.js";
|
|
16
|
+
|
|
17
|
+
/** Constructor options for `Gateway`. */
|
|
18
|
+
export interface GatewayBootOptions {
|
|
19
|
+
config: GatewayConfig;
|
|
20
|
+
sessionStorePath: string;
|
|
21
|
+
/** Max age for persisted runtime session entries. Defaults to 30 days. */
|
|
22
|
+
sessionStoreMaxEntryAgeMs?: number;
|
|
23
|
+
createChannel: (cfg: GatewayChannelConfig) => ChannelAdapter;
|
|
24
|
+
createRuntime?: RuntimeFactory;
|
|
25
|
+
log?: GatewayLogger;
|
|
26
|
+
turnTimeoutMs?: number;
|
|
27
|
+
backoffMs?: ChannelBackoffOptions;
|
|
28
|
+
/**
|
|
29
|
+
* Hook that composes per-turn system context (working memory, cross-room
|
|
30
|
+
* digest, etc.). Forwarded to the dispatcher; errors are logged and do not
|
|
31
|
+
* abort the turn.
|
|
32
|
+
*/
|
|
33
|
+
buildSystemContext?: SystemContextBuilder;
|
|
34
|
+
/**
|
|
35
|
+
* Observer called after the dispatcher acks each inbound message. Useful
|
|
36
|
+
* for activity tracking or metrics. Errors are logged and swallowed.
|
|
37
|
+
*/
|
|
38
|
+
onInbound?: InboundObserver;
|
|
39
|
+
/**
|
|
40
|
+
* Optional composer that wraps the user-turn text with channel-specific
|
|
41
|
+
* metadata (sender label, room header, NO_REPLY hint…) before it is handed
|
|
42
|
+
* to the runtime. Forwarded to the dispatcher; see {@link UserTurnBuilder}.
|
|
43
|
+
*/
|
|
44
|
+
composeUserTurn?: UserTurnBuilder;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Default runtime factory: delegates to the built-in registry; ignores extraArgs at construction. */
|
|
48
|
+
const defaultRuntimeFactory: RuntimeFactory = (runtimeId) => createRuntime(runtimeId);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Top-level gateway bootstrap. Wires `ChannelManager` → `Dispatcher` →
|
|
52
|
+
* `SessionStore` + runtime factory. Channel adapters are constructed from
|
|
53
|
+
* `opts.createChannel` per `config.channels[]` entry and keyed by adapter id.
|
|
54
|
+
*/
|
|
55
|
+
export class Gateway {
|
|
56
|
+
private readonly config: GatewayConfig;
|
|
57
|
+
private readonly log: GatewayLogger;
|
|
58
|
+
private readonly sessionStore: SessionStore;
|
|
59
|
+
private readonly dispatcher: Dispatcher;
|
|
60
|
+
private readonly channelManager: ChannelManager;
|
|
61
|
+
private readonly channelMap: Map<string, ChannelAdapter>;
|
|
62
|
+
private readonly createChannelFn: (cfg: GatewayChannelConfig) => ChannelAdapter;
|
|
63
|
+
private readonly managedRoutes: Map<string, GatewayRoute> = new Map();
|
|
64
|
+
private started = false;
|
|
65
|
+
private stopped = false;
|
|
66
|
+
|
|
67
|
+
constructor(opts: GatewayBootOptions) {
|
|
68
|
+
this.config = opts.config;
|
|
69
|
+
this.log = opts.log ?? consoleLogger;
|
|
70
|
+
this.createChannelFn = opts.createChannel;
|
|
71
|
+
|
|
72
|
+
this.channelMap = new Map();
|
|
73
|
+
const channelList: ChannelAdapter[] = [];
|
|
74
|
+
for (const cfg of opts.config.channels) {
|
|
75
|
+
const adapter = opts.createChannel(cfg);
|
|
76
|
+
this.channelMap.set(adapter.id, adapter);
|
|
77
|
+
channelList.push(adapter);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const route of opts.config.managedRoutes ?? []) {
|
|
81
|
+
const id = route.match?.accountId;
|
|
82
|
+
if (typeof id === "string") {
|
|
83
|
+
this.managedRoutes.set(id, route);
|
|
84
|
+
} else {
|
|
85
|
+
// Defensive: buildManagedRoutes always sets match.accountId, so
|
|
86
|
+
// reaching here means a caller constructed GatewayConfig directly
|
|
87
|
+
// with a malformed entry. Log so it's not silently dropped.
|
|
88
|
+
this.log.warn("gateway: dropping seed managed route with no accountId", {
|
|
89
|
+
runtime: route.runtime,
|
|
90
|
+
cwd: route.cwd,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
this.sessionStore = new SessionStore({
|
|
96
|
+
path: opts.sessionStorePath,
|
|
97
|
+
log: this.log,
|
|
98
|
+
maxEntryAgeMs: opts.sessionStoreMaxEntryAgeMs ?? DEFAULT_SESSION_STORE_MAX_ENTRY_AGE_MS,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const runtimeFactory = opts.createRuntime ?? defaultRuntimeFactory;
|
|
102
|
+
|
|
103
|
+
this.dispatcher = new Dispatcher({
|
|
104
|
+
config: this.config,
|
|
105
|
+
channels: this.channelMap,
|
|
106
|
+
runtime: runtimeFactory,
|
|
107
|
+
sessionStore: this.sessionStore,
|
|
108
|
+
log: this.log,
|
|
109
|
+
turnTimeoutMs: opts.turnTimeoutMs,
|
|
110
|
+
buildSystemContext: opts.buildSystemContext,
|
|
111
|
+
onInbound: opts.onInbound,
|
|
112
|
+
composeUserTurn: opts.composeUserTurn,
|
|
113
|
+
managedRoutes: this.managedRoutes,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
this.channelManager = new ChannelManager({
|
|
117
|
+
config: this.config,
|
|
118
|
+
channels: channelList,
|
|
119
|
+
log: this.log,
|
|
120
|
+
emit: (env) => this.dispatcher.handle(env),
|
|
121
|
+
backoffMs: opts.backoffMs,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Load persisted sessions and start every configured channel. */
|
|
126
|
+
async start(): Promise<void> {
|
|
127
|
+
if (this.started) return;
|
|
128
|
+
this.started = true;
|
|
129
|
+
await this.sessionStore.load();
|
|
130
|
+
await this.channelManager.startAll();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Tear down every channel; idempotent. */
|
|
134
|
+
async stop(reason?: string): Promise<void> {
|
|
135
|
+
if (this.stopped) return;
|
|
136
|
+
this.stopped = true;
|
|
137
|
+
await this.channelManager.stopAll(reason);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Aggregate status snapshot combining channel and turn state. */
|
|
141
|
+
snapshot(): GatewayRuntimeSnapshot {
|
|
142
|
+
return {
|
|
143
|
+
channels: this.channelManager.status(),
|
|
144
|
+
turns: this.dispatcher.turns(),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Read-only view of the synthesized per-agent routes. Exposed for
|
|
150
|
+
* snapshot/debug callers and tests; matching reads the live internal map.
|
|
151
|
+
*/
|
|
152
|
+
listManagedRoutes(): GatewayRoute[] {
|
|
153
|
+
return Array.from(this.managedRoutes.values());
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Replace all managed routes atomically. Used by `reload_config`. */
|
|
157
|
+
replaceManagedRoutes(routes: Map<string, GatewayRoute>): void {
|
|
158
|
+
this.managedRoutes.clear();
|
|
159
|
+
for (const [id, route] of routes) {
|
|
160
|
+
this.managedRoutes.set(id, route);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Add or update one managed route. Used by provision hot-add. */
|
|
165
|
+
upsertManagedRoute(accountId: string, route: GatewayRoute): void {
|
|
166
|
+
this.managedRoutes.set(accountId, route);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Drop one managed route. Used by revoke / removeChannel. */
|
|
170
|
+
removeManagedRoute(accountId: string): void {
|
|
171
|
+
this.managedRoutes.delete(accountId);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Hot-plug a new channel without restarting the gateway. The daemon's
|
|
176
|
+
* control plane calls this after a `provision_agent` frame: it has already
|
|
177
|
+
* written the new agent's credentials to disk and updated `config.json`,
|
|
178
|
+
* and now needs the channel to come online without tearing down peers.
|
|
179
|
+
*
|
|
180
|
+
* The caller supplies a fully-constructed `GatewayChannelConfig` entry;
|
|
181
|
+
* `createChannel` (from the original `GatewayBootOptions`) is invoked to
|
|
182
|
+
* build the adapter. The config entry is appended to `config.channels`
|
|
183
|
+
* so status/router lookups see it; it is otherwise a pure in-memory op.
|
|
184
|
+
*/
|
|
185
|
+
async addChannel(cfg: GatewayChannelConfig): Promise<void> {
|
|
186
|
+
if (this.stopped) {
|
|
187
|
+
throw new Error("gateway already stopped");
|
|
188
|
+
}
|
|
189
|
+
if (this.channelMap.has(cfg.id)) {
|
|
190
|
+
throw new Error(`channel "${cfg.id}" already registered`);
|
|
191
|
+
}
|
|
192
|
+
this.config.channels.push(cfg);
|
|
193
|
+
const adapter = this.createChannelFn(cfg);
|
|
194
|
+
this.channelMap.set(adapter.id, adapter);
|
|
195
|
+
this.channelManager.addOne(adapter);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Remove a channel registered earlier (either at boot or via
|
|
200
|
+
* `addChannel`). Aborts the running turn loop, awaits the adapter's
|
|
201
|
+
* `stop()`, drops the entry from the channel map and config.channels.
|
|
202
|
+
* No-op on unknown id.
|
|
203
|
+
*/
|
|
204
|
+
async removeChannel(id: string, reason?: string): Promise<void> {
|
|
205
|
+
if (!this.channelMap.has(id)) return;
|
|
206
|
+
await this.channelManager.removeOne(id, reason);
|
|
207
|
+
this.channelMap.delete(id);
|
|
208
|
+
const idx = this.config.channels.findIndex((c) => c.id === id);
|
|
209
|
+
if (idx >= 0) this.config.channels.splice(idx, 1);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export * from "./types.js";
|
|
2
|
+
export * from "./log.js";
|
|
3
|
+
export * from "./runtimes/registry.js";
|
|
4
|
+
export * from "./channels/index.js";
|
|
5
|
+
export { sanitizeUntrustedContent, sanitizeSenderName } from "./channels/sanitize.js";
|
|
6
|
+
export { sessionKey, SessionStore, type SessionStoreOptions } from "./session-store.js";
|
|
7
|
+
export { resolveRoute, matchesRoute } from "./router.js";
|
|
8
|
+
export { ChannelManager, type ChannelManagerOptions, type ChannelBackoffOptions } from "./channel-manager.js";
|
|
9
|
+
export { Dispatcher, type DispatcherOptions, type RuntimeFactory } from "./dispatcher.js";
|
|
10
|
+
export { Gateway, type GatewayBootOptions } from "./gateway.js";
|
|
11
|
+
export {
|
|
12
|
+
ClaudeCodeAdapter,
|
|
13
|
+
probeClaude,
|
|
14
|
+
resolveClaudeCommand,
|
|
15
|
+
} from "./runtimes/claude-code.js";
|
|
16
|
+
export { CodexAdapter, probeCodex, resolveCodexCommand } from "./runtimes/codex.js";
|
|
17
|
+
export { GeminiAdapter, probeGemini, resolveGeminiCommand } from "./runtimes/gemini.js";
|
|
18
|
+
export {
|
|
19
|
+
NdjsonStreamAdapter,
|
|
20
|
+
type NdjsonEventCtx,
|
|
21
|
+
type NdjsonRunState,
|
|
22
|
+
} from "./runtimes/ndjson-stream.js";
|
|
23
|
+
export {
|
|
24
|
+
firstExistingPath,
|
|
25
|
+
readCommandVersion,
|
|
26
|
+
resolveCommandOnPath,
|
|
27
|
+
resolveHomePath,
|
|
28
|
+
type ProbeDeps,
|
|
29
|
+
} from "./runtimes/probe.js";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Structured logger interface used across the gateway core and adapters. */
|
|
2
|
+
export interface GatewayLogger {
|
|
3
|
+
info(msg: string, meta?: Record<string, unknown>): void;
|
|
4
|
+
warn(msg: string, meta?: Record<string, unknown>): void;
|
|
5
|
+
error(msg: string, meta?: Record<string, unknown>): void;
|
|
6
|
+
debug(msg: string, meta?: Record<string, unknown>): void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type Level = "info" | "warn" | "error" | "debug";
|
|
10
|
+
|
|
11
|
+
function write(level: Level, msg: string, meta?: Record<string, unknown>): void {
|
|
12
|
+
const line = JSON.stringify({
|
|
13
|
+
ts: new Date().toISOString(),
|
|
14
|
+
level,
|
|
15
|
+
msg,
|
|
16
|
+
...(meta ?? {}),
|
|
17
|
+
});
|
|
18
|
+
// Always write to stderr so stdout stays clean for NDJSON-style channel output.
|
|
19
|
+
process.stderr.write(line + "\n");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Default logger that writes JSON lines to stderr; debug lines gated by BOTCORD_GATEWAY_DEBUG. */
|
|
23
|
+
export const consoleLogger: GatewayLogger = {
|
|
24
|
+
info: (msg, meta) => write("info", msg, meta),
|
|
25
|
+
warn: (msg, meta) => write("warn", msg, meta),
|
|
26
|
+
error: (msg, meta) => write("error", msg, meta),
|
|
27
|
+
debug: (msg, meta) => {
|
|
28
|
+
if (process.env.BOTCORD_GATEWAY_DEBUG) write("debug", msg, meta);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GatewayConfig,
|
|
3
|
+
GatewayInboundMessage,
|
|
4
|
+
GatewayRoute,
|
|
5
|
+
RouteMatch,
|
|
6
|
+
} from "./types.js";
|
|
7
|
+
|
|
8
|
+
/** Returns true if every provided field of `match` matches `message`; undefined match matches all. */
|
|
9
|
+
export function matchesRoute(
|
|
10
|
+
message: GatewayInboundMessage,
|
|
11
|
+
match: RouteMatch | undefined,
|
|
12
|
+
): boolean {
|
|
13
|
+
if (!match) return true;
|
|
14
|
+
if (match.channel !== undefined && match.channel !== message.channel) return false;
|
|
15
|
+
if (match.accountId !== undefined && match.accountId !== message.accountId) return false;
|
|
16
|
+
if (match.conversationId !== undefined && match.conversationId !== message.conversation.id) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
if (
|
|
20
|
+
match.conversationPrefix !== undefined &&
|
|
21
|
+
!message.conversation.id.startsWith(match.conversationPrefix)
|
|
22
|
+
) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
if (
|
|
26
|
+
match.conversationKind !== undefined &&
|
|
27
|
+
match.conversationKind !== message.conversation.kind
|
|
28
|
+
) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
if (match.senderId !== undefined && match.senderId !== message.sender.id) return false;
|
|
32
|
+
if (match.mentioned !== undefined) {
|
|
33
|
+
const actual = message.mentioned ?? false;
|
|
34
|
+
if (match.mentioned !== actual) return false;
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Picks the first matching route in priority order:
|
|
41
|
+
* 1. `config.routes[]` (user-authored)
|
|
42
|
+
* 2. `managedRoutes` (daemon-synthesized per-agent)
|
|
43
|
+
* 3. `config.defaultRoute`
|
|
44
|
+
*/
|
|
45
|
+
export function resolveRoute(
|
|
46
|
+
message: GatewayInboundMessage,
|
|
47
|
+
config: Pick<GatewayConfig, "defaultRoute" | "routes">,
|
|
48
|
+
managedRoutes?: readonly GatewayRoute[],
|
|
49
|
+
): GatewayRoute {
|
|
50
|
+
const routes = config.routes ?? [];
|
|
51
|
+
for (const route of routes) {
|
|
52
|
+
if (matchesRoute(message, route.match)) return route;
|
|
53
|
+
}
|
|
54
|
+
if (managedRoutes) {
|
|
55
|
+
for (const route of managedRoutes) {
|
|
56
|
+
if (matchesRoute(message, route.match)) return route;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return config.defaultRoute;
|
|
60
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { NdjsonStreamAdapter, type NdjsonEventCtx } from "./ndjson-stream.js";
|
|
3
|
+
import {
|
|
4
|
+
firstExistingPath,
|
|
5
|
+
readCommandVersion,
|
|
6
|
+
resolveCommandOnPath,
|
|
7
|
+
resolveHomePath,
|
|
8
|
+
type ProbeDeps,
|
|
9
|
+
} from "./probe.js";
|
|
10
|
+
import type { RuntimeProbeResult, RuntimeRunOptions, StreamBlock } from "../types.js";
|
|
11
|
+
|
|
12
|
+
const CLAUDE_DESKTOP_CLI_RELATIVE_PATH = path.join(
|
|
13
|
+
"Applications",
|
|
14
|
+
"Claude Code URL Handler.app",
|
|
15
|
+
"Contents",
|
|
16
|
+
"MacOS",
|
|
17
|
+
"claude",
|
|
18
|
+
);
|
|
19
|
+
const CLAUDE_DESKTOP_CLI_SYSTEM_PATH =
|
|
20
|
+
"/Applications/Claude Code URL Handler.app/Contents/MacOS/claude";
|
|
21
|
+
function isValidClaudeSessionId(sessionId: string): boolean {
|
|
22
|
+
if (sessionId.length === 0 || sessionId.length > 512) return false;
|
|
23
|
+
if (sessionId.startsWith("-")) return false;
|
|
24
|
+
for (const ch of sessionId) {
|
|
25
|
+
const code = ch.codePointAt(0);
|
|
26
|
+
if (code === undefined || code < 0x20 || code === 0x7f) return false;
|
|
27
|
+
}
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function invalidClaudeSessionIdError(): string {
|
|
32
|
+
return "claude-code: invalid sessionId (expected non-control text not starting with '-')";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Resolve the Claude Code CLI path on PATH or the macOS desktop bundle fallback. */
|
|
36
|
+
export function resolveClaudeCommand(deps: ProbeDeps = {}): string | null {
|
|
37
|
+
const onPath = resolveCommandOnPath("claude", deps);
|
|
38
|
+
if (onPath) return onPath;
|
|
39
|
+
if ((deps.platform ?? process.platform) !== "darwin") return null;
|
|
40
|
+
return firstExistingPath(
|
|
41
|
+
[resolveHomePath(CLAUDE_DESKTOP_CLI_RELATIVE_PATH, deps), CLAUDE_DESKTOP_CLI_SYSTEM_PATH],
|
|
42
|
+
deps,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Probe whether the Claude Code CLI is installed and report its version. */
|
|
47
|
+
export function probeClaude(deps: ProbeDeps = {}): RuntimeProbeResult {
|
|
48
|
+
const command = resolveClaudeCommand(deps);
|
|
49
|
+
if (!command) return { available: false };
|
|
50
|
+
return {
|
|
51
|
+
available: true,
|
|
52
|
+
path: command,
|
|
53
|
+
version: readCommandVersion(command, [], deps) ?? undefined,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Claude Code adapter — spawns `claude -p "<text>" --output-format stream-json`
|
|
59
|
+
* (with `--resume <sid>` when available) and parses the ndjson stream.
|
|
60
|
+
*
|
|
61
|
+
* stream-json shape (abridged):
|
|
62
|
+
* {type:"system", subtype:"init", session_id:"...", ...}
|
|
63
|
+
* {type:"assistant", message:{content:[{type:"text", text:"..."} | {type:"tool_use", ...}]}}
|
|
64
|
+
* {type:"user", message:{content:[{type:"tool_result", ...}]}}
|
|
65
|
+
* {type:"result", subtype:"success", session_id:"...", total_cost_usd: 0.01, result:"final text"}
|
|
66
|
+
*/
|
|
67
|
+
export class ClaudeCodeAdapter extends NdjsonStreamAdapter {
|
|
68
|
+
readonly id = "claude-code" as const;
|
|
69
|
+
|
|
70
|
+
private readonly explicitBinary: string | undefined;
|
|
71
|
+
private resolvedBinary: string | null = null;
|
|
72
|
+
|
|
73
|
+
constructor(opts?: { binary?: string }) {
|
|
74
|
+
super();
|
|
75
|
+
this.explicitBinary = opts?.binary ?? process.env.BOTCORD_CLAUDE_BIN;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
probe(): RuntimeProbeResult {
|
|
79
|
+
return probeClaude();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
override async run(opts: RuntimeRunOptions) {
|
|
83
|
+
if (opts.sessionId && !isValidClaudeSessionId(opts.sessionId)) {
|
|
84
|
+
return { text: "", newSessionId: "", error: invalidClaudeSessionIdError() };
|
|
85
|
+
}
|
|
86
|
+
return super.run(opts);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
protected resolveBinary(): string {
|
|
90
|
+
if (this.explicitBinary) return this.explicitBinary;
|
|
91
|
+
if (this.resolvedBinary) return this.resolvedBinary;
|
|
92
|
+
// Falls back to the macOS Claude Code URL Handler bundle when not on PATH.
|
|
93
|
+
this.resolvedBinary = resolveClaudeCommand() ?? "claude";
|
|
94
|
+
return this.resolvedBinary;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
protected buildArgs(opts: RuntimeRunOptions): string[] {
|
|
98
|
+
const args = ["-p", opts.text, "--output-format", "stream-json", "--verbose"];
|
|
99
|
+
if (opts.sessionId) {
|
|
100
|
+
if (!isValidClaudeSessionId(opts.sessionId)) throw new Error(invalidClaudeSessionIdError());
|
|
101
|
+
args.push("--resume", opts.sessionId);
|
|
102
|
+
}
|
|
103
|
+
// Permission-mode policy:
|
|
104
|
+
// - owner: acceptEdits (owner trusts their own agent).
|
|
105
|
+
// - non-owner (trusted/public): default (let Claude Code prompt / reject edits per its own rules).
|
|
106
|
+
// `extraArgs` still wins — operators who know what they're doing can override either.
|
|
107
|
+
if (!opts.extraArgs?.some((a) => a.startsWith("--permission-mode"))) {
|
|
108
|
+
if (opts.trustLevel === "owner") {
|
|
109
|
+
args.push("--permission-mode", "acceptEdits");
|
|
110
|
+
} else {
|
|
111
|
+
args.push("--permission-mode", "default");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Claude Code's `--append-system-prompt` is applied per invocation and NOT
|
|
115
|
+
// persisted in the resumed session transcript — ideal for memory / digest
|
|
116
|
+
// content that should re-evaluate every turn.
|
|
117
|
+
if (opts.systemContext && !opts.extraArgs?.includes("--append-system-prompt")) {
|
|
118
|
+
args.push("--append-system-prompt", opts.systemContext);
|
|
119
|
+
}
|
|
120
|
+
if (opts.extraArgs?.length) args.push(...opts.extraArgs);
|
|
121
|
+
return args;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
protected handleEvent(raw: unknown, ctx: NdjsonEventCtx): void {
|
|
125
|
+
const obj = raw as {
|
|
126
|
+
type?: string;
|
|
127
|
+
subtype?: string;
|
|
128
|
+
session_id?: string;
|
|
129
|
+
total_cost_usd?: number;
|
|
130
|
+
result?: string;
|
|
131
|
+
message?: { content?: Array<{ type?: string; text?: string }> };
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
ctx.emitBlock(normalizeBlock(obj, ctx.seq));
|
|
135
|
+
|
|
136
|
+
if (obj.type === "system" && obj.session_id) {
|
|
137
|
+
ctx.state.newSessionId = String(obj.session_id);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (obj.type === "assistant" && Array.isArray(obj.message?.content)) {
|
|
141
|
+
for (const c of obj.message.content) {
|
|
142
|
+
if (c?.type === "text" && typeof c.text === "string") {
|
|
143
|
+
ctx.appendAssistantText(c.text);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (obj.type === "result") {
|
|
149
|
+
if (typeof obj.total_cost_usd === "number") ctx.state.costUsd = obj.total_cost_usd;
|
|
150
|
+
if (obj.subtype === "success") {
|
|
151
|
+
if (typeof obj.session_id === "string") ctx.state.newSessionId = obj.session_id;
|
|
152
|
+
if (typeof obj.result === "string") ctx.state.finalText = obj.result;
|
|
153
|
+
} else {
|
|
154
|
+
// Non-success result (e.g. resume targeted a missing UUID). Claude Code
|
|
155
|
+
// still emits a fresh `session_id` for the just-spawned empty session —
|
|
156
|
+
// persisting it would trap us into resuming a useless UUID forever.
|
|
157
|
+
// Wipe newSessionId so the dispatcher deletes the stale entry instead.
|
|
158
|
+
// The CLI also exits non-zero, so the base adapter synthesizes errorText
|
|
159
|
+
// from stderr if `obj.result` is missing.
|
|
160
|
+
ctx.state.newSessionId = "";
|
|
161
|
+
if (typeof obj.result === "string") ctx.state.errorText = obj.result;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function normalizeBlock(obj: any, seq: number): StreamBlock {
|
|
168
|
+
let kind: StreamBlock["kind"] = "other";
|
|
169
|
+
if (obj?.type === "assistant") {
|
|
170
|
+
const contents = Array.isArray(obj.message?.content) ? obj.message.content : [];
|
|
171
|
+
if (contents.some((c: any) => c?.type === "tool_use")) kind = "tool_use";
|
|
172
|
+
else if (contents.some((c: any) => c?.type === "text")) kind = "assistant_text";
|
|
173
|
+
} else if (obj?.type === "user") {
|
|
174
|
+
const contents = Array.isArray(obj.message?.content) ? obj.message.content : [];
|
|
175
|
+
if (contents.some((c: any) => c?.type === "tool_result")) kind = "tool_result";
|
|
176
|
+
} else if (obj?.type === "system") {
|
|
177
|
+
kind = "system";
|
|
178
|
+
}
|
|
179
|
+
return { raw: obj, kind, seq };
|
|
180
|
+
}
|