@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.
@@ -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