@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,393 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
import { findOnPath, killTree, spawnCli } from "./util.js";
|
|
6
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
7
|
+
const str = (v) => (typeof v === "string" && v.length > 0 ? v : undefined);
|
|
8
|
+
/** Cap on tool output / diff stored in a transcript event. */
|
|
9
|
+
const MAX_OUTPUT = 64 * 1024;
|
|
10
|
+
const INIT_TIMEOUT_MS = 15_000;
|
|
11
|
+
const STDERR_RECENT_MS = 5_000;
|
|
12
|
+
const MODE_IDS = {
|
|
13
|
+
ask: "default",
|
|
14
|
+
plan: "plan",
|
|
15
|
+
auto_edit: "acceptEdits",
|
|
16
|
+
auto: "bypassPermissions",
|
|
17
|
+
};
|
|
18
|
+
function riskOf(tool) {
|
|
19
|
+
switch (tool) {
|
|
20
|
+
case "Read":
|
|
21
|
+
case "Glob":
|
|
22
|
+
case "Grep":
|
|
23
|
+
case "LS":
|
|
24
|
+
case "NotebookRead":
|
|
25
|
+
return "read";
|
|
26
|
+
case "Edit":
|
|
27
|
+
case "MultiEdit":
|
|
28
|
+
case "Write":
|
|
29
|
+
case "NotebookEdit":
|
|
30
|
+
return "edit";
|
|
31
|
+
case "Bash":
|
|
32
|
+
case "BashOutput":
|
|
33
|
+
case "KillShell":
|
|
34
|
+
return "shell";
|
|
35
|
+
case "WebFetch":
|
|
36
|
+
case "WebSearch":
|
|
37
|
+
return "network";
|
|
38
|
+
default:
|
|
39
|
+
return "other";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function summaryOf(tool, input) {
|
|
43
|
+
const i = isObj(input) ? input : {};
|
|
44
|
+
const detail = str(i["command"]) ?? str(i["file_path"]) ?? str(i["notebook_path"]) ?? str(i["url"]) ?? str(i["pattern"]) ?? str(i["query"]);
|
|
45
|
+
return (detail ? `${tool}: ${detail}` : tool).slice(0, 300);
|
|
46
|
+
}
|
|
47
|
+
/** Not a strict unified diff (no hunks); old lines prefixed "-", new lines "+". Enough for a phone review sheet. */
|
|
48
|
+
function simpleDiff(path, oldText, newText) {
|
|
49
|
+
const lines = (v, p) => (typeof v === "string" && v ? v.split("\n").map((l) => p + l) : []);
|
|
50
|
+
return [`--- ${path}`, `+++ ${path}`, ...lines(oldText, "-"), ...lines(newText, "+")].join("\n");
|
|
51
|
+
}
|
|
52
|
+
function diffOf(tool, input) {
|
|
53
|
+
if (!isObj(input))
|
|
54
|
+
return undefined;
|
|
55
|
+
const path = str(input["file_path"]) ?? str(input["notebook_path"]);
|
|
56
|
+
if (!path)
|
|
57
|
+
return undefined;
|
|
58
|
+
let d;
|
|
59
|
+
if (tool === "Edit")
|
|
60
|
+
d = simpleDiff(path, input["old_string"], input["new_string"]);
|
|
61
|
+
else if (tool === "Write")
|
|
62
|
+
d = simpleDiff(path, undefined, input["content"]);
|
|
63
|
+
else if (tool === "MultiEdit" && Array.isArray(input["edits"])) {
|
|
64
|
+
d = input["edits"]
|
|
65
|
+
.filter(isObj)
|
|
66
|
+
.map((e) => simpleDiff(path, e["old_string"], e["new_string"]))
|
|
67
|
+
.join("\n\n");
|
|
68
|
+
}
|
|
69
|
+
return d ? d.slice(0, MAX_OUTPUT) : undefined;
|
|
70
|
+
}
|
|
71
|
+
/** tool_result content is a string or an array of {type:"text"} blocks. */
|
|
72
|
+
function resultText(content) {
|
|
73
|
+
if (typeof content === "string")
|
|
74
|
+
return content ? content.slice(0, MAX_OUTPUT) : undefined;
|
|
75
|
+
if (!Array.isArray(content))
|
|
76
|
+
return undefined;
|
|
77
|
+
const t = content
|
|
78
|
+
.filter(isObj)
|
|
79
|
+
.map((c) => (c["type"] === "text" ? str(c["text"]) : undefined))
|
|
80
|
+
.filter((x) => !!x)
|
|
81
|
+
.join("\n");
|
|
82
|
+
return t ? t.slice(0, MAX_OUTPUT) : undefined;
|
|
83
|
+
}
|
|
84
|
+
function lastLine(s) {
|
|
85
|
+
const lines = s.trim().split(/\r?\n/);
|
|
86
|
+
return lines[lines.length - 1] ?? "";
|
|
87
|
+
}
|
|
88
|
+
/** Path of the `claude` executable if it is installed on this host. */
|
|
89
|
+
export function findClaude() {
|
|
90
|
+
// PATH first, then the usual install locations (the native installer's ~/.local/bin included): see findOnPath.
|
|
91
|
+
return findOnPath("claude");
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Only the `env` block of the host user's ~/.claude/settings.json (custom base URL, model, ...). User-level settings are
|
|
95
|
+
* NOT loaded by the CLI for Remogent sessions (see --setting-sources below), so this keeps the user's routing/auth
|
|
96
|
+
* setup working without importing their permission allow-rules.
|
|
97
|
+
*/
|
|
98
|
+
function userSettingsEnv() {
|
|
99
|
+
try {
|
|
100
|
+
const raw = JSON.parse(readFileSync(join(homedir(), ".claude", "settings.json"), "utf8"));
|
|
101
|
+
const env = isObj(raw) && isObj(raw["env"]) ? raw["env"] : {};
|
|
102
|
+
return Object.fromEntries(Object.entries(env).filter((e) => typeof e[1] === "string"));
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return {};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
export function createClaudeAdapter(binary) {
|
|
109
|
+
/** Spawns one `claude` process (optionally resuming an earlier conversation) and completes the initialize handshake. */
|
|
110
|
+
async function launch({ cwd, permissionMode }, sink, resumeId, onSessionId) {
|
|
111
|
+
// User-level settings are NOT loaded: a blanket allow-list there (e.g. `Bash(*)`) made tools run with no
|
|
112
|
+
// can_use_tool request, so a phone user's approvals would silently not gate anything (seen live, 2026-11-13).
|
|
113
|
+
// Project/local settings (the repo's own .claude/) still apply. The user's `env` block is passed through.
|
|
114
|
+
const args = [
|
|
115
|
+
"-p",
|
|
116
|
+
"--input-format", "stream-json",
|
|
117
|
+
"--output-format", "stream-json",
|
|
118
|
+
"--verbose",
|
|
119
|
+
"--setting-sources", "project,local",
|
|
120
|
+
"--permission-prompt-tool", "stdio",
|
|
121
|
+
"--permission-mode", MODE_IDS[permissionMode],
|
|
122
|
+
...(resumeId ? ["--resume", resumeId] : []),
|
|
123
|
+
];
|
|
124
|
+
const child = spawnCli(binary, args, cwd, userSettingsEnv());
|
|
125
|
+
let started = false; // initialize handshake finished (or timed out)
|
|
126
|
+
let closed = false; // set once the session is intentionally closed or has died
|
|
127
|
+
let cancelling = false; // an interrupt was sent for the running turn
|
|
128
|
+
let stderrTail = "";
|
|
129
|
+
let lastStderrAt = 0;
|
|
130
|
+
let dead = false; // the process exited on its own
|
|
131
|
+
let reqSeq = 0;
|
|
132
|
+
let turn;
|
|
133
|
+
let initAck;
|
|
134
|
+
child.stdin.on("error", () => { });
|
|
135
|
+
child.stderr.on("data", (d) => {
|
|
136
|
+
lastStderrAt = Date.now();
|
|
137
|
+
stderrTail = (stderrTail + d.toString()).slice(-2000); // kept only for exit diagnostics; never logged
|
|
138
|
+
});
|
|
139
|
+
const send = (o) => {
|
|
140
|
+
if (child.stdin.writable)
|
|
141
|
+
child.stdin.write(JSON.stringify(o) + "\n");
|
|
142
|
+
};
|
|
143
|
+
const reply = (requestId, response) => {
|
|
144
|
+
if ("error" in response) {
|
|
145
|
+
send({ type: "control_response", response: { subtype: "error", request_id: requestId, error: response.error } });
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
send({ type: "control_response", response: { subtype: "success", request_id: requestId, response } });
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
const died = (why) => {
|
|
152
|
+
dead = true;
|
|
153
|
+
// Only quote stderr that is recent: an old line (e.g. a rejected background request) is not why it exited.
|
|
154
|
+
const recent = stderrTail && Date.now() - lastStderrAt < STDERR_RECENT_MS;
|
|
155
|
+
const err = new Error(recent ? `${why}: ${lastLine(stderrTail)}` : why);
|
|
156
|
+
const t = turn;
|
|
157
|
+
turn = undefined;
|
|
158
|
+
if (closed || !started) {
|
|
159
|
+
closed = true;
|
|
160
|
+
t?.reject(err);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
closed = true;
|
|
164
|
+
if (t)
|
|
165
|
+
t.reject(err);
|
|
166
|
+
else
|
|
167
|
+
sink.emit({ kind: "error", message: err.message });
|
|
168
|
+
sink.emit({ kind: "status", status: "error" });
|
|
169
|
+
};
|
|
170
|
+
const exited = new Promise((_, reject) => {
|
|
171
|
+
child.once("exit", (code) => reject(new Error(`claude exited (code ${code})`)));
|
|
172
|
+
child.once("error", (e) => reject(new Error(`claude failed to start: ${e.message}`)));
|
|
173
|
+
});
|
|
174
|
+
exited.catch((e) => died(e.message));
|
|
175
|
+
const handleControlRequest = (m) => {
|
|
176
|
+
const requestId = str(m["request_id"]);
|
|
177
|
+
const req = isObj(m["request"]) ? m["request"] : {};
|
|
178
|
+
if (!requestId)
|
|
179
|
+
return;
|
|
180
|
+
if (req["subtype"] !== "can_use_tool") {
|
|
181
|
+
reply(requestId, { error: `unsupported control request: ${String(req["subtype"])}` });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const tool = str(req["tool_name"]) ?? "tool";
|
|
185
|
+
const input = req["input"] ?? {};
|
|
186
|
+
const toolUseId = str(req["tool_use_id"]);
|
|
187
|
+
const diff = diffOf(tool, input);
|
|
188
|
+
// `tool` is the Claude tool name, so a host-side allow_session covers that tool (e.g. every Bash command).
|
|
189
|
+
sink
|
|
190
|
+
.requestPermission({
|
|
191
|
+
...(toolUseId ? { toolCallId: toolUseId } : {}),
|
|
192
|
+
tool,
|
|
193
|
+
risk: riskOf(tool),
|
|
194
|
+
summary: summaryOf(tool, input),
|
|
195
|
+
input,
|
|
196
|
+
...(diff ? { diff } : {}),
|
|
197
|
+
})
|
|
198
|
+
.then((decision) => reply(requestId, decision === "deny"
|
|
199
|
+
? { behavior: "deny", message: "Denied from Remogent" }
|
|
200
|
+
: { behavior: "allow", updatedInput: input }), (e) => reply(requestId, { error: e instanceof Error ? e.message : String(e) }));
|
|
201
|
+
};
|
|
202
|
+
const handleResult = (m) => {
|
|
203
|
+
const subtype = str(m["subtype"]);
|
|
204
|
+
const isError = m["is_error"] === true;
|
|
205
|
+
let stopReason = "end_turn";
|
|
206
|
+
if (cancelling)
|
|
207
|
+
stopReason = "cancelled";
|
|
208
|
+
else if (subtype === "error_max_turns" || subtype === "error_max_budget_usd")
|
|
209
|
+
stopReason = "max_budget";
|
|
210
|
+
else if (isError || (subtype && subtype.startsWith("error"))) {
|
|
211
|
+
stopReason = "error";
|
|
212
|
+
sink.emit({ kind: "error", message: str(m["result"]) ?? subtype ?? "claude reported an error" });
|
|
213
|
+
}
|
|
214
|
+
const usage = m["usage"] !== undefined || m["total_cost_usd"] !== undefined
|
|
215
|
+
? { usage: m["usage"], total_cost_usd: m["total_cost_usd"] }
|
|
216
|
+
: undefined;
|
|
217
|
+
sink.emit({ kind: "turn_end", stopReason, ...(usage ? { usage } : {}) });
|
|
218
|
+
cancelling = false;
|
|
219
|
+
const t = turn;
|
|
220
|
+
turn = undefined;
|
|
221
|
+
t?.resolve();
|
|
222
|
+
};
|
|
223
|
+
const handleMessage = (m) => {
|
|
224
|
+
const claudeSessionId = str(m["session_id"]);
|
|
225
|
+
if (claudeSessionId)
|
|
226
|
+
onSessionId(claudeSessionId);
|
|
227
|
+
switch (m["type"]) {
|
|
228
|
+
case "control_response": {
|
|
229
|
+
const r = isObj(m["response"]) ? m["response"] : {};
|
|
230
|
+
if (r["request_id"] === "init-1")
|
|
231
|
+
initAck?.();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
case "control_request":
|
|
235
|
+
handleControlRequest(m);
|
|
236
|
+
return;
|
|
237
|
+
case "assistant": {
|
|
238
|
+
const content = isObj(m["message"]) ? m["message"]["content"] : undefined;
|
|
239
|
+
if (!Array.isArray(content))
|
|
240
|
+
return;
|
|
241
|
+
for (const b of content.filter(isObj)) {
|
|
242
|
+
if (b["type"] === "text") {
|
|
243
|
+
const text = str(b["text"]);
|
|
244
|
+
if (text)
|
|
245
|
+
sink.emit({ kind: "agent_text_delta", text });
|
|
246
|
+
}
|
|
247
|
+
else if (b["type"] === "thinking") {
|
|
248
|
+
const text = str(b["thinking"]);
|
|
249
|
+
if (text)
|
|
250
|
+
sink.emit({ kind: "agent_thought_delta", text });
|
|
251
|
+
}
|
|
252
|
+
else if (b["type"] === "tool_use") {
|
|
253
|
+
const toolCallId = str(b["id"]);
|
|
254
|
+
const name = str(b["name"]) ?? "tool";
|
|
255
|
+
if (!toolCallId)
|
|
256
|
+
continue;
|
|
257
|
+
sink.emit({
|
|
258
|
+
kind: "tool_call",
|
|
259
|
+
toolCallId,
|
|
260
|
+
name,
|
|
261
|
+
title: summaryOf(name, b["input"]),
|
|
262
|
+
risk: riskOf(name),
|
|
263
|
+
input: b["input"] ?? {},
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
case "user": {
|
|
270
|
+
const content = isObj(m["message"]) ? m["message"]["content"] : undefined;
|
|
271
|
+
if (!Array.isArray(content))
|
|
272
|
+
return;
|
|
273
|
+
for (const b of content.filter(isObj)) {
|
|
274
|
+
if (b["type"] !== "tool_result")
|
|
275
|
+
continue;
|
|
276
|
+
const toolCallId = str(b["tool_use_id"]);
|
|
277
|
+
if (!toolCallId)
|
|
278
|
+
continue;
|
|
279
|
+
const output = resultText(b["content"]);
|
|
280
|
+
sink.emit({
|
|
281
|
+
kind: "tool_call_update",
|
|
282
|
+
toolCallId,
|
|
283
|
+
status: b["is_error"] === true ? "failed" : "completed",
|
|
284
|
+
...(output ? { output } : {}),
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
case "result":
|
|
290
|
+
handleResult(m);
|
|
291
|
+
return;
|
|
292
|
+
default:
|
|
293
|
+
return; // system (init, status, thinking_tokens), stream_event, keep_alive, ...
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
createInterface({ input: child.stdout }).on("line", (line) => {
|
|
297
|
+
const t = line.trim();
|
|
298
|
+
if (!t.startsWith("{"))
|
|
299
|
+
return; // ignore any non-JSON noise on stdout
|
|
300
|
+
let m;
|
|
301
|
+
try {
|
|
302
|
+
m = JSON.parse(t);
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (isObj(m))
|
|
308
|
+
handleMessage(m);
|
|
309
|
+
});
|
|
310
|
+
// Handshake: ask the CLI to initialize and wait (bounded) for the ack, so a broken install or a missing login
|
|
311
|
+
// fails `session_create` instead of the first prompt. A timeout is not fatal: the CLI may only ack lazily.
|
|
312
|
+
try {
|
|
313
|
+
const ack = new Promise((resolve) => {
|
|
314
|
+
initAck = resolve;
|
|
315
|
+
});
|
|
316
|
+
send({ type: "control_request", request_id: "init-1", request: { subtype: "initialize" } });
|
|
317
|
+
let timer;
|
|
318
|
+
const timeout = new Promise((resolve) => {
|
|
319
|
+
timer = setTimeout(resolve, INIT_TIMEOUT_MS);
|
|
320
|
+
});
|
|
321
|
+
try {
|
|
322
|
+
await Promise.race([ack, exited, timeout]);
|
|
323
|
+
}
|
|
324
|
+
finally {
|
|
325
|
+
if (timer)
|
|
326
|
+
clearTimeout(timer);
|
|
327
|
+
}
|
|
328
|
+
started = true;
|
|
329
|
+
}
|
|
330
|
+
catch (e) {
|
|
331
|
+
closed = true;
|
|
332
|
+
killTree(child);
|
|
333
|
+
throw e instanceof Error ? e : new Error(String(e));
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
hasDied: () => dead,
|
|
337
|
+
handle: {
|
|
338
|
+
prompt(text) {
|
|
339
|
+
if (closed)
|
|
340
|
+
return Promise.reject(new Error("claude process is not running"));
|
|
341
|
+
if (turn)
|
|
342
|
+
return Promise.reject(new Error("a turn is already running"));
|
|
343
|
+
return new Promise((resolve, reject) => {
|
|
344
|
+
turn = { resolve, reject };
|
|
345
|
+
cancelling = false;
|
|
346
|
+
send({ type: "user", message: { role: "user", content: [{ type: "text", text }] } });
|
|
347
|
+
});
|
|
348
|
+
},
|
|
349
|
+
cancel() {
|
|
350
|
+
if (closed || !turn)
|
|
351
|
+
return;
|
|
352
|
+
cancelling = true;
|
|
353
|
+
send({ type: "control_request", request_id: `i${++reqSeq}`, request: { subtype: "interrupt" } });
|
|
354
|
+
},
|
|
355
|
+
close() {
|
|
356
|
+
closed = true;
|
|
357
|
+
killTree(child);
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
engine: "claude",
|
|
364
|
+
resumable: true,
|
|
365
|
+
async start(opts, sink) {
|
|
366
|
+
let claudeSessionId = opts.resumeId;
|
|
367
|
+
let closedByUser = false;
|
|
368
|
+
const remember = (id) => {
|
|
369
|
+
claudeSessionId = id;
|
|
370
|
+
};
|
|
371
|
+
// Resuming (after a host restart): spawn lazily on the first prompt instead of keeping an idle process per old chat.
|
|
372
|
+
let current = opts.resumeId ? undefined : await launch(opts, sink, undefined, remember);
|
|
373
|
+
return {
|
|
374
|
+
async prompt(text) {
|
|
375
|
+
if (closedByUser)
|
|
376
|
+
throw new Error("claude process is not running");
|
|
377
|
+
if (!current || (current.hasDied() && claudeSessionId)) {
|
|
378
|
+
// No process yet (restored chat) or it exited on its own (killed, crashed): continue the same conversation.
|
|
379
|
+
current = await launch(opts, sink, claudeSessionId, remember);
|
|
380
|
+
}
|
|
381
|
+
return current.handle.prompt(text);
|
|
382
|
+
},
|
|
383
|
+
cancel: () => current?.handle.cancel(),
|
|
384
|
+
close() {
|
|
385
|
+
closedByUser = true;
|
|
386
|
+
current?.handle.close();
|
|
387
|
+
},
|
|
388
|
+
engineSessionId: () => claudeSessionId,
|
|
389
|
+
};
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
//# sourceMappingURL=claude.js.map
|