@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,312 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { agentCodexHomeDir, ensureAgentCodexHome } from "../../agent-workspace.js";
5
+ import { NdjsonStreamAdapter, type NdjsonEventCtx } from "./ndjson-stream.js";
6
+ import {
7
+ firstExistingPath,
8
+ readCommandVersion,
9
+ resolveCommandOnPath,
10
+ type ProbeDeps,
11
+ } from "./probe.js";
12
+ import type { RuntimeProbeResult, RuntimeRunOptions, StreamBlock } from "../types.js";
13
+
14
+ const CODEX_DESKTOP_BUNDLE_PATH = "/Applications/Codex.app/Contents/Resources/codex";
15
+ /** Codex UUIDv7 / v4 session ids are 36-char dashed hex; reject anything else to keep argv safe. */
16
+ const CODEX_SESSION_ID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
17
+
18
+ /** Resolve the Codex CLI executable via PATH or macOS desktop bundle. */
19
+ export function resolveCodexCommand(deps: ProbeDeps = {}): string | null {
20
+ const onPath = resolveCommandOnPath("codex", deps);
21
+ if (onPath) return onPath;
22
+ return firstExistingPath([CODEX_DESKTOP_BUNDLE_PATH], deps);
23
+ }
24
+
25
+ function resolveCodexGlobalNpmEntry(): string | null {
26
+ try {
27
+ const globalRoot = execFileSync("npm", ["root", "-g"], {
28
+ encoding: "utf8",
29
+ stdio: ["ignore", "pipe", "ignore"],
30
+ timeout: 5000,
31
+ }).trim();
32
+ if (!globalRoot) return null;
33
+ const candidate = path.join(globalRoot, "@openai", "codex", "bin", "codex.js");
34
+ return existsSync(candidate) ? candidate : null;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ /** Probe whether the Codex CLI is installed and report its version. */
41
+ export function probeCodex(deps: ProbeDeps = {}): RuntimeProbeResult {
42
+ const command = resolveCodexCommand(deps);
43
+ if (command) {
44
+ return {
45
+ available: true,
46
+ path: command,
47
+ version: readCommandVersion(command, [], deps) ?? undefined,
48
+ };
49
+ }
50
+ const npmEntry = resolveCodexGlobalNpmEntry();
51
+ if (npmEntry) {
52
+ return {
53
+ available: true,
54
+ path: npmEntry,
55
+ version: readCommandVersion(process.execPath, [npmEntry], deps) ?? undefined,
56
+ };
57
+ }
58
+ return { available: false };
59
+ }
60
+
61
+ /**
62
+ * Codex adapter — spawns `codex exec [resume <sid>] --json ...` and parses the
63
+ * JSONL event stream.
64
+ *
65
+ * Event shape (abridged):
66
+ * {"type":"thread.started","thread_id":"<uuid>"}
67
+ * {"type":"turn.started"}
68
+ * {"type":"item.started","item":{"type":"command_execution", ...}}
69
+ * {"type":"item.completed","item":{"type":"agent_message","text":"..."}}
70
+ * {"type":"turn.completed","usage":{...}}
71
+ *
72
+ * `codex exec` does not report USD cost — only token usage — so `costUsd` is
73
+ * not populated from this adapter.
74
+ *
75
+ * ## systemContext injection: per-agent CODEX_HOME + AGENTS.md
76
+ *
77
+ * Codex has no `--append-system-prompt` equivalent. Its documented way to
78
+ * inject instructions that do NOT land in the stored transcript is the
79
+ * `AGENTS.md` loaded from `<CODEX_HOME>/AGENTS.md` (alongside the user-global
80
+ * `~/.codex/AGENTS.md` and the cwd's `<cwd>/AGENTS.md`).
81
+ *
82
+ * This adapter therefore:
83
+ * 1. Points `CODEX_HOME` at a per-agent directory:
84
+ * `~/.botcord/agents/<accountId>/codex-home/`
85
+ * 2. Writes `opts.systemContext` to `<CODEX_HOME>/AGENTS.md` atomically
86
+ * (tmp + rename) before spawning the child.
87
+ * 3. Leaves the positional prompt as just `opts.text` — no more prepending
88
+ * systemContext to the transcript.
89
+ *
90
+ * With the transcript no longer accumulating systemContext, resume is safe to
91
+ * turn back on: `thread.started.thread_id` is persisted as `newSessionId`, and
92
+ * when the next turn arrives with a sessionId the adapter runs `exec resume
93
+ * <sid>` instead of `exec`. The per-agent CODEX_HOME also isolates codex's
94
+ * `sessions/` directory from `~/.codex/sessions/`, so daemon-owned sessions
95
+ * don't pollute the user's interactive session picker.
96
+ *
97
+ * ## `exec resume` flag quirk
98
+ *
99
+ * `codex exec resume` accepts a smaller flag set than `codex exec` — notably
100
+ * `-s / --sandbox` is NOT accepted on `resume`. We therefore express sandbox
101
+ * policy as `-c sandbox_mode="..."` (a `-c` override works on both
102
+ * subcommands) and the same tail of flags applies to both paths.
103
+ */
104
+ export class CodexAdapter extends NdjsonStreamAdapter {
105
+ readonly id = "codex" as const;
106
+
107
+ private readonly explicitBinary: string | undefined;
108
+ private resolvedBinary: string | null = null;
109
+
110
+ constructor(opts?: { binary?: string }) {
111
+ super();
112
+ this.explicitBinary = opts?.binary ?? process.env.BOTCORD_CODEX_BIN;
113
+ }
114
+
115
+ probe(): RuntimeProbeResult {
116
+ return probeCodex();
117
+ }
118
+
119
+ /**
120
+ * Validate the sessionId shape and materialize the per-agent CODEX_HOME +
121
+ * AGENTS.md before handing off to the base adapter's spawn loop. Both steps
122
+ * must run BEFORE `super.run()` because `spawnEnv()` and `buildArgs()` are
123
+ * called synchronously from inside it and read the filesystem state we set
124
+ * up here.
125
+ */
126
+ override async run(opts: RuntimeRunOptions) {
127
+ if (opts.sessionId && !CODEX_SESSION_ID_RE.test(opts.sessionId)) {
128
+ throw new Error(`codex: invalid sessionId "${opts.sessionId}" (expected UUID)`);
129
+ }
130
+ if (opts.accountId) {
131
+ try {
132
+ ensureAgentCodexHome(opts.accountId);
133
+ writeCodexAgentsMd(opts.accountId, opts.systemContext);
134
+ } catch (err) {
135
+ // Writing AGENTS.md should never abort the turn — log and fall
136
+ // through. The child will spawn without the dynamic systemContext,
137
+ // which degrades to "codex replies without this turn's memory
138
+ // snapshot" rather than silence.
139
+ // eslint-disable-next-line no-console
140
+ console.warn("codex: failed to prepare CODEX_HOME/AGENTS.md", err);
141
+ }
142
+ }
143
+ return super.run(opts);
144
+ }
145
+
146
+ protected resolveBinary(): string {
147
+ if (this.explicitBinary) return this.explicitBinary;
148
+ if (this.resolvedBinary) return this.resolvedBinary;
149
+ // Use the executable resolver only — probeCodex's npm-global fallback
150
+ // yields a `.js` path that can't be spawned directly.
151
+ this.resolvedBinary = resolveCodexCommand() ?? "codex";
152
+ return this.resolvedBinary;
153
+ }
154
+
155
+ /**
156
+ * `extraArgs` are passed as Codex CLI flags (inserted before `--`), not
157
+ * prompt text. Use the route config's `extraArgs` for flags like
158
+ * `-c model="..."`, not for extra prompt content.
159
+ *
160
+ * Layout for fresh session: `exec <tail> -- <prompt>`
161
+ * Layout for resume: `exec resume <sid> <tail> -- <prompt>`
162
+ *
163
+ * Both paths share the same `<tail>`: sandbox/approval policy (as `-c`
164
+ * overrides so `resume` accepts them), `--skip-git-repo-check`, `--json`,
165
+ * and operator `extraArgs`.
166
+ */
167
+ protected buildArgs(opts: RuntimeRunOptions): string[] {
168
+ const tail: string[] = [];
169
+
170
+ // Sandbox / approval policy. Expressed as `-c` overrides because
171
+ // `codex exec resume` rejects `-s` / `--full-auto`. `-c` works on both
172
+ // the fresh `exec` and `exec resume` paths.
173
+ // - owner turn: bypass approvals + sandbox (owner trusts their agent)
174
+ // - non-owner turn: `workspace-write` sandbox + on-request approvals
175
+ const hasSandboxOverride =
176
+ opts.extraArgs?.some(
177
+ (a) =>
178
+ a === "-s" ||
179
+ a.startsWith("--sandbox") ||
180
+ a === "--full-auto" ||
181
+ a === "--dangerously-bypass-approvals-and-sandbox" ||
182
+ a.startsWith("-c sandbox_mode=") ||
183
+ a.startsWith("-csandbox_mode="),
184
+ ) ?? false;
185
+ if (!hasSandboxOverride) {
186
+ if (opts.trustLevel === "owner") {
187
+ tail.push(
188
+ "-c",
189
+ 'sandbox_mode="danger-full-access"',
190
+ "-c",
191
+ 'approval_policy="never"',
192
+ );
193
+ } else {
194
+ tail.push(
195
+ "-c",
196
+ 'sandbox_mode="workspace-write"',
197
+ "-c",
198
+ 'approval_policy="on-request"',
199
+ );
200
+ }
201
+ }
202
+ tail.push("--skip-git-repo-check", "--json");
203
+ if (opts.extraArgs?.length) tail.push(...opts.extraArgs);
204
+
205
+ // `--` separates flags from positionals so a prompt starting with `-`
206
+ // can never be parsed as an option. `systemContext` is NOT prepended to
207
+ // the prompt any more — it lives in `<CODEX_HOME>/AGENTS.md` written by
208
+ // `run()` — so the transcript stays clean across resumes.
209
+ const prompt = opts.text;
210
+ if (opts.sessionId) {
211
+ return ["exec", "resume", opts.sessionId, ...tail, "--", prompt];
212
+ }
213
+ return ["exec", ...tail, "--", prompt];
214
+ }
215
+
216
+ protected spawnEnv(opts: RuntimeRunOptions): NodeJS.ProcessEnv {
217
+ const env: NodeJS.ProcessEnv = {
218
+ ...process.env,
219
+ // Keep JSONL free of ANSI codes regardless of user terminal settings.
220
+ FORCE_COLOR: "0",
221
+ NO_COLOR: "1",
222
+ };
223
+ if (opts.accountId) {
224
+ env.CODEX_HOME = agentCodexHomeDir(opts.accountId);
225
+ }
226
+ return env;
227
+ }
228
+
229
+ protected handleEvent(raw: unknown, ctx: NdjsonEventCtx): void {
230
+ const obj = raw as {
231
+ type?: string;
232
+ thread_id?: string;
233
+ item?: { type?: string; text?: string };
234
+ error?: { message?: string } | string;
235
+ turn?: { status?: string; error?: { message?: string } };
236
+ };
237
+
238
+ ctx.emitBlock(normalizeBlock(obj, ctx.seq));
239
+
240
+ // Persist the thread_id so the next turn on this session key resumes
241
+ // instead of spawning fresh. Safe now that systemContext lives in
242
+ // AGENTS.md rather than the transcript.
243
+ if (obj.type === "thread.started") {
244
+ if (typeof obj.thread_id === "string") {
245
+ ctx.state.newSessionId = obj.thread_id;
246
+ }
247
+ return;
248
+ }
249
+
250
+ if (obj.type === "item.completed" && obj.item?.type === "agent_message") {
251
+ if (typeof obj.item.text === "string") {
252
+ ctx.appendAssistantText(obj.item.text);
253
+ // The last agent_message is the final reply.
254
+ ctx.state.finalText = obj.item.text;
255
+ }
256
+ return;
257
+ }
258
+
259
+ if (obj.type === "turn.completed" && obj.turn?.status === "failed") {
260
+ const msg = obj.turn.error?.message;
261
+ if (typeof msg === "string" && msg) ctx.state.errorText = msg;
262
+ return;
263
+ }
264
+
265
+ if (obj.type === "error") {
266
+ ctx.state.errorText =
267
+ typeof obj.error === "string"
268
+ ? obj.error
269
+ : obj.error?.message ?? "codex error";
270
+ }
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Atomically overwrite `<CODEX_HOME>/AGENTS.md` with `systemContext`. codex
276
+ * reads this file at process start, so the write must complete before spawn.
277
+ * An empty or missing systemContext writes an empty file — deleting would
278
+ * race with a prior turn's file still being readable; empty is simpler and
279
+ * codex treats it as "no user-global AGENTS.md".
280
+ */
281
+ function writeCodexAgentsMd(accountId: string, systemContext: string | undefined): void {
282
+ const dir = agentCodexHomeDir(accountId);
283
+ // ensureAgentCodexHome already mkdir's dir; defensive mkdir here too for
284
+ // code paths that invoke this helper directly (tests, future callers).
285
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
286
+ const target = path.join(dir, "AGENTS.md");
287
+ const tmp = path.join(dir, `.AGENTS.md.${process.pid}.tmp`);
288
+ writeFileSync(tmp, systemContext ?? "", { mode: 0o600 });
289
+ renameSync(tmp, target);
290
+ }
291
+
292
+ function normalizeBlock(obj: any, seq: number): StreamBlock {
293
+ let kind: StreamBlock["kind"] = "other";
294
+ const type: string | undefined = obj?.type;
295
+ const itemType: string | undefined = obj?.item?.type;
296
+
297
+ if (type === "thread.started" || type === "turn.started" || type === "turn.completed") {
298
+ kind = "system";
299
+ } else if (type === "item.completed" && itemType === "agent_message") {
300
+ kind = "assistant_text";
301
+ } else if (type === "item.started" || type === "item.completed") {
302
+ if (
303
+ itemType === "command_execution" ||
304
+ itemType === "file_change" ||
305
+ itemType === "mcp_tool_call" ||
306
+ itemType === "web_search"
307
+ ) {
308
+ kind = "tool_use";
309
+ }
310
+ }
311
+ return { raw: obj, kind, seq };
312
+ }
@@ -0,0 +1,43 @@
1
+ import {
2
+ readCommandVersion,
3
+ resolveCommandOnPath,
4
+ type ProbeDeps,
5
+ } from "./probe.js";
6
+ import type {
7
+ RuntimeAdapter,
8
+ RuntimeProbeResult,
9
+ RuntimeRunOptions,
10
+ RuntimeRunResult,
11
+ } from "../types.js";
12
+
13
+ /** Resolve the Gemini CLI executable on PATH. */
14
+ export function resolveGeminiCommand(deps: ProbeDeps = {}): string | null {
15
+ return resolveCommandOnPath("gemini", deps);
16
+ }
17
+
18
+ /** Probe whether the Gemini CLI is installed and report its version. */
19
+ export function probeGemini(deps: ProbeDeps = {}): RuntimeProbeResult {
20
+ const command = resolveGeminiCommand(deps);
21
+ if (!command) return { available: false };
22
+ return {
23
+ available: true,
24
+ path: command,
25
+ version: readCommandVersion(command, [], deps) ?? undefined,
26
+ };
27
+ }
28
+
29
+ /**
30
+ * Gemini adapter stub — probe() is wired up so `botcord-daemon doctor` can report it.
31
+ * run() is not implemented yet; routing a turn here will surface the error upstream.
32
+ */
33
+ export class GeminiAdapter implements RuntimeAdapter {
34
+ readonly id = "gemini" as const;
35
+
36
+ probe(): RuntimeProbeResult {
37
+ return probeGemini();
38
+ }
39
+
40
+ async run(_opts: RuntimeRunOptions): Promise<RuntimeRunResult> {
41
+ throw new Error("gemini adapter not implemented");
42
+ }
43
+ }
@@ -0,0 +1,225 @@
1
+ import { spawn } from "node:child_process";
2
+ import { consoleLogger } from "../log.js";
3
+ import type {
4
+ RuntimeAdapter,
5
+ RuntimeProbeResult,
6
+ RuntimeRunOptions,
7
+ RuntimeRunResult,
8
+ StreamBlock,
9
+ } from "../types.js";
10
+
11
+ /**
12
+ * Mutable state threaded through event callbacks while a single turn runs.
13
+ * The base class reads these fields to assemble the final RuntimeRunResult.
14
+ */
15
+ export interface NdjsonRunState {
16
+ /** Session id to persist for `--resume`. Seeded with the incoming sessionId. */
17
+ newSessionId: string;
18
+ /** Final text reported by a terminal "result"/"completed" event, if any. */
19
+ finalText: string;
20
+ /** Streamed assistant text chunks; concatenated as a fallback when finalText is empty. */
21
+ assistantTextChunks: string[];
22
+ /** Running byte total of everything pushed to assistantTextChunks. */
23
+ assistantTextBytes: number;
24
+ /** True once the per-turn text cap was hit; further chunks are dropped. */
25
+ assistantTextCapped: boolean;
26
+ costUsd?: number;
27
+ errorText?: string;
28
+ }
29
+
30
+ /** Per-event context handed to subclasses from the ndjson dispatch loop. */
31
+ export interface NdjsonEventCtx {
32
+ state: NdjsonRunState;
33
+ /** 1-based sequence within this turn, identical to what `onBlock` would see. */
34
+ seq: number;
35
+ /** Forward a normalized StreamBlock to the caller's onBlock handler. */
36
+ emitBlock: (block: StreamBlock) => void;
37
+ /**
38
+ * Push streamed assistant text while respecting the per-turn byte cap.
39
+ * Subclasses should use this instead of `state.assistantTextChunks.push(...)`.
40
+ */
41
+ appendAssistantText: (text: string) => void;
42
+ }
43
+
44
+ const log = consoleLogger;
45
+
46
+ /**
47
+ * Common scaffold for CLI adapters that emit newline-delimited JSON on stdout.
48
+ * Subclasses plug in:
49
+ * - resolveBinary() — which executable to spawn
50
+ * - buildArgs() — argv tail (excluding the binary itself)
51
+ * - handleEvent() — how to interpret one parsed JSON object
52
+ *
53
+ * The base class handles spawn, abort wiring, stderr capping, line splitting,
54
+ * and exit-code error synthesis so every new runtime only writes the parts
55
+ * that are actually runtime-specific.
56
+ */
57
+ /** How much stderr is retained for error reporting. */
58
+ const STDERR_TAIL_CAP = 8 * 1024;
59
+ /** How much of the retained stderr is included in the synthesized exit-code error. */
60
+ const STDERR_ERROR_SNIPPET = 500;
61
+ /** Cap on total streamed assistant text bytes per turn — guards against a runaway CLI. */
62
+ const ASSISTANT_TEXT_CAP = 1 * 1024 * 1024;
63
+ /** Grace period between SIGTERM and SIGKILL when an abort is requested. */
64
+ const KILL_GRACE_MS = 5_000;
65
+
66
+ /** Base class for runtime adapters that drive a CLI emitting newline-delimited JSON. */
67
+ export abstract class NdjsonStreamAdapter implements RuntimeAdapter {
68
+ abstract readonly id: string;
69
+
70
+ probe?(): RuntimeProbeResult;
71
+
72
+ protected abstract resolveBinary(opts: RuntimeRunOptions): string;
73
+ protected abstract buildArgs(opts: RuntimeRunOptions): string[];
74
+ protected abstract handleEvent(obj: unknown, ctx: NdjsonEventCtx): void;
75
+
76
+ /** Override to tweak env (FORCE_COLOR=0, NO_COLOR=1, etc). */
77
+ protected spawnEnv(_opts: RuntimeRunOptions): NodeJS.ProcessEnv {
78
+ return process.env;
79
+ }
80
+
81
+ async run(opts: RuntimeRunOptions): Promise<RuntimeRunResult> {
82
+ if (opts.signal.aborted) {
83
+ return {
84
+ text: "",
85
+ newSessionId: opts.sessionId ?? "",
86
+ error: `${this.id} aborted before spawn`,
87
+ };
88
+ }
89
+
90
+ const binary = this.resolveBinary(opts);
91
+ const args = this.buildArgs(opts);
92
+
93
+ log.debug(`${this.id} spawn`, {
94
+ cwd: opts.cwd,
95
+ sessionId: opts.sessionId,
96
+ argv: args,
97
+ });
98
+
99
+ const child = spawn(binary, args, {
100
+ cwd: opts.cwd,
101
+ env: this.spawnEnv(opts),
102
+ stdio: ["ignore", "pipe", "pipe"],
103
+ });
104
+
105
+ // Attach abort listener immediately — spawn is synchronous, but a racing
106
+ // `.abort()` between `spawn` and a listener added later would be lost.
107
+ let killTimer: ReturnType<typeof setTimeout> | null = null;
108
+ const onAbort = () => {
109
+ if (child.killed) return;
110
+ child.kill("SIGTERM");
111
+ // Escalate to SIGKILL if the child ignores the polite request.
112
+ killTimer = setTimeout(() => {
113
+ if (!child.killed) {
114
+ log.warn(`${this.id} did not exit after SIGTERM; sending SIGKILL`);
115
+ try {
116
+ child.kill("SIGKILL");
117
+ } catch {
118
+ // best-effort
119
+ }
120
+ }
121
+ }, KILL_GRACE_MS);
122
+ if (typeof killTimer.unref === "function") killTimer.unref();
123
+ };
124
+ opts.signal.addEventListener("abort", onAbort, { once: true });
125
+
126
+ const state: NdjsonRunState = {
127
+ newSessionId: opts.sessionId ?? "",
128
+ finalText: "",
129
+ assistantTextChunks: [],
130
+ assistantTextBytes: 0,
131
+ assistantTextCapped: false,
132
+ };
133
+
134
+ const appendAssistantText = (text: string): void => {
135
+ if (!text) return;
136
+ if (state.assistantTextCapped) return;
137
+ const budget = ASSISTANT_TEXT_CAP - state.assistantTextBytes;
138
+ if (budget <= 0) {
139
+ state.assistantTextCapped = true;
140
+ log.warn(`${this.id} assistant text exceeded ${ASSISTANT_TEXT_CAP} bytes; dropping further chunks`);
141
+ return;
142
+ }
143
+ if (text.length > budget) {
144
+ state.assistantTextChunks.push(text.slice(0, budget));
145
+ state.assistantTextBytes += budget;
146
+ state.assistantTextCapped = true;
147
+ log.warn(`${this.id} assistant text hit ${ASSISTANT_TEXT_CAP}-byte cap`);
148
+ return;
149
+ }
150
+ state.assistantTextChunks.push(text);
151
+ state.assistantTextBytes += text.length;
152
+ };
153
+
154
+ let stderrTail = "";
155
+ child.stderr?.setEncoding("utf8");
156
+ child.stderr?.on("data", (chunk: string) => {
157
+ stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_CAP);
158
+ });
159
+
160
+ let seq = 0;
161
+ let stdoutBuf = "";
162
+ child.stdout!.setEncoding("utf8");
163
+ const dispatchLine = (line: string) => {
164
+ if (!line) return;
165
+ let obj: unknown;
166
+ try {
167
+ obj = JSON.parse(line);
168
+ } catch {
169
+ log.warn(`${this.id} non-json stdout line`, { line: line.slice(0, 200) });
170
+ return;
171
+ }
172
+ seq += 1;
173
+ try {
174
+ this.handleEvent(obj, {
175
+ state,
176
+ seq,
177
+ emitBlock: (b) => opts.onBlock?.(b),
178
+ appendAssistantText,
179
+ });
180
+ } catch (err) {
181
+ log.warn(`${this.id} event handler threw`, { err: String(err) });
182
+ }
183
+ };
184
+
185
+ child.stdout!.on("data", (chunk: string) => {
186
+ stdoutBuf += chunk;
187
+ let idx: number;
188
+ while ((idx = stdoutBuf.indexOf("\n")) !== -1) {
189
+ const line = stdoutBuf.slice(0, idx).trim();
190
+ stdoutBuf = stdoutBuf.slice(idx + 1);
191
+ dispatchLine(line);
192
+ }
193
+ });
194
+
195
+ let code = 0;
196
+ try {
197
+ code = await new Promise<number>((resolve, reject) => {
198
+ child.on("error", reject);
199
+ child.on("close", (c) => resolve(c ?? 0));
200
+ });
201
+ } finally {
202
+ opts.signal.removeEventListener("abort", onAbort);
203
+ if (killTimer) clearTimeout(killTimer);
204
+ }
205
+
206
+ // Flush any final line that lacked a terminating newline.
207
+ const residual = stdoutBuf.trim();
208
+ if (residual) dispatchLine(residual);
209
+
210
+ if (code !== 0 && !state.errorText) {
211
+ state.errorText = `${this.id} exited with code ${code}: ${stderrTail.slice(-STDERR_ERROR_SNIPPET)}`;
212
+ }
213
+
214
+ const rawText = state.finalText || state.assistantTextChunks.join("").trim();
215
+ const text =
216
+ rawText.length > ASSISTANT_TEXT_CAP ? rawText.slice(0, ASSISTANT_TEXT_CAP) : rawText;
217
+
218
+ return {
219
+ text,
220
+ newSessionId: state.newSessionId,
221
+ ...(state.costUsd !== undefined ? { costUsd: state.costUsd } : {}),
222
+ ...(state.errorText ? { error: state.errorText } : {}),
223
+ };
224
+ }
225
+ }
@@ -0,0 +1,73 @@
1
+ import { execFileSync, type ExecFileSyncOptions } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+
5
+ /** Injection seam for PATH resolution + version probes, so tests can stub syscalls. */
6
+ export interface ProbeDeps {
7
+ platform?: NodeJS.Platform;
8
+ env?: NodeJS.ProcessEnv;
9
+ homeDir?: string;
10
+ execFileSyncFn?: typeof execFileSync;
11
+ existsSyncFn?: (p: string) => boolean;
12
+ }
13
+
14
+ function normalizeExecOutput(raw: Buffer | string | null | undefined): string {
15
+ return Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw ?? "");
16
+ }
17
+
18
+ /** Resolve a command name on PATH via `which`/`where`; returns null when missing. */
19
+ export function resolveCommandOnPath(command: string, deps: ProbeDeps = {}): string | null {
20
+ const platform = deps.platform ?? process.platform;
21
+ const env = deps.env ?? process.env;
22
+ const execFn = deps.execFileSyncFn ?? execFileSync;
23
+ const locator = platform === "win32" ? "where" : "which";
24
+ try {
25
+ const out = normalizeExecOutput(
26
+ execFn(locator, [command], {
27
+ stdio: ["ignore", "pipe", "ignore"],
28
+ env,
29
+ } as ExecFileSyncOptions),
30
+ );
31
+ const resolved = out.trim().split(/\r?\n/)[0];
32
+ return resolved || null;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /** Return the first path in `candidates` that exists on disk, or null. */
39
+ export function firstExistingPath(candidates: string[], deps: ProbeDeps = {}): string | null {
40
+ const exists = deps.existsSyncFn ?? existsSync;
41
+ for (const c of candidates) {
42
+ if (exists(c)) return c;
43
+ }
44
+ return null;
45
+ }
46
+
47
+ /** Run `<command> [...args] --version` and return the first output line, or null. */
48
+ export function readCommandVersion(
49
+ command: string,
50
+ args: string[] = [],
51
+ deps: ProbeDeps = {},
52
+ ): string | null {
53
+ const env = deps.env ?? process.env;
54
+ const execFn = deps.execFileSyncFn ?? execFileSync;
55
+ try {
56
+ const out = normalizeExecOutput(
57
+ execFn(command, [...args, "--version"], {
58
+ stdio: ["ignore", "pipe", "pipe"],
59
+ env,
60
+ timeout: 5000,
61
+ } as ExecFileSyncOptions),
62
+ );
63
+ return out.trim().split(/\r?\n/)[0] || null;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /** Join `relativePath` against HOME (falls back to empty when unset). */
70
+ export function resolveHomePath(relativePath: string, deps: ProbeDeps = {}): string {
71
+ const home = deps.homeDir ?? deps.env?.HOME ?? process.env.HOME ?? "";
72
+ return path.join(home, relativePath);
73
+ }