@botcord/daemon 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/activity-tracker.d.ts +43 -0
- package/dist/activity-tracker.js +110 -0
- package/dist/adapters/runtimes.d.ts +14 -0
- package/dist/adapters/runtimes.js +18 -0
- package/dist/agent-discovery.d.ts +81 -0
- package/dist/agent-discovery.js +181 -0
- package/dist/agent-workspace.d.ts +31 -0
- package/dist/agent-workspace.js +221 -0
- package/dist/config.d.ts +116 -0
- package/dist/config.js +180 -0
- package/dist/control-channel.d.ts +99 -0
- package/dist/control-channel.js +388 -0
- package/dist/cross-room.d.ts +23 -0
- package/dist/cross-room.js +55 -0
- package/dist/daemon-config-map.d.ts +61 -0
- package/dist/daemon-config-map.js +153 -0
- package/dist/daemon.d.ts +123 -0
- package/dist/daemon.js +349 -0
- package/dist/doctor.d.ts +89 -0
- package/dist/doctor.js +191 -0
- package/dist/gateway/channel-manager.d.ts +54 -0
- package/dist/gateway/channel-manager.js +292 -0
- package/dist/gateway/channels/botcord.d.ts +93 -0
- package/dist/gateway/channels/botcord.js +510 -0
- package/dist/gateway/channels/index.d.ts +2 -0
- package/dist/gateway/channels/index.js +1 -0
- package/dist/gateway/channels/sanitize.d.ts +20 -0
- package/dist/gateway/channels/sanitize.js +56 -0
- package/dist/gateway/dispatcher.d.ts +73 -0
- package/dist/gateway/dispatcher.js +431 -0
- package/dist/gateway/gateway.d.ts +87 -0
- package/dist/gateway/gateway.js +158 -0
- package/dist/gateway/index.d.ts +15 -0
- package/dist/gateway/index.js +15 -0
- package/dist/gateway/log.d.ts +9 -0
- package/dist/gateway/log.js +20 -0
- package/dist/gateway/router.d.ts +10 -0
- package/dist/gateway/router.js +48 -0
- package/dist/gateway/runtimes/claude-code.d.ts +30 -0
- package/dist/gateway/runtimes/claude-code.js +162 -0
- package/dist/gateway/runtimes/codex.d.ts +83 -0
- package/dist/gateway/runtimes/codex.js +272 -0
- package/dist/gateway/runtimes/gemini.d.ts +15 -0
- package/dist/gateway/runtimes/gemini.js +29 -0
- package/dist/gateway/runtimes/ndjson-stream.d.ts +43 -0
- package/dist/gateway/runtimes/ndjson-stream.js +169 -0
- package/dist/gateway/runtimes/probe.d.ts +17 -0
- package/dist/gateway/runtimes/probe.js +54 -0
- package/dist/gateway/runtimes/registry.d.ts +59 -0
- package/dist/gateway/runtimes/registry.js +94 -0
- package/dist/gateway/session-store.d.ts +39 -0
- package/dist/gateway/session-store.js +133 -0
- package/dist/gateway/types.d.ts +265 -0
- package/dist/gateway/types.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +854 -0
- package/dist/log.d.ts +7 -0
- package/dist/log.js +44 -0
- package/dist/provision.d.ts +88 -0
- package/dist/provision.js +749 -0
- package/dist/room-context-fetcher.d.ts +18 -0
- package/dist/room-context-fetcher.js +101 -0
- package/dist/room-context.d.ts +53 -0
- package/dist/room-context.js +112 -0
- package/dist/sender-classify.d.ts +30 -0
- package/dist/sender-classify.js +32 -0
- package/dist/snapshot-writer.d.ts +37 -0
- package/dist/snapshot-writer.js +84 -0
- package/dist/status-render.d.ts +28 -0
- package/dist/status-render.js +97 -0
- package/dist/system-context.d.ts +57 -0
- package/dist/system-context.js +91 -0
- package/dist/turn-text.d.ts +36 -0
- package/dist/turn-text.js +57 -0
- package/dist/user-auth.d.ts +75 -0
- package/dist/user-auth.js +245 -0
- package/dist/working-memory.d.ts +46 -0
- package/dist/working-memory.js +274 -0
- package/package.json +39 -0
- package/src/__tests__/activity-tracker.test.ts +130 -0
- package/src/__tests__/agent-discovery.test.ts +191 -0
- package/src/__tests__/agent-workspace.test.ts +147 -0
- package/src/__tests__/control-channel.test.ts +327 -0
- package/src/__tests__/cross-room.test.ts +116 -0
- package/src/__tests__/daemon-config-map.test.ts +416 -0
- package/src/__tests__/daemon.test.ts +300 -0
- package/src/__tests__/device-code.test.ts +152 -0
- package/src/__tests__/doctor.test.ts +218 -0
- package/src/__tests__/protocol-core-reexport.test.ts +24 -0
- package/src/__tests__/provision.test.ts +922 -0
- package/src/__tests__/room-context.test.ts +233 -0
- package/src/__tests__/runtime-discovery.test.ts +173 -0
- package/src/__tests__/snapshot-writer.test.ts +141 -0
- package/src/__tests__/status-render.test.ts +137 -0
- package/src/__tests__/system-context.test.ts +315 -0
- package/src/__tests__/turn-text.test.ts +116 -0
- package/src/__tests__/user-auth.test.ts +125 -0
- package/src/__tests__/working-memory.test.ts +240 -0
- package/src/activity-tracker.ts +140 -0
- package/src/adapters/runtimes.ts +30 -0
- package/src/agent-discovery.ts +262 -0
- package/src/agent-workspace.ts +247 -0
- package/src/config.ts +290 -0
- package/src/control-channel.ts +455 -0
- package/src/cross-room.ts +89 -0
- package/src/daemon-config-map.ts +200 -0
- package/src/daemon.ts +478 -0
- package/src/doctor.ts +282 -0
- package/src/gateway/__tests__/.gitkeep +0 -0
- package/src/gateway/__tests__/botcord-channel.test.ts +480 -0
- package/src/gateway/__tests__/channel-manager.test.ts +475 -0
- package/src/gateway/__tests__/claude-code-adapter.test.ts +318 -0
- package/src/gateway/__tests__/codex-adapter.test.ts +350 -0
- package/src/gateway/__tests__/dispatcher.test.ts +1159 -0
- package/src/gateway/__tests__/gateway-add-channel.test.ts +180 -0
- package/src/gateway/__tests__/gateway-managed-routes.test.ts +181 -0
- package/src/gateway/__tests__/gateway.test.ts +222 -0
- package/src/gateway/__tests__/router.test.ts +247 -0
- package/src/gateway/__tests__/sanitize.test.ts +193 -0
- package/src/gateway/__tests__/session-store.test.ts +235 -0
- package/src/gateway/channel-manager.ts +349 -0
- package/src/gateway/channels/botcord.ts +605 -0
- package/src/gateway/channels/index.ts +6 -0
- package/src/gateway/channels/sanitize.ts +68 -0
- package/src/gateway/dispatcher.ts +554 -0
- package/src/gateway/gateway.ts +211 -0
- package/src/gateway/index.ts +29 -0
- package/src/gateway/log.ts +30 -0
- package/src/gateway/router.ts +60 -0
- package/src/gateway/runtimes/claude-code.ts +180 -0
- package/src/gateway/runtimes/codex.ts +312 -0
- package/src/gateway/runtimes/gemini.ts +43 -0
- package/src/gateway/runtimes/ndjson-stream.ts +225 -0
- package/src/gateway/runtimes/probe.ts +73 -0
- package/src/gateway/runtimes/registry.ts +143 -0
- package/src/gateway/session-store.ts +157 -0
- package/src/gateway/types.ts +325 -0
- package/src/index.ts +961 -0
- package/src/log.ts +47 -0
- package/src/provision.ts +879 -0
- package/src/room-context-fetcher.ts +124 -0
- package/src/room-context.ts +167 -0
- package/src/sender-classify.ts +46 -0
- package/src/snapshot-writer.ts +103 -0
- package/src/status-render.ts +132 -0
- package/src/system-context.ts +162 -0
- package/src/turn-text.ts +93 -0
- package/src/user-auth.ts +295 -0
- package/src/working-memory.ts +352 -0
|
@@ -0,0 +1,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
|
+
}
|