@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,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
+ }