@infomity/remogent 0.0.0-stage → 0.2.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/LICENSE +202 -0
- package/README.md +225 -3
- package/dist/adapters/claude.js +518 -0
- package/dist/adapters/codex.js +510 -0
- package/dist/adapters/gemini.js +287 -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 +142 -0
- package/dist/server.js +170 -0
- package/dist/service.js +244 -0
- package/dist/sessions.js +482 -0
- package/dist/store.js +103 -0
- package/dist/title.js +43 -0
- package/package.json +63 -4
|
@@ -0,0 +1,518 @@
|
|
|
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
|
+
/**
|
|
109
|
+
* The `default` alias in Claude's model list means "no override": the CLI's own choice, which honours the user's
|
|
110
|
+
* `ANTHROPIC_MODEL` / provider settings. Passing the literal alias fails on custom providers (seen live against a router),
|
|
111
|
+
* so it is mapped to "no --model" at spawn and to a `set_model` without a model at runtime (resets to the default).
|
|
112
|
+
*/
|
|
113
|
+
const DEFAULT_MODEL = "default";
|
|
114
|
+
/**
|
|
115
|
+
* Asks a short-lived `claude` process for its model list (the `models` of the stream-json `initialize` response; no model
|
|
116
|
+
* call is made). Same environment as a real session so a custom base URL / provider in the user's settings is honoured.
|
|
117
|
+
* `id` is the alias or id Claude accepts for `--model` / `set_model` (`default`, `opus`, `sonnet`, ...).
|
|
118
|
+
*/
|
|
119
|
+
function queryClaudeModels(binary) {
|
|
120
|
+
return new Promise((resolve, reject) => {
|
|
121
|
+
const args = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose", "--setting-sources", "project,local", "--permission-prompt-tool", "stdio", "--permission-mode", "default"];
|
|
122
|
+
const child = spawnCli(binary, args, homedir(), userSettingsEnv());
|
|
123
|
+
let done = false;
|
|
124
|
+
const timer = setTimeout(() => finish(new Error("claude did not answer in time")), 20_000);
|
|
125
|
+
const finish = (err, list) => {
|
|
126
|
+
if (done)
|
|
127
|
+
return;
|
|
128
|
+
done = true;
|
|
129
|
+
clearTimeout(timer);
|
|
130
|
+
killTree(child);
|
|
131
|
+
if (err)
|
|
132
|
+
reject(err);
|
|
133
|
+
else
|
|
134
|
+
resolve(list ?? []);
|
|
135
|
+
};
|
|
136
|
+
child.stdin.on("error", () => { });
|
|
137
|
+
child.on("error", (e) => finish(new Error(`claude failed to start: ${e.message}`)));
|
|
138
|
+
child.on("exit", (code) => finish(new Error(`claude exited (code ${code})`)));
|
|
139
|
+
createInterface({ input: child.stdout }).on("line", (line) => {
|
|
140
|
+
const l = line.trim();
|
|
141
|
+
if (!l.startsWith("{"))
|
|
142
|
+
return;
|
|
143
|
+
let m;
|
|
144
|
+
try {
|
|
145
|
+
m = JSON.parse(l);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (!isObj(m) || m["type"] !== "control_response" || !isObj(m["response"]))
|
|
151
|
+
return;
|
|
152
|
+
const r = m["response"];
|
|
153
|
+
if (r["request_id"] !== "models-1")
|
|
154
|
+
return;
|
|
155
|
+
if (r["subtype"] === "error")
|
|
156
|
+
return finish(new Error(str(r["error"]) ?? "claude refused the model query"));
|
|
157
|
+
const body = isObj(r["response"]) ? r["response"] : {};
|
|
158
|
+
const raw = Array.isArray(body["models"]) ? body["models"].filter(isObj) : [];
|
|
159
|
+
finish(undefined, raw.flatMap((x) => {
|
|
160
|
+
const id = str(x["value"]);
|
|
161
|
+
if (!id)
|
|
162
|
+
return [];
|
|
163
|
+
const description = str(x["description"]);
|
|
164
|
+
const levels = Array.isArray(x["supportedEffortLevels"]) ? x["supportedEffortLevels"].filter((v) => typeof v === "string") : [];
|
|
165
|
+
return [
|
|
166
|
+
{
|
|
167
|
+
id,
|
|
168
|
+
name: str(x["displayName"]) ?? id,
|
|
169
|
+
...(description ? { description } : {}),
|
|
170
|
+
...(id === "default" ? { isDefault: true } : {}),
|
|
171
|
+
...(x["supportsEffort"] === true && levels.length ? { efforts: levels } : {}),
|
|
172
|
+
},
|
|
173
|
+
];
|
|
174
|
+
}));
|
|
175
|
+
});
|
|
176
|
+
child.stdin.write(JSON.stringify({ type: "control_request", request_id: "models-1", request: { subtype: "initialize" } }) + "\n");
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
export function createClaudeAdapter(binary) {
|
|
180
|
+
/** Spawns one `claude` process (optionally resuming an earlier conversation) and completes the initialize handshake. */
|
|
181
|
+
async function launch({ cwd, permissionMode, model }, sink, resumeId, onSessionId) {
|
|
182
|
+
// User-level settings are NOT loaded: a blanket allow-list there (e.g. `Bash(*)`) made tools run with no
|
|
183
|
+
// can_use_tool request, so a phone user's approvals would silently not gate anything (seen live, 2026-11-13).
|
|
184
|
+
// Project/local settings (the repo's own .claude/) still apply. The user's `env` block is passed through.
|
|
185
|
+
const args = [
|
|
186
|
+
"-p",
|
|
187
|
+
"--input-format", "stream-json",
|
|
188
|
+
"--output-format", "stream-json",
|
|
189
|
+
"--verbose",
|
|
190
|
+
"--setting-sources", "project,local",
|
|
191
|
+
"--permission-prompt-tool", "stdio",
|
|
192
|
+
"--permission-mode", MODE_IDS[permissionMode],
|
|
193
|
+
...(model && model !== DEFAULT_MODEL ? ["--model", model] : []),
|
|
194
|
+
...(resumeId ? ["--resume", resumeId] : []),
|
|
195
|
+
];
|
|
196
|
+
const child = spawnCli(binary, args, cwd, userSettingsEnv());
|
|
197
|
+
let started = false; // initialize handshake finished (or timed out)
|
|
198
|
+
let closed = false; // set once the session is intentionally closed or has died
|
|
199
|
+
let cancelling = false; // an interrupt was sent for the running turn
|
|
200
|
+
let stderrTail = "";
|
|
201
|
+
let lastStderrAt = 0;
|
|
202
|
+
let dead = false; // the process exited on its own
|
|
203
|
+
let reqSeq = 0;
|
|
204
|
+
const ctlWaiters = new Map(); // control requests we sent (set_model)
|
|
205
|
+
let turn;
|
|
206
|
+
let initAck;
|
|
207
|
+
child.stdin.on("error", () => { });
|
|
208
|
+
child.stderr.on("data", (d) => {
|
|
209
|
+
lastStderrAt = Date.now();
|
|
210
|
+
stderrTail = (stderrTail + d.toString()).slice(-2000); // kept only for exit diagnostics; never logged
|
|
211
|
+
});
|
|
212
|
+
const send = (o) => {
|
|
213
|
+
if (child.stdin.writable)
|
|
214
|
+
child.stdin.write(JSON.stringify(o) + "\n");
|
|
215
|
+
};
|
|
216
|
+
const reply = (requestId, response) => {
|
|
217
|
+
if ("error" in response) {
|
|
218
|
+
send({ type: "control_response", response: { subtype: "error", request_id: requestId, error: response.error } });
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
send({ type: "control_response", response: { subtype: "success", request_id: requestId, response } });
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
const died = (why) => {
|
|
225
|
+
dead = true;
|
|
226
|
+
// Only quote stderr that is recent: an old line (e.g. a rejected background request) is not why it exited.
|
|
227
|
+
const recent = stderrTail && Date.now() - lastStderrAt < STDERR_RECENT_MS;
|
|
228
|
+
const err = new Error(recent ? `${why}: ${lastLine(stderrTail)}` : why);
|
|
229
|
+
const t = turn;
|
|
230
|
+
turn = undefined;
|
|
231
|
+
if (closed || !started) {
|
|
232
|
+
closed = true;
|
|
233
|
+
t?.reject(err);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
closed = true;
|
|
237
|
+
if (t)
|
|
238
|
+
t.reject(err);
|
|
239
|
+
else
|
|
240
|
+
sink.emit({ kind: "error", message: err.message });
|
|
241
|
+
sink.emit({ kind: "status", status: "error" });
|
|
242
|
+
};
|
|
243
|
+
const exited = new Promise((_, reject) => {
|
|
244
|
+
child.once("exit", (code) => reject(new Error(`claude exited (code ${code})`)));
|
|
245
|
+
child.once("error", (e) => reject(new Error(`claude failed to start: ${e.message}`)));
|
|
246
|
+
});
|
|
247
|
+
exited.catch((e) => died(e.message));
|
|
248
|
+
const handleControlRequest = (m) => {
|
|
249
|
+
const requestId = str(m["request_id"]);
|
|
250
|
+
const req = isObj(m["request"]) ? m["request"] : {};
|
|
251
|
+
if (!requestId)
|
|
252
|
+
return;
|
|
253
|
+
if (req["subtype"] !== "can_use_tool") {
|
|
254
|
+
reply(requestId, { error: `unsupported control request: ${String(req["subtype"])}` });
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const tool = str(req["tool_name"]) ?? "tool";
|
|
258
|
+
const input = req["input"] ?? {};
|
|
259
|
+
const toolUseId = str(req["tool_use_id"]);
|
|
260
|
+
const diff = diffOf(tool, input);
|
|
261
|
+
// `tool` is the Claude tool name, so a host-side allow_session covers that tool (e.g. every Bash command).
|
|
262
|
+
sink
|
|
263
|
+
.requestPermission({
|
|
264
|
+
...(toolUseId ? { toolCallId: toolUseId } : {}),
|
|
265
|
+
tool,
|
|
266
|
+
risk: riskOf(tool),
|
|
267
|
+
summary: summaryOf(tool, input),
|
|
268
|
+
input,
|
|
269
|
+
...(diff ? { diff } : {}),
|
|
270
|
+
})
|
|
271
|
+
.then((decision) => reply(requestId, decision === "deny"
|
|
272
|
+
? { behavior: "deny", message: "Denied from Remogent" }
|
|
273
|
+
: { behavior: "allow", updatedInput: input }), (e) => reply(requestId, { error: e instanceof Error ? e.message : String(e) }));
|
|
274
|
+
};
|
|
275
|
+
const handleResult = (m) => {
|
|
276
|
+
const subtype = str(m["subtype"]);
|
|
277
|
+
const isError = m["is_error"] === true;
|
|
278
|
+
let stopReason = "end_turn";
|
|
279
|
+
if (cancelling)
|
|
280
|
+
stopReason = "cancelled";
|
|
281
|
+
else if (subtype === "error_max_turns" || subtype === "error_max_budget_usd")
|
|
282
|
+
stopReason = "max_budget";
|
|
283
|
+
else if (isError || (subtype && subtype.startsWith("error"))) {
|
|
284
|
+
stopReason = "error";
|
|
285
|
+
sink.emit({ kind: "error", message: str(m["result"]) ?? subtype ?? "claude reported an error" });
|
|
286
|
+
}
|
|
287
|
+
const usage = m["usage"] !== undefined || m["total_cost_usd"] !== undefined
|
|
288
|
+
? { usage: m["usage"], total_cost_usd: m["total_cost_usd"] }
|
|
289
|
+
: undefined;
|
|
290
|
+
sink.emit({ kind: "turn_end", stopReason, ...(usage ? { usage } : {}) });
|
|
291
|
+
cancelling = false;
|
|
292
|
+
const t = turn;
|
|
293
|
+
turn = undefined;
|
|
294
|
+
t?.resolve();
|
|
295
|
+
};
|
|
296
|
+
const handleMessage = (m) => {
|
|
297
|
+
const claudeSessionId = str(m["session_id"]);
|
|
298
|
+
if (claudeSessionId)
|
|
299
|
+
onSessionId(claudeSessionId);
|
|
300
|
+
switch (m["type"]) {
|
|
301
|
+
case "control_response": {
|
|
302
|
+
const r = isObj(m["response"]) ? m["response"] : {};
|
|
303
|
+
if (r["request_id"] === "init-1")
|
|
304
|
+
initAck?.();
|
|
305
|
+
const w = ctlWaiters.get(String(r["request_id"]));
|
|
306
|
+
if (w) {
|
|
307
|
+
ctlWaiters.delete(String(r["request_id"]));
|
|
308
|
+
if (r["subtype"] === "error")
|
|
309
|
+
w.reject(new Error(str(r["error"]) ?? "claude rejected the request"));
|
|
310
|
+
else
|
|
311
|
+
w.resolve();
|
|
312
|
+
}
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
case "control_request":
|
|
316
|
+
handleControlRequest(m);
|
|
317
|
+
return;
|
|
318
|
+
case "assistant": {
|
|
319
|
+
const content = isObj(m["message"]) ? m["message"]["content"] : undefined;
|
|
320
|
+
if (!Array.isArray(content))
|
|
321
|
+
return;
|
|
322
|
+
for (const b of content.filter(isObj)) {
|
|
323
|
+
if (b["type"] === "text") {
|
|
324
|
+
const text = str(b["text"]);
|
|
325
|
+
if (text)
|
|
326
|
+
sink.emit({ kind: "agent_text_delta", text });
|
|
327
|
+
}
|
|
328
|
+
else if (b["type"] === "thinking") {
|
|
329
|
+
const text = str(b["thinking"]);
|
|
330
|
+
if (text)
|
|
331
|
+
sink.emit({ kind: "agent_thought_delta", text });
|
|
332
|
+
}
|
|
333
|
+
else if (b["type"] === "tool_use") {
|
|
334
|
+
const toolCallId = str(b["id"]);
|
|
335
|
+
const name = str(b["name"]) ?? "tool";
|
|
336
|
+
if (!toolCallId)
|
|
337
|
+
continue;
|
|
338
|
+
sink.emit({
|
|
339
|
+
kind: "tool_call",
|
|
340
|
+
toolCallId,
|
|
341
|
+
name,
|
|
342
|
+
title: summaryOf(name, b["input"]),
|
|
343
|
+
risk: riskOf(name),
|
|
344
|
+
input: b["input"] ?? {},
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
case "user": {
|
|
351
|
+
const content = isObj(m["message"]) ? m["message"]["content"] : undefined;
|
|
352
|
+
if (!Array.isArray(content))
|
|
353
|
+
return;
|
|
354
|
+
for (const b of content.filter(isObj)) {
|
|
355
|
+
if (b["type"] !== "tool_result")
|
|
356
|
+
continue;
|
|
357
|
+
const toolCallId = str(b["tool_use_id"]);
|
|
358
|
+
if (!toolCallId)
|
|
359
|
+
continue;
|
|
360
|
+
const output = resultText(b["content"]);
|
|
361
|
+
sink.emit({
|
|
362
|
+
kind: "tool_call_update",
|
|
363
|
+
toolCallId,
|
|
364
|
+
status: b["is_error"] === true ? "failed" : "completed",
|
|
365
|
+
...(output ? { output } : {}),
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
case "result":
|
|
371
|
+
handleResult(m);
|
|
372
|
+
return;
|
|
373
|
+
default:
|
|
374
|
+
return; // system (init, status, thinking_tokens), stream_event, keep_alive, ...
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
createInterface({ input: child.stdout }).on("line", (line) => {
|
|
378
|
+
const t = line.trim();
|
|
379
|
+
if (!t.startsWith("{"))
|
|
380
|
+
return; // ignore any non-JSON noise on stdout
|
|
381
|
+
let m;
|
|
382
|
+
try {
|
|
383
|
+
m = JSON.parse(t);
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (isObj(m))
|
|
389
|
+
handleMessage(m);
|
|
390
|
+
});
|
|
391
|
+
// Handshake: ask the CLI to initialize and wait (bounded) for the ack, so a broken install or a missing login
|
|
392
|
+
// fails `session_create` instead of the first prompt. A timeout is not fatal: the CLI may only ack lazily.
|
|
393
|
+
try {
|
|
394
|
+
const ack = new Promise((resolve) => {
|
|
395
|
+
initAck = resolve;
|
|
396
|
+
});
|
|
397
|
+
send({ type: "control_request", request_id: "init-1", request: { subtype: "initialize" } });
|
|
398
|
+
let timer;
|
|
399
|
+
const timeout = new Promise((resolve) => {
|
|
400
|
+
timer = setTimeout(resolve, INIT_TIMEOUT_MS);
|
|
401
|
+
});
|
|
402
|
+
try {
|
|
403
|
+
await Promise.race([ack, exited, timeout]);
|
|
404
|
+
}
|
|
405
|
+
finally {
|
|
406
|
+
if (timer)
|
|
407
|
+
clearTimeout(timer);
|
|
408
|
+
}
|
|
409
|
+
started = true;
|
|
410
|
+
}
|
|
411
|
+
catch (e) {
|
|
412
|
+
closed = true;
|
|
413
|
+
killTree(child);
|
|
414
|
+
throw e instanceof Error ? e : new Error(String(e));
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
hasDied: () => dead,
|
|
418
|
+
handle: {
|
|
419
|
+
prompt(text) {
|
|
420
|
+
if (closed)
|
|
421
|
+
return Promise.reject(new Error("claude process is not running"));
|
|
422
|
+
if (turn)
|
|
423
|
+
return Promise.reject(new Error("a turn is already running"));
|
|
424
|
+
return new Promise((resolve, reject) => {
|
|
425
|
+
turn = { resolve, reject };
|
|
426
|
+
cancelling = false;
|
|
427
|
+
send({ type: "user", message: { role: "user", content: [{ type: "text", text }] } });
|
|
428
|
+
});
|
|
429
|
+
},
|
|
430
|
+
setModel(model) {
|
|
431
|
+
if (closed)
|
|
432
|
+
return Promise.reject(new Error("claude process is not running"));
|
|
433
|
+
if (turn)
|
|
434
|
+
return Promise.reject(new Error("a turn is already running"));
|
|
435
|
+
const id = `m${++reqSeq}`;
|
|
436
|
+
return new Promise((resolve, reject) => {
|
|
437
|
+
const timer = setTimeout(() => {
|
|
438
|
+
ctlWaiters.delete(id);
|
|
439
|
+
reject(new Error("claude did not confirm the model change"));
|
|
440
|
+
}, 15_000);
|
|
441
|
+
ctlWaiters.set(id, {
|
|
442
|
+
resolve: () => {
|
|
443
|
+
clearTimeout(timer);
|
|
444
|
+
resolve();
|
|
445
|
+
},
|
|
446
|
+
reject: (e) => {
|
|
447
|
+
clearTimeout(timer);
|
|
448
|
+
reject(e);
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
send({ type: "control_request", request_id: id, request: model === DEFAULT_MODEL ? { subtype: "set_model" } : { subtype: "set_model", model } });
|
|
452
|
+
});
|
|
453
|
+
},
|
|
454
|
+
cancel() {
|
|
455
|
+
if (closed || !turn)
|
|
456
|
+
return;
|
|
457
|
+
cancelling = true;
|
|
458
|
+
send({ type: "control_request", request_id: `i${++reqSeq}`, request: { subtype: "interrupt" } });
|
|
459
|
+
},
|
|
460
|
+
close() {
|
|
461
|
+
closed = true;
|
|
462
|
+
killTree(child);
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
return {
|
|
468
|
+
engine: "claude",
|
|
469
|
+
resumable: true,
|
|
470
|
+
listModels: () => queryClaudeModels(binary),
|
|
471
|
+
async start(opts, sink) {
|
|
472
|
+
let claudeSessionId = opts.resumeId;
|
|
473
|
+
let curModel = opts.model; // reapplied whenever the CLI is (re)spawned
|
|
474
|
+
let closedByUser = false;
|
|
475
|
+
const remember = (id) => {
|
|
476
|
+
claudeSessionId = id;
|
|
477
|
+
};
|
|
478
|
+
// Resuming (after a host restart): spawn lazily on the first prompt instead of keeping an idle process per old chat.
|
|
479
|
+
const launchOpts = () => ({ cwd: opts.cwd, permissionMode: opts.permissionMode, ...(curModel ? { model: curModel } : {}) });
|
|
480
|
+
let current = opts.resumeId ? undefined : await launch(launchOpts(), sink, undefined, remember);
|
|
481
|
+
return {
|
|
482
|
+
async prompt(text) {
|
|
483
|
+
if (closedByUser)
|
|
484
|
+
throw new Error("claude process is not running");
|
|
485
|
+
if (!current || (current.hasDied() && claudeSessionId)) {
|
|
486
|
+
// No process yet (restored chat) or it exited on its own (killed, crashed): continue the same conversation.
|
|
487
|
+
current = await launch(launchOpts(), sink, claudeSessionId, remember);
|
|
488
|
+
}
|
|
489
|
+
return current.handle.prompt(text);
|
|
490
|
+
},
|
|
491
|
+
async setModel(model) {
|
|
492
|
+
if (closedByUser)
|
|
493
|
+
throw new Error("claude process is not running");
|
|
494
|
+
// A live process switches in place; a restored/dead one picks the model up (--model) when it is respawned.
|
|
495
|
+
// Back to the default: a runtime reset ignores the user's ANTHROPIC_MODEL / provider (seen live against a
|
|
496
|
+
// router), a fresh spawn without --model honours it, so end the process and let the next prompt resume the
|
|
497
|
+
// same conversation without an override.
|
|
498
|
+
if (model === DEFAULT_MODEL) {
|
|
499
|
+
if (current && !current.hasDied()) {
|
|
500
|
+
current.handle.close();
|
|
501
|
+
current = undefined;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
else if (current && !current.hasDied())
|
|
505
|
+
await current.handle.setModel?.(model);
|
|
506
|
+
curModel = model;
|
|
507
|
+
},
|
|
508
|
+
cancel: () => current?.handle.cancel(),
|
|
509
|
+
close() {
|
|
510
|
+
closedByUser = true;
|
|
511
|
+
current?.handle.close();
|
|
512
|
+
},
|
|
513
|
+
engineSessionId: () => claudeSessionId,
|
|
514
|
+
};
|
|
515
|
+
},
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
//# sourceMappingURL=claude.js.map
|