@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,29 @@
1
+ import { readCommandVersion, resolveCommandOnPath, } from "./probe.js";
2
+ /** Resolve the Gemini CLI executable on PATH. */
3
+ export function resolveGeminiCommand(deps = {}) {
4
+ return resolveCommandOnPath("gemini", deps);
5
+ }
6
+ /** Probe whether the Gemini CLI is installed and report its version. */
7
+ export function probeGemini(deps = {}) {
8
+ const command = resolveGeminiCommand(deps);
9
+ if (!command)
10
+ return { available: false };
11
+ return {
12
+ available: true,
13
+ path: command,
14
+ version: readCommandVersion(command, [], deps) ?? undefined,
15
+ };
16
+ }
17
+ /**
18
+ * Gemini adapter stub — probe() is wired up so `botcord-daemon doctor` can report it.
19
+ * run() is not implemented yet; routing a turn here will surface the error upstream.
20
+ */
21
+ export class GeminiAdapter {
22
+ id = "gemini";
23
+ probe() {
24
+ return probeGemini();
25
+ }
26
+ async run(_opts) {
27
+ throw new Error("gemini adapter not implemented");
28
+ }
29
+ }
@@ -0,0 +1,43 @@
1
+ import type { RuntimeAdapter, RuntimeProbeResult, RuntimeRunOptions, RuntimeRunResult, StreamBlock } from "../types.js";
2
+ /**
3
+ * Mutable state threaded through event callbacks while a single turn runs.
4
+ * The base class reads these fields to assemble the final RuntimeRunResult.
5
+ */
6
+ export interface NdjsonRunState {
7
+ /** Session id to persist for `--resume`. Seeded with the incoming sessionId. */
8
+ newSessionId: string;
9
+ /** Final text reported by a terminal "result"/"completed" event, if any. */
10
+ finalText: string;
11
+ /** Streamed assistant text chunks; concatenated as a fallback when finalText is empty. */
12
+ assistantTextChunks: string[];
13
+ /** Running byte total of everything pushed to assistantTextChunks. */
14
+ assistantTextBytes: number;
15
+ /** True once the per-turn text cap was hit; further chunks are dropped. */
16
+ assistantTextCapped: boolean;
17
+ costUsd?: number;
18
+ errorText?: string;
19
+ }
20
+ /** Per-event context handed to subclasses from the ndjson dispatch loop. */
21
+ export interface NdjsonEventCtx {
22
+ state: NdjsonRunState;
23
+ /** 1-based sequence within this turn, identical to what `onBlock` would see. */
24
+ seq: number;
25
+ /** Forward a normalized StreamBlock to the caller's onBlock handler. */
26
+ emitBlock: (block: StreamBlock) => void;
27
+ /**
28
+ * Push streamed assistant text while respecting the per-turn byte cap.
29
+ * Subclasses should use this instead of `state.assistantTextChunks.push(...)`.
30
+ */
31
+ appendAssistantText: (text: string) => void;
32
+ }
33
+ /** Base class for runtime adapters that drive a CLI emitting newline-delimited JSON. */
34
+ export declare abstract class NdjsonStreamAdapter implements RuntimeAdapter {
35
+ abstract readonly id: string;
36
+ probe?(): RuntimeProbeResult;
37
+ protected abstract resolveBinary(opts: RuntimeRunOptions): string;
38
+ protected abstract buildArgs(opts: RuntimeRunOptions): string[];
39
+ protected abstract handleEvent(obj: unknown, ctx: NdjsonEventCtx): void;
40
+ /** Override to tweak env (FORCE_COLOR=0, NO_COLOR=1, etc). */
41
+ protected spawnEnv(_opts: RuntimeRunOptions): NodeJS.ProcessEnv;
42
+ run(opts: RuntimeRunOptions): Promise<RuntimeRunResult>;
43
+ }
@@ -0,0 +1,169 @@
1
+ import { spawn } from "node:child_process";
2
+ import { consoleLogger } from "../log.js";
3
+ const log = consoleLogger;
4
+ /**
5
+ * Common scaffold for CLI adapters that emit newline-delimited JSON on stdout.
6
+ * Subclasses plug in:
7
+ * - resolveBinary() — which executable to spawn
8
+ * - buildArgs() — argv tail (excluding the binary itself)
9
+ * - handleEvent() — how to interpret one parsed JSON object
10
+ *
11
+ * The base class handles spawn, abort wiring, stderr capping, line splitting,
12
+ * and exit-code error synthesis so every new runtime only writes the parts
13
+ * that are actually runtime-specific.
14
+ */
15
+ /** How much stderr is retained for error reporting. */
16
+ const STDERR_TAIL_CAP = 8 * 1024;
17
+ /** How much of the retained stderr is included in the synthesized exit-code error. */
18
+ const STDERR_ERROR_SNIPPET = 500;
19
+ /** Cap on total streamed assistant text bytes per turn — guards against a runaway CLI. */
20
+ const ASSISTANT_TEXT_CAP = 1 * 1024 * 1024;
21
+ /** Grace period between SIGTERM and SIGKILL when an abort is requested. */
22
+ const KILL_GRACE_MS = 5_000;
23
+ /** Base class for runtime adapters that drive a CLI emitting newline-delimited JSON. */
24
+ export class NdjsonStreamAdapter {
25
+ /** Override to tweak env (FORCE_COLOR=0, NO_COLOR=1, etc). */
26
+ spawnEnv(_opts) {
27
+ return process.env;
28
+ }
29
+ async run(opts) {
30
+ if (opts.signal.aborted) {
31
+ return {
32
+ text: "",
33
+ newSessionId: opts.sessionId ?? "",
34
+ error: `${this.id} aborted before spawn`,
35
+ };
36
+ }
37
+ const binary = this.resolveBinary(opts);
38
+ const args = this.buildArgs(opts);
39
+ log.debug(`${this.id} spawn`, {
40
+ cwd: opts.cwd,
41
+ sessionId: opts.sessionId,
42
+ argv: args,
43
+ });
44
+ const child = spawn(binary, args, {
45
+ cwd: opts.cwd,
46
+ env: this.spawnEnv(opts),
47
+ stdio: ["ignore", "pipe", "pipe"],
48
+ });
49
+ // Attach abort listener immediately — spawn is synchronous, but a racing
50
+ // `.abort()` between `spawn` and a listener added later would be lost.
51
+ let killTimer = null;
52
+ const onAbort = () => {
53
+ if (child.killed)
54
+ return;
55
+ child.kill("SIGTERM");
56
+ // Escalate to SIGKILL if the child ignores the polite request.
57
+ killTimer = setTimeout(() => {
58
+ if (!child.killed) {
59
+ log.warn(`${this.id} did not exit after SIGTERM; sending SIGKILL`);
60
+ try {
61
+ child.kill("SIGKILL");
62
+ }
63
+ catch {
64
+ // best-effort
65
+ }
66
+ }
67
+ }, KILL_GRACE_MS);
68
+ if (typeof killTimer.unref === "function")
69
+ killTimer.unref();
70
+ };
71
+ opts.signal.addEventListener("abort", onAbort, { once: true });
72
+ const state = {
73
+ newSessionId: opts.sessionId ?? "",
74
+ finalText: "",
75
+ assistantTextChunks: [],
76
+ assistantTextBytes: 0,
77
+ assistantTextCapped: false,
78
+ };
79
+ const appendAssistantText = (text) => {
80
+ if (!text)
81
+ return;
82
+ if (state.assistantTextCapped)
83
+ return;
84
+ const budget = ASSISTANT_TEXT_CAP - state.assistantTextBytes;
85
+ if (budget <= 0) {
86
+ state.assistantTextCapped = true;
87
+ log.warn(`${this.id} assistant text exceeded ${ASSISTANT_TEXT_CAP} bytes; dropping further chunks`);
88
+ return;
89
+ }
90
+ if (text.length > budget) {
91
+ state.assistantTextChunks.push(text.slice(0, budget));
92
+ state.assistantTextBytes += budget;
93
+ state.assistantTextCapped = true;
94
+ log.warn(`${this.id} assistant text hit ${ASSISTANT_TEXT_CAP}-byte cap`);
95
+ return;
96
+ }
97
+ state.assistantTextChunks.push(text);
98
+ state.assistantTextBytes += text.length;
99
+ };
100
+ let stderrTail = "";
101
+ child.stderr?.setEncoding("utf8");
102
+ child.stderr?.on("data", (chunk) => {
103
+ stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_CAP);
104
+ });
105
+ let seq = 0;
106
+ let stdoutBuf = "";
107
+ child.stdout.setEncoding("utf8");
108
+ const dispatchLine = (line) => {
109
+ if (!line)
110
+ return;
111
+ let obj;
112
+ try {
113
+ obj = JSON.parse(line);
114
+ }
115
+ catch {
116
+ log.warn(`${this.id} non-json stdout line`, { line: line.slice(0, 200) });
117
+ return;
118
+ }
119
+ seq += 1;
120
+ try {
121
+ this.handleEvent(obj, {
122
+ state,
123
+ seq,
124
+ emitBlock: (b) => opts.onBlock?.(b),
125
+ appendAssistantText,
126
+ });
127
+ }
128
+ catch (err) {
129
+ log.warn(`${this.id} event handler threw`, { err: String(err) });
130
+ }
131
+ };
132
+ child.stdout.on("data", (chunk) => {
133
+ stdoutBuf += chunk;
134
+ let idx;
135
+ while ((idx = stdoutBuf.indexOf("\n")) !== -1) {
136
+ const line = stdoutBuf.slice(0, idx).trim();
137
+ stdoutBuf = stdoutBuf.slice(idx + 1);
138
+ dispatchLine(line);
139
+ }
140
+ });
141
+ let code = 0;
142
+ try {
143
+ code = await new Promise((resolve, reject) => {
144
+ child.on("error", reject);
145
+ child.on("close", (c) => resolve(c ?? 0));
146
+ });
147
+ }
148
+ finally {
149
+ opts.signal.removeEventListener("abort", onAbort);
150
+ if (killTimer)
151
+ clearTimeout(killTimer);
152
+ }
153
+ // Flush any final line that lacked a terminating newline.
154
+ const residual = stdoutBuf.trim();
155
+ if (residual)
156
+ dispatchLine(residual);
157
+ if (code !== 0 && !state.errorText) {
158
+ state.errorText = `${this.id} exited with code ${code}: ${stderrTail.slice(-STDERR_ERROR_SNIPPET)}`;
159
+ }
160
+ const rawText = state.finalText || state.assistantTextChunks.join("").trim();
161
+ const text = rawText.length > ASSISTANT_TEXT_CAP ? rawText.slice(0, ASSISTANT_TEXT_CAP) : rawText;
162
+ return {
163
+ text,
164
+ newSessionId: state.newSessionId,
165
+ ...(state.costUsd !== undefined ? { costUsd: state.costUsd } : {}),
166
+ ...(state.errorText ? { error: state.errorText } : {}),
167
+ };
168
+ }
169
+ }
@@ -0,0 +1,17 @@
1
+ import { execFileSync } from "node:child_process";
2
+ /** Injection seam for PATH resolution + version probes, so tests can stub syscalls. */
3
+ export interface ProbeDeps {
4
+ platform?: NodeJS.Platform;
5
+ env?: NodeJS.ProcessEnv;
6
+ homeDir?: string;
7
+ execFileSyncFn?: typeof execFileSync;
8
+ existsSyncFn?: (p: string) => boolean;
9
+ }
10
+ /** Resolve a command name on PATH via `which`/`where`; returns null when missing. */
11
+ export declare function resolveCommandOnPath(command: string, deps?: ProbeDeps): string | null;
12
+ /** Return the first path in `candidates` that exists on disk, or null. */
13
+ export declare function firstExistingPath(candidates: string[], deps?: ProbeDeps): string | null;
14
+ /** Run `<command> [...args] --version` and return the first output line, or null. */
15
+ export declare function readCommandVersion(command: string, args?: string[], deps?: ProbeDeps): string | null;
16
+ /** Join `relativePath` against HOME (falls back to empty when unset). */
17
+ export declare function resolveHomePath(relativePath: string, deps?: ProbeDeps): string;
@@ -0,0 +1,54 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ function normalizeExecOutput(raw) {
5
+ return Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw ?? "");
6
+ }
7
+ /** Resolve a command name on PATH via `which`/`where`; returns null when missing. */
8
+ export function resolveCommandOnPath(command, deps = {}) {
9
+ const platform = deps.platform ?? process.platform;
10
+ const env = deps.env ?? process.env;
11
+ const execFn = deps.execFileSyncFn ?? execFileSync;
12
+ const locator = platform === "win32" ? "where" : "which";
13
+ try {
14
+ const out = normalizeExecOutput(execFn(locator, [command], {
15
+ stdio: ["ignore", "pipe", "ignore"],
16
+ env,
17
+ }));
18
+ const resolved = out.trim().split(/\r?\n/)[0];
19
+ return resolved || null;
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ /** Return the first path in `candidates` that exists on disk, or null. */
26
+ export function firstExistingPath(candidates, deps = {}) {
27
+ const exists = deps.existsSyncFn ?? existsSync;
28
+ for (const c of candidates) {
29
+ if (exists(c))
30
+ return c;
31
+ }
32
+ return null;
33
+ }
34
+ /** Run `<command> [...args] --version` and return the first output line, or null. */
35
+ export function readCommandVersion(command, args = [], deps = {}) {
36
+ const env = deps.env ?? process.env;
37
+ const execFn = deps.execFileSyncFn ?? execFileSync;
38
+ try {
39
+ const out = normalizeExecOutput(execFn(command, [...args, "--version"], {
40
+ stdio: ["ignore", "pipe", "pipe"],
41
+ env,
42
+ timeout: 5000,
43
+ }));
44
+ return out.trim().split(/\r?\n/)[0] || null;
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ /** Join `relativePath` against HOME (falls back to empty when unset). */
51
+ export function resolveHomePath(relativePath, deps = {}) {
52
+ const home = deps.homeDir ?? deps.env?.HOME ?? process.env.HOME ?? "";
53
+ return path.join(home, relativePath);
54
+ }
@@ -0,0 +1,59 @@
1
+ import type { RuntimeAdapter, RuntimeProbeResult } from "../types.js";
2
+ /**
3
+ * Metadata + factory for a single runtime adapter, used by the registry.
4
+ * Add a new runtime by exporting one of these from the adapter file and
5
+ * registering it in `REGISTRY` below.
6
+ */
7
+ export interface RuntimeModule {
8
+ id: string;
9
+ displayName: string;
10
+ /** Canonical PATH binary name — shown in `doctor` output. */
11
+ binary: string;
12
+ /**
13
+ * Env var that overrides the resolved CLI path for this adapter.
14
+ * Defaults to `BOTCORD_<ID>_BIN` with dashes → underscores, uppercased.
15
+ */
16
+ envVar?: string;
17
+ /** Module-level probe so callers don't have to instantiate the adapter. */
18
+ probe(): RuntimeProbeResult;
19
+ create(): RuntimeAdapter;
20
+ /**
21
+ * Whether `create().run()` is implemented. Defaults to true. Stubs
22
+ * (e.g. gemini until we wire its driver) should set `false` so the
23
+ * config loader rejects routing turns to this adapter.
24
+ */
25
+ supportsRun?: boolean;
26
+ }
27
+ /** Built-in runtime module entry for Claude Code. */
28
+ export declare const claudeCodeModule: RuntimeModule;
29
+ /** Built-in runtime module entry for Codex. */
30
+ export declare const codexModule: RuntimeModule;
31
+ /** Built-in runtime module entry for Gemini (probe-only stub). */
32
+ export declare const geminiModule: RuntimeModule;
33
+ /**
34
+ * Built-in runtime modules. To add a new runtime:
35
+ * 1. Create `runtimes/<name>.ts` extending `NdjsonStreamAdapter` (or
36
+ * implementing `RuntimeAdapter` directly).
37
+ * 2. Add a `RuntimeModule` entry + register it here.
38
+ */
39
+ export declare const RUNTIME_MODULES: readonly RuntimeModule[];
40
+ /** Lookup a runtime module by id, or null when the id is unknown. */
41
+ export declare function getRuntimeModule(id: string): RuntimeModule | null;
42
+ /** All registered runtime ids in registration order. */
43
+ export declare function listRuntimeIds(): string[];
44
+ /** Env var name used to override the binary path for a given runtime id. */
45
+ export declare function envVarForRuntime(id: string): string;
46
+ /** Instantiate a single runtime adapter by id; throws if unknown. */
47
+ export declare function createRuntime(id: string): RuntimeAdapter;
48
+ /** Instantiate every registered runtime — used as the dispatcher default. */
49
+ export declare function createAllRuntimes(): Record<string, RuntimeAdapter>;
50
+ /** One probe result per registered runtime, for `doctor`-style listings. */
51
+ export interface RuntimeProbeEntry {
52
+ id: string;
53
+ displayName: string;
54
+ binary: string;
55
+ supportsRun: boolean;
56
+ result: RuntimeProbeResult;
57
+ }
58
+ /** Probe every registered runtime and report installation status. */
59
+ export declare function detectRuntimes(): RuntimeProbeEntry[];
@@ -0,0 +1,94 @@
1
+ import { ClaudeCodeAdapter, probeClaude } from "./claude-code.js";
2
+ import { CodexAdapter, probeCodex } from "./codex.js";
3
+ import { GeminiAdapter, probeGemini } from "./gemini.js";
4
+ /** Built-in runtime module entry for Claude Code. */
5
+ export const claudeCodeModule = {
6
+ id: "claude-code",
7
+ displayName: "Claude Code",
8
+ binary: "claude",
9
+ envVar: "BOTCORD_CLAUDE_BIN",
10
+ probe: () => probeClaude(),
11
+ create: () => new ClaudeCodeAdapter(),
12
+ };
13
+ /** Built-in runtime module entry for Codex. */
14
+ export const codexModule = {
15
+ id: "codex",
16
+ displayName: "Codex CLI",
17
+ binary: "codex",
18
+ probe: () => probeCodex(),
19
+ create: () => new CodexAdapter(),
20
+ };
21
+ /** Built-in runtime module entry for Gemini (probe-only stub). */
22
+ export const geminiModule = {
23
+ id: "gemini",
24
+ displayName: "Gemini CLI",
25
+ binary: "gemini",
26
+ probe: () => probeGemini(),
27
+ create: () => new GeminiAdapter(),
28
+ supportsRun: false,
29
+ };
30
+ /**
31
+ * Built-in runtime modules. To add a new runtime:
32
+ * 1. Create `runtimes/<name>.ts` extending `NdjsonStreamAdapter` (or
33
+ * implementing `RuntimeAdapter` directly).
34
+ * 2. Add a `RuntimeModule` entry + register it here.
35
+ */
36
+ export const RUNTIME_MODULES = [
37
+ claudeCodeModule,
38
+ codexModule,
39
+ geminiModule,
40
+ ];
41
+ const BY_ID = new Map(RUNTIME_MODULES.map((m) => [m.id, m]));
42
+ /** Lookup a runtime module by id, or null when the id is unknown. */
43
+ export function getRuntimeModule(id) {
44
+ return BY_ID.get(id) ?? null;
45
+ }
46
+ /** All registered runtime ids in registration order. */
47
+ export function listRuntimeIds() {
48
+ return RUNTIME_MODULES.map((m) => m.id);
49
+ }
50
+ /** Env var name used to override the binary path for a given runtime id. */
51
+ export function envVarForRuntime(id) {
52
+ const mod = getRuntimeModule(id);
53
+ if (mod?.envVar)
54
+ return mod.envVar;
55
+ const token = id.replace(/-/g, "_").toUpperCase();
56
+ return `BOTCORD_${token}_BIN`;
57
+ }
58
+ /** Instantiate a single runtime adapter by id; throws if unknown. */
59
+ export function createRuntime(id) {
60
+ const mod = getRuntimeModule(id);
61
+ if (!mod) {
62
+ throw new Error(`Unknown runtime "${id}". Registered runtimes: ${listRuntimeIds().join(", ")}`);
63
+ }
64
+ return mod.create();
65
+ }
66
+ /** Instantiate every registered runtime — used as the dispatcher default. */
67
+ export function createAllRuntimes() {
68
+ const map = {};
69
+ for (const m of RUNTIME_MODULES) {
70
+ map[m.id] = m.create();
71
+ }
72
+ return map;
73
+ }
74
+ /** Probe every registered runtime and report installation status. */
75
+ export function detectRuntimes() {
76
+ const out = [];
77
+ for (const m of RUNTIME_MODULES) {
78
+ let result = { available: false };
79
+ try {
80
+ result = m.probe();
81
+ }
82
+ catch {
83
+ result = { available: false };
84
+ }
85
+ out.push({
86
+ id: m.id,
87
+ displayName: m.displayName,
88
+ binary: m.binary,
89
+ supportsRun: m.supportsRun !== false,
90
+ result,
91
+ });
92
+ }
93
+ return out;
94
+ }
@@ -0,0 +1,39 @@
1
+ import type { GatewayLogger } from "./log.js";
2
+ import type { GatewaySessionEntry, SessionKeyInput } from "./types.js";
3
+ export declare const DEFAULT_SESSION_STORE_MAX_ENTRY_AGE_MS: number;
4
+ /** Derive the canonical session-store key for a runtime + channel + conversation. */
5
+ export declare function sessionKey(input: SessionKeyInput): string;
6
+ /** Options for constructing a `SessionStore`. */
7
+ export interface SessionStoreOptions {
8
+ path: string;
9
+ log?: GatewayLogger;
10
+ /** Optional TTL for persisted entries. Omit to disable automatic pruning. */
11
+ maxEntryAgeMs?: number;
12
+ }
13
+ /** JSON-backed session store for runtime resume ids, keyed by `sessionKey()`. */
14
+ export declare class SessionStore {
15
+ private readonly filePath;
16
+ private readonly log?;
17
+ private readonly maxEntryAgeMs?;
18
+ private data;
19
+ private loaded;
20
+ private writeQueue;
21
+ constructor(opts: SessionStoreOptions);
22
+ /** Load entries from disk. Tolerates missing or corrupt files. */
23
+ load(): Promise<void>;
24
+ /** Look up an entry by its full session key. */
25
+ get(key: string): GatewaySessionEntry | undefined;
26
+ /** Upsert an entry and persist the store atomically. */
27
+ set(entry: GatewaySessionEntry): Promise<void>;
28
+ /** Remove an entry and persist the store atomically. */
29
+ delete(key: string): Promise<void>;
30
+ /** Snapshot of all entries (for status/debugging). */
31
+ all(): GatewaySessionEntry[];
32
+ /** Remove entries whose `updatedAt` is older than `maxAgeMs`; returns count removed. */
33
+ pruneExpired(opts: {
34
+ maxAgeMs: number;
35
+ now?: number;
36
+ }): Promise<number>;
37
+ private persist;
38
+ private flushOnce;
39
+ }
@@ -0,0 +1,133 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ export const DEFAULT_SESSION_STORE_MAX_ENTRY_AGE_MS = 30 * 24 * 60 * 60 * 1000;
4
+ /** Derive the canonical session-store key for a runtime + channel + conversation. */
5
+ export function sessionKey(input) {
6
+ const base = `${input.runtime}:${input.channel}:${input.accountId}:${input.conversationKind}:${input.conversationId}`;
7
+ const thread = input.threadId;
8
+ if (typeof thread === "string" && thread.length > 0) {
9
+ return `${base}:${thread}`;
10
+ }
11
+ return base;
12
+ }
13
+ function emptyFile() {
14
+ return { version: 1, entries: {} };
15
+ }
16
+ function isValidShape(x) {
17
+ if (!x || typeof x !== "object")
18
+ return false;
19
+ const o = x;
20
+ return o.version === 1 && !!o.entries && typeof o.entries === "object";
21
+ }
22
+ /** JSON-backed session store for runtime resume ids, keyed by `sessionKey()`. */
23
+ export class SessionStore {
24
+ filePath;
25
+ log;
26
+ maxEntryAgeMs;
27
+ data = emptyFile();
28
+ loaded = false;
29
+ writeQueue = Promise.resolve();
30
+ constructor(opts) {
31
+ this.filePath = opts.path;
32
+ this.log = opts.log;
33
+ if (Number.isFinite(opts.maxEntryAgeMs) && opts.maxEntryAgeMs > 0) {
34
+ this.maxEntryAgeMs = opts.maxEntryAgeMs;
35
+ }
36
+ }
37
+ /** Load entries from disk. Tolerates missing or corrupt files. */
38
+ async load() {
39
+ let raw;
40
+ try {
41
+ raw = await readFile(this.filePath, "utf8");
42
+ }
43
+ catch (err) {
44
+ const code = err.code;
45
+ if (code === "ENOENT") {
46
+ this.data = emptyFile();
47
+ this.loaded = true;
48
+ return;
49
+ }
50
+ throw err;
51
+ }
52
+ try {
53
+ const parsed = JSON.parse(raw);
54
+ if (isValidShape(parsed)) {
55
+ this.data = { version: 1, entries: parsed.entries };
56
+ }
57
+ else {
58
+ this.log?.warn("gateway.session-store.invalid-shape", { path: this.filePath });
59
+ this.data = emptyFile();
60
+ }
61
+ }
62
+ catch (err) {
63
+ this.log?.warn("gateway.session-store.parse-error", {
64
+ path: this.filePath,
65
+ error: err.message,
66
+ });
67
+ this.data = emptyFile();
68
+ }
69
+ this.loaded = true;
70
+ if (this.maxEntryAgeMs !== undefined) {
71
+ await this.pruneExpired({ maxAgeMs: this.maxEntryAgeMs });
72
+ }
73
+ }
74
+ /** Look up an entry by its full session key. */
75
+ get(key) {
76
+ return this.data.entries[key];
77
+ }
78
+ /** Upsert an entry and persist the store atomically. */
79
+ async set(entry) {
80
+ const callerTs = entry.updatedAt;
81
+ const updatedAt = Number.isFinite(callerTs) && callerTs > 0 ? callerTs : Date.now();
82
+ this.data.entries[entry.key] = { ...entry, updatedAt };
83
+ await this.persist();
84
+ }
85
+ /** Remove an entry and persist the store atomically. */
86
+ async delete(key) {
87
+ if (this.data.entries[key] !== undefined) {
88
+ delete this.data.entries[key];
89
+ }
90
+ await this.persist();
91
+ }
92
+ /** Snapshot of all entries (for status/debugging). */
93
+ all() {
94
+ return Object.values(this.data.entries);
95
+ }
96
+ /** Remove entries whose `updatedAt` is older than `maxAgeMs`; returns count removed. */
97
+ async pruneExpired(opts) {
98
+ const maxAgeMs = opts.maxAgeMs;
99
+ if (!Number.isFinite(maxAgeMs) || maxAgeMs <= 0)
100
+ return 0;
101
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
102
+ const cutoff = now - maxAgeMs;
103
+ let removed = 0;
104
+ for (const [key, entry] of Object.entries(this.data.entries)) {
105
+ if (!Number.isFinite(entry.updatedAt) || entry.updatedAt < cutoff) {
106
+ delete this.data.entries[key];
107
+ removed += 1;
108
+ }
109
+ }
110
+ if (removed > 0) {
111
+ this.log?.info("gateway.session-store.pruned-expired", {
112
+ path: this.filePath,
113
+ removed,
114
+ maxAgeMs,
115
+ });
116
+ await this.persist();
117
+ }
118
+ return removed;
119
+ }
120
+ persist() {
121
+ const next = this.writeQueue.then(() => this.flushOnce());
122
+ this.writeQueue = next.catch(() => undefined);
123
+ return next;
124
+ }
125
+ async flushOnce() {
126
+ const dir = path.dirname(this.filePath);
127
+ await mkdir(dir, { recursive: true });
128
+ const tmp = `${this.filePath}.${process.pid}.tmp`;
129
+ const payload = JSON.stringify(this.data, null, 2);
130
+ await writeFile(tmp, payload, { mode: 0o600 });
131
+ await rename(tmp, this.filePath);
132
+ }
133
+ }