@infomity/remogent 0.0.0-stage → 0.2.0
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/LICENSE +202 -0
- package/README.md +200 -2
- package/dist/adapters/claude.js +393 -0
- package/dist/adapters/codex.js +428 -0
- package/dist/adapters/gemini.js +229 -0
- package/dist/adapters/rpc.js +83 -0
- package/dist/adapters/util.js +90 -0
- package/dist/firewall.js +75 -0
- package/dist/identity.js +56 -0
- package/dist/index.js +416 -0
- package/dist/logfile.js +39 -0
- package/dist/port.js +19 -0
- package/dist/protocol.js +115 -0
- package/dist/server.js +161 -0
- package/dist/service.js +244 -0
- package/dist/sessions.js +389 -0
- package/dist/store.js +103 -0
- package/dist/title.js +43 -0
- package/package.json +54 -4
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import { RpcError, RpcPeer } from "./rpc.js";
|
|
2
|
+
import { findOnPath, killTree, spawnCli } from "./util.js";
|
|
3
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
4
|
+
const str = (v) => (typeof v === "string" && v.length > 0 ? v : undefined);
|
|
5
|
+
/** Cap on tool output stored in a transcript event. */
|
|
6
|
+
const MAX_OUTPUT = 64 * 1024;
|
|
7
|
+
const cap = (s) => (s.length > MAX_OUTPUT ? s.slice(0, MAX_OUTPUT) : s);
|
|
8
|
+
/** If Codex has not reported the end of an interrupted turn after this long, end it ourselves so the app unlocks. */
|
|
9
|
+
const INTERRUPT_GRACE_MS = 15_000;
|
|
10
|
+
/**
|
|
11
|
+
* Remogent permission mode -> Codex approval policy + sandbox.
|
|
12
|
+
* ask: everything except known read-only commands needs approval; plan: read-only and never ask (it cannot change
|
|
13
|
+
* anything); auto_edit: edits inside the workspace run, anything outside the sandbox asks; auto: no sandbox, no prompts.
|
|
14
|
+
*/
|
|
15
|
+
const POLICIES = {
|
|
16
|
+
ask: { approvalPolicy: "untrusted", sandbox: "workspace-write" },
|
|
17
|
+
plan: { approvalPolicy: "never", sandbox: "read-only" },
|
|
18
|
+
auto_edit: { approvalPolicy: "on-request", sandbox: "workspace-write" },
|
|
19
|
+
auto: { approvalPolicy: "never", sandbox: "danger-full-access" },
|
|
20
|
+
};
|
|
21
|
+
/** Path of the `codex` executable if it is installed on this host. */
|
|
22
|
+
export function findCodex() {
|
|
23
|
+
return findOnPath("codex");
|
|
24
|
+
}
|
|
25
|
+
const stripAnsi = (s) => s.replace(/\u001b\[[0-9;]*m/g, "");
|
|
26
|
+
function lastLine(s) {
|
|
27
|
+
const lines = stripAnsi(s).trim().split(/\r?\n/);
|
|
28
|
+
return lines[lines.length - 1] ?? "";
|
|
29
|
+
}
|
|
30
|
+
// ---- app-server -> Remogent mapping ----
|
|
31
|
+
function actionRisk(actions) {
|
|
32
|
+
if (!Array.isArray(actions) || actions.length === 0)
|
|
33
|
+
return "shell";
|
|
34
|
+
const readOnly = actions.every((a) => isObj(a) && (a["type"] === "read" || a["type"] === "listFiles" || a["type"] === "search"));
|
|
35
|
+
return readOnly ? "read" : "shell";
|
|
36
|
+
}
|
|
37
|
+
function commandTitle(item) {
|
|
38
|
+
const acts = Array.isArray(item["commandActions"]) ? item["commandActions"] : [];
|
|
39
|
+
const cmds = acts.map((a) => (isObj(a) ? str(a["command"]) : undefined)).filter((c) => !!c);
|
|
40
|
+
const t = cmds.length ? cmds.join(" ; ") : (str(item["command"]) ?? "command");
|
|
41
|
+
return t.length > 200 ? t.slice(0, 200) + "..." : t;
|
|
42
|
+
}
|
|
43
|
+
function changePaths(changes) {
|
|
44
|
+
if (!Array.isArray(changes))
|
|
45
|
+
return [];
|
|
46
|
+
return changes.map((c) => (isObj(c) ? str(c["path"]) : undefined)).filter((p) => !!p);
|
|
47
|
+
}
|
|
48
|
+
function changesDiff(changes) {
|
|
49
|
+
if (!Array.isArray(changes))
|
|
50
|
+
return undefined;
|
|
51
|
+
const parts = [];
|
|
52
|
+
for (const c of changes) {
|
|
53
|
+
if (!isObj(c))
|
|
54
|
+
continue;
|
|
55
|
+
const path = str(c["path"]);
|
|
56
|
+
if (!path)
|
|
57
|
+
continue;
|
|
58
|
+
const kind = isObj(c["kind"]) ? str(c["kind"]["type"]) : undefined;
|
|
59
|
+
const diff = typeof c["diff"] === "string" ? c["diff"] : "";
|
|
60
|
+
parts.push(`${kind ?? "change"} ${path}\n${diff}`.trimEnd());
|
|
61
|
+
}
|
|
62
|
+
return parts.length ? cap(parts.join("\n\n")) : undefined;
|
|
63
|
+
}
|
|
64
|
+
function toolStart(item) {
|
|
65
|
+
const toolCallId = str(item["id"]);
|
|
66
|
+
if (!toolCallId)
|
|
67
|
+
return undefined;
|
|
68
|
+
const titled = (title) => (title ? { title } : {});
|
|
69
|
+
switch (item["type"]) {
|
|
70
|
+
case "commandExecution":
|
|
71
|
+
return {
|
|
72
|
+
kind: "tool_call",
|
|
73
|
+
toolCallId,
|
|
74
|
+
name: "shell",
|
|
75
|
+
title: commandTitle(item),
|
|
76
|
+
risk: actionRisk(item["commandActions"]),
|
|
77
|
+
input: { command: item["command"], cwd: item["cwd"] },
|
|
78
|
+
};
|
|
79
|
+
case "fileChange": {
|
|
80
|
+
const paths = changePaths(item["changes"]);
|
|
81
|
+
return { kind: "tool_call", toolCallId, name: "edit", title: paths.length ? `Edit ${paths.join(", ")}` : "Edit files", risk: "edit", input: { files: paths } };
|
|
82
|
+
}
|
|
83
|
+
case "mcpToolCall": {
|
|
84
|
+
const name = [str(item["server"]), str(item["tool"])].filter(Boolean).join(".");
|
|
85
|
+
return { kind: "tool_call", toolCallId, name: name || "mcp", risk: "other", input: item["arguments"] ?? {} };
|
|
86
|
+
}
|
|
87
|
+
case "webSearch":
|
|
88
|
+
return { kind: "tool_call", toolCallId, name: "web_search", ...titled(str(item["query"])), risk: "network", input: { query: item["query"] } };
|
|
89
|
+
case "dynamicToolCall":
|
|
90
|
+
return { kind: "tool_call", toolCallId, name: str(item["tool"]) ?? "tool", risk: "other", input: item["arguments"] ?? {} };
|
|
91
|
+
default:
|
|
92
|
+
return undefined; // userMessage, agentMessage, reasoning, plan, ...
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function toolEnd(item) {
|
|
96
|
+
const toolCallId = str(item["id"]);
|
|
97
|
+
if (!toolCallId)
|
|
98
|
+
return undefined;
|
|
99
|
+
const type = item["type"];
|
|
100
|
+
if (type !== "commandExecution" && type !== "fileChange" && type !== "mcpToolCall" && type !== "webSearch" && type !== "dynamicToolCall") {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
const s = str(item["status"]);
|
|
104
|
+
const declined = s === "declined";
|
|
105
|
+
const status = s === "completed" ? "completed" : s === "failed" || declined ? "failed" : "running";
|
|
106
|
+
let output;
|
|
107
|
+
let diff;
|
|
108
|
+
if (type === "commandExecution")
|
|
109
|
+
output = str(item["aggregatedOutput"]);
|
|
110
|
+
else if (type === "fileChange")
|
|
111
|
+
diff = changesDiff(item["changes"]);
|
|
112
|
+
else if (type === "mcpToolCall") {
|
|
113
|
+
const r = item["error"] ?? item["result"];
|
|
114
|
+
if (r !== undefined && r !== null)
|
|
115
|
+
output = cap(typeof r === "string" ? r : JSON.stringify(r));
|
|
116
|
+
}
|
|
117
|
+
if (!output && declined)
|
|
118
|
+
output = "Declined.";
|
|
119
|
+
return {
|
|
120
|
+
kind: "tool_call_update",
|
|
121
|
+
toolCallId,
|
|
122
|
+
status: type === "webSearch" && status === "running" ? "completed" : status,
|
|
123
|
+
...(output ? { output: cap(output) } : {}),
|
|
124
|
+
...(diff ? { diff } : {}),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
export function createCodexAdapter(binary) {
|
|
128
|
+
return {
|
|
129
|
+
engine: "codex",
|
|
130
|
+
resumable: true,
|
|
131
|
+
async start({ cwd, permissionMode, resumeId }, sink) {
|
|
132
|
+
const policy = POLICIES[permissionMode];
|
|
133
|
+
let threadId = resumeId;
|
|
134
|
+
let conn;
|
|
135
|
+
let ready;
|
|
136
|
+
let closed = false; // intentionally closed by the host
|
|
137
|
+
let turn;
|
|
138
|
+
let textInTurn = false;
|
|
139
|
+
let lastMsgItem;
|
|
140
|
+
const deltaItems = new Set();
|
|
141
|
+
const fileChanges = new Map(); // itemId -> changes, for approval diffs
|
|
142
|
+
const finishTurn = (t, status, errorMessage) => {
|
|
143
|
+
if (turn !== t)
|
|
144
|
+
return;
|
|
145
|
+
turn = undefined;
|
|
146
|
+
if (status === "failed" && errorMessage && t.lastError !== errorMessage)
|
|
147
|
+
sink.emit({ kind: "error", message: errorMessage });
|
|
148
|
+
sink.emit({
|
|
149
|
+
kind: "turn_end",
|
|
150
|
+
stopReason: status === "interrupted" ? "cancelled" : status === "failed" ? "error" : "end_turn",
|
|
151
|
+
...(t.usage !== undefined ? { usage: t.usage } : {}),
|
|
152
|
+
});
|
|
153
|
+
t.resolve();
|
|
154
|
+
};
|
|
155
|
+
const handleNotification = (method, params) => {
|
|
156
|
+
if (!isObj(params))
|
|
157
|
+
return;
|
|
158
|
+
const tid = str(params["threadId"]);
|
|
159
|
+
if (tid && threadId && tid !== threadId)
|
|
160
|
+
return; // another (sub-agent) thread
|
|
161
|
+
switch (method) {
|
|
162
|
+
case "item/agentMessage/delta": {
|
|
163
|
+
const delta = typeof params["delta"] === "string" ? params["delta"] : "";
|
|
164
|
+
const itemId = str(params["itemId"]);
|
|
165
|
+
if (!delta)
|
|
166
|
+
return;
|
|
167
|
+
// Each agent message item is its own paragraph (commentary before tools, then the final answer).
|
|
168
|
+
if (itemId && itemId !== lastMsgItem && textInTurn)
|
|
169
|
+
sink.emit({ kind: "agent_text_delta", text: "\n\n" });
|
|
170
|
+
if (itemId) {
|
|
171
|
+
lastMsgItem = itemId;
|
|
172
|
+
deltaItems.add(itemId);
|
|
173
|
+
}
|
|
174
|
+
textInTurn = true;
|
|
175
|
+
sink.emit({ kind: "agent_text_delta", text: delta });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
case "item/reasoning/summaryTextDelta":
|
|
179
|
+
case "item/reasoning/textDelta": {
|
|
180
|
+
const delta = typeof params["delta"] === "string" ? params["delta"] : "";
|
|
181
|
+
if (delta)
|
|
182
|
+
sink.emit({ kind: "agent_thought_delta", text: delta });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
case "item/started": {
|
|
186
|
+
const item = params["item"];
|
|
187
|
+
if (!isObj(item))
|
|
188
|
+
return;
|
|
189
|
+
if (item["type"] === "fileChange")
|
|
190
|
+
fileChanges.set(String(item["id"]), item["changes"]);
|
|
191
|
+
const ev = toolStart(item);
|
|
192
|
+
if (ev)
|
|
193
|
+
sink.emit(ev);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
case "item/fileChange/patchUpdated": {
|
|
197
|
+
const itemId = str(params["itemId"]);
|
|
198
|
+
if (itemId)
|
|
199
|
+
fileChanges.set(itemId, params["changes"]);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
case "item/completed": {
|
|
203
|
+
const item = params["item"];
|
|
204
|
+
if (!isObj(item))
|
|
205
|
+
return;
|
|
206
|
+
if (item["type"] === "agentMessage") {
|
|
207
|
+
// Whole message without streamed deltas (some items arrive complete): show it now.
|
|
208
|
+
const id = str(item["id"]);
|
|
209
|
+
const text = str(item["text"]);
|
|
210
|
+
if (text && (!id || !deltaItems.has(id))) {
|
|
211
|
+
if (textInTurn)
|
|
212
|
+
sink.emit({ kind: "agent_text_delta", text: "\n\n" });
|
|
213
|
+
textInTurn = true;
|
|
214
|
+
if (id)
|
|
215
|
+
lastMsgItem = id;
|
|
216
|
+
sink.emit({ kind: "agent_text_delta", text });
|
|
217
|
+
}
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const ev = toolEnd(item);
|
|
221
|
+
if (ev)
|
|
222
|
+
sink.emit(ev);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
case "thread/tokenUsage/updated": {
|
|
226
|
+
if (turn && params["tokenUsage"] !== undefined)
|
|
227
|
+
turn.usage = params["tokenUsage"];
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
case "error": {
|
|
231
|
+
const err = isObj(params["error"]) ? params["error"] : {};
|
|
232
|
+
const message = str(err["message"]);
|
|
233
|
+
if (params["willRetry"] === true || !message)
|
|
234
|
+
return;
|
|
235
|
+
if (turn)
|
|
236
|
+
turn.lastError = message;
|
|
237
|
+
sink.emit({ kind: "error", message });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
case "turn/completed": {
|
|
241
|
+
const t = turn;
|
|
242
|
+
const tn = isObj(params["turn"]) ? params["turn"] : {};
|
|
243
|
+
if (!t)
|
|
244
|
+
return;
|
|
245
|
+
if (t.id && str(tn["id"]) && t.id !== tn["id"])
|
|
246
|
+
return;
|
|
247
|
+
const err = isObj(tn["error"]) ? str(tn["error"]["message"]) : undefined;
|
|
248
|
+
finishTurn(t, str(tn["status"]) ?? "completed", err ?? "the turn failed");
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
default:
|
|
252
|
+
return; // thread/*, mcpServer/*, account/*, rate limits, warnings, ...
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
const decide = (d) => (d === "deny" ? "decline" : "accept");
|
|
256
|
+
const handleRequest = async (method, params) => {
|
|
257
|
+
const p = isObj(params) ? params : {};
|
|
258
|
+
switch (method) {
|
|
259
|
+
case "item/commandExecution/requestApproval": {
|
|
260
|
+
const itemId = str(p["itemId"]);
|
|
261
|
+
const decision = await sink.requestPermission({
|
|
262
|
+
...(itemId ? { toolCallId: itemId } : {}),
|
|
263
|
+
tool: "shell",
|
|
264
|
+
risk: p["networkApprovalContext"] ? "network" : actionRisk(p["commandActions"]),
|
|
265
|
+
summary: str(p["reason"]) ?? commandTitle(p),
|
|
266
|
+
input: { command: p["command"], cwd: p["cwd"], reason: p["reason"] },
|
|
267
|
+
});
|
|
268
|
+
// allow_session is tracked by the host; toward Codex it is a one-time accept, so Codex never persists an
|
|
269
|
+
// exec-policy rule on disk.
|
|
270
|
+
return { decision: decide(decision) };
|
|
271
|
+
}
|
|
272
|
+
case "item/fileChange/requestApproval": {
|
|
273
|
+
const itemId = str(p["itemId"]);
|
|
274
|
+
const changes = itemId ? fileChanges.get(itemId) : undefined;
|
|
275
|
+
const paths = changePaths(changes);
|
|
276
|
+
const diff = changesDiff(changes);
|
|
277
|
+
const decision = await sink.requestPermission({
|
|
278
|
+
...(itemId ? { toolCallId: itemId } : {}),
|
|
279
|
+
tool: "edit",
|
|
280
|
+
risk: "edit",
|
|
281
|
+
summary: str(p["reason"]) ?? (paths.length ? `Edit ${paths.join(", ")}` : "Apply file changes"),
|
|
282
|
+
input: { files: paths, ...(p["grantRoot"] ? { grantRoot: p["grantRoot"] } : {}) },
|
|
283
|
+
...(diff ? { diff } : {}),
|
|
284
|
+
});
|
|
285
|
+
return { decision: decide(decision) };
|
|
286
|
+
}
|
|
287
|
+
case "item/permissions/requestApproval": {
|
|
288
|
+
const itemId = str(p["itemId"]);
|
|
289
|
+
const wanted = isObj(p["permissions"]) ? p["permissions"] : {};
|
|
290
|
+
const decision = await sink.requestPermission({
|
|
291
|
+
...(itemId ? { toolCallId: itemId } : {}),
|
|
292
|
+
tool: "permissions",
|
|
293
|
+
risk: wanted["network"] ? "network" : "edit",
|
|
294
|
+
summary: str(p["reason"]) ?? "Codex asks for extra permissions",
|
|
295
|
+
input: wanted,
|
|
296
|
+
});
|
|
297
|
+
return { permissions: decision === "deny" ? {} : wanted, scope: "turn" };
|
|
298
|
+
}
|
|
299
|
+
case "mcpServer/elicitation/request":
|
|
300
|
+
return { action: "decline" };
|
|
301
|
+
case "item/tool/requestUserInput":
|
|
302
|
+
return { answers: {} };
|
|
303
|
+
case "applyPatchApproval":
|
|
304
|
+
case "execCommandApproval":
|
|
305
|
+
return { decision: "denied" }; // legacy (v1) approvals: never used by this adapter's threads
|
|
306
|
+
default:
|
|
307
|
+
throw new RpcError(-32601, `unsupported method: ${method}`);
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
const boot = async () => {
|
|
311
|
+
const child = spawnCli(binary, ["app-server"], cwd);
|
|
312
|
+
const rpc = new RpcPeer(child, { jsonrpcField: false });
|
|
313
|
+
const c = { child, rpc, alive: true };
|
|
314
|
+
let stderrTail = "";
|
|
315
|
+
child.stderr.on("data", (d) => {
|
|
316
|
+
stderrTail = (stderrTail + d.toString()).slice(-2000); // kept only for exit diagnostics; never logged
|
|
317
|
+
});
|
|
318
|
+
const died = (why) => {
|
|
319
|
+
if (!c.alive)
|
|
320
|
+
return;
|
|
321
|
+
c.alive = false;
|
|
322
|
+
const err = new Error(stderrTail ? `${why}: ${lastLine(stderrTail)}` : why);
|
|
323
|
+
rpc.failAll(err);
|
|
324
|
+
const t = turn;
|
|
325
|
+
if (closed || !t)
|
|
326
|
+
return;
|
|
327
|
+
turn = undefined;
|
|
328
|
+
t.reject(err); // the prompt rejection reports the death; the next prompt respawns and resumes the thread
|
|
329
|
+
};
|
|
330
|
+
child.on("exit", (code) => died(`codex exited (code ${code})`));
|
|
331
|
+
child.on("error", (e) => died(`codex failed to start: ${e.message}`));
|
|
332
|
+
rpc.onNotification = handleNotification;
|
|
333
|
+
rpc.onRequest = handleRequest;
|
|
334
|
+
try {
|
|
335
|
+
await rpc.request("initialize", {
|
|
336
|
+
clientInfo: { name: "remogent", title: "Remogent", version: process.env["npm_package_version"] ?? "dev" },
|
|
337
|
+
});
|
|
338
|
+
rpc.notify("initialized");
|
|
339
|
+
const settings = { cwd, approvalPolicy: policy.approvalPolicy, sandbox: policy.sandbox };
|
|
340
|
+
if (threadId) {
|
|
341
|
+
await rpc.request("thread/resume", { threadId, excludeTurns: true, ...settings });
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
const res = await rpc.request("thread/start", settings);
|
|
345
|
+
const thread = isObj(res) ? res["thread"] : undefined;
|
|
346
|
+
threadId = isObj(thread) ? str(thread["id"]) : undefined;
|
|
347
|
+
if (!threadId)
|
|
348
|
+
throw new Error("codex did not return a thread id");
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
catch (e) {
|
|
352
|
+
c.alive = false;
|
|
353
|
+
killTree(child);
|
|
354
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
355
|
+
throw new Error(/auth|login|sign.?in|unauthor/i.test(msg) ? `${msg} (run \`codex login\` once on the host to sign in)` : msg);
|
|
356
|
+
}
|
|
357
|
+
return c;
|
|
358
|
+
};
|
|
359
|
+
const ensure = () => {
|
|
360
|
+
if (conn?.alive)
|
|
361
|
+
return Promise.resolve(conn);
|
|
362
|
+
ready ??= boot()
|
|
363
|
+
.then((c) => (conn = c))
|
|
364
|
+
.finally(() => {
|
|
365
|
+
ready = undefined;
|
|
366
|
+
});
|
|
367
|
+
return ready;
|
|
368
|
+
};
|
|
369
|
+
// A new chat starts the CLI now (so a missing login shows up immediately); a restored chat waits for its next prompt.
|
|
370
|
+
if (!resumeId)
|
|
371
|
+
await ensure();
|
|
372
|
+
return {
|
|
373
|
+
async prompt(text) {
|
|
374
|
+
if (closed)
|
|
375
|
+
throw new Error("codex session is closed");
|
|
376
|
+
const c = await ensure();
|
|
377
|
+
const t = { cancelled: false, resolve: () => { }, reject: () => { } };
|
|
378
|
+
const done = new Promise((resolve, reject) => {
|
|
379
|
+
t.resolve = resolve;
|
|
380
|
+
t.reject = reject;
|
|
381
|
+
});
|
|
382
|
+
turn = t;
|
|
383
|
+
textInTurn = false;
|
|
384
|
+
lastMsgItem = undefined;
|
|
385
|
+
deltaItems.clear();
|
|
386
|
+
try {
|
|
387
|
+
const res = await c.rpc.request("turn/start", { threadId, input: [{ type: "text", text, text_elements: [] }] });
|
|
388
|
+
const tn = isObj(res) ? res["turn"] : undefined;
|
|
389
|
+
const id = isObj(tn) ? str(tn["id"]) : undefined;
|
|
390
|
+
if (turn === t && id) {
|
|
391
|
+
t.id = id;
|
|
392
|
+
if (t.cancelled)
|
|
393
|
+
void c.rpc.request("turn/interrupt", { threadId, turnId: id }).catch(() => { });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
catch (e) {
|
|
397
|
+
if (turn === t)
|
|
398
|
+
turn = undefined;
|
|
399
|
+
throw e;
|
|
400
|
+
}
|
|
401
|
+
await done;
|
|
402
|
+
},
|
|
403
|
+
cancel() {
|
|
404
|
+
const t = turn;
|
|
405
|
+
const c = conn;
|
|
406
|
+
if (!t || !c?.alive || t.cancelled)
|
|
407
|
+
return;
|
|
408
|
+
t.cancelled = true;
|
|
409
|
+
if (t.id)
|
|
410
|
+
void c.rpc.request("turn/interrupt", { threadId, turnId: t.id }).catch(() => { });
|
|
411
|
+
setTimeout(() => finishTurn(t, "interrupted"), INTERRUPT_GRACE_MS).unref();
|
|
412
|
+
},
|
|
413
|
+
close() {
|
|
414
|
+
closed = true;
|
|
415
|
+
const t = turn;
|
|
416
|
+
turn = undefined;
|
|
417
|
+
t?.resolve();
|
|
418
|
+
if (conn) {
|
|
419
|
+
conn.alive = false;
|
|
420
|
+
killTree(conn.child);
|
|
421
|
+
}
|
|
422
|
+
},
|
|
423
|
+
engineSessionId: () => threadId,
|
|
424
|
+
};
|
|
425
|
+
},
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
//# sourceMappingURL=codex.js.map
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { RpcError, RpcPeer } from "./rpc.js";
|
|
2
|
+
import { findOnPath, killTree, spawnCli } from "./util.js";
|
|
3
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
4
|
+
const str = (v) => (typeof v === "string" && v.length > 0 ? v : undefined);
|
|
5
|
+
/** Cap on tool output stored in a transcript event. */
|
|
6
|
+
const MAX_OUTPUT = 64 * 1024;
|
|
7
|
+
// ---- ACP -> Remogent mapping ----
|
|
8
|
+
/** ACP ToolKind -> protocol risk. */
|
|
9
|
+
function riskOf(kind) {
|
|
10
|
+
switch (kind) {
|
|
11
|
+
case "read":
|
|
12
|
+
case "search":
|
|
13
|
+
return "read";
|
|
14
|
+
case "edit":
|
|
15
|
+
case "delete":
|
|
16
|
+
case "move":
|
|
17
|
+
return "edit";
|
|
18
|
+
case "execute":
|
|
19
|
+
return "shell";
|
|
20
|
+
case "fetch":
|
|
21
|
+
return "network";
|
|
22
|
+
default:
|
|
23
|
+
return "other";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Not a strict unified diff (no hunks); old lines prefixed "-", new lines "+". Enough for a phone review sheet. */
|
|
27
|
+
function simpleDiff(path, oldText, newText) {
|
|
28
|
+
const lines = (v, p) => (typeof v === "string" && v ? v.split("\n").map((l) => p + l) : []);
|
|
29
|
+
return [`--- ${path}`, `+++ ${path}`, ...lines(oldText, "-"), ...lines(newText, "+")].join("\n");
|
|
30
|
+
}
|
|
31
|
+
function extractContent(content) {
|
|
32
|
+
if (!Array.isArray(content))
|
|
33
|
+
return {};
|
|
34
|
+
const texts = [];
|
|
35
|
+
const diffs = [];
|
|
36
|
+
for (const c of content) {
|
|
37
|
+
if (!isObj(c))
|
|
38
|
+
continue;
|
|
39
|
+
if (c["type"] === "content" && isObj(c["content"]) && c["content"]["type"] === "text") {
|
|
40
|
+
const t = str(c["content"]["text"]);
|
|
41
|
+
if (t)
|
|
42
|
+
texts.push(t);
|
|
43
|
+
}
|
|
44
|
+
else if (c["type"] === "diff" && typeof c["path"] === "string") {
|
|
45
|
+
diffs.push(simpleDiff(c["path"], c["oldText"], c["newText"]));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
...(texts.length ? { output: texts.join("\n").slice(0, MAX_OUTPUT) } : {}),
|
|
50
|
+
...(diffs.length ? { diff: diffs.join("\n\n").slice(0, MAX_OUTPUT) } : {}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function textOf(update) {
|
|
54
|
+
const c = update["content"];
|
|
55
|
+
return isObj(c) && c["type"] === "text" ? str(c["text"]) : undefined;
|
|
56
|
+
}
|
|
57
|
+
function toEvent(update) {
|
|
58
|
+
switch (update["sessionUpdate"]) {
|
|
59
|
+
case "agent_message_chunk": {
|
|
60
|
+
const text = textOf(update);
|
|
61
|
+
// Gemini echoes mode changes as message text ("[MODE_UPDATE] yolo"); that is CLI noise, not agent output.
|
|
62
|
+
return text && !/^\[MODE_UPDATE\]/.test(text) ? { kind: "agent_text_delta", text } : undefined;
|
|
63
|
+
}
|
|
64
|
+
case "agent_thought_chunk": {
|
|
65
|
+
const text = textOf(update);
|
|
66
|
+
return text ? { kind: "agent_thought_delta", text } : undefined;
|
|
67
|
+
}
|
|
68
|
+
case "tool_call": {
|
|
69
|
+
const toolCallId = str(update["toolCallId"]);
|
|
70
|
+
if (!toolCallId)
|
|
71
|
+
return undefined;
|
|
72
|
+
const title = str(update["title"]);
|
|
73
|
+
const kind = str(update["kind"]);
|
|
74
|
+
return {
|
|
75
|
+
kind: "tool_call",
|
|
76
|
+
toolCallId,
|
|
77
|
+
name: kind ?? title ?? "tool",
|
|
78
|
+
...(title ? { title } : {}),
|
|
79
|
+
risk: riskOf(kind),
|
|
80
|
+
input: update["rawInput"] ?? {},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
case "tool_call_update": {
|
|
84
|
+
const toolCallId = str(update["toolCallId"]);
|
|
85
|
+
if (!toolCallId)
|
|
86
|
+
return undefined;
|
|
87
|
+
const s = str(update["status"]);
|
|
88
|
+
const status = s === "completed" ? "completed" : s === "failed" ? "failed" : "running";
|
|
89
|
+
return { kind: "tool_call_update", toolCallId, status, ...extractContent(update["content"]) };
|
|
90
|
+
}
|
|
91
|
+
default:
|
|
92
|
+
return undefined; // plan, available_commands_update, current_mode_update, user_message_chunk (history on load)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const MODE_IDS = { ask: "default", plan: "plan", auto_edit: "autoEdit", auto: "yolo" };
|
|
96
|
+
/** Path of the `gemini` executable if it is installed on this host. */
|
|
97
|
+
export function findGemini() {
|
|
98
|
+
return findOnPath("gemini");
|
|
99
|
+
}
|
|
100
|
+
function lastLine(s) {
|
|
101
|
+
const lines = s.trim().split(/\r?\n/);
|
|
102
|
+
return lines[lines.length - 1] ?? "";
|
|
103
|
+
}
|
|
104
|
+
export function createGeminiAdapter(binary) {
|
|
105
|
+
return {
|
|
106
|
+
engine: "gemini",
|
|
107
|
+
async start({ cwd, permissionMode }, sink) {
|
|
108
|
+
const child = spawnCli(binary, ["--acp"], cwd);
|
|
109
|
+
const rpc = new RpcPeer(child);
|
|
110
|
+
let closed = false; // set once the session is intentionally closed or has died
|
|
111
|
+
let inTurn = false; // a prompt request is in flight (its rejection reports process death)
|
|
112
|
+
let stderrTail = "";
|
|
113
|
+
let sessionId;
|
|
114
|
+
child.stderr.on("data", (d) => {
|
|
115
|
+
stderrTail = (stderrTail + d.toString()).slice(-2000); // kept only for exit diagnostics; never logged
|
|
116
|
+
});
|
|
117
|
+
const died = (why) => {
|
|
118
|
+
const err = new Error(stderrTail ? `${why}: ${lastLine(stderrTail)}` : why);
|
|
119
|
+
rpc.failAll(err);
|
|
120
|
+
if (closed)
|
|
121
|
+
return;
|
|
122
|
+
closed = true;
|
|
123
|
+
if (!inTurn)
|
|
124
|
+
sink.emit({ kind: "error", message: err.message });
|
|
125
|
+
sink.emit({ kind: "status", status: "error" });
|
|
126
|
+
};
|
|
127
|
+
child.on("exit", (code) => died(`gemini exited (code ${code})`));
|
|
128
|
+
child.on("error", (e) => died(`gemini failed to start: ${e.message}`));
|
|
129
|
+
rpc.onNotification = (method, params) => {
|
|
130
|
+
if (method !== "session/update" || !isObj(params) || !isObj(params["update"]))
|
|
131
|
+
return;
|
|
132
|
+
if (sessionId && params["sessionId"] !== sessionId)
|
|
133
|
+
return;
|
|
134
|
+
const ev = toEvent(params["update"]);
|
|
135
|
+
if (ev)
|
|
136
|
+
sink.emit(ev);
|
|
137
|
+
};
|
|
138
|
+
rpc.onRequest = async (method, params) => {
|
|
139
|
+
if (method !== "session/request_permission" || !isObj(params)) {
|
|
140
|
+
throw new RpcError(-32601, `unsupported method: ${method}`);
|
|
141
|
+
}
|
|
142
|
+
const tc = isObj(params["toolCall"]) ? params["toolCall"] : {};
|
|
143
|
+
const options = Array.isArray(params["options"]) ? params["options"].filter(isObj) : [];
|
|
144
|
+
const kind = str(tc["kind"]);
|
|
145
|
+
const title = str(tc["title"]);
|
|
146
|
+
const toolCallId = str(tc["toolCallId"]);
|
|
147
|
+
const { diff } = extractContent(tc["content"]);
|
|
148
|
+
// `tool` is the ACP kind (not the title) so a host-side allow_session covers the tool class, not one exact command.
|
|
149
|
+
const decision = await sink.requestPermission({
|
|
150
|
+
...(toolCallId ? { toolCallId } : {}),
|
|
151
|
+
tool: kind ?? "tool",
|
|
152
|
+
risk: riskOf(kind),
|
|
153
|
+
summary: title ?? kind ?? "tool call",
|
|
154
|
+
input: tc["rawInput"] ?? {},
|
|
155
|
+
...(diff ? { diff } : {}),
|
|
156
|
+
});
|
|
157
|
+
const pick = (...kinds) => {
|
|
158
|
+
for (const k of kinds) {
|
|
159
|
+
const o = options.find((x) => x["kind"] === k);
|
|
160
|
+
if (o && typeof o["optionId"] === "string")
|
|
161
|
+
return o["optionId"];
|
|
162
|
+
}
|
|
163
|
+
return undefined;
|
|
164
|
+
};
|
|
165
|
+
// allow_session is tracked by the host; toward Gemini it is a plain one-time allow, so Gemini never
|
|
166
|
+
// persists an "always" trust rule on disk.
|
|
167
|
+
const optionId = decision === "deny" ? pick("reject_once", "reject_always") : pick("allow_once", "allow_always");
|
|
168
|
+
return { outcome: optionId ? { outcome: "selected", optionId } : { outcome: "cancelled" } };
|
|
169
|
+
};
|
|
170
|
+
try {
|
|
171
|
+
await rpc.request("initialize", {
|
|
172
|
+
protocolVersion: 1,
|
|
173
|
+
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
|
|
174
|
+
});
|
|
175
|
+
const res = await rpc.request("session/new", { cwd, mcpServers: [] });
|
|
176
|
+
sessionId = isObj(res) ? str(res["sessionId"]) : undefined;
|
|
177
|
+
if (!sessionId)
|
|
178
|
+
throw new Error("gemini did not return a sessionId");
|
|
179
|
+
if (permissionMode !== "ask") {
|
|
180
|
+
await rpc.request("session/set_mode", { sessionId, modeId: MODE_IDS[permissionMode] });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch (e) {
|
|
184
|
+
closed = true;
|
|
185
|
+
killTree(child);
|
|
186
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
187
|
+
throw new Error(/auth/i.test(msg) ? `${msg} (run \`gemini\` once on the host to sign in)` : msg);
|
|
188
|
+
}
|
|
189
|
+
const sid = sessionId;
|
|
190
|
+
return {
|
|
191
|
+
async prompt(text) {
|
|
192
|
+
if (closed)
|
|
193
|
+
throw new Error("gemini process is not running");
|
|
194
|
+
inTurn = true;
|
|
195
|
+
try {
|
|
196
|
+
const res = await rpc.request("session/prompt", { sessionId: sid, prompt: [{ type: "text", text }] });
|
|
197
|
+
const stop = isObj(res) ? str(res["stopReason"]) : undefined;
|
|
198
|
+
const usage = isObj(res) ? res["_meta"] : undefined;
|
|
199
|
+
if (stop === "refusal")
|
|
200
|
+
sink.emit({ kind: "error", message: "the model refused this request" });
|
|
201
|
+
sink.emit({
|
|
202
|
+
kind: "turn_end",
|
|
203
|
+
stopReason: stop === "cancelled"
|
|
204
|
+
? "cancelled"
|
|
205
|
+
: stop === "max_tokens" || stop === "max_turn_requests"
|
|
206
|
+
? "max_budget"
|
|
207
|
+
: stop === "refusal"
|
|
208
|
+
? "error"
|
|
209
|
+
: "end_turn",
|
|
210
|
+
...(usage !== undefined ? { usage } : {}),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
inTurn = false;
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
cancel() {
|
|
218
|
+
if (!closed)
|
|
219
|
+
rpc.notify("session/cancel", { sessionId: sid });
|
|
220
|
+
},
|
|
221
|
+
close() {
|
|
222
|
+
closed = true;
|
|
223
|
+
killTree(child);
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
//# sourceMappingURL=gemini.js.map
|