@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,15 @@
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 } from "./session-store.js";
7
+ export { resolveRoute, matchesRoute } from "./router.js";
8
+ export { ChannelManager } from "./channel-manager.js";
9
+ export { Dispatcher } from "./dispatcher.js";
10
+ export { Gateway } from "./gateway.js";
11
+ export { ClaudeCodeAdapter, probeClaude, resolveClaudeCommand, } from "./runtimes/claude-code.js";
12
+ export { CodexAdapter, probeCodex, resolveCodexCommand } from "./runtimes/codex.js";
13
+ export { GeminiAdapter, probeGemini, resolveGeminiCommand } from "./runtimes/gemini.js";
14
+ export { NdjsonStreamAdapter, } from "./runtimes/ndjson-stream.js";
15
+ export { firstExistingPath, readCommandVersion, resolveCommandOnPath, resolveHomePath, } from "./runtimes/probe.js";
@@ -0,0 +1,9 @@
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
+ /** Default logger that writes JSON lines to stderr; debug lines gated by BOTCORD_GATEWAY_DEBUG. */
9
+ export declare const consoleLogger: GatewayLogger;
@@ -0,0 +1,20 @@
1
+ function write(level, msg, meta) {
2
+ const line = JSON.stringify({
3
+ ts: new Date().toISOString(),
4
+ level,
5
+ msg,
6
+ ...(meta ?? {}),
7
+ });
8
+ // Always write to stderr so stdout stays clean for NDJSON-style channel output.
9
+ process.stderr.write(line + "\n");
10
+ }
11
+ /** Default logger that writes JSON lines to stderr; debug lines gated by BOTCORD_GATEWAY_DEBUG. */
12
+ export const consoleLogger = {
13
+ info: (msg, meta) => write("info", msg, meta),
14
+ warn: (msg, meta) => write("warn", msg, meta),
15
+ error: (msg, meta) => write("error", msg, meta),
16
+ debug: (msg, meta) => {
17
+ if (process.env.BOTCORD_GATEWAY_DEBUG)
18
+ write("debug", msg, meta);
19
+ },
20
+ };
@@ -0,0 +1,10 @@
1
+ import type { GatewayConfig, GatewayInboundMessage, GatewayRoute, RouteMatch } from "./types.js";
2
+ /** Returns true if every provided field of `match` matches `message`; undefined match matches all. */
3
+ export declare function matchesRoute(message: GatewayInboundMessage, match: RouteMatch | undefined): boolean;
4
+ /**
5
+ * Picks the first matching route in priority order:
6
+ * 1. `config.routes[]` (user-authored)
7
+ * 2. `managedRoutes` (daemon-synthesized per-agent)
8
+ * 3. `config.defaultRoute`
9
+ */
10
+ export declare function resolveRoute(message: GatewayInboundMessage, config: Pick<GatewayConfig, "defaultRoute" | "routes">, managedRoutes?: readonly GatewayRoute[]): GatewayRoute;
@@ -0,0 +1,48 @@
1
+ /** Returns true if every provided field of `match` matches `message`; undefined match matches all. */
2
+ export function matchesRoute(message, match) {
3
+ if (!match)
4
+ return true;
5
+ if (match.channel !== undefined && match.channel !== message.channel)
6
+ return false;
7
+ if (match.accountId !== undefined && match.accountId !== message.accountId)
8
+ return false;
9
+ if (match.conversationId !== undefined && match.conversationId !== message.conversation.id) {
10
+ return false;
11
+ }
12
+ if (match.conversationPrefix !== undefined &&
13
+ !message.conversation.id.startsWith(match.conversationPrefix)) {
14
+ return false;
15
+ }
16
+ if (match.conversationKind !== undefined &&
17
+ match.conversationKind !== message.conversation.kind) {
18
+ return false;
19
+ }
20
+ if (match.senderId !== undefined && match.senderId !== message.sender.id)
21
+ return false;
22
+ if (match.mentioned !== undefined) {
23
+ const actual = message.mentioned ?? false;
24
+ if (match.mentioned !== actual)
25
+ return false;
26
+ }
27
+ return true;
28
+ }
29
+ /**
30
+ * Picks the first matching route in priority order:
31
+ * 1. `config.routes[]` (user-authored)
32
+ * 2. `managedRoutes` (daemon-synthesized per-agent)
33
+ * 3. `config.defaultRoute`
34
+ */
35
+ export function resolveRoute(message, config, managedRoutes) {
36
+ const routes = config.routes ?? [];
37
+ for (const route of routes) {
38
+ if (matchesRoute(message, route.match))
39
+ return route;
40
+ }
41
+ if (managedRoutes) {
42
+ for (const route of managedRoutes) {
43
+ if (matchesRoute(message, route.match))
44
+ return route;
45
+ }
46
+ }
47
+ return config.defaultRoute;
48
+ }
@@ -0,0 +1,30 @@
1
+ import { NdjsonStreamAdapter, type NdjsonEventCtx } from "./ndjson-stream.js";
2
+ import { type ProbeDeps } from "./probe.js";
3
+ import type { RuntimeProbeResult, RuntimeRunOptions } from "../types.js";
4
+ /** Resolve the Claude Code CLI path on PATH or the macOS desktop bundle fallback. */
5
+ export declare function resolveClaudeCommand(deps?: ProbeDeps): string | null;
6
+ /** Probe whether the Claude Code CLI is installed and report its version. */
7
+ export declare function probeClaude(deps?: ProbeDeps): RuntimeProbeResult;
8
+ /**
9
+ * Claude Code adapter — spawns `claude -p "<text>" --output-format stream-json`
10
+ * (with `--resume <sid>` when available) and parses the ndjson stream.
11
+ *
12
+ * stream-json shape (abridged):
13
+ * {type:"system", subtype:"init", session_id:"...", ...}
14
+ * {type:"assistant", message:{content:[{type:"text", text:"..."} | {type:"tool_use", ...}]}}
15
+ * {type:"user", message:{content:[{type:"tool_result", ...}]}}
16
+ * {type:"result", subtype:"success", session_id:"...", total_cost_usd: 0.01, result:"final text"}
17
+ */
18
+ export declare class ClaudeCodeAdapter extends NdjsonStreamAdapter {
19
+ readonly id: "claude-code";
20
+ private readonly explicitBinary;
21
+ private resolvedBinary;
22
+ constructor(opts?: {
23
+ binary?: string;
24
+ });
25
+ probe(): RuntimeProbeResult;
26
+ run(opts: RuntimeRunOptions): Promise<import("../types.js").RuntimeRunResult>;
27
+ protected resolveBinary(): string;
28
+ protected buildArgs(opts: RuntimeRunOptions): string[];
29
+ protected handleEvent(raw: unknown, ctx: NdjsonEventCtx): void;
30
+ }
@@ -0,0 +1,162 @@
1
+ import path from "node:path";
2
+ import { NdjsonStreamAdapter } from "./ndjson-stream.js";
3
+ import { firstExistingPath, readCommandVersion, resolveCommandOnPath, resolveHomePath, } from "./probe.js";
4
+ const CLAUDE_DESKTOP_CLI_RELATIVE_PATH = path.join("Applications", "Claude Code URL Handler.app", "Contents", "MacOS", "claude");
5
+ const CLAUDE_DESKTOP_CLI_SYSTEM_PATH = "/Applications/Claude Code URL Handler.app/Contents/MacOS/claude";
6
+ function isValidClaudeSessionId(sessionId) {
7
+ if (sessionId.length === 0 || sessionId.length > 512)
8
+ return false;
9
+ if (sessionId.startsWith("-"))
10
+ return false;
11
+ for (const ch of sessionId) {
12
+ const code = ch.codePointAt(0);
13
+ if (code === undefined || code < 0x20 || code === 0x7f)
14
+ return false;
15
+ }
16
+ return true;
17
+ }
18
+ function invalidClaudeSessionIdError() {
19
+ return "claude-code: invalid sessionId (expected non-control text not starting with '-')";
20
+ }
21
+ /** Resolve the Claude Code CLI path on PATH or the macOS desktop bundle fallback. */
22
+ export function resolveClaudeCommand(deps = {}) {
23
+ const onPath = resolveCommandOnPath("claude", deps);
24
+ if (onPath)
25
+ return onPath;
26
+ if ((deps.platform ?? process.platform) !== "darwin")
27
+ return null;
28
+ return firstExistingPath([resolveHomePath(CLAUDE_DESKTOP_CLI_RELATIVE_PATH, deps), CLAUDE_DESKTOP_CLI_SYSTEM_PATH], deps);
29
+ }
30
+ /** Probe whether the Claude Code CLI is installed and report its version. */
31
+ export function probeClaude(deps = {}) {
32
+ const command = resolveClaudeCommand(deps);
33
+ if (!command)
34
+ return { available: false };
35
+ return {
36
+ available: true,
37
+ path: command,
38
+ version: readCommandVersion(command, [], deps) ?? undefined,
39
+ };
40
+ }
41
+ /**
42
+ * Claude Code adapter — spawns `claude -p "<text>" --output-format stream-json`
43
+ * (with `--resume <sid>` when available) and parses the ndjson stream.
44
+ *
45
+ * stream-json shape (abridged):
46
+ * {type:"system", subtype:"init", session_id:"...", ...}
47
+ * {type:"assistant", message:{content:[{type:"text", text:"..."} | {type:"tool_use", ...}]}}
48
+ * {type:"user", message:{content:[{type:"tool_result", ...}]}}
49
+ * {type:"result", subtype:"success", session_id:"...", total_cost_usd: 0.01, result:"final text"}
50
+ */
51
+ export class ClaudeCodeAdapter extends NdjsonStreamAdapter {
52
+ id = "claude-code";
53
+ explicitBinary;
54
+ resolvedBinary = null;
55
+ constructor(opts) {
56
+ super();
57
+ this.explicitBinary = opts?.binary ?? process.env.BOTCORD_CLAUDE_BIN;
58
+ }
59
+ probe() {
60
+ return probeClaude();
61
+ }
62
+ async run(opts) {
63
+ if (opts.sessionId && !isValidClaudeSessionId(opts.sessionId)) {
64
+ return { text: "", newSessionId: "", error: invalidClaudeSessionIdError() };
65
+ }
66
+ return super.run(opts);
67
+ }
68
+ resolveBinary() {
69
+ if (this.explicitBinary)
70
+ return this.explicitBinary;
71
+ if (this.resolvedBinary)
72
+ return this.resolvedBinary;
73
+ // Falls back to the macOS Claude Code URL Handler bundle when not on PATH.
74
+ this.resolvedBinary = resolveClaudeCommand() ?? "claude";
75
+ return this.resolvedBinary;
76
+ }
77
+ buildArgs(opts) {
78
+ const args = ["-p", opts.text, "--output-format", "stream-json", "--verbose"];
79
+ if (opts.sessionId) {
80
+ if (!isValidClaudeSessionId(opts.sessionId))
81
+ throw new Error(invalidClaudeSessionIdError());
82
+ args.push("--resume", opts.sessionId);
83
+ }
84
+ // Permission-mode policy:
85
+ // - owner: acceptEdits (owner trusts their own agent).
86
+ // - non-owner (trusted/public): default (let Claude Code prompt / reject edits per its own rules).
87
+ // `extraArgs` still wins — operators who know what they're doing can override either.
88
+ if (!opts.extraArgs?.some((a) => a.startsWith("--permission-mode"))) {
89
+ if (opts.trustLevel === "owner") {
90
+ args.push("--permission-mode", "acceptEdits");
91
+ }
92
+ else {
93
+ args.push("--permission-mode", "default");
94
+ }
95
+ }
96
+ // Claude Code's `--append-system-prompt` is applied per invocation and NOT
97
+ // persisted in the resumed session transcript — ideal for memory / digest
98
+ // content that should re-evaluate every turn.
99
+ if (opts.systemContext && !opts.extraArgs?.includes("--append-system-prompt")) {
100
+ args.push("--append-system-prompt", opts.systemContext);
101
+ }
102
+ if (opts.extraArgs?.length)
103
+ args.push(...opts.extraArgs);
104
+ return args;
105
+ }
106
+ handleEvent(raw, ctx) {
107
+ const obj = raw;
108
+ ctx.emitBlock(normalizeBlock(obj, ctx.seq));
109
+ if (obj.type === "system" && obj.session_id) {
110
+ ctx.state.newSessionId = String(obj.session_id);
111
+ return;
112
+ }
113
+ if (obj.type === "assistant" && Array.isArray(obj.message?.content)) {
114
+ for (const c of obj.message.content) {
115
+ if (c?.type === "text" && typeof c.text === "string") {
116
+ ctx.appendAssistantText(c.text);
117
+ }
118
+ }
119
+ return;
120
+ }
121
+ if (obj.type === "result") {
122
+ if (typeof obj.total_cost_usd === "number")
123
+ ctx.state.costUsd = obj.total_cost_usd;
124
+ if (obj.subtype === "success") {
125
+ if (typeof obj.session_id === "string")
126
+ ctx.state.newSessionId = obj.session_id;
127
+ if (typeof obj.result === "string")
128
+ ctx.state.finalText = obj.result;
129
+ }
130
+ else {
131
+ // Non-success result (e.g. resume targeted a missing UUID). Claude Code
132
+ // still emits a fresh `session_id` for the just-spawned empty session —
133
+ // persisting it would trap us into resuming a useless UUID forever.
134
+ // Wipe newSessionId so the dispatcher deletes the stale entry instead.
135
+ // The CLI also exits non-zero, so the base adapter synthesizes errorText
136
+ // from stderr if `obj.result` is missing.
137
+ ctx.state.newSessionId = "";
138
+ if (typeof obj.result === "string")
139
+ ctx.state.errorText = obj.result;
140
+ }
141
+ }
142
+ }
143
+ }
144
+ function normalizeBlock(obj, seq) {
145
+ let kind = "other";
146
+ if (obj?.type === "assistant") {
147
+ const contents = Array.isArray(obj.message?.content) ? obj.message.content : [];
148
+ if (contents.some((c) => c?.type === "tool_use"))
149
+ kind = "tool_use";
150
+ else if (contents.some((c) => c?.type === "text"))
151
+ kind = "assistant_text";
152
+ }
153
+ else if (obj?.type === "user") {
154
+ const contents = Array.isArray(obj.message?.content) ? obj.message.content : [];
155
+ if (contents.some((c) => c?.type === "tool_result"))
156
+ kind = "tool_result";
157
+ }
158
+ else if (obj?.type === "system") {
159
+ kind = "system";
160
+ }
161
+ return { raw: obj, kind, seq };
162
+ }
@@ -0,0 +1,83 @@
1
+ import { NdjsonStreamAdapter, type NdjsonEventCtx } from "./ndjson-stream.js";
2
+ import { type ProbeDeps } from "./probe.js";
3
+ import type { RuntimeProbeResult, RuntimeRunOptions } from "../types.js";
4
+ /** Resolve the Codex CLI executable via PATH or macOS desktop bundle. */
5
+ export declare function resolveCodexCommand(deps?: ProbeDeps): string | null;
6
+ /** Probe whether the Codex CLI is installed and report its version. */
7
+ export declare function probeCodex(deps?: ProbeDeps): RuntimeProbeResult;
8
+ /**
9
+ * Codex adapter — spawns `codex exec [resume <sid>] --json ...` and parses the
10
+ * JSONL event stream.
11
+ *
12
+ * Event shape (abridged):
13
+ * {"type":"thread.started","thread_id":"<uuid>"}
14
+ * {"type":"turn.started"}
15
+ * {"type":"item.started","item":{"type":"command_execution", ...}}
16
+ * {"type":"item.completed","item":{"type":"agent_message","text":"..."}}
17
+ * {"type":"turn.completed","usage":{...}}
18
+ *
19
+ * `codex exec` does not report USD cost — only token usage — so `costUsd` is
20
+ * not populated from this adapter.
21
+ *
22
+ * ## systemContext injection: per-agent CODEX_HOME + AGENTS.md
23
+ *
24
+ * Codex has no `--append-system-prompt` equivalent. Its documented way to
25
+ * inject instructions that do NOT land in the stored transcript is the
26
+ * `AGENTS.md` loaded from `<CODEX_HOME>/AGENTS.md` (alongside the user-global
27
+ * `~/.codex/AGENTS.md` and the cwd's `<cwd>/AGENTS.md`).
28
+ *
29
+ * This adapter therefore:
30
+ * 1. Points `CODEX_HOME` at a per-agent directory:
31
+ * `~/.botcord/agents/<accountId>/codex-home/`
32
+ * 2. Writes `opts.systemContext` to `<CODEX_HOME>/AGENTS.md` atomically
33
+ * (tmp + rename) before spawning the child.
34
+ * 3. Leaves the positional prompt as just `opts.text` — no more prepending
35
+ * systemContext to the transcript.
36
+ *
37
+ * With the transcript no longer accumulating systemContext, resume is safe to
38
+ * turn back on: `thread.started.thread_id` is persisted as `newSessionId`, and
39
+ * when the next turn arrives with a sessionId the adapter runs `exec resume
40
+ * <sid>` instead of `exec`. The per-agent CODEX_HOME also isolates codex's
41
+ * `sessions/` directory from `~/.codex/sessions/`, so daemon-owned sessions
42
+ * don't pollute the user's interactive session picker.
43
+ *
44
+ * ## `exec resume` flag quirk
45
+ *
46
+ * `codex exec resume` accepts a smaller flag set than `codex exec` — notably
47
+ * `-s / --sandbox` is NOT accepted on `resume`. We therefore express sandbox
48
+ * policy as `-c sandbox_mode="..."` (a `-c` override works on both
49
+ * subcommands) and the same tail of flags applies to both paths.
50
+ */
51
+ export declare class CodexAdapter extends NdjsonStreamAdapter {
52
+ readonly id: "codex";
53
+ private readonly explicitBinary;
54
+ private resolvedBinary;
55
+ constructor(opts?: {
56
+ binary?: string;
57
+ });
58
+ probe(): RuntimeProbeResult;
59
+ /**
60
+ * Validate the sessionId shape and materialize the per-agent CODEX_HOME +
61
+ * AGENTS.md before handing off to the base adapter's spawn loop. Both steps
62
+ * must run BEFORE `super.run()` because `spawnEnv()` and `buildArgs()` are
63
+ * called synchronously from inside it and read the filesystem state we set
64
+ * up here.
65
+ */
66
+ run(opts: RuntimeRunOptions): Promise<import("../types.js").RuntimeRunResult>;
67
+ protected resolveBinary(): string;
68
+ /**
69
+ * `extraArgs` are passed as Codex CLI flags (inserted before `--`), not
70
+ * prompt text. Use the route config's `extraArgs` for flags like
71
+ * `-c model="..."`, not for extra prompt content.
72
+ *
73
+ * Layout for fresh session: `exec <tail> -- <prompt>`
74
+ * Layout for resume: `exec resume <sid> <tail> -- <prompt>`
75
+ *
76
+ * Both paths share the same `<tail>`: sandbox/approval policy (as `-c`
77
+ * overrides so `resume` accepts them), `--skip-git-repo-check`, `--json`,
78
+ * and operator `extraArgs`.
79
+ */
80
+ protected buildArgs(opts: RuntimeRunOptions): string[];
81
+ protected spawnEnv(opts: RuntimeRunOptions): NodeJS.ProcessEnv;
82
+ protected handleEvent(raw: unknown, ctx: NdjsonEventCtx): void;
83
+ }
@@ -0,0 +1,272 @@
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 } from "./ndjson-stream.js";
6
+ import { firstExistingPath, readCommandVersion, resolveCommandOnPath, } from "./probe.js";
7
+ const CODEX_DESKTOP_BUNDLE_PATH = "/Applications/Codex.app/Contents/Resources/codex";
8
+ /** Codex UUIDv7 / v4 session ids are 36-char dashed hex; reject anything else to keep argv safe. */
9
+ 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}$/;
10
+ /** Resolve the Codex CLI executable via PATH or macOS desktop bundle. */
11
+ export function resolveCodexCommand(deps = {}) {
12
+ const onPath = resolveCommandOnPath("codex", deps);
13
+ if (onPath)
14
+ return onPath;
15
+ return firstExistingPath([CODEX_DESKTOP_BUNDLE_PATH], deps);
16
+ }
17
+ function resolveCodexGlobalNpmEntry() {
18
+ try {
19
+ const globalRoot = execFileSync("npm", ["root", "-g"], {
20
+ encoding: "utf8",
21
+ stdio: ["ignore", "pipe", "ignore"],
22
+ timeout: 5000,
23
+ }).trim();
24
+ if (!globalRoot)
25
+ return null;
26
+ const candidate = path.join(globalRoot, "@openai", "codex", "bin", "codex.js");
27
+ return existsSync(candidate) ? candidate : null;
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ /** Probe whether the Codex CLI is installed and report its version. */
34
+ export function probeCodex(deps = {}) {
35
+ const command = resolveCodexCommand(deps);
36
+ if (command) {
37
+ return {
38
+ available: true,
39
+ path: command,
40
+ version: readCommandVersion(command, [], deps) ?? undefined,
41
+ };
42
+ }
43
+ const npmEntry = resolveCodexGlobalNpmEntry();
44
+ if (npmEntry) {
45
+ return {
46
+ available: true,
47
+ path: npmEntry,
48
+ version: readCommandVersion(process.execPath, [npmEntry], deps) ?? undefined,
49
+ };
50
+ }
51
+ return { available: false };
52
+ }
53
+ /**
54
+ * Codex adapter — spawns `codex exec [resume <sid>] --json ...` and parses the
55
+ * JSONL event stream.
56
+ *
57
+ * Event shape (abridged):
58
+ * {"type":"thread.started","thread_id":"<uuid>"}
59
+ * {"type":"turn.started"}
60
+ * {"type":"item.started","item":{"type":"command_execution", ...}}
61
+ * {"type":"item.completed","item":{"type":"agent_message","text":"..."}}
62
+ * {"type":"turn.completed","usage":{...}}
63
+ *
64
+ * `codex exec` does not report USD cost — only token usage — so `costUsd` is
65
+ * not populated from this adapter.
66
+ *
67
+ * ## systemContext injection: per-agent CODEX_HOME + AGENTS.md
68
+ *
69
+ * Codex has no `--append-system-prompt` equivalent. Its documented way to
70
+ * inject instructions that do NOT land in the stored transcript is the
71
+ * `AGENTS.md` loaded from `<CODEX_HOME>/AGENTS.md` (alongside the user-global
72
+ * `~/.codex/AGENTS.md` and the cwd's `<cwd>/AGENTS.md`).
73
+ *
74
+ * This adapter therefore:
75
+ * 1. Points `CODEX_HOME` at a per-agent directory:
76
+ * `~/.botcord/agents/<accountId>/codex-home/`
77
+ * 2. Writes `opts.systemContext` to `<CODEX_HOME>/AGENTS.md` atomically
78
+ * (tmp + rename) before spawning the child.
79
+ * 3. Leaves the positional prompt as just `opts.text` — no more prepending
80
+ * systemContext to the transcript.
81
+ *
82
+ * With the transcript no longer accumulating systemContext, resume is safe to
83
+ * turn back on: `thread.started.thread_id` is persisted as `newSessionId`, and
84
+ * when the next turn arrives with a sessionId the adapter runs `exec resume
85
+ * <sid>` instead of `exec`. The per-agent CODEX_HOME also isolates codex's
86
+ * `sessions/` directory from `~/.codex/sessions/`, so daemon-owned sessions
87
+ * don't pollute the user's interactive session picker.
88
+ *
89
+ * ## `exec resume` flag quirk
90
+ *
91
+ * `codex exec resume` accepts a smaller flag set than `codex exec` — notably
92
+ * `-s / --sandbox` is NOT accepted on `resume`. We therefore express sandbox
93
+ * policy as `-c sandbox_mode="..."` (a `-c` override works on both
94
+ * subcommands) and the same tail of flags applies to both paths.
95
+ */
96
+ export class CodexAdapter extends NdjsonStreamAdapter {
97
+ id = "codex";
98
+ explicitBinary;
99
+ resolvedBinary = null;
100
+ constructor(opts) {
101
+ super();
102
+ this.explicitBinary = opts?.binary ?? process.env.BOTCORD_CODEX_BIN;
103
+ }
104
+ probe() {
105
+ return probeCodex();
106
+ }
107
+ /**
108
+ * Validate the sessionId shape and materialize the per-agent CODEX_HOME +
109
+ * AGENTS.md before handing off to the base adapter's spawn loop. Both steps
110
+ * must run BEFORE `super.run()` because `spawnEnv()` and `buildArgs()` are
111
+ * called synchronously from inside it and read the filesystem state we set
112
+ * up here.
113
+ */
114
+ async run(opts) {
115
+ if (opts.sessionId && !CODEX_SESSION_ID_RE.test(opts.sessionId)) {
116
+ throw new Error(`codex: invalid sessionId "${opts.sessionId}" (expected UUID)`);
117
+ }
118
+ if (opts.accountId) {
119
+ try {
120
+ ensureAgentCodexHome(opts.accountId);
121
+ writeCodexAgentsMd(opts.accountId, opts.systemContext);
122
+ }
123
+ catch (err) {
124
+ // Writing AGENTS.md should never abort the turn — log and fall
125
+ // through. The child will spawn without the dynamic systemContext,
126
+ // which degrades to "codex replies without this turn's memory
127
+ // snapshot" rather than silence.
128
+ // eslint-disable-next-line no-console
129
+ console.warn("codex: failed to prepare CODEX_HOME/AGENTS.md", err);
130
+ }
131
+ }
132
+ return super.run(opts);
133
+ }
134
+ resolveBinary() {
135
+ if (this.explicitBinary)
136
+ return this.explicitBinary;
137
+ if (this.resolvedBinary)
138
+ return this.resolvedBinary;
139
+ // Use the executable resolver only — probeCodex's npm-global fallback
140
+ // yields a `.js` path that can't be spawned directly.
141
+ this.resolvedBinary = resolveCodexCommand() ?? "codex";
142
+ return this.resolvedBinary;
143
+ }
144
+ /**
145
+ * `extraArgs` are passed as Codex CLI flags (inserted before `--`), not
146
+ * prompt text. Use the route config's `extraArgs` for flags like
147
+ * `-c model="..."`, not for extra prompt content.
148
+ *
149
+ * Layout for fresh session: `exec <tail> -- <prompt>`
150
+ * Layout for resume: `exec resume <sid> <tail> -- <prompt>`
151
+ *
152
+ * Both paths share the same `<tail>`: sandbox/approval policy (as `-c`
153
+ * overrides so `resume` accepts them), `--skip-git-repo-check`, `--json`,
154
+ * and operator `extraArgs`.
155
+ */
156
+ buildArgs(opts) {
157
+ const tail = [];
158
+ // Sandbox / approval policy. Expressed as `-c` overrides because
159
+ // `codex exec resume` rejects `-s` / `--full-auto`. `-c` works on both
160
+ // the fresh `exec` and `exec resume` paths.
161
+ // - owner turn: bypass approvals + sandbox (owner trusts their agent)
162
+ // - non-owner turn: `workspace-write` sandbox + on-request approvals
163
+ const hasSandboxOverride = opts.extraArgs?.some((a) => a === "-s" ||
164
+ a.startsWith("--sandbox") ||
165
+ a === "--full-auto" ||
166
+ a === "--dangerously-bypass-approvals-and-sandbox" ||
167
+ a.startsWith("-c sandbox_mode=") ||
168
+ a.startsWith("-csandbox_mode=")) ?? false;
169
+ if (!hasSandboxOverride) {
170
+ if (opts.trustLevel === "owner") {
171
+ tail.push("-c", 'sandbox_mode="danger-full-access"', "-c", 'approval_policy="never"');
172
+ }
173
+ else {
174
+ tail.push("-c", 'sandbox_mode="workspace-write"', "-c", 'approval_policy="on-request"');
175
+ }
176
+ }
177
+ tail.push("--skip-git-repo-check", "--json");
178
+ if (opts.extraArgs?.length)
179
+ tail.push(...opts.extraArgs);
180
+ // `--` separates flags from positionals so a prompt starting with `-`
181
+ // can never be parsed as an option. `systemContext` is NOT prepended to
182
+ // the prompt any more — it lives in `<CODEX_HOME>/AGENTS.md` written by
183
+ // `run()` — so the transcript stays clean across resumes.
184
+ const prompt = opts.text;
185
+ if (opts.sessionId) {
186
+ return ["exec", "resume", opts.sessionId, ...tail, "--", prompt];
187
+ }
188
+ return ["exec", ...tail, "--", prompt];
189
+ }
190
+ spawnEnv(opts) {
191
+ const env = {
192
+ ...process.env,
193
+ // Keep JSONL free of ANSI codes regardless of user terminal settings.
194
+ FORCE_COLOR: "0",
195
+ NO_COLOR: "1",
196
+ };
197
+ if (opts.accountId) {
198
+ env.CODEX_HOME = agentCodexHomeDir(opts.accountId);
199
+ }
200
+ return env;
201
+ }
202
+ handleEvent(raw, ctx) {
203
+ const obj = raw;
204
+ ctx.emitBlock(normalizeBlock(obj, ctx.seq));
205
+ // Persist the thread_id so the next turn on this session key resumes
206
+ // instead of spawning fresh. Safe now that systemContext lives in
207
+ // AGENTS.md rather than the transcript.
208
+ if (obj.type === "thread.started") {
209
+ if (typeof obj.thread_id === "string") {
210
+ ctx.state.newSessionId = obj.thread_id;
211
+ }
212
+ return;
213
+ }
214
+ if (obj.type === "item.completed" && obj.item?.type === "agent_message") {
215
+ if (typeof obj.item.text === "string") {
216
+ ctx.appendAssistantText(obj.item.text);
217
+ // The last agent_message is the final reply.
218
+ ctx.state.finalText = obj.item.text;
219
+ }
220
+ return;
221
+ }
222
+ if (obj.type === "turn.completed" && obj.turn?.status === "failed") {
223
+ const msg = obj.turn.error?.message;
224
+ if (typeof msg === "string" && msg)
225
+ ctx.state.errorText = msg;
226
+ return;
227
+ }
228
+ if (obj.type === "error") {
229
+ ctx.state.errorText =
230
+ typeof obj.error === "string"
231
+ ? obj.error
232
+ : obj.error?.message ?? "codex error";
233
+ }
234
+ }
235
+ }
236
+ /**
237
+ * Atomically overwrite `<CODEX_HOME>/AGENTS.md` with `systemContext`. codex
238
+ * reads this file at process start, so the write must complete before spawn.
239
+ * An empty or missing systemContext writes an empty file — deleting would
240
+ * race with a prior turn's file still being readable; empty is simpler and
241
+ * codex treats it as "no user-global AGENTS.md".
242
+ */
243
+ function writeCodexAgentsMd(accountId, systemContext) {
244
+ const dir = agentCodexHomeDir(accountId);
245
+ // ensureAgentCodexHome already mkdir's dir; defensive mkdir here too for
246
+ // code paths that invoke this helper directly (tests, future callers).
247
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
248
+ const target = path.join(dir, "AGENTS.md");
249
+ const tmp = path.join(dir, `.AGENTS.md.${process.pid}.tmp`);
250
+ writeFileSync(tmp, systemContext ?? "", { mode: 0o600 });
251
+ renameSync(tmp, target);
252
+ }
253
+ function normalizeBlock(obj, seq) {
254
+ let kind = "other";
255
+ const type = obj?.type;
256
+ const itemType = obj?.item?.type;
257
+ if (type === "thread.started" || type === "turn.started" || type === "turn.completed") {
258
+ kind = "system";
259
+ }
260
+ else if (type === "item.completed" && itemType === "agent_message") {
261
+ kind = "assistant_text";
262
+ }
263
+ else if (type === "item.started" || type === "item.completed") {
264
+ if (itemType === "command_execution" ||
265
+ itemType === "file_change" ||
266
+ itemType === "mcp_tool_call" ||
267
+ itemType === "web_search") {
268
+ kind = "tool_use";
269
+ }
270
+ }
271
+ return { raw: obj, kind, seq };
272
+ }
@@ -0,0 +1,15 @@
1
+ import { type ProbeDeps } from "./probe.js";
2
+ import type { RuntimeAdapter, RuntimeProbeResult, RuntimeRunOptions, RuntimeRunResult } from "../types.js";
3
+ /** Resolve the Gemini CLI executable on PATH. */
4
+ export declare function resolveGeminiCommand(deps?: ProbeDeps): string | null;
5
+ /** Probe whether the Gemini CLI is installed and report its version. */
6
+ export declare function probeGemini(deps?: ProbeDeps): RuntimeProbeResult;
7
+ /**
8
+ * Gemini adapter stub — probe() is wired up so `botcord-daemon doctor` can report it.
9
+ * run() is not implemented yet; routing a turn here will surface the error upstream.
10
+ */
11
+ export declare class GeminiAdapter implements RuntimeAdapter {
12
+ readonly id: "gemini";
13
+ probe(): RuntimeProbeResult;
14
+ run(_opts: RuntimeRunOptions): Promise<RuntimeRunResult>;
15
+ }