@primusclaw/connector 0.0.0-stage → 0.1.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 +37 -0
- package/README.md +419 -3
- package/dist/cli.js +3972 -0
- package/dist/terminal-hook.js +206 -0
- package/package.json +35 -6
package/dist/cli.js
ADDED
|
@@ -0,0 +1,3972 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/commands.ts
|
|
4
|
+
import fs13 from "node:fs";
|
|
5
|
+
import os4 from "node:os";
|
|
6
|
+
import path14 from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { parseArgs } from "node:util";
|
|
9
|
+
|
|
10
|
+
// src/audit.ts
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
// src/redact.ts
|
|
15
|
+
var MASK = "[hidden]";
|
|
16
|
+
var B64 = "[A-Za-z0-9+/=_-]";
|
|
17
|
+
var RULES = [
|
|
18
|
+
[/-----BEGIN [A-Z ]*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY(?: BLOCK)?-----|$)/g, 0],
|
|
19
|
+
[/\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/g, 0],
|
|
20
|
+
[/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi, 0],
|
|
21
|
+
[/\bak-[A-Za-z0-9_-]{16,}/g, 0],
|
|
22
|
+
[/\bsk-[A-Za-z0-9_-]{16,}/g, 0],
|
|
23
|
+
[/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, 0],
|
|
24
|
+
[/\bxox[abposr]-[A-Za-z0-9-]{10,}/g, 0],
|
|
25
|
+
[/\bglpat-[A-Za-z0-9_-]{16,}/g, 0],
|
|
26
|
+
[/\bAIza[A-Za-z0-9_-]{30,}/g, 0],
|
|
27
|
+
[/\bnpm_[A-Za-z0-9]{30,}/g, 0],
|
|
28
|
+
[/\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}/g, 0],
|
|
29
|
+
[/\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}/g, 0],
|
|
30
|
+
// A long opaque value right after a word like token/secret/key/password.
|
|
31
|
+
[new RegExp(`(?:token|secret|api[_-]?key|password|passwd|\\bkey)[\\w-]*["']?\\s*(?::|=|\\bis\\b)\\s*["']?(?=${B64}*\\d)(?=${B64}*[A-Za-z])(${B64}{32,})`, "gi"), 1]
|
|
32
|
+
];
|
|
33
|
+
function redactText(text) {
|
|
34
|
+
let masked = false;
|
|
35
|
+
let out = text;
|
|
36
|
+
for (const [re, group] of RULES) {
|
|
37
|
+
out = out.replace(re, (...args) => {
|
|
38
|
+
masked = true;
|
|
39
|
+
const match = args[0];
|
|
40
|
+
if (group === 0) return MASK;
|
|
41
|
+
const value = args[group];
|
|
42
|
+
const at = match.lastIndexOf(value);
|
|
43
|
+
return match.slice(0, at) + MASK + match.slice(at + value.length);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return { text: out, masked };
|
|
47
|
+
}
|
|
48
|
+
function redactDeep(value) {
|
|
49
|
+
if (typeof value === "string") return redactText(value).text;
|
|
50
|
+
if (Array.isArray(value)) return value.map((v) => redactDeep(v));
|
|
51
|
+
if (value && typeof value === "object") {
|
|
52
|
+
const out = {};
|
|
53
|
+
for (const [k, v] of Object.entries(value)) out[k] = redactDeep(v);
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// src/audit.ts
|
|
60
|
+
function fileAudit(file, now = Date.now) {
|
|
61
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 448 });
|
|
62
|
+
return {
|
|
63
|
+
record(kind, detail = {}) {
|
|
64
|
+
const line = JSON.stringify({ ts: new Date(now()).toISOString(), ...redactDeep(detail), kind });
|
|
65
|
+
try {
|
|
66
|
+
fs.appendFileSync(file, line + "\n", { mode: 384 });
|
|
67
|
+
} catch {
|
|
68
|
+
process.stderr.write(`audit write failed: ${kind}
|
|
69
|
+
`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// src/children.ts
|
|
76
|
+
import { execFileSync } from "node:child_process";
|
|
77
|
+
import fs2 from "node:fs";
|
|
78
|
+
import path2 from "node:path";
|
|
79
|
+
|
|
80
|
+
// src/util.ts
|
|
81
|
+
import { createHash } from "node:crypto";
|
|
82
|
+
var sleep = (ms, signal) => new Promise((resolve) => {
|
|
83
|
+
if (signal?.aborted) return resolve();
|
|
84
|
+
const t = setTimeout(done, ms);
|
|
85
|
+
function done() {
|
|
86
|
+
signal?.removeEventListener("abort", done);
|
|
87
|
+
clearTimeout(t);
|
|
88
|
+
resolve();
|
|
89
|
+
}
|
|
90
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
91
|
+
});
|
|
92
|
+
var sha256 = (s) => createHash("sha256").update(s).digest("hex");
|
|
93
|
+
var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
94
|
+
var errMessage = (e) => e instanceof Error ? e.message : String(e);
|
|
95
|
+
function truncateBytes(text, max) {
|
|
96
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
97
|
+
if (bytes <= max) return { text, truncated: false, bytes };
|
|
98
|
+
let cut = Buffer.from(text, "utf8").subarray(0, max).toString("utf8");
|
|
99
|
+
if (cut.endsWith("\uFFFD")) cut = cut.slice(0, -1);
|
|
100
|
+
return { text: `${cut}
|
|
101
|
+
[truncated, ${bytes} bytes in all]`, truncated: true, bytes };
|
|
102
|
+
}
|
|
103
|
+
function backoffMs(attempt, minMs, maxMs, random = Math.random) {
|
|
104
|
+
const raw = Math.min(maxMs, minMs * 2 ** Math.min(attempt, 30));
|
|
105
|
+
return Math.round(raw * (0.5 + random() / 2));
|
|
106
|
+
}
|
|
107
|
+
var stderrLogger = (quiet = false) => {
|
|
108
|
+
const w = (level) => (msg, extra) => {
|
|
109
|
+
if (quiet && level === "info") return;
|
|
110
|
+
process.stderr.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${level} ${msg}${extra ? " " + JSON.stringify(extra) : ""}
|
|
111
|
+
`);
|
|
112
|
+
};
|
|
113
|
+
return { info: w("info"), warn: w("warn"), error: w("error") };
|
|
114
|
+
};
|
|
115
|
+
var UrlError = class extends Error {
|
|
116
|
+
};
|
|
117
|
+
function secureUrl(raw, what) {
|
|
118
|
+
let u;
|
|
119
|
+
try {
|
|
120
|
+
u = new URL(raw);
|
|
121
|
+
} catch {
|
|
122
|
+
throw new UrlError(`${what} is not a valid URL: ${JSON.stringify(raw.slice(0, 80))}`);
|
|
123
|
+
}
|
|
124
|
+
const loopback = u.hostname === "localhost" || u.hostname === "[::1]" || /^127(\.\d{1,3}){3}$/.test(u.hostname);
|
|
125
|
+
if (u.protocol !== "https:" && !(u.protocol === "http:" && loopback)) {
|
|
126
|
+
throw new UrlError(`${what} must be an https:// URL (http:// is accepted only for localhost), got ${u.protocol}//${u.host}`);
|
|
127
|
+
}
|
|
128
|
+
return raw.replace(/\/+$/, "");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// src/children.ts
|
|
132
|
+
function processStart(pid) {
|
|
133
|
+
try {
|
|
134
|
+
if (process.platform === "linux") {
|
|
135
|
+
const stat = fs2.readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
136
|
+
const rest = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
137
|
+
return rest[19] ?? null;
|
|
138
|
+
}
|
|
139
|
+
return execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
var isAlive = (pid) => {
|
|
145
|
+
try {
|
|
146
|
+
process.kill(pid, 0);
|
|
147
|
+
return true;
|
|
148
|
+
} catch (e) {
|
|
149
|
+
return e.code === "EPERM";
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
var ChildRegistry = class {
|
|
153
|
+
constructor(file) {
|
|
154
|
+
this.file = file;
|
|
155
|
+
this.recs = new Map(readRecords(file).map((r) => [r.pid, r]));
|
|
156
|
+
}
|
|
157
|
+
file;
|
|
158
|
+
recs = /* @__PURE__ */ new Map();
|
|
159
|
+
save() {
|
|
160
|
+
fs2.mkdirSync(path2.dirname(this.file), { recursive: true, mode: 448 });
|
|
161
|
+
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
162
|
+
fs2.writeFileSync(tmp, JSON.stringify([...this.recs.values()]), { mode: 384 });
|
|
163
|
+
fs2.renameSync(tmp, this.file);
|
|
164
|
+
}
|
|
165
|
+
add(rec) {
|
|
166
|
+
const full = { ...rec, start: rec.start ?? processStart(rec.pid) ?? "", started_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
167
|
+
this.recs.set(full.pid, full);
|
|
168
|
+
this.save();
|
|
169
|
+
return full;
|
|
170
|
+
}
|
|
171
|
+
remove(pid) {
|
|
172
|
+
if (this.recs.delete(pid)) this.save();
|
|
173
|
+
}
|
|
174
|
+
list() {
|
|
175
|
+
return [...this.recs.values()];
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* True when the recorded process group is ours to signal.
|
|
179
|
+
*
|
|
180
|
+
* Either its leader is still the process we started (same pid and start time), or the leader is gone and members
|
|
181
|
+
* remain: a process group id is a pid that cannot be handed to another process while any member lives, so a
|
|
182
|
+
* group that is still alive under a recorded id with a dead leader can only be the one we started (an `npx`
|
|
183
|
+
* wrapper exits and the agent below it stays). A leader pid that now belongs to a different process is a
|
|
184
|
+
* recycled id and is left alone.
|
|
185
|
+
*/
|
|
186
|
+
isOurs(rec) {
|
|
187
|
+
if (rec.start === "") return false;
|
|
188
|
+
const now = processStart(rec.pid);
|
|
189
|
+
if (now === rec.start) return true;
|
|
190
|
+
if (now === null) return groupAlive(rec.pgid);
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* The leader exited: stop whatever it left behind in its group, then forget it.
|
|
195
|
+
* Called at the moment of exit, while the group id cannot yet belong to anyone else.
|
|
196
|
+
*/
|
|
197
|
+
async reap(pid, graceMs = 1e3) {
|
|
198
|
+
const r = this.recs.get(pid);
|
|
199
|
+
if (!r) return;
|
|
200
|
+
if (groupAlive(r.pgid)) {
|
|
201
|
+
signalGroup(r.pgid, "SIGTERM");
|
|
202
|
+
const deadline = Date.now() + graceMs;
|
|
203
|
+
while (Date.now() < deadline && groupAlive(r.pgid)) await sleep(25);
|
|
204
|
+
if (groupAlive(r.pgid)) signalGroup(r.pgid, "SIGKILL");
|
|
205
|
+
}
|
|
206
|
+
this.recs.delete(pid);
|
|
207
|
+
this.save();
|
|
208
|
+
}
|
|
209
|
+
/** Stop one recorded process group (the same way {@link killAll} does), and only that one. */
|
|
210
|
+
async kill(pid, graceMs = 1500) {
|
|
211
|
+
const r = this.recs.get(pid);
|
|
212
|
+
if (!r) return false;
|
|
213
|
+
if (!this.isOurs(r)) {
|
|
214
|
+
this.recs.delete(pid);
|
|
215
|
+
this.save();
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
signalGroup(r.pgid, "SIGTERM");
|
|
219
|
+
const deadline = Date.now() + graceMs;
|
|
220
|
+
while (Date.now() < deadline && groupAlive(r.pgid)) await sleep(25);
|
|
221
|
+
if (groupAlive(r.pgid)) signalGroup(r.pgid, "SIGKILL");
|
|
222
|
+
this.recs.delete(pid);
|
|
223
|
+
this.save();
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Stop every recorded process group: SIGTERM, a short grace, then SIGKILL.
|
|
228
|
+
* Records that are not ours (see {@link isOurs}: a recycled pid, or a group with nobody left in it) are dropped
|
|
229
|
+
* without signalling anything.
|
|
230
|
+
*/
|
|
231
|
+
async killAll(graceMs = 2e3) {
|
|
232
|
+
const killed = [];
|
|
233
|
+
const skipped = [];
|
|
234
|
+
const mine = this.list().filter((r) => {
|
|
235
|
+
if (this.isOurs(r)) return true;
|
|
236
|
+
skipped.push(r.pid);
|
|
237
|
+
this.recs.delete(r.pid);
|
|
238
|
+
return false;
|
|
239
|
+
});
|
|
240
|
+
for (const r of mine) signalGroup(r.pgid, "SIGTERM");
|
|
241
|
+
const deadline = Date.now() + graceMs;
|
|
242
|
+
while (Date.now() < deadline && mine.some((r) => groupAlive(r.pgid))) await sleep(50);
|
|
243
|
+
for (const r of mine) {
|
|
244
|
+
if (groupAlive(r.pgid)) signalGroup(r.pgid, "SIGKILL");
|
|
245
|
+
killed.push(r.pid);
|
|
246
|
+
this.recs.delete(r.pid);
|
|
247
|
+
}
|
|
248
|
+
this.save();
|
|
249
|
+
return { killed, skipped };
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
function readRecords(file) {
|
|
253
|
+
try {
|
|
254
|
+
const raw = JSON.parse(fs2.readFileSync(file, "utf8"));
|
|
255
|
+
return Array.isArray(raw) ? raw.filter((r) => Number.isInteger(r?.pid) && Number.isInteger(r?.pgid) && r.pgid > 1) : [];
|
|
256
|
+
} catch {
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function signalGroup(pgid, sig) {
|
|
261
|
+
try {
|
|
262
|
+
process.kill(-pgid, sig);
|
|
263
|
+
} catch {
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function groupAlive(pgid) {
|
|
267
|
+
try {
|
|
268
|
+
process.kill(-pgid, 0);
|
|
269
|
+
return true;
|
|
270
|
+
} catch (e) {
|
|
271
|
+
return e.code === "EPERM";
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// src/sse.ts
|
|
276
|
+
var SseParser = class {
|
|
277
|
+
buf = "";
|
|
278
|
+
push(chunk) {
|
|
279
|
+
this.buf += chunk;
|
|
280
|
+
const out = [];
|
|
281
|
+
for (; ; ) {
|
|
282
|
+
const m = /\r\n\r\n|\n\n|\r\r/.exec(this.buf);
|
|
283
|
+
if (!m) break;
|
|
284
|
+
const block = this.buf.slice(0, m.index);
|
|
285
|
+
this.buf = this.buf.slice(m.index + m[0].length);
|
|
286
|
+
const f = parseBlock(block);
|
|
287
|
+
if (f) out.push(f);
|
|
288
|
+
}
|
|
289
|
+
return out;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
function parseBlock(block) {
|
|
293
|
+
const f = {};
|
|
294
|
+
let any = false;
|
|
295
|
+
for (const line of block.split(/\r\n|\n|\r/)) {
|
|
296
|
+
if (line === "") continue;
|
|
297
|
+
if (line.startsWith(":")) {
|
|
298
|
+
f.comment = true;
|
|
299
|
+
any = true;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const i = line.indexOf(":");
|
|
303
|
+
const key = i < 0 ? line : line.slice(0, i);
|
|
304
|
+
const val = i < 0 ? "" : line.slice(i + 1).replace(/^ /, "");
|
|
305
|
+
if (key === "id") f.id = val;
|
|
306
|
+
else if (key === "event") f.event = val;
|
|
307
|
+
else if (key === "data") f.data = f.data === void 0 ? val : f.data + "\n" + val;
|
|
308
|
+
else continue;
|
|
309
|
+
any = true;
|
|
310
|
+
}
|
|
311
|
+
return any ? f : null;
|
|
312
|
+
}
|
|
313
|
+
async function* readSse(body, onBytes = () => {
|
|
314
|
+
}) {
|
|
315
|
+
const reader = body.getReader();
|
|
316
|
+
const dec = new TextDecoder();
|
|
317
|
+
const p = new SseParser();
|
|
318
|
+
try {
|
|
319
|
+
for (; ; ) {
|
|
320
|
+
const { done, value } = await reader.read();
|
|
321
|
+
if (done) return;
|
|
322
|
+
onBytes();
|
|
323
|
+
for (const f of p.push(dec.decode(value, { stream: true }))) yield f;
|
|
324
|
+
}
|
|
325
|
+
} finally {
|
|
326
|
+
reader.cancel().catch(() => {
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// src/claw-client.ts
|
|
332
|
+
var RevokedError = class extends Error {
|
|
333
|
+
constructor() {
|
|
334
|
+
super("this connector was revoked");
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
var HttpError = class extends Error {
|
|
338
|
+
constructor(status, code, path15) {
|
|
339
|
+
super(`${path15} answered ${status} ${code}`);
|
|
340
|
+
this.status = status;
|
|
341
|
+
this.code = code;
|
|
342
|
+
this.path = path15;
|
|
343
|
+
}
|
|
344
|
+
status;
|
|
345
|
+
code;
|
|
346
|
+
path;
|
|
347
|
+
};
|
|
348
|
+
var TransportError = class extends Error {
|
|
349
|
+
};
|
|
350
|
+
var ClawClient = class {
|
|
351
|
+
constructor(o) {
|
|
352
|
+
this.o = o;
|
|
353
|
+
this.o = { ...o, server: secureUrl(o.server, "the Claw server URL") };
|
|
354
|
+
this.f = o.fetch ?? fetch;
|
|
355
|
+
}
|
|
356
|
+
o;
|
|
357
|
+
f;
|
|
358
|
+
async headers(extra = {}) {
|
|
359
|
+
return {
|
|
360
|
+
authorization: `Bearer ${await this.o.tokens.bearer()}`,
|
|
361
|
+
"x-claw-client": this.o.clientLabel ?? "connector",
|
|
362
|
+
accept: "application/json",
|
|
363
|
+
...extra
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
async send(method, path15, body, signal) {
|
|
367
|
+
const url = this.o.server + path15;
|
|
368
|
+
for (let attempt = 0; ; attempt++) {
|
|
369
|
+
const headers = await this.headers(body === void 0 ? {} : { "content-type": "application/json" });
|
|
370
|
+
let res;
|
|
371
|
+
try {
|
|
372
|
+
res = await this.f(url, {
|
|
373
|
+
method,
|
|
374
|
+
headers,
|
|
375
|
+
body: body === void 0 ? void 0 : JSON.stringify(body),
|
|
376
|
+
signal: signal ?? AbortSignal.timeout(this.o.requestTimeoutMs ?? 2e4)
|
|
377
|
+
});
|
|
378
|
+
} catch (e) {
|
|
379
|
+
throw new TransportError(`${method} ${path15}: ${errMessage(e)}`);
|
|
380
|
+
}
|
|
381
|
+
if (res.status !== 401) return res;
|
|
382
|
+
const err = await errorCode(res);
|
|
383
|
+
if (err === "revoked") throw new RevokedError();
|
|
384
|
+
if (attempt === 0) {
|
|
385
|
+
this.o.tokens.invalidate();
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
throw new HttpError(401, err, path15);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
async json(method, path15, body) {
|
|
392
|
+
const res = await this.send(method, path15, body);
|
|
393
|
+
if (!res.ok) throw new HttpError(res.status, await errorCode(res), path15);
|
|
394
|
+
const parsed = await res.json().catch(() => null);
|
|
395
|
+
return isObject(parsed) && isObject(parsed.data) ? parsed.data : {};
|
|
396
|
+
}
|
|
397
|
+
async register(name, version) {
|
|
398
|
+
const d = await this.json("POST", "/v1/remote-agents/connectors", { name, version });
|
|
399
|
+
if (typeof d.connector_id !== "string") throw new HttpError(500, "no_connector_id", "/v1/remote-agents/connectors");
|
|
400
|
+
return d.connector_id;
|
|
401
|
+
}
|
|
402
|
+
async heartbeat(cid, hb) {
|
|
403
|
+
await this.json("POST", `/v1/remote-agents/connectors/${cid}/heartbeat`, hb);
|
|
404
|
+
}
|
|
405
|
+
async postEvents(cid, batchId, events) {
|
|
406
|
+
const d = await this.json("POST", `/v1/remote-agents/connectors/${cid}/events`, { batch_id: batchId, events });
|
|
407
|
+
return { acked: isObject(d.acked) ? d.acked : {}, rejected: Array.isArray(d.rejected) ? d.rejected : [] };
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Hold the command stream. Yields every frame (a `ping` too, since it is
|
|
411
|
+
* proof of life). Ends normally when the server closes it; throws
|
|
412
|
+
* {@link TransportError} when the network fails or nothing arrives for
|
|
413
|
+
* `watchdogMs`, and {@link RevokedError} on 401 `revoked`.
|
|
414
|
+
*/
|
|
415
|
+
async *commands(cid, opts) {
|
|
416
|
+
const stop = new AbortController();
|
|
417
|
+
const onAbort = () => stop.abort();
|
|
418
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
419
|
+
let timedOut = false;
|
|
420
|
+
let timer;
|
|
421
|
+
const arm = () => {
|
|
422
|
+
clearTimeout(timer);
|
|
423
|
+
timer = setTimeout(() => {
|
|
424
|
+
timedOut = true;
|
|
425
|
+
stop.abort();
|
|
426
|
+
}, opts.watchdogMs);
|
|
427
|
+
timer.unref?.();
|
|
428
|
+
};
|
|
429
|
+
try {
|
|
430
|
+
arm();
|
|
431
|
+
const path15 = `/v1/remote-agents/connectors/${cid}/commands`;
|
|
432
|
+
const headers = await this.headers({ accept: "text/event-stream", ...opts.lastEventId ? { "last-event-id": String(opts.lastEventId) } : {} });
|
|
433
|
+
let res;
|
|
434
|
+
try {
|
|
435
|
+
res = await this.f(this.o.server + path15, { headers, signal: stop.signal });
|
|
436
|
+
} catch (e) {
|
|
437
|
+
throw new TransportError(timedOut ? `no response for ${opts.watchdogMs} ms` : `GET ${path15}: ${errMessage(e)}`);
|
|
438
|
+
}
|
|
439
|
+
if (res.status === 401) {
|
|
440
|
+
const err = await errorCode(res);
|
|
441
|
+
if (err === "revoked") throw new RevokedError();
|
|
442
|
+
this.o.tokens.invalidate();
|
|
443
|
+
throw new HttpError(401, err, path15);
|
|
444
|
+
}
|
|
445
|
+
if (!res.ok || !res.body) throw new HttpError(res.status, await errorCode(res), path15);
|
|
446
|
+
try {
|
|
447
|
+
for await (const frame of readSse(res.body, arm)) yield frame;
|
|
448
|
+
} catch (e) {
|
|
449
|
+
throw new TransportError(timedOut ? `no byte for ${opts.watchdogMs} ms` : `command stream: ${errMessage(e)}`);
|
|
450
|
+
}
|
|
451
|
+
if (timedOut) throw new TransportError(`no byte for ${opts.watchdogMs} ms`);
|
|
452
|
+
} finally {
|
|
453
|
+
clearTimeout(timer);
|
|
454
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
455
|
+
stop.abort();
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
async function errorCode(res) {
|
|
460
|
+
const body = await res.json().catch(() => null);
|
|
461
|
+
return isObject(body) && typeof body.error === "string" ? body.error : `http_${res.status}`;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// src/config.ts
|
|
465
|
+
import fs4 from "node:fs";
|
|
466
|
+
import os from "node:os";
|
|
467
|
+
import path4 from "node:path";
|
|
468
|
+
import { parse as parseToml } from "smol-toml";
|
|
469
|
+
|
|
470
|
+
// src/roots.ts
|
|
471
|
+
import fs3 from "node:fs";
|
|
472
|
+
import path3 from "node:path";
|
|
473
|
+
function realRoot(root) {
|
|
474
|
+
try {
|
|
475
|
+
return fs3.realpathSync.native(root);
|
|
476
|
+
} catch {
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
var inside = (child, root) => child === root || child.startsWith(root.endsWith(path3.sep) ? root : root + path3.sep);
|
|
481
|
+
function checkCwd(cwd, roots) {
|
|
482
|
+
if (typeof cwd !== "string" || cwd === "" || cwd.includes("\0")) return { ok: false, reason: "invalid_cwd" };
|
|
483
|
+
if (!path3.isAbsolute(cwd)) return { ok: false, reason: "cwd_not_absolute" };
|
|
484
|
+
let real;
|
|
485
|
+
try {
|
|
486
|
+
real = fs3.realpathSync.native(cwd);
|
|
487
|
+
} catch {
|
|
488
|
+
return { ok: false, reason: "cwd_not_found" };
|
|
489
|
+
}
|
|
490
|
+
try {
|
|
491
|
+
if (!fs3.statSync(real).isDirectory()) return { ok: false, reason: "cwd_not_directory" };
|
|
492
|
+
} catch {
|
|
493
|
+
return { ok: false, reason: "cwd_not_found" };
|
|
494
|
+
}
|
|
495
|
+
for (const root of roots) {
|
|
496
|
+
const r = realRoot(root);
|
|
497
|
+
if (r && inside(real, r)) return { ok: true, real };
|
|
498
|
+
}
|
|
499
|
+
return { ok: false, reason: "cwd_outside_roots" };
|
|
500
|
+
}
|
|
501
|
+
function relativeInside(p, cwd) {
|
|
502
|
+
const abs = path3.resolve(cwd, p);
|
|
503
|
+
let real;
|
|
504
|
+
try {
|
|
505
|
+
real = fs3.realpathSync.native(abs);
|
|
506
|
+
} catch {
|
|
507
|
+
try {
|
|
508
|
+
real = path3.join(fs3.realpathSync.native(path3.dirname(abs)), path3.basename(abs));
|
|
509
|
+
} catch {
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (!inside(real, cwd)) return null;
|
|
514
|
+
return path3.relative(cwd, real).split(path3.sep).join("/");
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// src/config.ts
|
|
518
|
+
var ConfigError = class extends Error {
|
|
519
|
+
constructor(problems) {
|
|
520
|
+
super(`agents.toml: ${problems.join("; ")}`);
|
|
521
|
+
this.problems = problems;
|
|
522
|
+
}
|
|
523
|
+
problems;
|
|
524
|
+
};
|
|
525
|
+
var PRESETS = {
|
|
526
|
+
claude: {
|
|
527
|
+
kind: "claude",
|
|
528
|
+
name: "Claude Code",
|
|
529
|
+
command: "npx",
|
|
530
|
+
args: ["-y", "@agentclientprotocol/claude-agent-acp@0.99.0"],
|
|
531
|
+
askMode: "default",
|
|
532
|
+
safeModes: ["default", "plan"],
|
|
533
|
+
unattended: { askMode: "bypassPermissions" }
|
|
534
|
+
},
|
|
535
|
+
// Existing Claude Code sessions, taken over from the phone. The kind is the opaque label a chat client picks an icon
|
|
536
|
+
// from; it says what the entry is for, not which product serves it. Same adapter and same ask mode as `claude`.
|
|
537
|
+
"claude-attach": {
|
|
538
|
+
kind: "attach",
|
|
539
|
+
name: "Claude Code (existing sessions)",
|
|
540
|
+
command: "npx",
|
|
541
|
+
args: ["-y", "@agentclientprotocol/claude-agent-acp@0.99.0"],
|
|
542
|
+
askMode: "default",
|
|
543
|
+
safeModes: ["default", "plan"],
|
|
544
|
+
attach: true,
|
|
545
|
+
unattended: { askMode: "bypassPermissions" }
|
|
546
|
+
},
|
|
547
|
+
codex: {
|
|
548
|
+
kind: "codex",
|
|
549
|
+
name: "Codex",
|
|
550
|
+
command: "npx",
|
|
551
|
+
args: ["-y", "@agentclientprotocol/codex-acp@2.3.0"],
|
|
552
|
+
askMode: "read-only",
|
|
553
|
+
safeModes: ["read-only"],
|
|
554
|
+
unattended: { askMode: "agent-full-access" }
|
|
555
|
+
},
|
|
556
|
+
cursor: {
|
|
557
|
+
kind: "cursor",
|
|
558
|
+
name: "Cursor",
|
|
559
|
+
command: "agent",
|
|
560
|
+
args: ["acp"],
|
|
561
|
+
askMode: "ask",
|
|
562
|
+
safeModes: ["ask", "plan"],
|
|
563
|
+
unattended: { askMode: "agent", args: ["--force", "acp"] }
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
var ID_RE = /^[A-Za-z0-9._-]{1,64}$/;
|
|
567
|
+
var KIND_RE = /^[A-Za-z0-9._:-]{1,64}$/;
|
|
568
|
+
var BYPASS_ARG_RE = /^--?(?:force|yolo|dangerously[\w-]*|trust|auto-?approve[\w-]*|approve-mcps|full-auto|bypass[\w-]*)$/i;
|
|
569
|
+
var strList = (v, what, problems) => {
|
|
570
|
+
if (v === void 0) return [];
|
|
571
|
+
if (!Array.isArray(v) || v.some((x) => typeof x !== "string")) {
|
|
572
|
+
problems.push(`${what} must be a list of strings`);
|
|
573
|
+
return [];
|
|
574
|
+
}
|
|
575
|
+
return v;
|
|
576
|
+
};
|
|
577
|
+
var expandHome = (p) => p === "~" ? os.homedir() : p.startsWith("~/") ? path4.join(os.homedir(), p.slice(2)) : p;
|
|
578
|
+
function parseConfig(text, defaults = {}) {
|
|
579
|
+
const problems = [];
|
|
580
|
+
let doc;
|
|
581
|
+
try {
|
|
582
|
+
doc = parseToml(text);
|
|
583
|
+
} catch (e) {
|
|
584
|
+
throw new ConfigError([`not valid TOML (${e.message.split("\n")[0]})`]);
|
|
585
|
+
}
|
|
586
|
+
const allowUnsafeModes = doc.allow_unsafe_modes === true;
|
|
587
|
+
if (doc.allow_unsafe_modes !== void 0 && typeof doc.allow_unsafe_modes !== "boolean") problems.push("allow_unsafe_modes must be true or false");
|
|
588
|
+
const server = typeof doc.server === "string" && doc.server.trim() ? doc.server.trim().replace(/\/+$/, "") : null;
|
|
589
|
+
const identityUrl = typeof doc.identity_url === "string" && doc.identity_url.trim() ? doc.identity_url.trim().replace(/\/+$/, "") : null;
|
|
590
|
+
const name = typeof doc.name === "string" && doc.name.trim() ? doc.name.trim().slice(0, 64) : (defaults.hostname ?? os.hostname()).slice(0, 64);
|
|
591
|
+
const dropMessagePatterns = [];
|
|
592
|
+
for (const p of strList(doc.drop_message_patterns, "drop_message_patterns", problems)) {
|
|
593
|
+
try {
|
|
594
|
+
dropMessagePatterns.push(new RegExp(p));
|
|
595
|
+
} catch {
|
|
596
|
+
problems.push(`drop_message_patterns: invalid regular expression ${JSON.stringify(p)}`);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
const globalAllow = parseAutoAllow(doc.auto_allow, "auto_allow", problems);
|
|
600
|
+
const agents = [];
|
|
601
|
+
const seen = /* @__PURE__ */ new Set();
|
|
602
|
+
const rawAgents = doc.agents === void 0 ? [] : doc.agents;
|
|
603
|
+
if (!Array.isArray(rawAgents)) problems.push("agents must be a list of [[agents]] tables");
|
|
604
|
+
else rawAgents.forEach((raw, i) => {
|
|
605
|
+
const at = `agents[${i}]`;
|
|
606
|
+
if (!isObject(raw)) return void problems.push(`${at} must be a table`);
|
|
607
|
+
const id = raw.id;
|
|
608
|
+
if (typeof id !== "string" || !ID_RE.test(id)) return void problems.push(`${at}.id must be 1-64 characters of A-Z a-z 0-9 . _ -`);
|
|
609
|
+
if (seen.has(id)) return void problems.push(`${at}.id ${id} is used twice`);
|
|
610
|
+
seen.add(id);
|
|
611
|
+
const label = `agent ${id}`;
|
|
612
|
+
const presetName = typeof raw.preset === "string" ? raw.preset : null;
|
|
613
|
+
const preset = presetName ? PRESETS[presetName] : void 0;
|
|
614
|
+
if (presetName && !preset) return void problems.push(`${label}: unknown preset ${JSON.stringify(presetName)} (known: ${Object.keys(PRESETS).join(", ")})`);
|
|
615
|
+
const command = typeof raw.command === "string" && raw.command ? raw.command : preset?.command;
|
|
616
|
+
if (!command) return void problems.push(`${label}: needs a preset or a command`);
|
|
617
|
+
if (raw.approval !== void 0 && raw.approval !== "ask" && raw.approval !== "never") return void problems.push(`${label}: approval must be "ask" or "never"`);
|
|
618
|
+
const approval = raw.approval === "ask" || raw.approval === "never" ? raw.approval : null;
|
|
619
|
+
const never = approval === "never";
|
|
620
|
+
const allowUnsafe = approval === null ? allowUnsafeModes : never;
|
|
621
|
+
const args = raw.args !== void 0 ? strList(raw.args, `${label}.args`, problems) : [...(never ? preset?.unattended.args : void 0) ?? preset?.args ?? []];
|
|
622
|
+
const kind = typeof raw.kind === "string" ? raw.kind : preset?.kind ?? "custom";
|
|
623
|
+
if (!KIND_RE.test(kind)) problems.push(`${label}: kind must be 1-64 characters of A-Z a-z 0-9 . _ : -`);
|
|
624
|
+
const explicitAsk = typeof raw.ask_mode === "string" && raw.ask_mode ? raw.ask_mode : null;
|
|
625
|
+
const askMode = explicitAsk ?? (never ? preset?.unattended.askMode : preset?.askMode);
|
|
626
|
+
if (!askMode) return void problems.push(never ? `${label}: approval = "never" needs ask_mode, the mode in which this agent does not ask, for a custom agent` : `${label}: ask_mode is required for a custom agent`);
|
|
627
|
+
const listed = [...raw.safe_modes !== void 0 ? strList(raw.safe_modes, `${label}.safe_modes`, problems) : preset?.safeModes ?? [askMode]];
|
|
628
|
+
const asking = new Set(preset ? preset.safeModes : raw.safe_modes !== void 0 ? listed : []);
|
|
629
|
+
let safeModes = listed;
|
|
630
|
+
if (preset && !allowUnsafe) {
|
|
631
|
+
const extra = listed.filter((m) => !asking.has(m));
|
|
632
|
+
if (extra.length) problems.push(`${label}: safe_modes lists ${extra.join(", ")}, which preset ${presetName} does not know to ask before every step; remove it, set approval = "never" (or allow_unsafe_modes = true), or define the agent without a preset`);
|
|
633
|
+
}
|
|
634
|
+
if (never) {
|
|
635
|
+
if (explicitAsk && asking.has(explicitAsk)) problems.push(`${label}: approval = "never" but ask_mode ${explicitAsk} is a mode that asks before every step; remove ask_mode, or approval`);
|
|
636
|
+
asking.delete(askMode);
|
|
637
|
+
safeModes = safeModes.filter((m) => m !== askMode);
|
|
638
|
+
} else if (!asking.has(askMode)) {
|
|
639
|
+
if (preset && explicitAsk) {
|
|
640
|
+
if (!allowUnsafe) problems.push(`${label}: ask_mode ${askMode} is not a mode that asks before every step; set approval = "never" (or allow_unsafe_modes = true) to use it`);
|
|
641
|
+
} else asking.add(askMode);
|
|
642
|
+
}
|
|
643
|
+
if (asking.has(askMode) && !safeModes.includes(askMode)) safeModes.push(askMode);
|
|
644
|
+
const roots = strList(raw.roots, `${label}.roots`, problems).map((r) => path4.resolve(expandHome(r)));
|
|
645
|
+
if (roots.length === 0) problems.push(`${label}: roots is required (an agent with no allowed directory is never offered)`);
|
|
646
|
+
for (const r of roots) {
|
|
647
|
+
if (r === path4.parse(r).root) problems.push(`${label}: a root may not be the filesystem root`);
|
|
648
|
+
else if (!realRoot(r)) problems.push(`${label}: root ${r} does not exist`);
|
|
649
|
+
}
|
|
650
|
+
const env = {};
|
|
651
|
+
if (raw.env !== void 0) {
|
|
652
|
+
if (!isObject(raw.env)) problems.push(`${label}.env must be a table of strings`);
|
|
653
|
+
else for (const [k, v] of Object.entries(raw.env)) {
|
|
654
|
+
if (typeof v !== "string") problems.push(`${label}.env.${k} must be a string`);
|
|
655
|
+
else env[k] = v;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
if (!allowUnsafe) {
|
|
659
|
+
const bad = args.find((a) => BYPASS_ARG_RE.test(a));
|
|
660
|
+
if (bad) problems.push(`${label}: ${bad} turns the tool's own approvals off; remove it or set approval = "never" (or allow_unsafe_modes = true)`);
|
|
661
|
+
}
|
|
662
|
+
const own = parseAutoAllow(raw.auto_allow, `${label}.auto_allow`, problems);
|
|
663
|
+
let attach = null;
|
|
664
|
+
if (preset?.attach) {
|
|
665
|
+
const cli = raw.attach_cli === void 0 ? "claude" : raw.attach_cli;
|
|
666
|
+
if (typeof cli !== "string" || !cli.trim()) problems.push(`${label}.attach_cli must be a command name or path`);
|
|
667
|
+
else attach = { cli: cli.trim() };
|
|
668
|
+
} else if (raw.attach_cli !== void 0) {
|
|
669
|
+
problems.push(`${label}: attach_cli only applies to an agent made from the claude-attach preset`);
|
|
670
|
+
}
|
|
671
|
+
agents.push({
|
|
672
|
+
id,
|
|
673
|
+
kind,
|
|
674
|
+
name: typeof raw.name === "string" && raw.name.trim() ? raw.name.trim().slice(0, 128) : preset?.name ?? id,
|
|
675
|
+
command,
|
|
676
|
+
args,
|
|
677
|
+
env,
|
|
678
|
+
roots,
|
|
679
|
+
askMode,
|
|
680
|
+
safeModes,
|
|
681
|
+
askingModes: [...asking],
|
|
682
|
+
allowUnsafeModes: allowUnsafe,
|
|
683
|
+
unattended: !asking.has(askMode),
|
|
684
|
+
autoAllow: [...globalAllow, ...own],
|
|
685
|
+
attach
|
|
686
|
+
});
|
|
687
|
+
});
|
|
688
|
+
if (problems.length) throw new ConfigError(problems);
|
|
689
|
+
return { server, identityUrl, name, allowUnsafeModes, dropMessagePatterns, agents };
|
|
690
|
+
}
|
|
691
|
+
function parseAutoAllow(raw, at, problems) {
|
|
692
|
+
if (raw === void 0) return [];
|
|
693
|
+
if (!Array.isArray(raw)) {
|
|
694
|
+
problems.push(`${at} must be a list of { kind, path_glob }`);
|
|
695
|
+
return [];
|
|
696
|
+
}
|
|
697
|
+
const rules = [];
|
|
698
|
+
raw.forEach((r, i) => {
|
|
699
|
+
if (!isObject(r) || typeof r.kind !== "string" || typeof r.path_glob !== "string" || !r.kind || !r.path_glob) {
|
|
700
|
+
return void problems.push(`${at}[${i}] needs a kind and a path_glob`);
|
|
701
|
+
}
|
|
702
|
+
if (path4.isAbsolute(r.path_glob) || r.path_glob.split("/").includes("..")) {
|
|
703
|
+
return void problems.push(`${at}[${i}].path_glob must be relative to the working directory and may not contain ..`);
|
|
704
|
+
}
|
|
705
|
+
rules.push({ kind: r.kind, pathGlob: r.path_glob });
|
|
706
|
+
});
|
|
707
|
+
return rules;
|
|
708
|
+
}
|
|
709
|
+
function loadConfig(file, defaults = {}) {
|
|
710
|
+
let text;
|
|
711
|
+
try {
|
|
712
|
+
text = fs4.readFileSync(file, "utf8");
|
|
713
|
+
} catch (e) {
|
|
714
|
+
if (e.code === "ENOENT") throw new ConfigError([`${file} does not exist; run "claw-connector init" to create one`]);
|
|
715
|
+
throw e;
|
|
716
|
+
}
|
|
717
|
+
return parseConfig(text, defaults);
|
|
718
|
+
}
|
|
719
|
+
var SAMPLE_AGENTS = ["claude", "claude-attach", "codex", "cursor"];
|
|
720
|
+
var SAMPLE_ACTIVE = ["claude"];
|
|
721
|
+
function sampleApprovals(approval) {
|
|
722
|
+
const never = SAMPLE_AGENTS.filter((id) => approval[id] === "never");
|
|
723
|
+
return { unattended: never.filter((id) => SAMPLE_ACTIVE.includes(id)), parked: never.filter((id) => !SAMPLE_ACTIVE.includes(id)) };
|
|
724
|
+
}
|
|
725
|
+
var SAMPLE_NOTES = {
|
|
726
|
+
"claude-attach": "# Take over a Claude Code conversation that already exists on this machine (exit it in the terminal first)."
|
|
727
|
+
};
|
|
728
|
+
var NEVER_NOTE = `# No per-step approval: this agent runs like it does in your terminal. A phone session writes files and runs
|
|
729
|
+
# commands without asking, and the phone shows it as unattended. To go back to asking, set it to "ask".`;
|
|
730
|
+
function sampleConfig(approval = {}) {
|
|
731
|
+
const agent = (id) => {
|
|
732
|
+
const lines = [...SAMPLE_NOTES[id] ? [SAMPLE_NOTES[id]] : [], `[[agents]]`, `id = "${id}"`, `preset = "${id}"`, `roots = ["~/work"]`];
|
|
733
|
+
if (approval[id] === "never") lines.push(...NEVER_NOTE.split("\n"), `approval = "never"`);
|
|
734
|
+
return lines.map((l) => SAMPLE_ACTIVE.includes(id) || l.startsWith("#") ? l : `# ${l}`).join("\n");
|
|
735
|
+
};
|
|
736
|
+
return `# claw-connector: the agents this machine offers, and what they may touch.
|
|
737
|
+
# Policy lives here, on your machine; nothing Claw sends can widen it.
|
|
738
|
+
|
|
739
|
+
# server = "https://claw.example.com/claw-api"
|
|
740
|
+
# identity_url = "https://identity.example.com"
|
|
741
|
+
# name = "my-laptop"
|
|
742
|
+
|
|
743
|
+
# By default every agent asks before it writes a file or runs a command, and the phone answers each question.
|
|
744
|
+
# approval = "never" on an agent turns that off for it (a phone session runs like your terminal);
|
|
745
|
+
# approval = "ask" keeps it on. Modes in which an agent does not ask (bypassPermissions, acceptEdits, full access...)
|
|
746
|
+
# are refused for an agent without approval = "never" unless you set this for every agent at once:
|
|
747
|
+
# allow_unsafe_modes = false
|
|
748
|
+
|
|
749
|
+
${SAMPLE_AGENTS.map(agent).join("\n\n")}
|
|
750
|
+
|
|
751
|
+
# Let one kind of change through without asking, inside the session's directory.
|
|
752
|
+
# Every automatic approval is written to audit.jsonl and shown on the phone.
|
|
753
|
+
# auto_allow = [{ kind = "edit", path_glob = "docs/**" }]
|
|
754
|
+
`;
|
|
755
|
+
}
|
|
756
|
+
var SAMPLE_CONFIG = sampleConfig();
|
|
757
|
+
|
|
758
|
+
// src/credentials.ts
|
|
759
|
+
import fs5 from "node:fs";
|
|
760
|
+
import path5 from "node:path";
|
|
761
|
+
var DEFAULT_CLIENT_ID = "claw-connector";
|
|
762
|
+
var CONNECTOR_SCOPE = "claw.connector";
|
|
763
|
+
var CredentialsError = class extends Error {
|
|
764
|
+
};
|
|
765
|
+
var GrantRevokedError = class extends Error {
|
|
766
|
+
constructor(message = "the sign-in was revoked") {
|
|
767
|
+
super(message);
|
|
768
|
+
}
|
|
769
|
+
};
|
|
770
|
+
var TransientAuthError = class extends Error {
|
|
771
|
+
};
|
|
772
|
+
function loadCredentials(file) {
|
|
773
|
+
let fd;
|
|
774
|
+
try {
|
|
775
|
+
fd = fs5.openSync(file, "r");
|
|
776
|
+
} catch (e) {
|
|
777
|
+
if (e.code === "ENOENT") return null;
|
|
778
|
+
throw e;
|
|
779
|
+
}
|
|
780
|
+
let text;
|
|
781
|
+
try {
|
|
782
|
+
const st = fs5.fstatSync(fd);
|
|
783
|
+
if (process.platform !== "win32" && (st.mode & 63) !== 0) {
|
|
784
|
+
throw new CredentialsError(`${file} is readable by other users (mode ${(st.mode & 511).toString(8)}); run: chmod 600 ${file}`);
|
|
785
|
+
}
|
|
786
|
+
text = fs5.readFileSync(fd, "utf8");
|
|
787
|
+
} finally {
|
|
788
|
+
fs5.closeSync(fd);
|
|
789
|
+
}
|
|
790
|
+
let raw;
|
|
791
|
+
try {
|
|
792
|
+
raw = JSON.parse(text);
|
|
793
|
+
} catch (e) {
|
|
794
|
+
throw new CredentialsError(`${file} is not valid JSON (${errMessage(e)})`);
|
|
795
|
+
}
|
|
796
|
+
if (!isObject(raw) || raw.v !== 1 || typeof raw.server !== "string" || raw.kind !== "device" && raw.kind !== "key") {
|
|
797
|
+
throw new CredentialsError(`${file} is not a sign-in file written by this program; run login again`);
|
|
798
|
+
}
|
|
799
|
+
return raw;
|
|
800
|
+
}
|
|
801
|
+
function saveCredentials(file, creds) {
|
|
802
|
+
fs5.mkdirSync(path5.dirname(file), { recursive: true, mode: 448 });
|
|
803
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
804
|
+
fs5.writeFileSync(tmp, JSON.stringify(creds, null, 2) + "\n", { mode: 384 });
|
|
805
|
+
fs5.chmodSync(tmp, 384);
|
|
806
|
+
fs5.renameSync(tmp, file);
|
|
807
|
+
}
|
|
808
|
+
var KeyTokenProvider = class {
|
|
809
|
+
constructor(key) {
|
|
810
|
+
this.key = key;
|
|
811
|
+
}
|
|
812
|
+
key;
|
|
813
|
+
async bearer() {
|
|
814
|
+
return this.key;
|
|
815
|
+
}
|
|
816
|
+
invalidate() {
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
function parseTokenResponse(body) {
|
|
820
|
+
if (!isObject(body) || typeof body.access_token !== "string" || !body.access_token) return null;
|
|
821
|
+
const expires = typeof body.expires_in === "number" && body.expires_in > 0 ? body.expires_in : 300;
|
|
822
|
+
return {
|
|
823
|
+
access_token: body.access_token,
|
|
824
|
+
expires_in: expires,
|
|
825
|
+
refresh_token: typeof body.refresh_token === "string" && body.refresh_token ? body.refresh_token : void 0
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
var DeviceTokenProvider = class {
|
|
829
|
+
constructor(opts) {
|
|
830
|
+
this.opts = opts;
|
|
831
|
+
this.f = opts.fetch ?? fetch;
|
|
832
|
+
this.now = opts.now ?? Date.now;
|
|
833
|
+
this.skew = opts.skewMs ?? 6e4;
|
|
834
|
+
this.creds = opts.creds;
|
|
835
|
+
if (opts.initial) {
|
|
836
|
+
this.token = opts.initial.access_token;
|
|
837
|
+
this.expiresAt = this.now() + opts.initial.expires_in * 1e3;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
opts;
|
|
841
|
+
token = null;
|
|
842
|
+
expiresAt = 0;
|
|
843
|
+
inflight = null;
|
|
844
|
+
f;
|
|
845
|
+
now;
|
|
846
|
+
skew;
|
|
847
|
+
creds;
|
|
848
|
+
async bearer() {
|
|
849
|
+
if (this.token && this.now() < this.expiresAt - this.skew) return this.token;
|
|
850
|
+
return this.refresh();
|
|
851
|
+
}
|
|
852
|
+
invalidate() {
|
|
853
|
+
this.token = null;
|
|
854
|
+
this.expiresAt = 0;
|
|
855
|
+
}
|
|
856
|
+
/** One refresh at a time; the refresh token rotates, so two in flight would revoke the grant. */
|
|
857
|
+
refresh() {
|
|
858
|
+
this.inflight ??= this.doRefresh().finally(() => {
|
|
859
|
+
this.inflight = null;
|
|
860
|
+
});
|
|
861
|
+
return this.inflight;
|
|
862
|
+
}
|
|
863
|
+
async doRefresh() {
|
|
864
|
+
const c = this.creds;
|
|
865
|
+
if (!c.identity_url || !c.refresh_token) throw new CredentialsError("the sign-in file has no refresh token; run login again");
|
|
866
|
+
const identity = secureUrl(c.identity_url, "the identity service URL");
|
|
867
|
+
let res;
|
|
868
|
+
try {
|
|
869
|
+
res = await this.f(`${identity}/oauth/token`, {
|
|
870
|
+
method: "POST",
|
|
871
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
872
|
+
body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: c.refresh_token, client_id: c.client_id ?? DEFAULT_CLIENT_ID, scope: CONNECTOR_SCOPE }),
|
|
873
|
+
signal: AbortSignal.timeout(15e3)
|
|
874
|
+
});
|
|
875
|
+
} catch (e) {
|
|
876
|
+
throw new TransientAuthError(`could not reach the identity service: ${errMessage(e)}`);
|
|
877
|
+
}
|
|
878
|
+
const body = await res.json().catch(() => null);
|
|
879
|
+
if (res.status === 400 || res.status === 401) {
|
|
880
|
+
const err = isObject(body) ? body.error : void 0;
|
|
881
|
+
if (err === "invalid_grant" || err === "invalid_client" || err === "unauthorized_client") throw new GrantRevokedError(`the identity service refused the refresh token (${String(err)})`);
|
|
882
|
+
}
|
|
883
|
+
const parsed = res.ok ? parseTokenResponse(body) : null;
|
|
884
|
+
if (!parsed) throw new TransientAuthError(`the identity service answered ${res.status} to a refresh`);
|
|
885
|
+
if (parsed.refresh_token) {
|
|
886
|
+
this.creds = { ...c, refresh_token: parsed.refresh_token, saved_at: new Date(this.now()).toISOString() };
|
|
887
|
+
this.opts.save(this.creds);
|
|
888
|
+
}
|
|
889
|
+
this.token = parsed.access_token;
|
|
890
|
+
this.expiresAt = this.now() + parsed.expires_in * 1e3;
|
|
891
|
+
return this.token;
|
|
892
|
+
}
|
|
893
|
+
};
|
|
894
|
+
function providerFor(creds, save2, extra = {}) {
|
|
895
|
+
if (creds.kind === "key") {
|
|
896
|
+
if (!creds.key) throw new CredentialsError("the sign-in file has no key; run login again");
|
|
897
|
+
return new KeyTokenProvider(creds.key);
|
|
898
|
+
}
|
|
899
|
+
return new DeviceTokenProvider({ creds, save: save2, ...extra });
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// src/deviceflow.ts
|
|
903
|
+
var DeviceFlowError = class extends Error {
|
|
904
|
+
constructor(code, message) {
|
|
905
|
+
super(message);
|
|
906
|
+
this.code = code;
|
|
907
|
+
}
|
|
908
|
+
code;
|
|
909
|
+
};
|
|
910
|
+
var form = (o) => new URLSearchParams(o);
|
|
911
|
+
async function startDeviceAuthorization(o) {
|
|
912
|
+
secureUrl(o.identityUrl, "the identity service URL");
|
|
913
|
+
const f = o.fetch ?? fetch;
|
|
914
|
+
let res;
|
|
915
|
+
try {
|
|
916
|
+
res = await f(`${o.identityUrl}/oauth/device_authorization`, {
|
|
917
|
+
method: "POST",
|
|
918
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
919
|
+
body: form({ client_id: o.clientId ?? DEFAULT_CLIENT_ID, scope: o.scope ?? CONNECTOR_SCOPE, device_name: o.deviceName }),
|
|
920
|
+
signal: AbortSignal.timeout(15e3)
|
|
921
|
+
});
|
|
922
|
+
} catch (e) {
|
|
923
|
+
throw new DeviceFlowError("unreachable", `could not reach the identity service: ${errMessage(e)}`);
|
|
924
|
+
}
|
|
925
|
+
const body = await res.json().catch(() => null);
|
|
926
|
+
if (!res.ok || !isObject(body) || typeof body.device_code !== "string" || typeof body.user_code !== "string" || typeof body.verification_uri !== "string") {
|
|
927
|
+
const why = isObject(body) && typeof body.error === "string" ? body.error : `HTTP ${res.status}`;
|
|
928
|
+
throw new DeviceFlowError("protocol", `the identity service refused the sign-in request (${why})`);
|
|
929
|
+
}
|
|
930
|
+
return {
|
|
931
|
+
device_code: body.device_code,
|
|
932
|
+
user_code: body.user_code,
|
|
933
|
+
verification_uri: body.verification_uri,
|
|
934
|
+
verification_uri_complete: typeof body.verification_uri_complete === "string" ? body.verification_uri_complete : void 0,
|
|
935
|
+
expires_in: typeof body.expires_in === "number" && body.expires_in > 0 ? body.expires_in : 600,
|
|
936
|
+
interval: typeof body.interval === "number" && body.interval > 0 ? body.interval : 5
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
async function pollDeviceToken(o, auth) {
|
|
940
|
+
const f = o.fetch ?? fetch;
|
|
941
|
+
const wait = o.sleep ?? sleep;
|
|
942
|
+
const now = o.now ?? Date.now;
|
|
943
|
+
const deadline = now() + auth.expires_in * 1e3;
|
|
944
|
+
let interval = auth.interval;
|
|
945
|
+
let unreachable = 0;
|
|
946
|
+
for (; ; ) {
|
|
947
|
+
await wait(interval * 1e3, o.signal);
|
|
948
|
+
if (o.signal?.aborted) throw new DeviceFlowError("protocol", "sign-in cancelled");
|
|
949
|
+
if (now() >= deadline) throw new DeviceFlowError("expired_token", "the code expired before it was confirmed; run login again");
|
|
950
|
+
let res;
|
|
951
|
+
try {
|
|
952
|
+
res = await f(`${o.identityUrl}/oauth/token`, {
|
|
953
|
+
method: "POST",
|
|
954
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
955
|
+
body: form({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: auth.device_code, client_id: o.clientId ?? DEFAULT_CLIENT_ID }),
|
|
956
|
+
signal: AbortSignal.timeout(15e3)
|
|
957
|
+
});
|
|
958
|
+
} catch (e) {
|
|
959
|
+
if (++unreachable >= 5) throw new DeviceFlowError("unreachable", `could not reach the identity service: ${errMessage(e)}`);
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
if (res.status >= 500) {
|
|
963
|
+
if (++unreachable >= 5) throw new DeviceFlowError("unreachable", `the identity service keeps answering ${res.status}`);
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
unreachable = 0;
|
|
967
|
+
const body = await res.json().catch(() => null);
|
|
968
|
+
if (res.ok) {
|
|
969
|
+
const tok = parseTokenResponse(body);
|
|
970
|
+
if (!tok || !tok.refresh_token) throw new DeviceFlowError("protocol", "the identity service answered a confirmed sign-in without a refresh token");
|
|
971
|
+
return tok;
|
|
972
|
+
}
|
|
973
|
+
const err = isObject(body) && typeof body.error === "string" ? body.error : "";
|
|
974
|
+
if (err === "authorization_pending") continue;
|
|
975
|
+
if (err === "slow_down") {
|
|
976
|
+
interval += 5;
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
if (err === "access_denied") throw new DeviceFlowError("access_denied", "the sign-in was denied in the browser");
|
|
980
|
+
if (err === "expired_token") throw new DeviceFlowError("expired_token", "the code expired before it was confirmed; run login again");
|
|
981
|
+
throw new DeviceFlowError("protocol", `the identity service refused the sign-in (${err || `HTTP ${res.status}`})`);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
async function deviceLogin(o) {
|
|
985
|
+
const auth = await startDeviceAuthorization(o);
|
|
986
|
+
const say = o.print ?? (() => {
|
|
987
|
+
});
|
|
988
|
+
say(`To connect this machine, open: ${auth.verification_uri_complete ?? auth.verification_uri}`);
|
|
989
|
+
if (auth.verification_uri_complete) say(`or open ${auth.verification_uri} and enter the code: ${auth.user_code}`);
|
|
990
|
+
else say(`and enter the code: ${auth.user_code}`);
|
|
991
|
+
say(`The code works for ${Math.round(auth.expires_in / 60)} minutes. Waiting for you to confirm...`);
|
|
992
|
+
return pollDeviceToken(o, auth);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// src/hooks.ts
|
|
996
|
+
import fs7 from "node:fs";
|
|
997
|
+
import path7 from "node:path";
|
|
998
|
+
|
|
999
|
+
// src/terminals.ts
|
|
1000
|
+
import fs6 from "node:fs";
|
|
1001
|
+
import path6 from "node:path";
|
|
1002
|
+
var SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1003
|
+
var TERMINALS_DIR = "claw-connector-terminals";
|
|
1004
|
+
var terminalsDir = (configDir) => path6.join(configDir, TERMINALS_DIR);
|
|
1005
|
+
var UNREADABLE_GRACE_MS = 6e4;
|
|
1006
|
+
var MAX_STDIN = 1 << 20;
|
|
1007
|
+
function localPidDomain() {
|
|
1008
|
+
if (process.platform !== "linux") return null;
|
|
1009
|
+
try {
|
|
1010
|
+
return `linux:${fs6.readFileSync("/etc/machine-id", "utf8").trim()}:${fs6.readlinkSync("/proc/self/ns/pid")}`;
|
|
1011
|
+
} catch {
|
|
1012
|
+
return null;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
var isObject2 = (x) => typeof x === "object" && x !== null && !Array.isArray(x);
|
|
1016
|
+
function holdsStill(row, localDomain) {
|
|
1017
|
+
const foreign = row.pidDomain !== null && localDomain !== null && row.pidDomain !== localDomain;
|
|
1018
|
+
if (foreign) return { open: true, foreign };
|
|
1019
|
+
if (!isAlive(row.pid)) return { open: false, foreign };
|
|
1020
|
+
if (row.procStart !== null && processStart(row.pid) !== row.procStart) return { open: false, foreign };
|
|
1021
|
+
return { open: true, foreign };
|
|
1022
|
+
}
|
|
1023
|
+
function parseRow(doc) {
|
|
1024
|
+
if (!isObject2(doc) || doc.v !== 1) return null;
|
|
1025
|
+
if (typeof doc.pid !== "number" || !Number.isInteger(doc.pid) || doc.pid <= 1) return null;
|
|
1026
|
+
if (typeof doc.sessionId !== "string" || !SESSION_ID_RE.test(doc.sessionId)) return null;
|
|
1027
|
+
return {
|
|
1028
|
+
v: 1,
|
|
1029
|
+
pid: doc.pid,
|
|
1030
|
+
sessionId: doc.sessionId.toLowerCase(),
|
|
1031
|
+
procStart: typeof doc.procStart === "string" && doc.procStart ? doc.procStart : null,
|
|
1032
|
+
cwd: typeof doc.cwd === "string" ? doc.cwd : "",
|
|
1033
|
+
at: typeof doc.at === "number" ? doc.at : 0,
|
|
1034
|
+
pidDomain: typeof doc.pidDomain === "string" ? doc.pidDomain : null
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
function readTerminals(configDir, localDomain, now = Date.now()) {
|
|
1038
|
+
const dir = terminalsDir(configDir);
|
|
1039
|
+
let names;
|
|
1040
|
+
try {
|
|
1041
|
+
names = fs6.readdirSync(dir);
|
|
1042
|
+
} catch (e) {
|
|
1043
|
+
if (e.code === "ENOENT") return [];
|
|
1044
|
+
throw e;
|
|
1045
|
+
}
|
|
1046
|
+
const out = [];
|
|
1047
|
+
for (const name of names) {
|
|
1048
|
+
if (!name.endsWith(".json")) continue;
|
|
1049
|
+
const file = path6.join(dir, name);
|
|
1050
|
+
let doc;
|
|
1051
|
+
try {
|
|
1052
|
+
doc = JSON.parse(fs6.readFileSync(file, "utf8"));
|
|
1053
|
+
} catch (e) {
|
|
1054
|
+
if (e.code === "ENOENT") continue;
|
|
1055
|
+
let age = Infinity;
|
|
1056
|
+
try {
|
|
1057
|
+
age = now - fs6.statSync(file).mtimeMs;
|
|
1058
|
+
} catch {
|
|
1059
|
+
}
|
|
1060
|
+
if (age < UNREADABLE_GRACE_MS) throw new Error(`${file} is unreadable (${e.message.split("\n")[0]})`);
|
|
1061
|
+
continue;
|
|
1062
|
+
}
|
|
1063
|
+
const row = parseRow(doc);
|
|
1064
|
+
if (!row) continue;
|
|
1065
|
+
const still = holdsStill(row, localDomain);
|
|
1066
|
+
if (still.open) out.push({ pid: row.pid, sessionId: row.sessionId, status: null, foreign: still.foreign, via: "hook" });
|
|
1067
|
+
}
|
|
1068
|
+
return out;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// src/hooks.ts
|
|
1072
|
+
var HooksError = class extends Error {
|
|
1073
|
+
};
|
|
1074
|
+
var HOOKED = [
|
|
1075
|
+
{ event: "SessionStart", arg: "start", timeout: 2 },
|
|
1076
|
+
{ event: "SessionEnd", arg: "end", timeout: 1 }
|
|
1077
|
+
];
|
|
1078
|
+
var MANAGED_MARK = "--managed-by claw-connector";
|
|
1079
|
+
var OURS_RE = /terminal-hook\.(?:js|ts)["']?\s+(?:start|end)\s/;
|
|
1080
|
+
var shellQuote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
1081
|
+
function hookCommand(node, script, arg, configDir) {
|
|
1082
|
+
return `${shellQuote(node)} ${shellQuote(script)} ${arg} --dir ${shellQuote(configDir)} ${MANAGED_MARK}`;
|
|
1083
|
+
}
|
|
1084
|
+
var settingsFile = (configDir) => path7.join(configDir, "settings.json");
|
|
1085
|
+
var isObject3 = (x) => typeof x === "object" && x !== null && !Array.isArray(x);
|
|
1086
|
+
var isOurs = (h) => isObject3(h) && h.type === "command" && typeof h.command === "string" && h.command.endsWith(` ${MANAGED_MARK}`) && OURS_RE.test(h.command);
|
|
1087
|
+
function load(configDir) {
|
|
1088
|
+
const given = settingsFile(configDir);
|
|
1089
|
+
let file = given;
|
|
1090
|
+
try {
|
|
1091
|
+
file = fs7.realpathSync(given);
|
|
1092
|
+
} catch {
|
|
1093
|
+
}
|
|
1094
|
+
let text;
|
|
1095
|
+
try {
|
|
1096
|
+
text = fs7.readFileSync(file, "utf8");
|
|
1097
|
+
} catch (e) {
|
|
1098
|
+
if (e.code === "ENOENT") return { doc: {}, text: null, file };
|
|
1099
|
+
throw new HooksError(`cannot read ${given}: ${e.message}`);
|
|
1100
|
+
}
|
|
1101
|
+
if (text.trim() === "") return { doc: {}, text, file };
|
|
1102
|
+
let doc;
|
|
1103
|
+
try {
|
|
1104
|
+
doc = JSON.parse(text);
|
|
1105
|
+
} catch (e) {
|
|
1106
|
+
throw new HooksError(`${given} is not valid JSON (${e.message.split("\n")[0]}); left untouched`);
|
|
1107
|
+
}
|
|
1108
|
+
if (!isObject3(doc)) throw new HooksError(`${given} is not a JSON object; left untouched`);
|
|
1109
|
+
return { doc, text, file };
|
|
1110
|
+
}
|
|
1111
|
+
function save(l, doc) {
|
|
1112
|
+
const indent = l.text?.match(/^([ \t]+)"/m)?.[1] ?? " ";
|
|
1113
|
+
const body = JSON.stringify(doc, null, indent) + "\n";
|
|
1114
|
+
fs7.mkdirSync(path7.dirname(l.file), { recursive: true, mode: 448 });
|
|
1115
|
+
let mode = 384;
|
|
1116
|
+
try {
|
|
1117
|
+
mode = fs7.statSync(l.file).mode & 511;
|
|
1118
|
+
} catch {
|
|
1119
|
+
}
|
|
1120
|
+
const tmp = `${l.file}.claw-connector.${process.pid}.tmp`;
|
|
1121
|
+
fs7.writeFileSync(tmp, body, { mode });
|
|
1122
|
+
try {
|
|
1123
|
+
fs7.renameSync(tmp, l.file);
|
|
1124
|
+
} catch (e) {
|
|
1125
|
+
fs7.rmSync(tmp, { force: true });
|
|
1126
|
+
throw e;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
function hooksOf(doc, where) {
|
|
1130
|
+
if (doc.hooks === void 0) return null;
|
|
1131
|
+
if (!isObject3(doc.hooks)) throw new HooksError(`${where}: "hooks" is not an object; left untouched`);
|
|
1132
|
+
return doc.hooks;
|
|
1133
|
+
}
|
|
1134
|
+
function groupsOf(hooks, event, where) {
|
|
1135
|
+
const g = hooks[event];
|
|
1136
|
+
if (g === void 0) return null;
|
|
1137
|
+
if (!Array.isArray(g)) throw new HooksError(`${where}: hooks.${event} is not a list; left untouched`);
|
|
1138
|
+
return g;
|
|
1139
|
+
}
|
|
1140
|
+
function installHooks(configDir, node, script) {
|
|
1141
|
+
const l = load(configDir);
|
|
1142
|
+
const where = settingsFile(configDir);
|
|
1143
|
+
const before = JSON.stringify(l.doc);
|
|
1144
|
+
const doc = JSON.parse(before);
|
|
1145
|
+
const hooks = hooksOf(doc, where) ?? (doc.hooks = {});
|
|
1146
|
+
for (const h of HOOKED) {
|
|
1147
|
+
const entry = { type: "command", command: hookCommand(node, script, h.arg, configDir), timeout: h.timeout };
|
|
1148
|
+
const groups = groupsOf(hooks, h.event, where) ?? (hooks[h.event] = []);
|
|
1149
|
+
let seen = false;
|
|
1150
|
+
for (let i = 0; i < groups.length; i++) {
|
|
1151
|
+
const g = groups[i];
|
|
1152
|
+
if (!isObject3(g) || !Array.isArray(g.hooks)) continue;
|
|
1153
|
+
const kept = [];
|
|
1154
|
+
let dropped = false;
|
|
1155
|
+
for (const x of g.hooks) {
|
|
1156
|
+
if (!isOurs(x)) {
|
|
1157
|
+
kept.push(x);
|
|
1158
|
+
continue;
|
|
1159
|
+
}
|
|
1160
|
+
if (seen) {
|
|
1161
|
+
dropped = true;
|
|
1162
|
+
continue;
|
|
1163
|
+
}
|
|
1164
|
+
seen = true;
|
|
1165
|
+
kept.push({ ...x, ...entry });
|
|
1166
|
+
}
|
|
1167
|
+
g.hooks = kept;
|
|
1168
|
+
if (dropped && kept.length === 0) {
|
|
1169
|
+
groups.splice(i, 1);
|
|
1170
|
+
i--;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
if (!seen) groups.push({ hooks: [entry] });
|
|
1174
|
+
}
|
|
1175
|
+
const changed = JSON.stringify(doc) !== before || l.text === null;
|
|
1176
|
+
if (changed) save(l, doc);
|
|
1177
|
+
return { changed, file: l.file };
|
|
1178
|
+
}
|
|
1179
|
+
function uninstallHooks(configDir) {
|
|
1180
|
+
const l = load(configDir);
|
|
1181
|
+
const where = settingsFile(configDir);
|
|
1182
|
+
const doc = JSON.parse(JSON.stringify(l.doc));
|
|
1183
|
+
const hooks = hooksOf(doc, where);
|
|
1184
|
+
let touchedAny = false;
|
|
1185
|
+
if (hooks) {
|
|
1186
|
+
for (const h of HOOKED) {
|
|
1187
|
+
const groups = groupsOf(hooks, h.event, where);
|
|
1188
|
+
if (!groups) continue;
|
|
1189
|
+
let touched = false;
|
|
1190
|
+
for (let i = groups.length - 1; i >= 0; i--) {
|
|
1191
|
+
const g = groups[i];
|
|
1192
|
+
if (!isObject3(g) || !Array.isArray(g.hooks)) continue;
|
|
1193
|
+
const rest = g.hooks.filter((x) => !isOurs(x));
|
|
1194
|
+
if (rest.length === g.hooks.length) continue;
|
|
1195
|
+
touched = true;
|
|
1196
|
+
g.hooks = rest;
|
|
1197
|
+
if (rest.length === 0) groups.splice(i, 1);
|
|
1198
|
+
}
|
|
1199
|
+
if (touched && groups.length === 0) delete hooks[h.event];
|
|
1200
|
+
touchedAny ||= touched;
|
|
1201
|
+
}
|
|
1202
|
+
if (touchedAny && Object.keys(hooks).length === 0) delete doc.hooks;
|
|
1203
|
+
}
|
|
1204
|
+
if (touchedAny) save(l, doc);
|
|
1205
|
+
fs7.rmSync(terminalsDir(configDir), { recursive: true, force: true });
|
|
1206
|
+
return { changed: touchedAny, file: l.file };
|
|
1207
|
+
}
|
|
1208
|
+
function hooksStatus(configDir) {
|
|
1209
|
+
let l;
|
|
1210
|
+
try {
|
|
1211
|
+
l = load(configDir);
|
|
1212
|
+
} catch (e) {
|
|
1213
|
+
return { installed: false, events: [], healthy: false, problem: e.message };
|
|
1214
|
+
}
|
|
1215
|
+
const events = [];
|
|
1216
|
+
let healthy = true;
|
|
1217
|
+
let problem = null;
|
|
1218
|
+
const hooks = isObject3(l.doc.hooks) ? l.doc.hooks : {};
|
|
1219
|
+
for (const h of HOOKED) {
|
|
1220
|
+
const groups = Array.isArray(hooks[h.event]) ? hooks[h.event] : [];
|
|
1221
|
+
const mine = groups.flatMap((g) => isObject3(g) && Array.isArray(g.hooks) ? g.hooks.filter(isOurs) : []);
|
|
1222
|
+
if (mine.length === 0) continue;
|
|
1223
|
+
events.push(h.event);
|
|
1224
|
+
for (const x of mine) {
|
|
1225
|
+
const m = /^'((?:[^']|'\\'')*)' '((?:[^']|'\\'')*)' /.exec(String(x.command));
|
|
1226
|
+
if (!m) continue;
|
|
1227
|
+
for (const f of [m[1], m[2]].map((s) => s.replace(/'\\''/g, "'"))) {
|
|
1228
|
+
if (!fs7.existsSync(f)) {
|
|
1229
|
+
healthy = false;
|
|
1230
|
+
problem ??= `${f} (named by the ${h.event} hook) does not exist; run: claw-connector hooks install`;
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
return { installed: events.length === HOOKED.length, events, healthy, problem };
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// src/connector.ts
|
|
1239
|
+
import fs12 from "node:fs";
|
|
1240
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1241
|
+
|
|
1242
|
+
// src/attach.ts
|
|
1243
|
+
import { execFile, execFileSync as execFileSync2 } from "node:child_process";
|
|
1244
|
+
import fs9 from "node:fs";
|
|
1245
|
+
import os2 from "node:os";
|
|
1246
|
+
import path9 from "node:path";
|
|
1247
|
+
|
|
1248
|
+
// src/adapter.ts
|
|
1249
|
+
import { spawn } from "node:child_process";
|
|
1250
|
+
import fs8 from "node:fs";
|
|
1251
|
+
import path8 from "node:path";
|
|
1252
|
+
import { Readable, Writable } from "node:stream";
|
|
1253
|
+
import * as acp from "@agentclientprotocol/sdk";
|
|
1254
|
+
var AdapterProcess = class _AdapterProcess {
|
|
1255
|
+
constructor(child, pid, registry) {
|
|
1256
|
+
this.child = child;
|
|
1257
|
+
this.pid = pid;
|
|
1258
|
+
this.registry = registry;
|
|
1259
|
+
}
|
|
1260
|
+
child;
|
|
1261
|
+
pid;
|
|
1262
|
+
registry;
|
|
1263
|
+
caps = {};
|
|
1264
|
+
exited = false;
|
|
1265
|
+
conn;
|
|
1266
|
+
static async start(o) {
|
|
1267
|
+
fs8.mkdirSync(path8.dirname(o.logFile), { recursive: true, mode: 448 });
|
|
1268
|
+
const log = fs8.openSync(o.logFile, "a", 384);
|
|
1269
|
+
const child = spawn(o.cfg.command, o.cfg.args, {
|
|
1270
|
+
cwd: o.cwd,
|
|
1271
|
+
env: { ...process.env, ...o.cfg.env },
|
|
1272
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1273
|
+
// Its own process group, so the whole tree (an npx wrapper and the agent below it) can be stopped as one.
|
|
1274
|
+
detached: true
|
|
1275
|
+
});
|
|
1276
|
+
await new Promise((resolve, reject) => {
|
|
1277
|
+
child.once("spawn", () => resolve());
|
|
1278
|
+
child.once("error", (e) => reject(new Error(`could not start ${o.cfg.command}: ${errMessage(e)}`)));
|
|
1279
|
+
}).catch((e) => {
|
|
1280
|
+
fs8.closeSync(log);
|
|
1281
|
+
throw e;
|
|
1282
|
+
});
|
|
1283
|
+
const pid = child.pid;
|
|
1284
|
+
o.registry.add({ pid, pgid: pid, agent_id: o.cfg.id, session_id: o.sessionId });
|
|
1285
|
+
const a = new _AdapterProcess(child, pid, o.registry);
|
|
1286
|
+
let tail = "";
|
|
1287
|
+
const flushLine = (line) => {
|
|
1288
|
+
const text = redactText(line).text;
|
|
1289
|
+
try {
|
|
1290
|
+
fs8.writeSync(log, text + "\n");
|
|
1291
|
+
} catch {
|
|
1292
|
+
}
|
|
1293
|
+
try {
|
|
1294
|
+
o.hooks.onStderr(text);
|
|
1295
|
+
} catch {
|
|
1296
|
+
}
|
|
1297
|
+
};
|
|
1298
|
+
child.stderr.on("data", (d) => {
|
|
1299
|
+
tail += d.toString("utf8");
|
|
1300
|
+
let nl;
|
|
1301
|
+
while ((nl = tail.indexOf("\n")) >= 0) {
|
|
1302
|
+
flushLine(tail.slice(0, nl));
|
|
1303
|
+
tail = tail.slice(nl + 1);
|
|
1304
|
+
}
|
|
1305
|
+
if (tail.length > 64 * 1024) {
|
|
1306
|
+
flushLine(tail);
|
|
1307
|
+
tail = "";
|
|
1308
|
+
}
|
|
1309
|
+
});
|
|
1310
|
+
child.stderr.once("close", () => {
|
|
1311
|
+
if (tail) {
|
|
1312
|
+
flushLine(tail);
|
|
1313
|
+
tail = "";
|
|
1314
|
+
}
|
|
1315
|
+
try {
|
|
1316
|
+
fs8.closeSync(log);
|
|
1317
|
+
} catch {
|
|
1318
|
+
}
|
|
1319
|
+
});
|
|
1320
|
+
child.stdin.on("error", () => {
|
|
1321
|
+
});
|
|
1322
|
+
child.once("exit", (code, signal) => {
|
|
1323
|
+
a.exited = true;
|
|
1324
|
+
void o.registry.reap(pid).catch(() => void 0);
|
|
1325
|
+
o.hooks.onExit({ code, signal });
|
|
1326
|
+
});
|
|
1327
|
+
const app = acp.client({ name: "claw-connector" }).onRequest(acp.methods.client.session.requestPermission, async ({ params }) => await o.hooks.onPermission(params)).onNotification(acp.methods.client.session.update, async ({ params }) => {
|
|
1328
|
+
const p = params;
|
|
1329
|
+
o.hooks.onUpdate(p.update, p);
|
|
1330
|
+
}).onRequest("*", async (ctx) => {
|
|
1331
|
+
if (ctx.method.startsWith("cursor/")) return await o.hooks.onExtension(ctx.method, ctx.params ?? {});
|
|
1332
|
+
throw acp.RequestError.methodNotFound(ctx.method);
|
|
1333
|
+
}).onNotification("*", async (ctx) => {
|
|
1334
|
+
o.hooks.onNotification(ctx.method, ctx.params ?? {});
|
|
1335
|
+
});
|
|
1336
|
+
a.conn = app.connect(acp.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout)));
|
|
1337
|
+
try {
|
|
1338
|
+
const init = await withTimeout(
|
|
1339
|
+
a.conn.agent.request("initialize", { protocolVersion: 1, clientCapabilities: {}, clientInfo: { name: "claw-connector", title: "Claw connector", version: o.version } }),
|
|
1340
|
+
o.initTimeoutMs ?? 12e4,
|
|
1341
|
+
"initialize"
|
|
1342
|
+
);
|
|
1343
|
+
a.caps = init.agentCapabilities ?? {};
|
|
1344
|
+
} catch (e) {
|
|
1345
|
+
await a.kill();
|
|
1346
|
+
throw e;
|
|
1347
|
+
}
|
|
1348
|
+
return a;
|
|
1349
|
+
}
|
|
1350
|
+
request(method, params, timeoutMs = 6e4) {
|
|
1351
|
+
return withTimeout(this.conn.agent.request(method, params), timeoutMs, method);
|
|
1352
|
+
}
|
|
1353
|
+
/** A request with no deadline: a prompt runs as long as the turn does. */
|
|
1354
|
+
requestUnbounded(method, params) {
|
|
1355
|
+
return this.conn.agent.request(method, params);
|
|
1356
|
+
}
|
|
1357
|
+
async notify(method, params) {
|
|
1358
|
+
await this.conn.agent.notify(method, params);
|
|
1359
|
+
}
|
|
1360
|
+
/** Stop the adapter and everything it started: its whole process group. */
|
|
1361
|
+
async kill() {
|
|
1362
|
+
try {
|
|
1363
|
+
this.conn?.close();
|
|
1364
|
+
} catch {
|
|
1365
|
+
}
|
|
1366
|
+
try {
|
|
1367
|
+
this.child.stdin?.end();
|
|
1368
|
+
} catch {
|
|
1369
|
+
}
|
|
1370
|
+
await this.registry.kill(this.pid, 1500).catch(() => void 0);
|
|
1371
|
+
}
|
|
1372
|
+
};
|
|
1373
|
+
function withTimeout(p, ms, what) {
|
|
1374
|
+
let timer;
|
|
1375
|
+
const t = new Promise((_, reject) => {
|
|
1376
|
+
timer = setTimeout(() => reject(new Error(`${what} timed out after ${ms} ms`)), ms);
|
|
1377
|
+
timer.unref?.();
|
|
1378
|
+
});
|
|
1379
|
+
return Promise.race([p, t]).finally(() => clearTimeout(timer));
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// ../claw/packages/protocol/src/remote-agents.ts
|
|
1383
|
+
var REMOTE_AGENT_LIMITS = {
|
|
1384
|
+
/** Connectors one user may keep (revoked ones do not count). */
|
|
1385
|
+
maxConnectorsPerUser: 5,
|
|
1386
|
+
/** Sessions one user may have open at once. */
|
|
1387
|
+
maxActiveSessionsPerUser: 10,
|
|
1388
|
+
/** Bytes of one event's `data`, as JSON. */
|
|
1389
|
+
maxEventBytes: 64 * 1024,
|
|
1390
|
+
maxEventsPerBatch: 200,
|
|
1391
|
+
maxAgentsPerConnector: 20,
|
|
1392
|
+
/** Bytes of the capabilities object one agent reports. */
|
|
1393
|
+
maxCapabilitiesBytes: 4 * 1024,
|
|
1394
|
+
/** Bytes of one prompt. */
|
|
1395
|
+
maxPromptBytes: 64 * 1024,
|
|
1396
|
+
/** Commands one user may issue per minute, across all connectors. */
|
|
1397
|
+
commandsPerMinute: 60
|
|
1398
|
+
};
|
|
1399
|
+
var REMOTE_AGENT_TIMING = {
|
|
1400
|
+
/** The server sends an `event: ping` frame this often on every stream. */
|
|
1401
|
+
pingMs: 25e3,
|
|
1402
|
+
/** A connector posts a heartbeat this often. */
|
|
1403
|
+
heartbeatMs: 3e4,
|
|
1404
|
+
/**
|
|
1405
|
+
* A connector is online while its command stream is open and its last
|
|
1406
|
+
* heartbeat is at most this old. Three missed heartbeats.
|
|
1407
|
+
*/
|
|
1408
|
+
onlineWindowMs: 9e4,
|
|
1409
|
+
/**
|
|
1410
|
+
* How long a `prompt`, `set_mode` or `resume` (the commands with `needsOnline`) stays
|
|
1411
|
+
* deliverable once queued. They are accepted only while the connector is online, so one
|
|
1412
|
+
* still uncollected after this was sent into a connection that had died unnoticed, and
|
|
1413
|
+
* nobody is waiting on it: it is dropped, not replayed when the machine wakes hours later.
|
|
1414
|
+
* Longer than the connector's watchdog plus a reconnect, so an ordinary blip loses nothing.
|
|
1415
|
+
*/
|
|
1416
|
+
liveCommandTtlMs: 5 * 6e4,
|
|
1417
|
+
/** A connector that sees no byte for this long drops the stream and reconnects. */
|
|
1418
|
+
watchdogMs: 75e3
|
|
1419
|
+
};
|
|
1420
|
+
var REMOTE_COMMAND_TYPES = ["prompt", "cancel", "set_mode", "resume", "close", "decision"];
|
|
1421
|
+
function isRemoteCommandType(v) {
|
|
1422
|
+
return typeof v === "string" && REMOTE_COMMAND_TYPES.includes(v);
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// src/attach.ts
|
|
1426
|
+
var TwinCheckError = class extends Error {
|
|
1427
|
+
};
|
|
1428
|
+
var SESSION_REF_RE = SESSION_ID_RE;
|
|
1429
|
+
var MAX_REPORTED = 12;
|
|
1430
|
+
var MAX_LIST_PAGES = 8;
|
|
1431
|
+
var MAX_TITLE = 80;
|
|
1432
|
+
var MAX_SCANNED_PROJECT_DIRS = 2e3;
|
|
1433
|
+
var UNREADABLE_GRACE_MS2 = 6e4;
|
|
1434
|
+
var NO_HOOKS = {
|
|
1435
|
+
onUpdate() {
|
|
1436
|
+
},
|
|
1437
|
+
onPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
|
1438
|
+
onExtension: async (method) => {
|
|
1439
|
+
throw new Error(`unsupported extension ${method}`);
|
|
1440
|
+
},
|
|
1441
|
+
onNotification() {
|
|
1442
|
+
},
|
|
1443
|
+
onStderr() {
|
|
1444
|
+
},
|
|
1445
|
+
onExit() {
|
|
1446
|
+
}
|
|
1447
|
+
};
|
|
1448
|
+
var AttachSupport = class {
|
|
1449
|
+
constructor(agent, d) {
|
|
1450
|
+
this.agent = agent;
|
|
1451
|
+
this.d = d;
|
|
1452
|
+
this.configDir = path9.resolve(agent.env.CLAUDE_CONFIG_DIR ?? d.env.CLAUDE_CONFIG_DIR ?? path9.join(os2.homedir(), ".claude"));
|
|
1453
|
+
}
|
|
1454
|
+
agent;
|
|
1455
|
+
d;
|
|
1456
|
+
configDir;
|
|
1457
|
+
listed = [];
|
|
1458
|
+
listedAt = 0;
|
|
1459
|
+
listError = null;
|
|
1460
|
+
refreshing = null;
|
|
1461
|
+
waitedOnce = false;
|
|
1462
|
+
/** Where each conversation's transcript was found, so that it is not searched for again on every prompt. */
|
|
1463
|
+
transcripts = /* @__PURE__ */ new Map();
|
|
1464
|
+
localDomain = localPidDomain();
|
|
1465
|
+
now() {
|
|
1466
|
+
return this.d.now ? this.d.now() : Date.now();
|
|
1467
|
+
}
|
|
1468
|
+
hookNote = false;
|
|
1469
|
+
lastHookError = "";
|
|
1470
|
+
/** Once: say whether the terminal hooks are in this agent's Claude config, so a missing one is not a mystery. */
|
|
1471
|
+
noteHooks() {
|
|
1472
|
+
if (this.hookNote) return;
|
|
1473
|
+
this.hookNote = true;
|
|
1474
|
+
const st = hooksStatus(this.configDir);
|
|
1475
|
+
if (!st.installed) this.d.log.info("the terminal hooks are not installed in this Claude config directory; sessions open in a terminal are found by listing processes only (claw-connector hooks install)", { agent: this.agent.id, dir: this.configDir });
|
|
1476
|
+
else if (!st.healthy) this.d.log.warn("an installed terminal hook points at a file that is gone", { agent: this.agent.id, problem: st.problem });
|
|
1477
|
+
}
|
|
1478
|
+
/**
|
|
1479
|
+
* The sessions the terminal hooks say are open. This source only ever adds: when it cannot be read it answers
|
|
1480
|
+
* nothing (and says so once), because the sources behind it decide everything it does not know.
|
|
1481
|
+
*/
|
|
1482
|
+
terminalRows() {
|
|
1483
|
+
try {
|
|
1484
|
+
this.lastHookError = "";
|
|
1485
|
+
return readTerminals(this.configDir, this.localDomain, this.now());
|
|
1486
|
+
} catch (e) {
|
|
1487
|
+
const msg = errMessage(e);
|
|
1488
|
+
if (msg !== this.lastHookError) this.d.log.warn("the terminal hook rows could not be read; the other checks decide", { agent: this.agent.id, error: msg });
|
|
1489
|
+
this.lastHookError = msg;
|
|
1490
|
+
return [];
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
// --- what exists -------------------------------------------------------------------
|
|
1494
|
+
/** Ask the adapter for the sessions on this machine and keep those whose directory is inside the roots. One adapter process, started and stopped here. */
|
|
1495
|
+
refreshCatalog() {
|
|
1496
|
+
this.refreshing ??= this.doRefresh().finally(() => {
|
|
1497
|
+
this.refreshing = null;
|
|
1498
|
+
});
|
|
1499
|
+
return this.refreshing;
|
|
1500
|
+
}
|
|
1501
|
+
async doRefresh() {
|
|
1502
|
+
let adapter = null;
|
|
1503
|
+
this.noteHooks();
|
|
1504
|
+
try {
|
|
1505
|
+
adapter = await AdapterProcess.start({
|
|
1506
|
+
cfg: this.agent,
|
|
1507
|
+
registry: this.d.registry,
|
|
1508
|
+
sessionId: `list-${this.agent.id}`,
|
|
1509
|
+
cwd: this.agent.roots[0] ?? os2.homedir(),
|
|
1510
|
+
hooks: NO_HOOKS,
|
|
1511
|
+
logFile: path9.join(this.d.logDir, `adapter-${this.agent.id}.log`),
|
|
1512
|
+
version: this.d.version,
|
|
1513
|
+
initTimeoutMs: 3e4
|
|
1514
|
+
});
|
|
1515
|
+
const caps = adapter.caps.sessionCapabilities;
|
|
1516
|
+
if (!caps?.list) throw new Error("the adapter cannot list sessions");
|
|
1517
|
+
const found = /* @__PURE__ */ new Map();
|
|
1518
|
+
let cursor;
|
|
1519
|
+
for (let page = 0; page < MAX_LIST_PAGES; page++) {
|
|
1520
|
+
const res = await adapter.request("session/list", cursor ? { cursor } : {}, 3e4);
|
|
1521
|
+
for (const s of Array.isArray(res.sessions) ? res.sessions : []) {
|
|
1522
|
+
if (!isObject(s) || typeof s.sessionId !== "string" || !SESSION_REF_RE.test(s.sessionId) || typeof s.cwd !== "string") continue;
|
|
1523
|
+
const where = checkCwd(s.cwd, this.agent.roots);
|
|
1524
|
+
if (!where.ok) continue;
|
|
1525
|
+
const at = typeof s.updatedAt === "string" ? Date.parse(s.updatedAt) : NaN;
|
|
1526
|
+
found.set(s.sessionId.toLowerCase(), {
|
|
1527
|
+
ref: s.sessionId.toLowerCase(),
|
|
1528
|
+
cwd: where.real,
|
|
1529
|
+
updatedAt: Number.isFinite(at) ? at : 0,
|
|
1530
|
+
title: cleanTitle(typeof s.title === "string" ? s.title : "")
|
|
1531
|
+
});
|
|
1532
|
+
}
|
|
1533
|
+
cursor = typeof res.nextCursor === "string" && res.nextCursor ? res.nextCursor : void 0;
|
|
1534
|
+
if (!cursor) break;
|
|
1535
|
+
}
|
|
1536
|
+
this.listed = [...found.values()].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
1537
|
+
this.listedAt = this.now();
|
|
1538
|
+
this.listError = null;
|
|
1539
|
+
} catch (e) {
|
|
1540
|
+
this.listError = errMessage(e);
|
|
1541
|
+
this.d.log.warn("listing the sessions on this machine failed", { agent: this.agent.id, error: this.listError });
|
|
1542
|
+
} finally {
|
|
1543
|
+
if (adapter) await adapter.kill();
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
/** The session `ref` names, from a fresh enough list; the directory it records is the one it will be resumed in. */
|
|
1547
|
+
async resolve(ref) {
|
|
1548
|
+
const want = ref.toLowerCase();
|
|
1549
|
+
const have = () => this.listed.find((l) => l.ref === want);
|
|
1550
|
+
let hit = have();
|
|
1551
|
+
if (!hit || this.now() - this.listedAt > this.d.catalogMs) {
|
|
1552
|
+
await this.refreshCatalog();
|
|
1553
|
+
hit = have();
|
|
1554
|
+
}
|
|
1555
|
+
if (hit) return { ok: true, item: hit };
|
|
1556
|
+
return { ok: false, reason: this.listedAt === 0 || this.listError ? "catalog_unavailable" : "unknown_session" };
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* What the heartbeat reports: the newest sessions with their state now. The list itself comes from the last
|
|
1560
|
+
* refresh (a new one starts in the background when it is old); the state is read fresh, but never holds the
|
|
1561
|
+
* heartbeat for more than `capMs`.
|
|
1562
|
+
*/
|
|
1563
|
+
async resumable(capMs = 3e3) {
|
|
1564
|
+
if (!this.waitedOnce) {
|
|
1565
|
+
this.waitedOnce = true;
|
|
1566
|
+
await within(this.refreshCatalog().then(() => true), 5e3, false);
|
|
1567
|
+
} else if (this.now() - this.listedAt > this.d.catalogMs) void this.refreshCatalog();
|
|
1568
|
+
const live = await within(this.liveEntries().catch(() => null), capMs, null);
|
|
1569
|
+
const attached = this.d.attachedRefs();
|
|
1570
|
+
return this.listed.slice(0, MAX_REPORTED).map((l) => ({
|
|
1571
|
+
ref: l.ref,
|
|
1572
|
+
title: l.title,
|
|
1573
|
+
cwd: l.cwd,
|
|
1574
|
+
updated_at: Math.floor(l.updatedAt / 1e3),
|
|
1575
|
+
state: attached.has(l.ref) ? "attached" : live === null ? "unknown" : live.some((e) => e.sessionId.toLowerCase() === l.ref) ? "live" : "idle"
|
|
1576
|
+
}));
|
|
1577
|
+
}
|
|
1578
|
+
// --- the twin check ----------------------------------------------------------------
|
|
1579
|
+
/**
|
|
1580
|
+
* May this session be opened (or prompted) now?
|
|
1581
|
+
* `ownPgid` is the process group of this connector's own adapter for it: that adapter's agent process is listed
|
|
1582
|
+
* too and is not a twin. `transcript` also refuses a session whose transcript was written within the last
|
|
1583
|
+
* moments, which covers a terminal that has only just exited and is still flushing; it is skipped while this
|
|
1584
|
+
* connector's own adapter is the one writing.
|
|
1585
|
+
*/
|
|
1586
|
+
async check(ref, o) {
|
|
1587
|
+
if (!SESSION_REF_RE.test(ref)) return { ok: false, reason: "twin_check_failed", detail: "not a session id" };
|
|
1588
|
+
const want = ref.toLowerCase();
|
|
1589
|
+
const inTerminal = this.terminalRows().find((e) => isTwin(e, want, o.ownPgid));
|
|
1590
|
+
if (inTerminal) return { ok: false, reason: "session_live", detail: describeTwin(inTerminal) };
|
|
1591
|
+
let live;
|
|
1592
|
+
try {
|
|
1593
|
+
live = await this.liveEntries();
|
|
1594
|
+
} catch (e) {
|
|
1595
|
+
return { ok: false, reason: "twin_check_failed", detail: errMessage(e) };
|
|
1596
|
+
}
|
|
1597
|
+
const twin = live.find((e) => isTwin(e, want, o.ownPgid));
|
|
1598
|
+
if (twin) {
|
|
1599
|
+
return { ok: false, reason: "session_live", detail: describeTwin(twin) };
|
|
1600
|
+
}
|
|
1601
|
+
if (o.transcript) {
|
|
1602
|
+
const file = this.transcriptFile(want);
|
|
1603
|
+
let age = null;
|
|
1604
|
+
try {
|
|
1605
|
+
if (file) age = this.now() - fs9.statSync(file).mtimeMs;
|
|
1606
|
+
} catch {
|
|
1607
|
+
}
|
|
1608
|
+
if (age !== null && age < this.d.recentWriteMs) return { ok: false, reason: "recently_written", detail: `its transcript was written ${Math.max(0, Math.round(age / 1e3))} s ago` };
|
|
1609
|
+
}
|
|
1610
|
+
return { ok: true };
|
|
1611
|
+
}
|
|
1612
|
+
/** Every live process that holds a session open. Throws {@link TwinCheckError} when neither source can be read. */
|
|
1613
|
+
async liveEntries() {
|
|
1614
|
+
const env = { ...this.d.env, ...this.agent.env, CLAUDE_CONFIG_DIR: this.configDir };
|
|
1615
|
+
let viaCli = null;
|
|
1616
|
+
let cliError = "";
|
|
1617
|
+
try {
|
|
1618
|
+
viaCli = parseAgentsJson(await run(this.agent.attach?.cli ?? "claude", ["agents", "--json"], env, this.d.cliTimeoutMs));
|
|
1619
|
+
} catch (e) {
|
|
1620
|
+
cliError = errMessage(e).split("\n")[0];
|
|
1621
|
+
}
|
|
1622
|
+
let registry = [];
|
|
1623
|
+
let registryError = "";
|
|
1624
|
+
try {
|
|
1625
|
+
registry = readRegistry(this.configDir, this.localDomain);
|
|
1626
|
+
} catch (e) {
|
|
1627
|
+
registryError = errMessage(e);
|
|
1628
|
+
}
|
|
1629
|
+
let out;
|
|
1630
|
+
if (viaCli) {
|
|
1631
|
+
const seen = new Set(viaCli.map((e) => e.pid));
|
|
1632
|
+
out = [...viaCli, ...registry.filter((e) => e.foreign || !seen.has(e.pid))];
|
|
1633
|
+
} else if (!registryError) {
|
|
1634
|
+
out = registry;
|
|
1635
|
+
} else {
|
|
1636
|
+
throw new TwinCheckError(`cannot tell which sessions are open: "${this.agent.attach?.cli ?? "claude"} agents --json" failed (${cliError}) and the session registry could not be read (${registryError})`);
|
|
1637
|
+
}
|
|
1638
|
+
return [...this.terminalRows(), ...out];
|
|
1639
|
+
}
|
|
1640
|
+
/**
|
|
1641
|
+
* A look at the registry alone, with nothing started: is another process listed as holding `ref` open? It is cheap
|
|
1642
|
+
* enough to repeat every second while this connector's own agent is writing to the transcript, which is when a
|
|
1643
|
+
* terminal that opens the conversation and closes it again must not go unseen (see `Session.watchTwins`). Null when
|
|
1644
|
+
* the registry cannot be read; the full {@link check} made when the work ends is the one that has to answer.
|
|
1645
|
+
*/
|
|
1646
|
+
peek(ref, ownPgid) {
|
|
1647
|
+
if (!SESSION_REF_RE.test(ref)) return null;
|
|
1648
|
+
const want = ref.toLowerCase();
|
|
1649
|
+
const hook = this.terminalRows().find((e) => isTwin(e, want, ownPgid));
|
|
1650
|
+
try {
|
|
1651
|
+
return { twin: hook ?? readRegistry(this.configDir, this.localDomain, this.now()).find((e) => isTwin(e, want, ownPgid)) ?? null };
|
|
1652
|
+
} catch {
|
|
1653
|
+
return hook ? { twin: hook } : null;
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
/** The size of the conversation's transcript now, or null when there is none to look at. */
|
|
1657
|
+
transcriptSize(ref) {
|
|
1658
|
+
const file = SESSION_REF_RE.test(ref) ? this.transcriptFile(ref.toLowerCase()) : null;
|
|
1659
|
+
try {
|
|
1660
|
+
return file ? fs9.statSync(file).size : null;
|
|
1661
|
+
} catch {
|
|
1662
|
+
return null;
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
/** `<config dir>/projects/<working directory with every non-alphanumeric turned into "-">/<id>.jsonl`, found by name when that guess misses. */
|
|
1666
|
+
transcriptFile(ref) {
|
|
1667
|
+
const cached = this.transcripts.get(ref);
|
|
1668
|
+
if (cached && fs9.existsSync(cached)) return cached;
|
|
1669
|
+
this.transcripts.delete(ref);
|
|
1670
|
+
const projects = path9.join(this.configDir, "projects");
|
|
1671
|
+
const remember = (f) => {
|
|
1672
|
+
this.transcripts.set(ref, f);
|
|
1673
|
+
return f;
|
|
1674
|
+
};
|
|
1675
|
+
const known = this.listed.find((l) => l.ref === ref);
|
|
1676
|
+
if (known) {
|
|
1677
|
+
const guess = path9.join(projects, known.cwd.replace(/[^A-Za-z0-9]/g, "-"), `${ref}.jsonl`);
|
|
1678
|
+
if (fs9.existsSync(guess)) return remember(guess);
|
|
1679
|
+
}
|
|
1680
|
+
try {
|
|
1681
|
+
let n = 0;
|
|
1682
|
+
for (const dir of fs9.readdirSync(projects)) {
|
|
1683
|
+
if (++n > MAX_SCANNED_PROJECT_DIRS) break;
|
|
1684
|
+
const f = path9.join(projects, dir, `${ref}.jsonl`);
|
|
1685
|
+
if (fs9.existsSync(f)) return remember(f);
|
|
1686
|
+
}
|
|
1687
|
+
} catch {
|
|
1688
|
+
}
|
|
1689
|
+
return null;
|
|
1690
|
+
}
|
|
1691
|
+
};
|
|
1692
|
+
function describeTwin(e) {
|
|
1693
|
+
if (e.via === "hook") return e.foreign ? `open in a terminal, registered by a process this machine cannot see (pid ${e.pid})` : `open in a terminal (pid ${e.pid}, registered by its session hook)`;
|
|
1694
|
+
return e.foreign ? `registered by a process this machine cannot see (pid ${e.pid})` : `process ${e.pid} has it open (${e.status ?? "running"})`;
|
|
1695
|
+
}
|
|
1696
|
+
function isTwin(e, want, ownPgid) {
|
|
1697
|
+
if (e.sessionId.toLowerCase() !== want) return false;
|
|
1698
|
+
return e.foreign || !(ownPgid != null && processGroup(e.pid) === ownPgid);
|
|
1699
|
+
}
|
|
1700
|
+
function within(p, ms, late) {
|
|
1701
|
+
return new Promise((resolve) => {
|
|
1702
|
+
const t = setTimeout(() => resolve(late), ms);
|
|
1703
|
+
t.unref?.();
|
|
1704
|
+
void p.then((v) => {
|
|
1705
|
+
clearTimeout(t);
|
|
1706
|
+
resolve(v);
|
|
1707
|
+
}, () => {
|
|
1708
|
+
clearTimeout(t);
|
|
1709
|
+
resolve(late);
|
|
1710
|
+
});
|
|
1711
|
+
});
|
|
1712
|
+
}
|
|
1713
|
+
function run(file, args, env, timeoutMs) {
|
|
1714
|
+
return new Promise((resolve, reject) => {
|
|
1715
|
+
const child = execFile(file, args, { env, timeout: timeoutMs, killSignal: "SIGKILL", maxBuffer: 4 * 1024 * 1024, encoding: "utf8", windowsHide: true }, (err, stdout) => err ? reject(err) : resolve(stdout));
|
|
1716
|
+
child.stdin?.end();
|
|
1717
|
+
});
|
|
1718
|
+
}
|
|
1719
|
+
function parseAgentsJson(text) {
|
|
1720
|
+
const raw = JSON.parse(text);
|
|
1721
|
+
if (!Array.isArray(raw)) throw new Error("the answer is not a list");
|
|
1722
|
+
const out = [];
|
|
1723
|
+
for (const x of raw) {
|
|
1724
|
+
if (isObject(x) && typeof x.pid === "number" && Number.isInteger(x.pid) && typeof x.sessionId === "string") {
|
|
1725
|
+
out.push({ pid: x.pid, sessionId: x.sessionId, status: typeof x.status === "string" ? x.status : null, foreign: false });
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
return out;
|
|
1729
|
+
}
|
|
1730
|
+
function readRegistry(configDir, localDomain, now = Date.now()) {
|
|
1731
|
+
const dir = path9.join(configDir, "sessions");
|
|
1732
|
+
let names;
|
|
1733
|
+
try {
|
|
1734
|
+
names = fs9.readdirSync(dir);
|
|
1735
|
+
} catch (e) {
|
|
1736
|
+
throw new TwinCheckError(`${dir}: ${errMessage(e)}`);
|
|
1737
|
+
}
|
|
1738
|
+
const out = [];
|
|
1739
|
+
for (const name of names) {
|
|
1740
|
+
if (!name.endsWith(".json")) continue;
|
|
1741
|
+
const file = path9.join(dir, name);
|
|
1742
|
+
let doc;
|
|
1743
|
+
try {
|
|
1744
|
+
doc = JSON.parse(fs9.readFileSync(file, "utf8"));
|
|
1745
|
+
} catch (e) {
|
|
1746
|
+
let age = Infinity;
|
|
1747
|
+
try {
|
|
1748
|
+
age = now - fs9.statSync(file).mtimeMs;
|
|
1749
|
+
} catch {
|
|
1750
|
+
}
|
|
1751
|
+
if (age < UNREADABLE_GRACE_MS2) throw new TwinCheckError(`${file} is unreadable (${errMessage(e).split("\n")[0]})`);
|
|
1752
|
+
continue;
|
|
1753
|
+
}
|
|
1754
|
+
if (!isObject(doc) || typeof doc.pid !== "number" || !Number.isInteger(doc.pid) || typeof doc.sessionId !== "string") continue;
|
|
1755
|
+
const domain = typeof doc.pidDomain === "string" ? doc.pidDomain : null;
|
|
1756
|
+
const foreign = domain !== null && localDomain !== null && domain !== localDomain;
|
|
1757
|
+
if (!foreign) {
|
|
1758
|
+
if (!isAlive(doc.pid)) continue;
|
|
1759
|
+
if (process.platform === "linux" && typeof doc.procStart === "string" && processStart(doc.pid) !== doc.procStart) continue;
|
|
1760
|
+
}
|
|
1761
|
+
out.push({ pid: doc.pid, sessionId: doc.sessionId, status: typeof doc.status === "string" ? doc.status : null, foreign });
|
|
1762
|
+
}
|
|
1763
|
+
return out;
|
|
1764
|
+
}
|
|
1765
|
+
function processGroup(pid) {
|
|
1766
|
+
try {
|
|
1767
|
+
if (process.platform === "linux") {
|
|
1768
|
+
const stat = fs9.readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
1769
|
+
return Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[2]) || null;
|
|
1770
|
+
}
|
|
1771
|
+
return Number(execFileSync2("ps", ["-o", "pgid=", "-p", String(pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim()) || null;
|
|
1772
|
+
} catch {
|
|
1773
|
+
return null;
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
function cleanTitle(raw) {
|
|
1777
|
+
const one = redactText(raw).text.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
|
|
1778
|
+
return one.length > MAX_TITLE ? `${one.slice(0, MAX_TITLE - 1)}\u2026` : one;
|
|
1779
|
+
}
|
|
1780
|
+
function fitResumable(base, items, limit = REMOTE_AGENT_LIMITS.maxCapabilitiesBytes) {
|
|
1781
|
+
let n = Math.min(items.length, MAX_REPORTED);
|
|
1782
|
+
const size = (c) => new TextEncoder().encode(JSON.stringify(c)).length;
|
|
1783
|
+
for (; ; ) {
|
|
1784
|
+
const caps = { ...base, resumable: items.slice(0, n) };
|
|
1785
|
+
if (size(caps) <= limit) return caps;
|
|
1786
|
+
if (n === 0) return base;
|
|
1787
|
+
n--;
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
// src/session.ts
|
|
1792
|
+
import path10 from "node:path";
|
|
1793
|
+
|
|
1794
|
+
// src/permissions.ts
|
|
1795
|
+
import { randomUUID } from "node:crypto";
|
|
1796
|
+
|
|
1797
|
+
// src/glob.ts
|
|
1798
|
+
function globToRegExp(glob) {
|
|
1799
|
+
let re = "^";
|
|
1800
|
+
for (let i = 0; i < glob.length; i++) {
|
|
1801
|
+
const c = glob[i];
|
|
1802
|
+
if (c === "*") {
|
|
1803
|
+
if (glob[i + 1] === "*") {
|
|
1804
|
+
i++;
|
|
1805
|
+
if (glob[i + 1] === "/") {
|
|
1806
|
+
i++;
|
|
1807
|
+
re += "(?:.*/)?";
|
|
1808
|
+
} else re += ".*";
|
|
1809
|
+
} else re += "[^/]*";
|
|
1810
|
+
} else if (c === "?") re += "[^/]";
|
|
1811
|
+
else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
1812
|
+
}
|
|
1813
|
+
return new RegExp(re + "$");
|
|
1814
|
+
}
|
|
1815
|
+
var globMatches = (glob, relPath) => globToRegExp(glob).test(relPath);
|
|
1816
|
+
|
|
1817
|
+
// src/permissions.ts
|
|
1818
|
+
function normalizeOptions(raw) {
|
|
1819
|
+
if (!Array.isArray(raw)) return [];
|
|
1820
|
+
const out = [];
|
|
1821
|
+
for (const o of raw.slice(0, 12)) {
|
|
1822
|
+
if (!isObject(o) || typeof o.optionId !== "string" || o.optionId === "") continue;
|
|
1823
|
+
out.push({
|
|
1824
|
+
optionId: o.optionId,
|
|
1825
|
+
name: typeof o.name === "string" ? o.name.slice(0, 200) : o.optionId,
|
|
1826
|
+
kind: typeof o.kind === "string" ? o.kind : "allow_once"
|
|
1827
|
+
});
|
|
1828
|
+
}
|
|
1829
|
+
return out;
|
|
1830
|
+
}
|
|
1831
|
+
function boundedInput(raw, max = 4096) {
|
|
1832
|
+
if (raw === void 0 || raw === null) return null;
|
|
1833
|
+
const text = typeof raw === "string" ? raw : JSON.stringify(raw);
|
|
1834
|
+
if (Buffer.byteLength(text, "utf8") <= max) return raw;
|
|
1835
|
+
return truncateBytes(text, max).text;
|
|
1836
|
+
}
|
|
1837
|
+
function toolPaths(toolCall) {
|
|
1838
|
+
const out = [];
|
|
1839
|
+
if (Array.isArray(toolCall.locations)) {
|
|
1840
|
+
for (const l of toolCall.locations) if (isObject(l) && typeof l.path === "string") out.push(l.path);
|
|
1841
|
+
}
|
|
1842
|
+
if (isObject(toolCall.rawInput)) for (const k of ["file_path", "filePath", "path", "target_file"]) {
|
|
1843
|
+
const v = toolCall.rawInput[k];
|
|
1844
|
+
if (typeof v === "string") out.push(v);
|
|
1845
|
+
}
|
|
1846
|
+
return [...new Set(out)];
|
|
1847
|
+
}
|
|
1848
|
+
function matchAutoAllow(rules, toolCall, cwd) {
|
|
1849
|
+
const kind = typeof toolCall.kind === "string" ? toolCall.kind : "";
|
|
1850
|
+
const paths = toolPaths(toolCall);
|
|
1851
|
+
if (!kind || paths.length === 0) return null;
|
|
1852
|
+
const rel = [];
|
|
1853
|
+
for (const p of paths) {
|
|
1854
|
+
const r = relativeInside(p, cwd);
|
|
1855
|
+
if (r === null) return null;
|
|
1856
|
+
rel.push(r);
|
|
1857
|
+
}
|
|
1858
|
+
return rules.find((rule) => rule.kind === kind && rel.every((r) => globMatches(rule.pathGlob, r))) ?? null;
|
|
1859
|
+
}
|
|
1860
|
+
var PermissionBook = class {
|
|
1861
|
+
constructor(ttlMs, onExpire) {
|
|
1862
|
+
this.ttlMs = ttlMs;
|
|
1863
|
+
this.onExpire = onExpire;
|
|
1864
|
+
}
|
|
1865
|
+
ttlMs;
|
|
1866
|
+
onExpire;
|
|
1867
|
+
pending = /* @__PURE__ */ new Map();
|
|
1868
|
+
get ttl() {
|
|
1869
|
+
return this.ttlMs;
|
|
1870
|
+
}
|
|
1871
|
+
open(options, payload, settle) {
|
|
1872
|
+
const actionId = randomUUID();
|
|
1873
|
+
const p = {
|
|
1874
|
+
actionId,
|
|
1875
|
+
options,
|
|
1876
|
+
payload: payload(actionId),
|
|
1877
|
+
settle,
|
|
1878
|
+
timer: setTimeout(() => {
|
|
1879
|
+
if (this.pending.delete(actionId)) {
|
|
1880
|
+
clearTimeout(p.timer);
|
|
1881
|
+
settle(null);
|
|
1882
|
+
this.onExpire(actionId);
|
|
1883
|
+
}
|
|
1884
|
+
}, this.ttlMs)
|
|
1885
|
+
};
|
|
1886
|
+
p.timer.unref?.();
|
|
1887
|
+
this.pending.set(actionId, p);
|
|
1888
|
+
return p;
|
|
1889
|
+
}
|
|
1890
|
+
/** Answer with one of the options that were offered; anything else leaves the request pending. */
|
|
1891
|
+
decide(actionId, optionId) {
|
|
1892
|
+
const p = this.pending.get(actionId);
|
|
1893
|
+
if (!p) return { ok: false, reason: "unknown_action" };
|
|
1894
|
+
if (!p.options.some((o) => o.optionId === optionId)) return { ok: false, reason: "option_not_offered" };
|
|
1895
|
+
this.pending.delete(actionId);
|
|
1896
|
+
clearTimeout(p.timer);
|
|
1897
|
+
p.settle(optionId);
|
|
1898
|
+
return { ok: true, optionId };
|
|
1899
|
+
}
|
|
1900
|
+
/** Answer every waiting request `cancelled` (a cancel, a close, an adapter that went away). */
|
|
1901
|
+
cancelAll() {
|
|
1902
|
+
const ids = [...this.pending.keys()];
|
|
1903
|
+
for (const id of ids) {
|
|
1904
|
+
const p = this.pending.get(id);
|
|
1905
|
+
this.pending.delete(id);
|
|
1906
|
+
clearTimeout(p.timer);
|
|
1907
|
+
p.settle(null);
|
|
1908
|
+
}
|
|
1909
|
+
return ids;
|
|
1910
|
+
}
|
|
1911
|
+
list() {
|
|
1912
|
+
return [...this.pending.values()];
|
|
1913
|
+
}
|
|
1914
|
+
get size() {
|
|
1915
|
+
return this.pending.size;
|
|
1916
|
+
}
|
|
1917
|
+
};
|
|
1918
|
+
|
|
1919
|
+
// src/session.ts
|
|
1920
|
+
var DEFAULT_SESSION_TIMING = { idleMs: 30 * 6e4, permissionTtlMs: 10 * 6e4, modeConfirmMs: 3e3, twinWatchMs: 1e3 };
|
|
1921
|
+
var SessionRefusal = class extends Error {
|
|
1922
|
+
constructor(reason, message) {
|
|
1923
|
+
super(message ?? reason);
|
|
1924
|
+
this.reason = reason;
|
|
1925
|
+
}
|
|
1926
|
+
reason;
|
|
1927
|
+
};
|
|
1928
|
+
var MAX_TOOL_OUTPUT_BYTES = 8 * 1024;
|
|
1929
|
+
var MAX_QUEUED_PROMPTS = 5;
|
|
1930
|
+
var PERMISSION_WARNING_RE = /canUseTool will not be invoked/i;
|
|
1931
|
+
var Session = class {
|
|
1932
|
+
constructor(id, agent, cwd, env, acpId = null, mode = null) {
|
|
1933
|
+
this.id = id;
|
|
1934
|
+
this.agent = agent;
|
|
1935
|
+
this.env = env;
|
|
1936
|
+
this.cwd = cwd;
|
|
1937
|
+
this.acpId = acpId;
|
|
1938
|
+
this.announced = acpId !== null;
|
|
1939
|
+
this.targetMode = mode ?? agent.askMode;
|
|
1940
|
+
if (!agent.safeModes.includes(this.targetMode) && !agent.allowUnsafeModes && this.targetMode !== agent.askMode) {
|
|
1941
|
+
this.env.audit.record("mode_reverted", { session_id: id, agent_id: agent.id, from: this.targetMode, to: agent.askMode, reason: "no_longer_allowed" });
|
|
1942
|
+
this.targetMode = agent.askMode;
|
|
1943
|
+
}
|
|
1944
|
+
this.book = new PermissionBook(env.timing.permissionTtlMs, (actionId) => {
|
|
1945
|
+
this.env.audit.record("permission_expired", { session_id: this.id, action_id: actionId });
|
|
1946
|
+
this.emit("permission_resolved", { actionId, optionId: null, outcome: "cancelled", by: "timeout" });
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
1949
|
+
id;
|
|
1950
|
+
agent;
|
|
1951
|
+
env;
|
|
1952
|
+
cwd;
|
|
1953
|
+
acpId;
|
|
1954
|
+
adapter = null;
|
|
1955
|
+
currentMode = null;
|
|
1956
|
+
modes = [];
|
|
1957
|
+
closed = false;
|
|
1958
|
+
/** The adapter warned that it will not ask for permission and the owner's opt-in accepted that. Per adapter process. */
|
|
1959
|
+
noAskWarning = false;
|
|
1960
|
+
/** The phone already knows this session: a mode change is worth an event. */
|
|
1961
|
+
announced;
|
|
1962
|
+
modeConfigId = null;
|
|
1963
|
+
targetMode;
|
|
1964
|
+
enforcing = false;
|
|
1965
|
+
modeSeen = [];
|
|
1966
|
+
replaying = false;
|
|
1967
|
+
/**
|
|
1968
|
+
* An attached session starts in the mode its transcript last had, which is often one that never asks, and the
|
|
1969
|
+
* adapter says so on stderr while it sets the session up. That warning describes the mode about to be replaced,
|
|
1970
|
+
* so it is set aside until the ask mode is locked and read back; one that comes later closes the session as ever.
|
|
1971
|
+
*/
|
|
1972
|
+
inheritedWarningOk = false;
|
|
1973
|
+
/** The transcript's size when this connector last left it (see {@link preflight}). */
|
|
1974
|
+
transcriptMark = null;
|
|
1975
|
+
/**
|
|
1976
|
+
* Another process was seen holding the conversation open while this connector's own agent was running a turn on
|
|
1977
|
+
* it. That agent's memory then lacks whatever the process wrote, and the transcript's size cannot say so (see
|
|
1978
|
+
* {@link watchTwins}): the agent is discarded before the next prompt. Cleared when an agent is started afresh.
|
|
1979
|
+
*/
|
|
1980
|
+
twinSeen = false;
|
|
1981
|
+
turnRunning = false;
|
|
1982
|
+
queue = [];
|
|
1983
|
+
launching = null;
|
|
1984
|
+
idle;
|
|
1985
|
+
book;
|
|
1986
|
+
// --- helpers -----------------------------------------------------------------
|
|
1987
|
+
emit(type, data) {
|
|
1988
|
+
this.env.emit(this.id, type, data);
|
|
1989
|
+
}
|
|
1990
|
+
audit(kind, detail = {}) {
|
|
1991
|
+
this.env.audit.record(kind, { session_id: this.id, agent_id: this.agent.id, ...detail });
|
|
1992
|
+
}
|
|
1993
|
+
touch() {
|
|
1994
|
+
clearTimeout(this.idle);
|
|
1995
|
+
if (this.closed) return;
|
|
1996
|
+
this.idle = setTimeout(() => void this.recycle(), this.env.timing.idleMs);
|
|
1997
|
+
this.idle.unref?.();
|
|
1998
|
+
}
|
|
1999
|
+
get pendingCount() {
|
|
2000
|
+
return this.book.size;
|
|
2001
|
+
}
|
|
2002
|
+
get busy() {
|
|
2003
|
+
return this.turnRunning || this.queue.length > 0 || this.book.size > 0;
|
|
2004
|
+
}
|
|
2005
|
+
/** Whether `mode` may be selected from a phone. */
|
|
2006
|
+
modeAllowed(mode) {
|
|
2007
|
+
return this.agent.safeModes.includes(mode) || this.agent.allowUnsafeModes && (this.modes.length === 0 || this.modes.some((m) => m.id === mode));
|
|
2008
|
+
}
|
|
2009
|
+
/** Whether this session runs without asking before every write and command: its mode is not a safe one, or its adapter said it will not ask. */
|
|
2010
|
+
get unattended() {
|
|
2011
|
+
return this.noAskWarning || this.currentMode !== null && !this.agent.askingModes.includes(this.currentMode);
|
|
2012
|
+
}
|
|
2013
|
+
shownModes() {
|
|
2014
|
+
return this.modes.filter((m) => this.modeAllowed(m.id)).map((m) => ({ ...m, unattended: !this.agent.askingModes.includes(m.id) }));
|
|
2015
|
+
}
|
|
2016
|
+
// --- starting ----------------------------------------------------------------
|
|
2017
|
+
/** `session.new`: start the adapter, create the ACP session, lock the mode. A refusal closes the session with a reason. */
|
|
2018
|
+
async create(requestedMode) {
|
|
2019
|
+
if (requestedMode && !this.agent.safeModes.includes(requestedMode) && !this.agent.allowUnsafeModes) {
|
|
2020
|
+
return this.refuse(new SessionRefusal("mode_not_allowed", `mode ${requestedMode} needs approval = "never" for this agent (or allow_unsafe_modes) on this machine`));
|
|
2021
|
+
}
|
|
2022
|
+
if (requestedMode) this.targetMode = requestedMode;
|
|
2023
|
+
try {
|
|
2024
|
+
await this.launch("new");
|
|
2025
|
+
} catch (e) {
|
|
2026
|
+
return this.refuse(e);
|
|
2027
|
+
}
|
|
2028
|
+
if (this.closed) return;
|
|
2029
|
+
this.env.store.put(this.id, { agent_id: this.agent.id, acp_session_id: this.acpId, cwd: this.cwd, status: "active", mode: this.currentMode, created_at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
2030
|
+
this.audit("session_started", { cwd: this.cwd, mode: this.currentMode, unattended: this.unattended, acp_session_id: this.acpId });
|
|
2031
|
+
this.emit("session_started", { acp_session_id: this.acpId, agent_id: this.agent.id, kind: this.agent.kind, cwd: this.cwd, modes: this.shownModes(), currentMode: this.currentMode, unattended: this.unattended });
|
|
2032
|
+
this.announced = true;
|
|
2033
|
+
this.touch();
|
|
2034
|
+
}
|
|
2035
|
+
/**
|
|
2036
|
+
* `session.new` that names a session which already exists: resume it in the ask mode. The twin check runs inside
|
|
2037
|
+
* the launch (so it also runs again whenever a recycled adapter comes back), and a refusal closes the session.
|
|
2038
|
+
*/
|
|
2039
|
+
async attach(requestedMode, title) {
|
|
2040
|
+
if (requestedMode && !this.agent.safeModes.includes(requestedMode) && !this.agent.allowUnsafeModes) {
|
|
2041
|
+
return this.refuse(new SessionRefusal("mode_not_allowed", `mode ${requestedMode} needs approval = "never" for this agent (or allow_unsafe_modes) on this machine`));
|
|
2042
|
+
}
|
|
2043
|
+
if (requestedMode) this.targetMode = requestedMode;
|
|
2044
|
+
try {
|
|
2045
|
+
await this.launch("resume");
|
|
2046
|
+
} catch (e) {
|
|
2047
|
+
return this.refuse(e);
|
|
2048
|
+
}
|
|
2049
|
+
if (this.closed) return;
|
|
2050
|
+
this.env.store.put(this.id, { agent_id: this.agent.id, acp_session_id: this.acpId, cwd: this.cwd, status: "active", mode: this.currentMode, created_at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
2051
|
+
this.audit("session_attached", { cwd: this.cwd, mode: this.currentMode, unattended: this.unattended, acp_session_id: this.acpId });
|
|
2052
|
+
this.emit("session_started", { acp_session_id: this.acpId, agent_id: this.agent.id, kind: this.agent.kind, cwd: this.cwd, title, attached: true, modes: this.shownModes(), currentMode: this.currentMode, unattended: this.unattended });
|
|
2053
|
+
this.touch();
|
|
2054
|
+
}
|
|
2055
|
+
/** Start (or restart) the adapter and bring the ACP session up in a verified mode. One at a time. */
|
|
2056
|
+
launch(how) {
|
|
2057
|
+
this.launching ??= this.guardedLaunch(how).finally(() => {
|
|
2058
|
+
this.launching = null;
|
|
2059
|
+
});
|
|
2060
|
+
return this.launching;
|
|
2061
|
+
}
|
|
2062
|
+
/** For an attach agent, nothing is started until no other process holds the session open. */
|
|
2063
|
+
async guardedLaunch(how) {
|
|
2064
|
+
if (!this.agent.attach) return this.doLaunch(how);
|
|
2065
|
+
const support = this.env.attach.get(this.agent.id);
|
|
2066
|
+
if (!support) throw new SessionRefusal("attach_unavailable", "this agent has no takeover support");
|
|
2067
|
+
if (!this.acpId) throw new SessionRefusal("adapter_cannot_resume", "no session to resume");
|
|
2068
|
+
const verdict = await support.check(this.acpId, { transcript: true });
|
|
2069
|
+
if (!verdict.ok) {
|
|
2070
|
+
this.audit("attach_refused", { reason: verdict.reason, detail: verdict.detail.slice(0, 300) });
|
|
2071
|
+
throw new SessionRefusal(verdict.reason, verdict.detail);
|
|
2072
|
+
}
|
|
2073
|
+
this.inheritedWarningOk = true;
|
|
2074
|
+
const watch = this.watchTwins(support, this.acpId);
|
|
2075
|
+
try {
|
|
2076
|
+
await this.doLaunch(how);
|
|
2077
|
+
this.markTranscript();
|
|
2078
|
+
if (await watch.finish()) {
|
|
2079
|
+
await this.dropAdapter();
|
|
2080
|
+
const detail = "another process opened the conversation while it was being resumed";
|
|
2081
|
+
this.audit("attach_refused", { reason: "session_live", detail });
|
|
2082
|
+
throw new SessionRefusal("session_live", detail);
|
|
2083
|
+
}
|
|
2084
|
+
this.twinSeen = false;
|
|
2085
|
+
} finally {
|
|
2086
|
+
watch.stop();
|
|
2087
|
+
this.inheritedWarningOk = false;
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
/**
|
|
2091
|
+
* Watch for a second writer while this connector's own agent is working on the conversation (starting, or running a
|
|
2092
|
+
* turn). The check before a prompt is a look at one moment, and the transcript's size cannot stand in for it during
|
|
2093
|
+
* the work: the agent's own writes and a terminal's are the same kind of bytes, so a terminal that opens the
|
|
2094
|
+
* conversation, takes a turn and exits meanwhile would be gone by the next look and its bytes already counted as
|
|
2095
|
+
* the agent's. So the registry is read every `twinWatchMs` for as long as the work lasts (a process that has held the
|
|
2096
|
+
* conversation open for longer than that is seen), and once more, in full (the command too), when it ends.
|
|
2097
|
+
* `finish()` answers whether another process was seen holding the conversation open at any of those moments, or the
|
|
2098
|
+
* last look could not be made.
|
|
2099
|
+
*/
|
|
2100
|
+
watchTwins(support, ref) {
|
|
2101
|
+
let seen = false;
|
|
2102
|
+
const own = () => this.adapter && !this.adapter.exited ? this.adapter.pid : null;
|
|
2103
|
+
const look = () => {
|
|
2104
|
+
if (seen) return;
|
|
2105
|
+
const r = support.peek(ref, own());
|
|
2106
|
+
if (r?.twin) {
|
|
2107
|
+
seen = true;
|
|
2108
|
+
this.audit("twin_seen", { pid: r.twin.pid, foreign: r.twin.foreign });
|
|
2109
|
+
}
|
|
2110
|
+
};
|
|
2111
|
+
const timer = setInterval(look, this.env.timing.twinWatchMs);
|
|
2112
|
+
timer.unref?.();
|
|
2113
|
+
const stop = () => clearInterval(timer);
|
|
2114
|
+
return {
|
|
2115
|
+
stop,
|
|
2116
|
+
finish: async () => {
|
|
2117
|
+
stop();
|
|
2118
|
+
look();
|
|
2119
|
+
if (!seen) {
|
|
2120
|
+
const v = await support.check(ref, { ownPgid: own(), transcript: false });
|
|
2121
|
+
if (!v.ok) {
|
|
2122
|
+
seen = true;
|
|
2123
|
+
this.audit("twin_seen", { reason: v.reason, detail: v.detail.slice(0, 300) });
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
return seen;
|
|
2127
|
+
}
|
|
2128
|
+
};
|
|
2129
|
+
}
|
|
2130
|
+
async doLaunch(how) {
|
|
2131
|
+
this.noAskWarning = false;
|
|
2132
|
+
const adapter = await AdapterProcess.start({
|
|
2133
|
+
cfg: this.agent,
|
|
2134
|
+
registry: this.env.registry,
|
|
2135
|
+
sessionId: this.id,
|
|
2136
|
+
cwd: this.cwd,
|
|
2137
|
+
hooks: this.hooks(),
|
|
2138
|
+
logFile: path10.join(this.env.logDir, `adapter-${this.agent.id}.log`),
|
|
2139
|
+
version: this.env.version
|
|
2140
|
+
}).catch((e) => {
|
|
2141
|
+
throw new SessionRefusal("adapter_start_failed", errMessage(e));
|
|
2142
|
+
});
|
|
2143
|
+
if (this.closed) {
|
|
2144
|
+
await adapter.kill();
|
|
2145
|
+
throw new SessionRefusal("session_closed");
|
|
2146
|
+
}
|
|
2147
|
+
this.adapter = adapter;
|
|
2148
|
+
this.modeSeen = [];
|
|
2149
|
+
try {
|
|
2150
|
+
if (how === "new") {
|
|
2151
|
+
const res = await adapter.request("session/new", { cwd: this.cwd, mcpServers: [] });
|
|
2152
|
+
if (typeof res.sessionId !== "string") throw new SessionRefusal("adapter_protocol", "session/new returned no session id");
|
|
2153
|
+
this.acpId = res.sessionId;
|
|
2154
|
+
this.absorb(res);
|
|
2155
|
+
} else {
|
|
2156
|
+
if (!this.acpId) throw new SessionRefusal("adapter_cannot_resume", "no adapter session to resume");
|
|
2157
|
+
const caps = adapter.caps.sessionCapabilities;
|
|
2158
|
+
if (caps?.resume) {
|
|
2159
|
+
this.absorb(await adapter.request("session/resume", { sessionId: this.acpId, cwd: this.cwd, mcpServers: [] }));
|
|
2160
|
+
} else if (adapter.caps.loadSession) {
|
|
2161
|
+
this.replaying = true;
|
|
2162
|
+
try {
|
|
2163
|
+
this.absorb(await adapter.requestUnbounded("session/load", { sessionId: this.acpId, cwd: this.cwd, mcpServers: [] }));
|
|
2164
|
+
} finally {
|
|
2165
|
+
this.replaying = false;
|
|
2166
|
+
}
|
|
2167
|
+
} else {
|
|
2168
|
+
throw new SessionRefusal("adapter_cannot_resume", "the adapter can neither resume nor load a session");
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
await this.enforceMode(this.targetMode);
|
|
2172
|
+
if (this.closed) throw new SessionRefusal("session_closed");
|
|
2173
|
+
} catch (e) {
|
|
2174
|
+
await this.dropAdapter();
|
|
2175
|
+
throw e instanceof SessionRefusal ? e : mapAdapterError(e);
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
/** Take the modes and the mode config option out of a `session/new|resume|load` answer. */
|
|
2179
|
+
absorb(res) {
|
|
2180
|
+
const modes = res.modes;
|
|
2181
|
+
if (modes && Array.isArray(modes.availableModes)) {
|
|
2182
|
+
this.modes = modes.availableModes.filter((m) => isObject(m) && typeof m.id === "string").map((m) => ({ id: m.id, name: m.name, description: m.description }));
|
|
2183
|
+
}
|
|
2184
|
+
const opts = res.configOptions;
|
|
2185
|
+
if (Array.isArray(opts)) {
|
|
2186
|
+
const mode = opts.find((o) => isObject(o) && o.category === "mode" && typeof o.id === "string");
|
|
2187
|
+
if (mode) this.modeConfigId = mode.id;
|
|
2188
|
+
}
|
|
2189
|
+
this.currentMode = typeof modes?.currentModeId === "string" ? modes.currentModeId : this.currentMode;
|
|
2190
|
+
}
|
|
2191
|
+
// --- the mode lock --------------------------------------------------------------
|
|
2192
|
+
/**
|
|
2193
|
+
* Put the session in `mode` and read the adapter's own answer back.
|
|
2194
|
+
*
|
|
2195
|
+
* The read-back is not the answer to `set_mode` (that is empty). It is the
|
|
2196
|
+
* adapter reporting its current mode: the mode option's value in the answer to
|
|
2197
|
+
* `session/set_config_option`, or, for an adapter with no such option, a mode
|
|
2198
|
+
* notification that arrives after the change. If neither confirms `mode`, the
|
|
2199
|
+
* session is not opened.
|
|
2200
|
+
*/
|
|
2201
|
+
async enforceMode(mode) {
|
|
2202
|
+
const a = this.adapter;
|
|
2203
|
+
if (!a || !this.acpId) throw new SessionRefusal("adapter_gone");
|
|
2204
|
+
if (this.modes.length && !this.modes.some((m) => m.id === mode)) throw new SessionRefusal("mode_unavailable", `the adapter offers no mode ${mode}`);
|
|
2205
|
+
this.enforcing = true;
|
|
2206
|
+
this.modeSeen = [];
|
|
2207
|
+
try {
|
|
2208
|
+
try {
|
|
2209
|
+
await a.request("session/set_mode", { sessionId: this.acpId, modeId: mode });
|
|
2210
|
+
} catch (e) {
|
|
2211
|
+
throw new SessionRefusal("mode_not_enforced", `set_mode ${mode} failed: ${errMessage(e)}`);
|
|
2212
|
+
}
|
|
2213
|
+
const confirmed = await this.readBackMode(a, mode);
|
|
2214
|
+
if (confirmed === null) throw new SessionRefusal("mode_not_confirmed", `the adapter did not confirm mode ${mode}`);
|
|
2215
|
+
if (confirmed !== mode) throw new SessionRefusal("mode_mismatch", `asked for mode ${mode}, the adapter reports ${confirmed}`);
|
|
2216
|
+
this.currentMode = confirmed;
|
|
2217
|
+
this.targetMode = mode;
|
|
2218
|
+
this.env.store.patch(this.id, { mode: confirmed });
|
|
2219
|
+
} finally {
|
|
2220
|
+
this.enforcing = false;
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
async readBackMode(a, mode) {
|
|
2224
|
+
if (this.modeConfigId && this.acpId) {
|
|
2225
|
+
try {
|
|
2226
|
+
const res = await a.request("session/set_config_option", { sessionId: this.acpId, configId: this.modeConfigId, value: mode });
|
|
2227
|
+
const opt = Array.isArray(res.configOptions) ? res.configOptions.find((o) => o?.id === this.modeConfigId) : void 0;
|
|
2228
|
+
if (opt && typeof opt.currentValue === "string") return opt.currentValue;
|
|
2229
|
+
} catch {
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
const deadline = Date.now() + this.env.timing.modeConfirmMs;
|
|
2233
|
+
while (Date.now() < deadline) {
|
|
2234
|
+
const last = this.modeSeen[this.modeSeen.length - 1];
|
|
2235
|
+
if (last !== void 0) return last;
|
|
2236
|
+
await sleep(25);
|
|
2237
|
+
}
|
|
2238
|
+
return this.modeSeen[this.modeSeen.length - 1] ?? null;
|
|
2239
|
+
}
|
|
2240
|
+
/** An adapter reported its mode. A mode this machine does not allow is undone at once. */
|
|
2241
|
+
noteMode(mode) {
|
|
2242
|
+
this.modeSeen.push(mode);
|
|
2243
|
+
if (this.enforcing) return;
|
|
2244
|
+
if (mode === this.currentMode) return;
|
|
2245
|
+
const from = this.currentMode;
|
|
2246
|
+
this.currentMode = mode;
|
|
2247
|
+
if (!this.modeAllowed(mode)) {
|
|
2248
|
+
this.audit("mode_reverted", { from, to: mode, back_to: this.agent.askMode });
|
|
2249
|
+
this.emit("mode_update", { currentMode: mode, reverted: true, unattended: this.unattended });
|
|
2250
|
+
void this.enforceMode(this.agent.askMode).then(
|
|
2251
|
+
() => this.emit("mode_update", { currentMode: this.currentMode, unattended: this.unattended }),
|
|
2252
|
+
(e) => this.refuse(e)
|
|
2253
|
+
);
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2256
|
+
this.emit("mode_update", { currentMode: mode, unattended: this.unattended });
|
|
2257
|
+
}
|
|
2258
|
+
/** `set_mode`: a user-chosen mode, only from the allowed list, always read back. */
|
|
2259
|
+
async setMode(modeId, cmdId) {
|
|
2260
|
+
if (!this.adapter) await this.ensureAdapter();
|
|
2261
|
+
if (!this.modeAllowed(modeId)) return this.rejectCommand("set_mode", cmdId, "mode_not_allowed");
|
|
2262
|
+
if (this.modes.length && !this.modes.some((m) => m.id === modeId)) return this.rejectCommand("set_mode", cmdId, "mode_unavailable");
|
|
2263
|
+
const previous = this.targetMode;
|
|
2264
|
+
try {
|
|
2265
|
+
await this.enforceMode(modeId);
|
|
2266
|
+
this.audit("mode_set", { mode: modeId, unattended: this.unattended });
|
|
2267
|
+
this.emit("mode_update", { currentMode: this.currentMode, unattended: this.unattended });
|
|
2268
|
+
} catch (e) {
|
|
2269
|
+
this.audit("mode_set_failed", { mode: modeId, error: errMessage(e) });
|
|
2270
|
+
try {
|
|
2271
|
+
await this.enforceMode(previous);
|
|
2272
|
+
} catch (again) {
|
|
2273
|
+
return this.refuse(again);
|
|
2274
|
+
}
|
|
2275
|
+
this.rejectCommand("set_mode", cmdId, e instanceof SessionRefusal ? e.reason : "set_mode_failed");
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
// --- adapter callbacks ------------------------------------------------------------
|
|
2279
|
+
hooks() {
|
|
2280
|
+
return {
|
|
2281
|
+
onUpdate: (update, params) => this.onUpdate(update, params),
|
|
2282
|
+
onPermission: (params) => this.onPermission(params),
|
|
2283
|
+
onExtension: (method, params) => this.onExtension(method, params),
|
|
2284
|
+
onNotification: (method, params) => this.onNotification(method, params),
|
|
2285
|
+
onStderr: (line) => {
|
|
2286
|
+
if (PERMISSION_WARNING_RE.test(line)) this.onUnsafeWarning(line);
|
|
2287
|
+
},
|
|
2288
|
+
onExit: (info) => this.onExit(info)
|
|
2289
|
+
};
|
|
2290
|
+
}
|
|
2291
|
+
/**
|
|
2292
|
+
* The adapter says it will not ask for permission (its own settings start it in a mode that does not, or an allow
|
|
2293
|
+
* rule shadows the callback). With the owner's opt-in for this agent that is accepted: audited, and the session says
|
|
2294
|
+
* it is unattended. Without it the session is closed, as before.
|
|
2295
|
+
*/
|
|
2296
|
+
onUnsafeWarning(text) {
|
|
2297
|
+
if (this.inheritedWarningOk) return this.audit("permission_callback_warning_inherited", { text: text.slice(0, 300) });
|
|
2298
|
+
const accepted = this.agent.allowUnsafeModes;
|
|
2299
|
+
this.audit("permission_callback_warning", { text: text.slice(0, 300), accepted });
|
|
2300
|
+
if (!accepted) {
|
|
2301
|
+
void this.refuse(new SessionRefusal("mode_not_enforced", "the adapter warned that it will not ask for permission"));
|
|
2302
|
+
return;
|
|
2303
|
+
}
|
|
2304
|
+
if (this.noAskWarning) return;
|
|
2305
|
+
this.noAskWarning = true;
|
|
2306
|
+
if (this.announced && !this.closed) this.emit("mode_update", { currentMode: this.currentMode, unattended: true });
|
|
2307
|
+
}
|
|
2308
|
+
onExit(info) {
|
|
2309
|
+
this.adapter = null;
|
|
2310
|
+
const hadTurn = this.turnRunning;
|
|
2311
|
+
const cancelled = this.book.cancelAll();
|
|
2312
|
+
for (const actionId of cancelled) this.emit("permission_resolved", { actionId, optionId: null, outcome: "cancelled", by: "adapter_exit" });
|
|
2313
|
+
if (!this.closed) this.audit("adapter_exited", { code: info.code, signal: info.signal, during_turn: hadTurn });
|
|
2314
|
+
}
|
|
2315
|
+
onUpdate(update, params) {
|
|
2316
|
+
this.touch();
|
|
2317
|
+
const type = typeof update?.sessionUpdate === "string" ? update.sessionUpdate : null;
|
|
2318
|
+
if (!type) return;
|
|
2319
|
+
if (type === "current_mode_update" && typeof update.currentModeId === "string") return this.noteMode(update.currentModeId);
|
|
2320
|
+
if (type === "config_option_update" && Array.isArray(update.configOptions)) {
|
|
2321
|
+
const mode = update.configOptions.find((o) => o?.category === "mode");
|
|
2322
|
+
if (mode && typeof mode.currentValue === "string") this.noteMode(mode.currentValue);
|
|
2323
|
+
return;
|
|
2324
|
+
}
|
|
2325
|
+
if (type === "available_commands_update") return;
|
|
2326
|
+
const text = type === "agent_message_chunk" && isObject(update.content) && typeof update.content.text === "string" ? update.content.text : "";
|
|
2327
|
+
if (text) {
|
|
2328
|
+
if (PERMISSION_WARNING_RE.test(text)) return this.onUnsafeWarning(text);
|
|
2329
|
+
if (this.env.config.dropMessagePatterns.some((re) => re.test(text))) return;
|
|
2330
|
+
}
|
|
2331
|
+
const data = limitStrings(redactDeep({ sessionId: params.sessionId, update }), MAX_TOOL_OUTPUT_BYTES);
|
|
2332
|
+
if (this.replaying) {
|
|
2333
|
+
if (type === "user_message_chunk" || type === "agent_message_chunk" || type === "agent_thought_chunk" || type === "tool_call") this.emit(type, { ...data, replay: true });
|
|
2334
|
+
return;
|
|
2335
|
+
}
|
|
2336
|
+
this.emit(type, data);
|
|
2337
|
+
}
|
|
2338
|
+
onNotification(method, params) {
|
|
2339
|
+
this.touch();
|
|
2340
|
+
const type = method.replace(/[^A-Za-z0-9._:-]/g, ".").slice(0, 64);
|
|
2341
|
+
this.emit(type, limitStrings(redactDeep({ method, params }), MAX_TOOL_OUTPUT_BYTES));
|
|
2342
|
+
}
|
|
2343
|
+
// --- permissions ------------------------------------------------------------------
|
|
2344
|
+
onPermission(params) {
|
|
2345
|
+
this.touch();
|
|
2346
|
+
const toolCall = isObject(params.toolCall) ? params.toolCall : {};
|
|
2347
|
+
const options = normalizeOptions(params.options);
|
|
2348
|
+
if (this.closed || options.length === 0) return Promise.resolve({ outcome: { outcome: "cancelled" } });
|
|
2349
|
+
const rule = matchAutoAllow(this.agent.autoAllow, toolCall, this.cwd);
|
|
2350
|
+
const allowOnce = options.find((o) => o.kind === "allow_once");
|
|
2351
|
+
if (rule && allowOnce) {
|
|
2352
|
+
this.audit("auto_allow", { rule: `${rule.kind}:${rule.pathGlob}`, tool: toolCall.title, tool_kind: toolCall.kind, option_id: allowOnce.optionId });
|
|
2353
|
+
this.emit("permission_auto_allowed", { tool: str(toolCall.title, 200), kind: str(toolCall.kind, 64), rule: `${rule.kind}:${rule.pathGlob}`, optionId: allowOnce.optionId });
|
|
2354
|
+
return Promise.resolve({ outcome: { outcome: "selected", optionId: allowOnce.optionId } });
|
|
2355
|
+
}
|
|
2356
|
+
return this.askPerson(options, {
|
|
2357
|
+
tool: str(toolCall.title, 200),
|
|
2358
|
+
kind: str(toolCall.kind, 64),
|
|
2359
|
+
input: boundedInput(toolCall.rawInput),
|
|
2360
|
+
toolCallId: str(toolCall.toolCallId, 128)
|
|
2361
|
+
}, (optionId) => optionId === null ? { outcome: { outcome: "cancelled" } } : { outcome: { outcome: "selected", optionId } });
|
|
2362
|
+
}
|
|
2363
|
+
/** Put a question on the phone and wait for the answer, a cancel or the timeout. `shape` turns the answer into what the adapter expects. */
|
|
2364
|
+
askPerson(options, info, shape) {
|
|
2365
|
+
return new Promise((resolve) => {
|
|
2366
|
+
const p = this.book.open(options, (actionId) => redactDeep({ actionId, ...info, options, timeout_ms: this.book.ttl }), (optionId) => {
|
|
2367
|
+
this.env.store.removePending(this.id, p.actionId);
|
|
2368
|
+
resolve(shape(optionId));
|
|
2369
|
+
});
|
|
2370
|
+
this.env.store.addPending(this.id, p.actionId);
|
|
2371
|
+
this.audit("permission_requested", { action_id: p.actionId, tool: info.tool, tool_kind: info.kind, options: options.map((o) => o.optionId) });
|
|
2372
|
+
this.emit("permissionRequest", p.payload);
|
|
2373
|
+
});
|
|
2374
|
+
}
|
|
2375
|
+
/** A person's answer from the phone. Only an option that was offered is accepted. */
|
|
2376
|
+
decide(actionId, optionId, by, cmdId) {
|
|
2377
|
+
this.touch();
|
|
2378
|
+
const result = this.book.decide(actionId, optionId);
|
|
2379
|
+
if (!result.ok) {
|
|
2380
|
+
this.audit("decision_refused", { action_id: actionId, option_id: optionId, reason: result.reason, cmd_id: cmdId });
|
|
2381
|
+
return;
|
|
2382
|
+
}
|
|
2383
|
+
this.audit("permission_resolved", { action_id: actionId, option_id: optionId, by });
|
|
2384
|
+
this.emit("permission_resolved", { actionId, optionId, by });
|
|
2385
|
+
}
|
|
2386
|
+
/** After a reconnect: the cards that are still waiting, sent again (same `actionId`, so a client treats them as the same card). */
|
|
2387
|
+
resendPending() {
|
|
2388
|
+
for (const p of this.book.list()) this.emit("permissionRequest", { ...p.payload, resent: true });
|
|
2389
|
+
}
|
|
2390
|
+
/** The Cursor extension requests the adapter blocks on. Never left unanswered. */
|
|
2391
|
+
onExtension(method, params) {
|
|
2392
|
+
this.touch();
|
|
2393
|
+
if (method === "cursor/create_plan") {
|
|
2394
|
+
const options = [
|
|
2395
|
+
{ optionId: "plan-accept", name: "Accept the plan", kind: "allow_once" },
|
|
2396
|
+
{ optionId: "plan-reject", name: "Reject the plan", kind: "reject_once" }
|
|
2397
|
+
];
|
|
2398
|
+
return this.askPerson(options, {
|
|
2399
|
+
tool: `Plan: ${str(params.name, 150) || "proposed plan"}`,
|
|
2400
|
+
kind: "switch_mode",
|
|
2401
|
+
input: boundedInput({ overview: params.overview, plan: params.plan, todos: params.todos }),
|
|
2402
|
+
toolCallId: str(params.toolCallId, 128)
|
|
2403
|
+
}, (id) => ({ outcome: id === "plan-accept" ? { outcome: "accepted" } : id === "plan-reject" ? { outcome: "rejected", reason: "rejected from the phone" } : { outcome: "cancelled" } }));
|
|
2404
|
+
}
|
|
2405
|
+
if (method === "cursor/ask_question") {
|
|
2406
|
+
const qs = Array.isArray(params.questions) ? params.questions : [];
|
|
2407
|
+
const q = qs.length === 1 && isObject(qs[0]) ? qs[0] : null;
|
|
2408
|
+
const opts = q && Array.isArray(q.options) ? q.options.filter((o) => isObject(o) && typeof o.id === "string" && o.id !== "skip") : [];
|
|
2409
|
+
if (!q || q.allowMultiple === true || opts.length === 0 || opts.length > 8) {
|
|
2410
|
+
this.audit("extension_skipped", { method, questions: qs.length });
|
|
2411
|
+
this.emit("extension_skipped", { method, reason: "unsupported_question_shape", questions: redactDeep(qs) });
|
|
2412
|
+
return Promise.resolve({ outcome: { outcome: "skipped", reason: "this client cannot show that question" } });
|
|
2413
|
+
}
|
|
2414
|
+
const options = [
|
|
2415
|
+
...opts.map((o) => ({ optionId: String(o.id), name: String(o.label ?? o.id).slice(0, 200), kind: "allow_once" })),
|
|
2416
|
+
{ optionId: "skip", name: "Skip the question", kind: "reject_once" }
|
|
2417
|
+
];
|
|
2418
|
+
return this.askPerson(
|
|
2419
|
+
options,
|
|
2420
|
+
{ tool: str(q.prompt, 200) || "Question", kind: "question", input: null, toolCallId: str(params.toolCallId, 128) },
|
|
2421
|
+
(id) => id === null ? { outcome: { outcome: "cancelled" } } : id === "skip" ? { outcome: { outcome: "skipped" } } : { outcome: { outcome: "answered", answers: [{ questionId: String(q.id), selectedOptionIds: [id] }] } }
|
|
2422
|
+
);
|
|
2423
|
+
}
|
|
2424
|
+
return Promise.reject(new Error(`unsupported extension ${method}`));
|
|
2425
|
+
}
|
|
2426
|
+
// --- turns ----------------------------------------------------------------------------
|
|
2427
|
+
/**
|
|
2428
|
+
* `prompt`: queued behind a running turn, run in order. Returns once accepted, not when the turn ends.
|
|
2429
|
+
* `started` is called exactly once, when the prompt reaches the adapter or is dropped (refused, cancelled,
|
|
2430
|
+
* the session closed): only then does the connector count the command as handled, so a restart before that
|
|
2431
|
+
* delivers it again instead of losing it.
|
|
2432
|
+
*/
|
|
2433
|
+
enqueuePrompt(text, cmdId, started = () => {
|
|
2434
|
+
}) {
|
|
2435
|
+
if (this.closed) {
|
|
2436
|
+
this.rejectCommand("prompt", cmdId, "session_closed");
|
|
2437
|
+
return started();
|
|
2438
|
+
}
|
|
2439
|
+
if (this.queue.length >= MAX_QUEUED_PROMPTS) {
|
|
2440
|
+
this.rejectCommand("prompt", cmdId, "too_many_queued_prompts");
|
|
2441
|
+
return started();
|
|
2442
|
+
}
|
|
2443
|
+
this.queue.push({ text, cmdId, started });
|
|
2444
|
+
if (!this.turnRunning) void this.pump();
|
|
2445
|
+
}
|
|
2446
|
+
/** Prompts that will now never run (the process is being stopped): they count as handled. */
|
|
2447
|
+
dropQueued() {
|
|
2448
|
+
const q = this.queue;
|
|
2449
|
+
this.queue = [];
|
|
2450
|
+
for (const item of q) item.started();
|
|
2451
|
+
}
|
|
2452
|
+
async pump() {
|
|
2453
|
+
if (this.turnRunning) return;
|
|
2454
|
+
this.turnRunning = true;
|
|
2455
|
+
try {
|
|
2456
|
+
while (this.queue.length && !this.closed) {
|
|
2457
|
+
const next = this.queue.shift();
|
|
2458
|
+
await this.runTurn(next.text, next.started, next.cmdId);
|
|
2459
|
+
}
|
|
2460
|
+
} finally {
|
|
2461
|
+
this.turnRunning = false;
|
|
2462
|
+
this.touch();
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
async runTurn(text, started, cmdId) {
|
|
2466
|
+
if (this.agent.attach) {
|
|
2467
|
+
const refused = await this.preflight();
|
|
2468
|
+
if (refused) {
|
|
2469
|
+
started();
|
|
2470
|
+
this.audit("prompt_refused", { reason: refused.reason, detail: refused.detail.slice(0, 300) });
|
|
2471
|
+
this.rejectCommand("prompt", cmdId, refused.reason);
|
|
2472
|
+
this.emit("turn_end", { stopReason: "error", error: refused.reason });
|
|
2473
|
+
return;
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
try {
|
|
2477
|
+
await this.ensureAdapter();
|
|
2478
|
+
} catch (e) {
|
|
2479
|
+
started();
|
|
2480
|
+
this.emit("turn_end", { stopReason: "error", error: e instanceof SessionRefusal ? e.reason : "adapter_unavailable" });
|
|
2481
|
+
return this.refuse(e);
|
|
2482
|
+
}
|
|
2483
|
+
const a = this.adapter;
|
|
2484
|
+
this.audit("turn_started", { length: text.length });
|
|
2485
|
+
const support = this.agent.attach && this.acpId ? this.env.attach.get(this.agent.id) : void 0;
|
|
2486
|
+
const watch = support ? this.watchTwins(support, this.acpId) : null;
|
|
2487
|
+
try {
|
|
2488
|
+
const reply = a.requestUnbounded("session/prompt", { sessionId: this.acpId, prompt: [{ type: "text", text }] });
|
|
2489
|
+
started();
|
|
2490
|
+
const res = await reply;
|
|
2491
|
+
const usage = isObject(res.usage) ? res.usage : void 0;
|
|
2492
|
+
this.emit("turn_end", { stopReason: typeof res.stopReason === "string" ? res.stopReason : "end_turn", ...usage ? { usage } : {} });
|
|
2493
|
+
} catch (e) {
|
|
2494
|
+
this.audit("turn_failed", { error: errMessage(e) });
|
|
2495
|
+
this.emit("turn_end", { stopReason: "error", error: errMessage(e).slice(0, 300) });
|
|
2496
|
+
} finally {
|
|
2497
|
+
this.markTranscript();
|
|
2498
|
+
if (watch) {
|
|
2499
|
+
if (this.closed) watch.stop();
|
|
2500
|
+
else if (await watch.finish()) this.twinSeen = true;
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
/**
|
|
2505
|
+
* For an attach agent: may this prompt go to the agent now? Null when it may, else why not (the prompt is
|
|
2506
|
+
* rejected and the session stays, so it goes through once the cause is gone).
|
|
2507
|
+
*
|
|
2508
|
+
* Someone may have opened the conversation in a terminal since the last turn. Sending the prompt would make a
|
|
2509
|
+
* second writer, and even after that terminal has gone the adapter's memory of the conversation predates what
|
|
2510
|
+
* the terminal wrote: continuing from it forks the transcript. So an adapter is dropped when another process
|
|
2511
|
+
* holds the conversation open, when one was seen holding it open during the last turn (see {@link watchTwins}), and
|
|
2512
|
+
* when the transcript is no longer the size this adapter left it (a terminal that came and went between two
|
|
2513
|
+
* prompts); the next start reads the transcript afresh, after the full check.
|
|
2514
|
+
*/
|
|
2515
|
+
async preflight() {
|
|
2516
|
+
const support = this.env.attach.get(this.agent.id);
|
|
2517
|
+
const ref = this.acpId;
|
|
2518
|
+
if (!support || !ref) return { reason: "twin_check_failed", detail: "no takeover support" };
|
|
2519
|
+
let live = this.adapter && !this.adapter.exited ? this.adapter : null;
|
|
2520
|
+
if (live && this.twinSeen) {
|
|
2521
|
+
this.audit("adapter_discarded", { why: "another process held the conversation open during the last turn" });
|
|
2522
|
+
await this.dropAdapter();
|
|
2523
|
+
live = null;
|
|
2524
|
+
}
|
|
2525
|
+
const verdict = await support.check(ref, { ownPgid: live?.pid ?? null, transcript: live === null });
|
|
2526
|
+
if (!verdict.ok) {
|
|
2527
|
+
if (live) await this.dropAdapter();
|
|
2528
|
+
return verdict;
|
|
2529
|
+
}
|
|
2530
|
+
if (live && this.transcriptMark !== null) {
|
|
2531
|
+
const now = support.transcriptSize(ref);
|
|
2532
|
+
if (now !== null && now !== this.transcriptMark) {
|
|
2533
|
+
this.audit("transcript_changed", { was: this.transcriptMark, now });
|
|
2534
|
+
await this.dropAdapter();
|
|
2535
|
+
return this.preflight();
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
return null;
|
|
2539
|
+
}
|
|
2540
|
+
/** What size the transcript is as this connector leaves it: after it starts the session and after every turn. */
|
|
2541
|
+
markTranscript() {
|
|
2542
|
+
if (this.agent.attach && this.acpId) this.transcriptMark = this.env.attach.get(this.agent.id)?.transcriptSize(this.acpId) ?? null;
|
|
2543
|
+
}
|
|
2544
|
+
/** A recycled (or crashed) adapter comes back on demand. */
|
|
2545
|
+
async ensureAdapter() {
|
|
2546
|
+
if (this.adapter && !this.adapter.exited) return;
|
|
2547
|
+
this.adapter = null;
|
|
2548
|
+
await this.launch("resume");
|
|
2549
|
+
this.audit("session_resumed", { mode: this.currentMode });
|
|
2550
|
+
}
|
|
2551
|
+
/** `resume`: bring the session back. A live adapter is NOT asked to resume (that would reset its mode); the mode is re-asserted instead. */
|
|
2552
|
+
async resume(cmdId) {
|
|
2553
|
+
if (this.closed) return this.rejectCommand("resume", cmdId, "session_closed");
|
|
2554
|
+
try {
|
|
2555
|
+
if (this.adapter && !this.adapter.exited) await this.enforceMode(this.targetMode);
|
|
2556
|
+
else await this.launch("resume");
|
|
2557
|
+
} catch (e) {
|
|
2558
|
+
return this.refuse(e);
|
|
2559
|
+
}
|
|
2560
|
+
this.audit("session_resumed", { mode: this.currentMode, unattended: this.unattended });
|
|
2561
|
+
this.emit("session_resumed", { acp_session_id: this.acpId, modes: this.shownModes(), currentMode: this.currentMode, unattended: this.unattended });
|
|
2562
|
+
this.touch();
|
|
2563
|
+
}
|
|
2564
|
+
/** `cancel`: stop the turn, drop what was queued, answer every waiting permission request `cancelled`. */
|
|
2565
|
+
async cancel() {
|
|
2566
|
+
const queued = this.queue.length;
|
|
2567
|
+
this.dropQueued();
|
|
2568
|
+
const wasRunning = this.turnRunning;
|
|
2569
|
+
if (wasRunning && this.adapter && this.acpId) await this.adapter.notify("session/cancel", { sessionId: this.acpId }).catch(() => void 0);
|
|
2570
|
+
for (const actionId of this.book.cancelAll()) this.emit("permission_resolved", { actionId, optionId: null, outcome: "cancelled", by: "cancel" });
|
|
2571
|
+
this.audit("cancelled", { turn_running: wasRunning, queued_dropped: queued });
|
|
2572
|
+
if (!wasRunning) this.emit("turn_end", { stopReason: "cancelled" });
|
|
2573
|
+
}
|
|
2574
|
+
// --- ending ----------------------------------------------------------------------------
|
|
2575
|
+
/** The adapter's process goes away; the session stays and resumes on demand. */
|
|
2576
|
+
async recycle() {
|
|
2577
|
+
if (this.closed || this.busy || !this.adapter) return;
|
|
2578
|
+
this.audit("adapter_recycled", { idle_ms: this.env.timing.idleMs });
|
|
2579
|
+
await this.dropAdapter();
|
|
2580
|
+
}
|
|
2581
|
+
async dropAdapter() {
|
|
2582
|
+
const a = this.adapter;
|
|
2583
|
+
this.adapter = null;
|
|
2584
|
+
if (a) await a.kill();
|
|
2585
|
+
}
|
|
2586
|
+
/** `close`, and the common end of every refusal. */
|
|
2587
|
+
async close(reason) {
|
|
2588
|
+
if (this.closed) return;
|
|
2589
|
+
this.closed = true;
|
|
2590
|
+
clearTimeout(this.idle);
|
|
2591
|
+
this.dropQueued();
|
|
2592
|
+
for (const actionId of this.book.cancelAll()) this.emit("permission_resolved", { actionId, optionId: null, outcome: "cancelled", by: reason });
|
|
2593
|
+
const a = this.adapter;
|
|
2594
|
+
if (a && this.acpId && a.caps.sessionCapabilities?.close) await a.request("session/close", { sessionId: this.acpId }, 5e3).catch(() => void 0);
|
|
2595
|
+
await this.dropAdapter();
|
|
2596
|
+
this.env.store.patch(this.id, { status: "closed" });
|
|
2597
|
+
this.audit("session_closed", { reason });
|
|
2598
|
+
this.emit("session_closed", { reason });
|
|
2599
|
+
}
|
|
2600
|
+
/** A session that cannot be opened (or kept safe) is closed with the reason, never left half-open. */
|
|
2601
|
+
async refuse(e) {
|
|
2602
|
+
const r = e instanceof SessionRefusal ? e : mapAdapterError(e);
|
|
2603
|
+
this.env.log.warn("session refused", { session: this.id, reason: r.reason, message: r.message });
|
|
2604
|
+
this.audit("session_refused", { reason: r.reason, message: r.message.slice(0, 300) });
|
|
2605
|
+
await this.close(r.reason);
|
|
2606
|
+
}
|
|
2607
|
+
rejectCommand(type, cmdId, reason) {
|
|
2608
|
+
this.audit("command_rejected", { type, cmd_id: cmdId, reason });
|
|
2609
|
+
this.emit("command_rejected", { type, cmd_id: cmdId, reason });
|
|
2610
|
+
}
|
|
2611
|
+
};
|
|
2612
|
+
function mapAdapterError(e) {
|
|
2613
|
+
const message = errMessage(e);
|
|
2614
|
+
if (/authentication required|auth_required|not logged in|login required/i.test(message) || isObject(e) && e.code === -32e3) {
|
|
2615
|
+
return new SessionRefusal("agent_auth_required", message);
|
|
2616
|
+
}
|
|
2617
|
+
return new SessionRefusal("adapter_error", message);
|
|
2618
|
+
}
|
|
2619
|
+
function str(v, max) {
|
|
2620
|
+
return typeof v === "string" ? v.slice(0, max) : "";
|
|
2621
|
+
}
|
|
2622
|
+
function limitStrings(value, max) {
|
|
2623
|
+
if (typeof value === "string") return truncateBytes(value, max).text;
|
|
2624
|
+
if (Array.isArray(value)) return value.map((v) => limitStrings(v, max));
|
|
2625
|
+
if (value && typeof value === "object") {
|
|
2626
|
+
const out = {};
|
|
2627
|
+
for (const [k, v] of Object.entries(value)) out[k] = limitStrings(v, max);
|
|
2628
|
+
return out;
|
|
2629
|
+
}
|
|
2630
|
+
return value;
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
// src/manager.ts
|
|
2634
|
+
var MAX_ACTIVE_SESSIONS = 10;
|
|
2635
|
+
var SessionManager = class {
|
|
2636
|
+
constructor(env) {
|
|
2637
|
+
this.env = env;
|
|
2638
|
+
}
|
|
2639
|
+
env;
|
|
2640
|
+
sessions = /* @__PURE__ */ new Map();
|
|
2641
|
+
/** Takeovers that have passed the "already attached" check and have not yet registered their session (`agent:ref`). */
|
|
2642
|
+
attaching = /* @__PURE__ */ new Set();
|
|
2643
|
+
chains = /* @__PURE__ */ new Map();
|
|
2644
|
+
agent(id) {
|
|
2645
|
+
return typeof id === "string" ? this.env.config.agents.find((a) => a.id === id) : void 0;
|
|
2646
|
+
}
|
|
2647
|
+
/**
|
|
2648
|
+
* Commands of one session run one after another; sessions run side by side.
|
|
2649
|
+
* `handled` is called exactly once, when the command counts as carried out: for most that is when the
|
|
2650
|
+
* handler returns; for a prompt it is when the prompt reaches the adapter (see Session.enqueuePrompt).
|
|
2651
|
+
*/
|
|
2652
|
+
handle(cmd, handled = () => {
|
|
2653
|
+
}) {
|
|
2654
|
+
const sid = cmd.session_id;
|
|
2655
|
+
let done = false;
|
|
2656
|
+
const once = () => {
|
|
2657
|
+
if (!done) {
|
|
2658
|
+
done = true;
|
|
2659
|
+
handled();
|
|
2660
|
+
}
|
|
2661
|
+
};
|
|
2662
|
+
const prev = this.chains.get(sid) ?? Promise.resolve();
|
|
2663
|
+
const next = prev.then(() => this.dispatch(cmd, once)).catch((e) => {
|
|
2664
|
+
this.env.log.error("command failed", { type: cmd.type, session: sid, error: errMessage(e) });
|
|
2665
|
+
this.env.audit.record("command_failed", { type: cmd.type, session_id: sid, cmd_id: cmd.cmd_id, error: errMessage(e).slice(0, 300) });
|
|
2666
|
+
once();
|
|
2667
|
+
});
|
|
2668
|
+
this.chains.set(sid, next);
|
|
2669
|
+
void next.finally(() => {
|
|
2670
|
+
if (this.chains.get(sid) === next) this.chains.delete(sid);
|
|
2671
|
+
});
|
|
2672
|
+
return next;
|
|
2673
|
+
}
|
|
2674
|
+
async dispatch(cmd, handled) {
|
|
2675
|
+
let deferred = false;
|
|
2676
|
+
try {
|
|
2677
|
+
deferred = await this.run(cmd, handled);
|
|
2678
|
+
} finally {
|
|
2679
|
+
if (!deferred) handled();
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
/** Returns true when `handled` will be called later by the session itself (a prompt). */
|
|
2683
|
+
async run(cmd, handled) {
|
|
2684
|
+
const { type, session_id: sid, cmd_id: cmdId } = cmd;
|
|
2685
|
+
const detail = type === "prompt" && typeof cmd.text === "string" ? { sha256: sha256(cmd.text), length: cmd.text.length } : {};
|
|
2686
|
+
this.env.audit.record("command", { type, session_id: sid, cmd_id: cmdId, ...detail });
|
|
2687
|
+
if (type === "session.new") {
|
|
2688
|
+
await this.sessionNew(cmd);
|
|
2689
|
+
return false;
|
|
2690
|
+
}
|
|
2691
|
+
if (!isRemoteCommandType(type)) {
|
|
2692
|
+
this.env.audit.record("command_dropped", { type: String(type).slice(0, 64), session_id: sid, cmd_id: cmdId, reason: "not_a_known_command", known: [...REMOTE_COMMAND_TYPES, "session.new"] });
|
|
2693
|
+
return false;
|
|
2694
|
+
}
|
|
2695
|
+
const s = await this.lookup(sid);
|
|
2696
|
+
if (!s) {
|
|
2697
|
+
this.env.emit(sid, "session_closed", { reason: "unknown_session" });
|
|
2698
|
+
return false;
|
|
2699
|
+
}
|
|
2700
|
+
switch (type) {
|
|
2701
|
+
case "prompt":
|
|
2702
|
+
if (typeof cmd.text !== "string" || cmd.text === "") {
|
|
2703
|
+
s.rejectCommand("prompt", cmdId, "text_required");
|
|
2704
|
+
return false;
|
|
2705
|
+
}
|
|
2706
|
+
s.enqueuePrompt(cmd.text, cmdId, handled);
|
|
2707
|
+
return true;
|
|
2708
|
+
case "cancel":
|
|
2709
|
+
await s.cancel();
|
|
2710
|
+
return false;
|
|
2711
|
+
case "set_mode":
|
|
2712
|
+
if (typeof cmd.mode_id !== "string") s.rejectCommand("set_mode", cmdId, "mode_id_required");
|
|
2713
|
+
else await s.setMode(cmd.mode_id, cmdId);
|
|
2714
|
+
return false;
|
|
2715
|
+
case "resume":
|
|
2716
|
+
await s.resume(cmdId);
|
|
2717
|
+
return false;
|
|
2718
|
+
case "close":
|
|
2719
|
+
await s.close("closed");
|
|
2720
|
+
return false;
|
|
2721
|
+
case "decision":
|
|
2722
|
+
if (typeof cmd.action_id !== "string" || typeof cmd.option_id !== "string") s.rejectCommand("decision", cmdId, "action_and_option_required");
|
|
2723
|
+
else s.decide(cmd.action_id, cmd.option_id, typeof cmd.by === "string" ? cmd.by : null, cmdId);
|
|
2724
|
+
return false;
|
|
2725
|
+
}
|
|
2726
|
+
return false;
|
|
2727
|
+
}
|
|
2728
|
+
async sessionNew(cmd) {
|
|
2729
|
+
const sid = cmd.session_id;
|
|
2730
|
+
if (this.sessions.has(sid)) return;
|
|
2731
|
+
const agent = this.agent(cmd.agent_id);
|
|
2732
|
+
if (!agent) {
|
|
2733
|
+
this.env.audit.record("session_refused", { session_id: sid, reason: "unknown_agent", agent_id: String(cmd.agent_id).slice(0, 64) });
|
|
2734
|
+
this.env.emit(sid, "session_closed", { reason: "unknown_agent" });
|
|
2735
|
+
return;
|
|
2736
|
+
}
|
|
2737
|
+
const ref = cmd.resume_ref;
|
|
2738
|
+
if (agent.attach) return this.attachNew(cmd, agent);
|
|
2739
|
+
if (ref !== void 0 && ref !== null) {
|
|
2740
|
+
this.env.audit.record("session_refused", { session_id: sid, agent_id: agent.id, reason: "resume_not_supported" });
|
|
2741
|
+
this.env.emit(sid, "session_closed", { reason: "resume_not_supported" });
|
|
2742
|
+
return;
|
|
2743
|
+
}
|
|
2744
|
+
const verdict = checkCwd(typeof cmd.cwd === "string" ? cmd.cwd : "", agent.roots);
|
|
2745
|
+
if (!verdict.ok) {
|
|
2746
|
+
this.env.audit.record("session_refused", { session_id: sid, agent_id: agent.id, reason: verdict.reason, cwd: String(cmd.cwd).slice(0, 300) });
|
|
2747
|
+
this.env.emit(sid, "session_closed", { reason: verdict.reason });
|
|
2748
|
+
return;
|
|
2749
|
+
}
|
|
2750
|
+
const active = [...this.sessions.values()].filter((s2) => !s2.closed).length;
|
|
2751
|
+
if (active >= MAX_ACTIVE_SESSIONS) {
|
|
2752
|
+
this.env.audit.record("session_refused", { session_id: sid, agent_id: agent.id, reason: "too_many_sessions" });
|
|
2753
|
+
this.env.emit(sid, "session_closed", { reason: "too_many_sessions" });
|
|
2754
|
+
return;
|
|
2755
|
+
}
|
|
2756
|
+
const s = new Session(sid, agent, verdict.real, this.env);
|
|
2757
|
+
this.sessions.set(sid, s);
|
|
2758
|
+
await s.create(typeof cmd.mode === "string" ? cmd.mode : null);
|
|
2759
|
+
}
|
|
2760
|
+
/**
|
|
2761
|
+
* `session.new` for an agent that takes over an existing session: `resume_ref` names it, and it must be one the
|
|
2762
|
+
* adapter lists under this agent's roots. The directory it is resumed in is the one the session itself records;
|
|
2763
|
+
* the one Claw sent has to be the same. Whether another process has it open is checked when it is launched.
|
|
2764
|
+
*/
|
|
2765
|
+
async attachNew(cmd, agent) {
|
|
2766
|
+
const sid = cmd.session_id;
|
|
2767
|
+
const refuse = (reason, extra = {}) => {
|
|
2768
|
+
this.env.audit.record("session_refused", { session_id: sid, agent_id: agent.id, reason, ...extra });
|
|
2769
|
+
this.env.emit(sid, "session_closed", { reason });
|
|
2770
|
+
};
|
|
2771
|
+
const ref = typeof cmd.resume_ref === "string" ? cmd.resume_ref : "";
|
|
2772
|
+
if (!ref) return refuse("resume_ref_required");
|
|
2773
|
+
if (!SESSION_REF_RE.test(ref)) return refuse("invalid_resume_ref");
|
|
2774
|
+
const support = this.env.attach.get(agent.id);
|
|
2775
|
+
if (!support) return refuse("attach_unavailable");
|
|
2776
|
+
const want = ref.toLowerCase();
|
|
2777
|
+
const claim = `${agent.id}:${want}`;
|
|
2778
|
+
if (this.attaching.has(claim) || [...this.sessions.values()].some((s) => !s.closed && s.agent.id === agent.id && s.acpId?.toLowerCase() === want)) return refuse("already_attached");
|
|
2779
|
+
if ([...this.sessions.values()].filter((s) => !s.closed).length + this.attaching.size >= MAX_ACTIVE_SESSIONS) return refuse("too_many_sessions");
|
|
2780
|
+
this.attaching.add(claim);
|
|
2781
|
+
try {
|
|
2782
|
+
const found = await support.resolve(want);
|
|
2783
|
+
if (!found.ok) return refuse(found.reason);
|
|
2784
|
+
const asked = checkCwd(typeof cmd.cwd === "string" ? cmd.cwd : "", agent.roots);
|
|
2785
|
+
if (!asked.ok) return refuse(asked.reason, { cwd: String(cmd.cwd).slice(0, 300) });
|
|
2786
|
+
if (asked.real !== found.item.cwd) return refuse("cwd_mismatch", { cwd: String(cmd.cwd).slice(0, 300) });
|
|
2787
|
+
const s = new Session(sid, agent, found.item.cwd, this.env, found.item.ref);
|
|
2788
|
+
this.sessions.set(sid, s);
|
|
2789
|
+
this.attaching.delete(claim);
|
|
2790
|
+
await s.attach(typeof cmd.mode === "string" ? cmd.mode : null, found.item.title);
|
|
2791
|
+
} finally {
|
|
2792
|
+
this.attaching.delete(claim);
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
2795
|
+
/** The ids of the existing sessions this connector holds open, by agent. */
|
|
2796
|
+
attachedRefs(agentId) {
|
|
2797
|
+
const out = /* @__PURE__ */ new Set();
|
|
2798
|
+
for (const s of this.sessions.values()) if (!s.closed && s.agent.id === agentId && s.acpId) out.add(s.acpId.toLowerCase());
|
|
2799
|
+
return out;
|
|
2800
|
+
}
|
|
2801
|
+
/** A session this process knows, or one it made earlier (before a restart) and can bring back. */
|
|
2802
|
+
async lookup(sid) {
|
|
2803
|
+
const have = this.sessions.get(sid);
|
|
2804
|
+
if (have) return have;
|
|
2805
|
+
const rec = this.env.store.get(sid);
|
|
2806
|
+
if (!rec || rec.status !== "active") return null;
|
|
2807
|
+
const agent = this.agent(rec.agent_id);
|
|
2808
|
+
const verdict = agent ? checkCwd(rec.cwd, agent.roots) : null;
|
|
2809
|
+
if (!agent || !verdict?.ok) {
|
|
2810
|
+
this.env.store.patch(sid, { status: "closed" });
|
|
2811
|
+
this.env.audit.record("session_refused", { session_id: sid, reason: agent ? "cwd_outside_roots" : "unknown_agent" });
|
|
2812
|
+
this.env.emit(sid, "session_closed", { reason: agent ? "cwd_outside_roots" : "unknown_agent" });
|
|
2813
|
+
return null;
|
|
2814
|
+
}
|
|
2815
|
+
const s = new Session(sid, agent, verdict.real, this.env, rec.acp_session_id, rec.mode);
|
|
2816
|
+
this.sessions.set(sid, s);
|
|
2817
|
+
return s;
|
|
2818
|
+
}
|
|
2819
|
+
/** After the command stream reconnects: cards still waiting are shown again. */
|
|
2820
|
+
resendPending() {
|
|
2821
|
+
for (const s of this.sessions.values()) s.resendPending();
|
|
2822
|
+
}
|
|
2823
|
+
get pendingPermissions() {
|
|
2824
|
+
return [...this.sessions.values()].reduce((n, s) => n + s.pendingCount, 0);
|
|
2825
|
+
}
|
|
2826
|
+
/** The kill switch: every adapter process, now. Sessions stay recorded and can be resumed. */
|
|
2827
|
+
async killAll(reason) {
|
|
2828
|
+
for (const s of this.sessions.values()) {
|
|
2829
|
+
this.env.audit.record("session_killed", { session_id: s.id, reason });
|
|
2830
|
+
s.dropQueued();
|
|
2831
|
+
}
|
|
2832
|
+
await this.env.registry.killAll();
|
|
2833
|
+
this.sessions.clear();
|
|
2834
|
+
}
|
|
2835
|
+
};
|
|
2836
|
+
|
|
2837
|
+
// src/outbox.ts
|
|
2838
|
+
import fs10 from "node:fs";
|
|
2839
|
+
import path11 from "node:path";
|
|
2840
|
+
var DROPPABLE_TYPES = /* @__PURE__ */ new Set([
|
|
2841
|
+
"agent_message_chunk",
|
|
2842
|
+
"agent_thought_chunk",
|
|
2843
|
+
"tool_call_update",
|
|
2844
|
+
"usage_update",
|
|
2845
|
+
"plan"
|
|
2846
|
+
]);
|
|
2847
|
+
var TOMBSTONE_MS = 30 * 24 * 3600 * 1e3;
|
|
2848
|
+
var safeName = (sid) => sid.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
2849
|
+
function fitData(data, max = REMOTE_AGENT_LIMITS.maxEventBytes - 1024) {
|
|
2850
|
+
const bytes = Buffer.byteLength(JSON.stringify(data), "utf8");
|
|
2851
|
+
if (bytes <= max) return data;
|
|
2852
|
+
return { truncated: true, original_bytes: bytes };
|
|
2853
|
+
}
|
|
2854
|
+
var Outbox = class {
|
|
2855
|
+
dir;
|
|
2856
|
+
maxBytes;
|
|
2857
|
+
now;
|
|
2858
|
+
queue = [];
|
|
2859
|
+
meta = /* @__PURE__ */ new Map();
|
|
2860
|
+
bytes = /* @__PURE__ */ new Map();
|
|
2861
|
+
closed = /* @__PURE__ */ new Set();
|
|
2862
|
+
constructor(opts) {
|
|
2863
|
+
this.dir = opts.dir;
|
|
2864
|
+
this.maxBytes = opts.maxBytesPerSession ?? 5 * 1024 * 1024;
|
|
2865
|
+
this.now = opts.now ?? Date.now;
|
|
2866
|
+
fs10.mkdirSync(this.dir, { recursive: true, mode: 448 });
|
|
2867
|
+
this.load();
|
|
2868
|
+
}
|
|
2869
|
+
eventsFile = (sid) => path11.join(this.dir, `${safeName(sid)}.jsonl`);
|
|
2870
|
+
metaFile = (sid) => path11.join(this.dir, `${safeName(sid)}.meta.json`);
|
|
2871
|
+
writeMeta(sid) {
|
|
2872
|
+
const tmp = this.metaFile(sid) + ".tmp";
|
|
2873
|
+
fs10.writeFileSync(tmp, JSON.stringify(this.meta.get(sid)), { mode: 384 });
|
|
2874
|
+
fs10.renameSync(tmp, this.metaFile(sid));
|
|
2875
|
+
}
|
|
2876
|
+
/** Rebuild the queue from disk. The highest sequence number ever used is the larger of the meta file and the log itself. */
|
|
2877
|
+
load() {
|
|
2878
|
+
const loaded = [];
|
|
2879
|
+
const bases = /* @__PURE__ */ new Set();
|
|
2880
|
+
for (const name of fs10.readdirSync(this.dir)) {
|
|
2881
|
+
if (name.endsWith(".meta.json")) bases.add(name.slice(0, -".meta.json".length));
|
|
2882
|
+
else if (name.endsWith(".jsonl")) bases.add(name.slice(0, -".jsonl".length));
|
|
2883
|
+
}
|
|
2884
|
+
for (const base of bases) {
|
|
2885
|
+
let meta = { lastSeq: 0, acked: 0 };
|
|
2886
|
+
try {
|
|
2887
|
+
meta = JSON.parse(fs10.readFileSync(path11.join(this.dir, `${base}.meta.json`), "utf8"));
|
|
2888
|
+
} catch {
|
|
2889
|
+
}
|
|
2890
|
+
let sid = null;
|
|
2891
|
+
const file = path11.join(this.dir, `${base}.jsonl`);
|
|
2892
|
+
let text = "";
|
|
2893
|
+
try {
|
|
2894
|
+
text = fs10.readFileSync(file, "utf8");
|
|
2895
|
+
} catch {
|
|
2896
|
+
}
|
|
2897
|
+
for (const line of text.split("\n")) {
|
|
2898
|
+
if (!line) continue;
|
|
2899
|
+
let ev;
|
|
2900
|
+
try {
|
|
2901
|
+
ev = JSON.parse(line);
|
|
2902
|
+
} catch {
|
|
2903
|
+
continue;
|
|
2904
|
+
}
|
|
2905
|
+
sid = ev.session_id;
|
|
2906
|
+
meta.lastSeq = Math.max(meta.lastSeq, ev.seq);
|
|
2907
|
+
if (ev.seq > meta.acked) loaded.push(ev);
|
|
2908
|
+
}
|
|
2909
|
+
if (sid === null) {
|
|
2910
|
+
sid = base;
|
|
2911
|
+
try {
|
|
2912
|
+
if (this.now() - fs10.statSync(path11.join(this.dir, `${base}.meta.json`)).mtimeMs > TOMBSTONE_MS) {
|
|
2913
|
+
for (const f of [`${base}.meta.json`, `${base}.jsonl`]) fs10.rmSync(path11.join(this.dir, f), { force: true });
|
|
2914
|
+
continue;
|
|
2915
|
+
}
|
|
2916
|
+
} catch {
|
|
2917
|
+
}
|
|
2918
|
+
}
|
|
2919
|
+
this.meta.set(sid, meta);
|
|
2920
|
+
}
|
|
2921
|
+
loaded.sort((a, b) => a.ts - b.ts || a.seq - b.seq);
|
|
2922
|
+
this.queue = loaded;
|
|
2923
|
+
for (const ev of loaded) this.bytes.set(ev.session_id, (this.bytes.get(ev.session_id) ?? 0) + JSON.stringify(ev).length);
|
|
2924
|
+
}
|
|
2925
|
+
lastSeq(sessionId) {
|
|
2926
|
+
return this.meta.get(sessionId)?.lastSeq ?? 0;
|
|
2927
|
+
}
|
|
2928
|
+
/** Assign the next sequence number, persist the event, queue it. */
|
|
2929
|
+
append(sessionId, type, data, ts = this.now()) {
|
|
2930
|
+
const meta = this.meta.get(sessionId) ?? { lastSeq: 0, acked: 0 };
|
|
2931
|
+
const ev = { session_id: sessionId, seq: meta.lastSeq + 1, type, ts, data: fitData(data) };
|
|
2932
|
+
meta.lastSeq = ev.seq;
|
|
2933
|
+
this.meta.set(sessionId, meta);
|
|
2934
|
+
const line = JSON.stringify(ev);
|
|
2935
|
+
fs10.appendFileSync(this.eventsFile(sessionId), line + "\n", { mode: 384 });
|
|
2936
|
+
if (ev.seq === 1) this.writeMeta(sessionId);
|
|
2937
|
+
this.queue.push(ev);
|
|
2938
|
+
this.bytes.set(sessionId, (this.bytes.get(sessionId) ?? 0) + line.length);
|
|
2939
|
+
if ((this.bytes.get(sessionId) ?? 0) > this.maxBytes) this.shed(sessionId);
|
|
2940
|
+
return ev;
|
|
2941
|
+
}
|
|
2942
|
+
/** Drop the oldest droppable events of a session until it is back under its cap. */
|
|
2943
|
+
shed(sessionId) {
|
|
2944
|
+
let total = this.bytes.get(sessionId) ?? 0;
|
|
2945
|
+
const keep = [];
|
|
2946
|
+
let dropped = 0;
|
|
2947
|
+
for (const ev of this.queue) {
|
|
2948
|
+
if (ev.session_id === sessionId && total > this.maxBytes && DROPPABLE_TYPES.has(ev.type)) {
|
|
2949
|
+
total -= JSON.stringify(ev).length;
|
|
2950
|
+
dropped++;
|
|
2951
|
+
continue;
|
|
2952
|
+
}
|
|
2953
|
+
keep.push(ev);
|
|
2954
|
+
}
|
|
2955
|
+
if (!dropped) return;
|
|
2956
|
+
this.queue = keep;
|
|
2957
|
+
this.bytes.set(sessionId, total);
|
|
2958
|
+
this.rewrite(sessionId);
|
|
2959
|
+
}
|
|
2960
|
+
rewrite(sessionId) {
|
|
2961
|
+
this.writeMeta(sessionId);
|
|
2962
|
+
const mine = this.queue.filter((e) => e.session_id === sessionId);
|
|
2963
|
+
const tmp = this.eventsFile(sessionId) + ".tmp";
|
|
2964
|
+
fs10.writeFileSync(tmp, mine.map((e) => JSON.stringify(e) + "\n").join(""), { mode: 384 });
|
|
2965
|
+
fs10.renameSync(tmp, this.eventsFile(sessionId));
|
|
2966
|
+
}
|
|
2967
|
+
/** The oldest events not yet acknowledged, in the order they were appended. */
|
|
2968
|
+
pending(max) {
|
|
2969
|
+
return this.queue.slice(0, max);
|
|
2970
|
+
}
|
|
2971
|
+
get size() {
|
|
2972
|
+
return this.queue.length;
|
|
2973
|
+
}
|
|
2974
|
+
/** The server stored everything up to `acked[session]`. */
|
|
2975
|
+
ack(acked) {
|
|
2976
|
+
const touched = /* @__PURE__ */ new Set();
|
|
2977
|
+
this.queue = this.queue.filter((ev) => {
|
|
2978
|
+
const upTo = acked[ev.session_id];
|
|
2979
|
+
if (typeof upTo === "number" && ev.seq <= upTo) {
|
|
2980
|
+
touched.add(ev.session_id);
|
|
2981
|
+
this.bytes.set(ev.session_id, Math.max(0, (this.bytes.get(ev.session_id) ?? 0) - JSON.stringify(ev).length));
|
|
2982
|
+
return false;
|
|
2983
|
+
}
|
|
2984
|
+
return true;
|
|
2985
|
+
});
|
|
2986
|
+
for (const sid of touched) this.settle(sid, acked[sid]);
|
|
2987
|
+
}
|
|
2988
|
+
/** An event the server refused for good (retrying cannot help): forget it. */
|
|
2989
|
+
drop(sessionId, seq) {
|
|
2990
|
+
const before = this.queue.length;
|
|
2991
|
+
this.queue = this.queue.filter((e) => !(e.session_id === sessionId && e.seq === seq));
|
|
2992
|
+
if (this.queue.length === before) return;
|
|
2993
|
+
this.rewrite(sessionId);
|
|
2994
|
+
this.settle(sessionId, this.meta.get(sessionId)?.acked ?? 0);
|
|
2995
|
+
}
|
|
2996
|
+
settle(sid, ackedUpTo) {
|
|
2997
|
+
const meta = this.meta.get(sid) ?? { lastSeq: 0, acked: 0 };
|
|
2998
|
+
meta.acked = Math.max(meta.acked, ackedUpTo);
|
|
2999
|
+
this.meta.set(sid, meta);
|
|
3000
|
+
this.writeMeta(sid);
|
|
3001
|
+
const left = this.queue.filter((e) => e.session_id === sid);
|
|
3002
|
+
if (left.length === 0) {
|
|
3003
|
+
fs10.writeFileSync(this.eventsFile(sid), "", { mode: 384 });
|
|
3004
|
+
this.bytes.set(sid, 0);
|
|
3005
|
+
if (this.closed.has(sid)) this.forget(sid);
|
|
3006
|
+
} else if (left.length < this.queue.length / 2 || meta.acked > 1e3) {
|
|
3007
|
+
this.rewrite(sid);
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
/** A closed session needs no more numbers once its events are acknowledged. */
|
|
3011
|
+
markClosed(sessionId) {
|
|
3012
|
+
this.closed.add(sessionId);
|
|
3013
|
+
if (!this.queue.some((e) => e.session_id === sessionId)) this.forget(sessionId);
|
|
3014
|
+
}
|
|
3015
|
+
/**
|
|
3016
|
+
* A closed session needs no events once they are acknowledged, but its high-water mark must outlive them: a later
|
|
3017
|
+
* event for the same session id (the answer to a stale command, `session_closed {unknown_session}`) must go on
|
|
3018
|
+
* from the last number, because the server keeps the first event it saw for each `(session, seq)` and drops a
|
|
3019
|
+
* repeat. What stays is the small meta file, a tombstone, removed after {@link TOMBSTONE_MS}.
|
|
3020
|
+
*/
|
|
3021
|
+
forget(sid) {
|
|
3022
|
+
fs10.rmSync(this.eventsFile(sid), { force: true });
|
|
3023
|
+
if (this.meta.has(sid)) this.writeMeta(sid);
|
|
3024
|
+
this.bytes.delete(sid);
|
|
3025
|
+
this.closed.delete(sid);
|
|
3026
|
+
}
|
|
3027
|
+
};
|
|
3028
|
+
|
|
3029
|
+
// src/paths.ts
|
|
3030
|
+
import os3 from "node:os";
|
|
3031
|
+
import path12 from "node:path";
|
|
3032
|
+
function resolveDirs(env = process.env, home = os3.homedir()) {
|
|
3033
|
+
const one = env.CLAW_CONNECTOR_HOME?.trim();
|
|
3034
|
+
if (one) return { config: path12.join(one, "config"), state: path12.join(one, "state") };
|
|
3035
|
+
const cfg = env.XDG_CONFIG_HOME?.trim() || path12.join(home, ".config");
|
|
3036
|
+
const st = env.XDG_STATE_HOME?.trim() || path12.join(home, ".local", "state");
|
|
3037
|
+
return { config: path12.join(cfg, "claw-connector"), state: path12.join(st, "claw-connector") };
|
|
3038
|
+
}
|
|
3039
|
+
var configFile = (d) => path12.join(d.config, "agents.toml");
|
|
3040
|
+
var credentialsFile = (d) => path12.join(d.config, "credentials.json");
|
|
3041
|
+
var identityFile = (d) => path12.join(d.state, "connector.json");
|
|
3042
|
+
var sessionsFile = (d) => path12.join(d.state, "sessions.json");
|
|
3043
|
+
var childrenFile = (d) => path12.join(d.state, "children.json");
|
|
3044
|
+
var pidFile = (d) => path12.join(d.state, "connector.pid");
|
|
3045
|
+
var pauseFile = (d) => path12.join(d.state, "paused");
|
|
3046
|
+
var auditFile = (d) => path12.join(d.state, "audit.jsonl");
|
|
3047
|
+
var outboxDir = (d) => path12.join(d.state, "outbox");
|
|
3048
|
+
var logDir = (d) => path12.join(d.state, "logs");
|
|
3049
|
+
|
|
3050
|
+
// src/session-store.ts
|
|
3051
|
+
import fs11 from "node:fs";
|
|
3052
|
+
import path13 from "node:path";
|
|
3053
|
+
var SessionStore = class {
|
|
3054
|
+
constructor(file) {
|
|
3055
|
+
this.file = file;
|
|
3056
|
+
this.pendingFile = `${file}.pending`;
|
|
3057
|
+
try {
|
|
3058
|
+
const raw = JSON.parse(fs11.readFileSync(this.pendingFile, "utf8"));
|
|
3059
|
+
if (Array.isArray(raw)) this.pending = raw.filter((p) => p && typeof p.session_id === "string" && typeof p.action_id === "string");
|
|
3060
|
+
} catch {
|
|
3061
|
+
}
|
|
3062
|
+
try {
|
|
3063
|
+
const raw = JSON.parse(fs11.readFileSync(file, "utf8"));
|
|
3064
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) this.recs = raw;
|
|
3065
|
+
} catch {
|
|
3066
|
+
}
|
|
3067
|
+
}
|
|
3068
|
+
file;
|
|
3069
|
+
recs = {};
|
|
3070
|
+
pending = [];
|
|
3071
|
+
pendingFile;
|
|
3072
|
+
get(id) {
|
|
3073
|
+
return this.recs[id];
|
|
3074
|
+
}
|
|
3075
|
+
put(id, rec) {
|
|
3076
|
+
this.recs[id] = rec;
|
|
3077
|
+
this.save();
|
|
3078
|
+
}
|
|
3079
|
+
patch(id, p) {
|
|
3080
|
+
const cur = this.recs[id];
|
|
3081
|
+
if (!cur) return;
|
|
3082
|
+
this.recs[id] = { ...cur, ...p };
|
|
3083
|
+
this.save();
|
|
3084
|
+
}
|
|
3085
|
+
/**
|
|
3086
|
+
* Permission requests a person has not answered. They live in the adapter's memory, so a crash
|
|
3087
|
+
* of the connector ends them; the next run reads this list and closes each card (cancelled).
|
|
3088
|
+
*/
|
|
3089
|
+
addPending(sessionId, actionId) {
|
|
3090
|
+
this.pending.push({ session_id: sessionId, action_id: actionId });
|
|
3091
|
+
this.savePending();
|
|
3092
|
+
}
|
|
3093
|
+
removePending(sessionId, actionId) {
|
|
3094
|
+
const before = this.pending.length;
|
|
3095
|
+
this.pending = this.pending.filter((p) => !(p.session_id === sessionId && p.action_id === actionId));
|
|
3096
|
+
if (this.pending.length !== before) this.savePending();
|
|
3097
|
+
}
|
|
3098
|
+
/** Return what was pending and forget it. */
|
|
3099
|
+
takePending() {
|
|
3100
|
+
const out = this.pending;
|
|
3101
|
+
this.pending = [];
|
|
3102
|
+
if (out.length) this.savePending();
|
|
3103
|
+
return out;
|
|
3104
|
+
}
|
|
3105
|
+
savePending() {
|
|
3106
|
+
fs11.mkdirSync(path13.dirname(this.pendingFile), { recursive: true, mode: 448 });
|
|
3107
|
+
const tmp = `${this.pendingFile}.${process.pid}.tmp`;
|
|
3108
|
+
fs11.writeFileSync(tmp, JSON.stringify(this.pending), { mode: 384 });
|
|
3109
|
+
fs11.renameSync(tmp, this.pendingFile);
|
|
3110
|
+
}
|
|
3111
|
+
ids() {
|
|
3112
|
+
return Object.keys(this.recs);
|
|
3113
|
+
}
|
|
3114
|
+
save() {
|
|
3115
|
+
fs11.mkdirSync(path13.dirname(this.file), { recursive: true, mode: 448 });
|
|
3116
|
+
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
3117
|
+
fs11.writeFileSync(tmp, JSON.stringify(this.recs), { mode: 384 });
|
|
3118
|
+
fs11.renameSync(tmp, this.file);
|
|
3119
|
+
}
|
|
3120
|
+
};
|
|
3121
|
+
|
|
3122
|
+
// src/connector.ts
|
|
3123
|
+
var DEFAULT_TIMING = {
|
|
3124
|
+
...DEFAULT_SESSION_TIMING,
|
|
3125
|
+
heartbeatMs: REMOTE_AGENT_TIMING.heartbeatMs,
|
|
3126
|
+
watchdogMs: REMOTE_AGENT_TIMING.watchdogMs,
|
|
3127
|
+
batchMs: 150,
|
|
3128
|
+
batchMax: 20,
|
|
3129
|
+
backoffMinMs: 1e3,
|
|
3130
|
+
backoffMaxMs: 6e4,
|
|
3131
|
+
pausePollMs: 1e3,
|
|
3132
|
+
catalogMs: 12e4,
|
|
3133
|
+
attachCliMs: 8e3,
|
|
3134
|
+
recentWriteMs: 1e4
|
|
3135
|
+
};
|
|
3136
|
+
var CommandLedger = class {
|
|
3137
|
+
inflight = /* @__PURE__ */ new Set();
|
|
3138
|
+
inflightIds = /* @__PURE__ */ new Set();
|
|
3139
|
+
ids;
|
|
3140
|
+
highest = 0;
|
|
3141
|
+
floor;
|
|
3142
|
+
constructor(cursor = 0, handled = []) {
|
|
3143
|
+
this.floor = cursor;
|
|
3144
|
+
this.highest = cursor;
|
|
3145
|
+
this.ids = [...handled];
|
|
3146
|
+
}
|
|
3147
|
+
/** Whether this command was already carried out (or is being carried out now). */
|
|
3148
|
+
seen(seq, cmdId) {
|
|
3149
|
+
return seq <= this.floor || this.inflight.has(seq) || this.inflightIds.has(cmdId) || this.ids.includes(cmdId);
|
|
3150
|
+
}
|
|
3151
|
+
begin(seq, cmdId = "") {
|
|
3152
|
+
this.inflight.add(seq);
|
|
3153
|
+
this.inflightIds.add(cmdId);
|
|
3154
|
+
this.highest = Math.max(this.highest, seq);
|
|
3155
|
+
}
|
|
3156
|
+
end(seq, cmdId) {
|
|
3157
|
+
this.inflight.delete(seq);
|
|
3158
|
+
this.inflightIds.delete(cmdId);
|
|
3159
|
+
this.ids.push(cmdId);
|
|
3160
|
+
if (this.ids.length > 500) this.ids.splice(0, this.ids.length - 500);
|
|
3161
|
+
const safe = this.inflight.size ? Math.min(...this.inflight) - 1 : this.highest;
|
|
3162
|
+
this.floor = Math.max(this.floor, safe);
|
|
3163
|
+
}
|
|
3164
|
+
get cursor() {
|
|
3165
|
+
return this.floor;
|
|
3166
|
+
}
|
|
3167
|
+
get recentIds() {
|
|
3168
|
+
return [...this.ids];
|
|
3169
|
+
}
|
|
3170
|
+
get highestSeen() {
|
|
3171
|
+
return this.highest;
|
|
3172
|
+
}
|
|
3173
|
+
};
|
|
3174
|
+
function isTransient(e) {
|
|
3175
|
+
return e instanceof TransientAuthError || e instanceof TransportError || e instanceof HttpError && (e.status >= 500 || e.status === 429);
|
|
3176
|
+
}
|
|
3177
|
+
var Connector = class {
|
|
3178
|
+
constructor(d) {
|
|
3179
|
+
this.d = d;
|
|
3180
|
+
this.timing = { ...DEFAULT_TIMING, ...d.timing };
|
|
3181
|
+
this.outbox = new Outbox({ dir: outboxDir(d.dirs) });
|
|
3182
|
+
this.registry = new ChildRegistry(childrenFile(d.dirs));
|
|
3183
|
+
this.store = new SessionStore(sessionsFile(d.dirs));
|
|
3184
|
+
for (const a of d.config.agents) {
|
|
3185
|
+
if (!a.attach) continue;
|
|
3186
|
+
this.attach.set(a.id, new AttachSupport(a, {
|
|
3187
|
+
registry: this.registry,
|
|
3188
|
+
audit: d.audit,
|
|
3189
|
+
log: d.log,
|
|
3190
|
+
logDir: logDir(d.dirs),
|
|
3191
|
+
version: d.version,
|
|
3192
|
+
env: process.env,
|
|
3193
|
+
catalogMs: this.timing.catalogMs,
|
|
3194
|
+
cliTimeoutMs: this.timing.attachCliMs,
|
|
3195
|
+
recentWriteMs: this.timing.recentWriteMs,
|
|
3196
|
+
attachedRefs: () => this.manager.attachedRefs(a.id)
|
|
3197
|
+
}));
|
|
3198
|
+
}
|
|
3199
|
+
const env = {
|
|
3200
|
+
config: d.config,
|
|
3201
|
+
audit: d.audit,
|
|
3202
|
+
registry: this.registry,
|
|
3203
|
+
store: this.store,
|
|
3204
|
+
attach: this.attach,
|
|
3205
|
+
timing: this.timing,
|
|
3206
|
+
logDir: logDir(d.dirs),
|
|
3207
|
+
version: d.version,
|
|
3208
|
+
log: d.log,
|
|
3209
|
+
emit: (sid, type, data) => this.emit(sid, type, data)
|
|
3210
|
+
};
|
|
3211
|
+
this.manager = new SessionManager(env);
|
|
3212
|
+
}
|
|
3213
|
+
d;
|
|
3214
|
+
timing;
|
|
3215
|
+
outbox;
|
|
3216
|
+
registry;
|
|
3217
|
+
manager;
|
|
3218
|
+
store;
|
|
3219
|
+
attach = /* @__PURE__ */ new Map();
|
|
3220
|
+
ledger = new CommandLedger();
|
|
3221
|
+
cid = "";
|
|
3222
|
+
revoked = false;
|
|
3223
|
+
/** The connector row itself was revoked (not just the sign-in): recorded in connector.json for `login`. */
|
|
3224
|
+
rowRevoked = false;
|
|
3225
|
+
paused = false;
|
|
3226
|
+
wakeSender = null;
|
|
3227
|
+
wakeHeartbeat = null;
|
|
3228
|
+
streamCtl = null;
|
|
3229
|
+
ctl = new AbortController();
|
|
3230
|
+
/** Every event leaves through here: scrubbed of credential shapes, numbered, written to disk, then posted. */
|
|
3231
|
+
emit(sessionId, type, data) {
|
|
3232
|
+
this.outbox.append(sessionId, type, redactDeep(data));
|
|
3233
|
+
if (type === "session_closed") this.outbox.markClosed(sessionId);
|
|
3234
|
+
this.wakeSender?.();
|
|
3235
|
+
}
|
|
3236
|
+
get connectorId() {
|
|
3237
|
+
return this.cid;
|
|
3238
|
+
}
|
|
3239
|
+
get isRevoked() {
|
|
3240
|
+
return this.revoked;
|
|
3241
|
+
}
|
|
3242
|
+
/** Ask the loops to end; `run` returns once adapters are stopped. */
|
|
3243
|
+
stop() {
|
|
3244
|
+
this.ctl.abort();
|
|
3245
|
+
this.streamCtl?.abort();
|
|
3246
|
+
}
|
|
3247
|
+
async descriptors() {
|
|
3248
|
+
return Promise.all(this.d.config.agents.map(async (a) => {
|
|
3249
|
+
const base = { ask_mode: a.askMode, modes: a.safeModes, roots: a.roots.slice(0, 8), protocol: 1, unattended: a.unattended, unattended_allowed: a.allowUnsafeModes };
|
|
3250
|
+
const support = this.attach.get(a.id);
|
|
3251
|
+
const capabilities = support ? fitResumable(base, await support.resumable()) : base;
|
|
3252
|
+
return { agent_id: a.id, kind: a.kind, name: a.name, capabilities };
|
|
3253
|
+
}));
|
|
3254
|
+
}
|
|
3255
|
+
// --- identity -------------------------------------------------------------------
|
|
3256
|
+
readIdentity() {
|
|
3257
|
+
try {
|
|
3258
|
+
const raw = JSON.parse(fs12.readFileSync(identityFile(this.d.dirs), "utf8"));
|
|
3259
|
+
if (isObject(raw) && raw.server === this.d.server && typeof raw.connector_id === "string") {
|
|
3260
|
+
return { server: raw.server, connector_id: raw.connector_id, cursor: typeof raw.cursor === "number" ? raw.cursor : 0, handled: Array.isArray(raw.handled) ? raw.handled.filter((x) => typeof x === "string") : [] };
|
|
3261
|
+
}
|
|
3262
|
+
} catch {
|
|
3263
|
+
}
|
|
3264
|
+
return null;
|
|
3265
|
+
}
|
|
3266
|
+
writeIdentity() {
|
|
3267
|
+
const st = { server: this.d.server, connector_id: this.cid, cursor: this.ledger.cursor, handled: this.ledger.recentIds, ...this.rowRevoked ? { revoked: true } : {} };
|
|
3268
|
+
const file = identityFile(this.d.dirs);
|
|
3269
|
+
fs12.mkdirSync(this.d.dirs.state, { recursive: true, mode: 448 });
|
|
3270
|
+
fs12.writeFileSync(`${file}.tmp`, JSON.stringify(st), { mode: 384 });
|
|
3271
|
+
fs12.renameSync(`${file}.tmp`, file);
|
|
3272
|
+
}
|
|
3273
|
+
/** Use the connector id this machine already has, or register a new one. */
|
|
3274
|
+
async identify(forceNew = false) {
|
|
3275
|
+
const have = forceNew ? null : this.readIdentity();
|
|
3276
|
+
if (have) {
|
|
3277
|
+
this.cid = have.connector_id;
|
|
3278
|
+
this.ledger = new CommandLedger(have.cursor, have.handled);
|
|
3279
|
+
return;
|
|
3280
|
+
}
|
|
3281
|
+
this.cid = await this.d.client.register(this.d.config.name, this.d.version);
|
|
3282
|
+
this.ledger = new CommandLedger();
|
|
3283
|
+
this.writeIdentity();
|
|
3284
|
+
this.d.audit.record("connector_registered", { connector_id: this.cid, name: this.d.config.name });
|
|
3285
|
+
}
|
|
3286
|
+
async heartbeat() {
|
|
3287
|
+
await this.d.client.heartbeat(this.cid, {
|
|
3288
|
+
version: this.d.version,
|
|
3289
|
+
agents: await this.descriptors(),
|
|
3290
|
+
cmd_cursor: this.ledger.cursor > 0 ? this.ledger.cursor : null
|
|
3291
|
+
});
|
|
3292
|
+
}
|
|
3293
|
+
/** The first heartbeat, answering "not found" once by registering afresh (the stored id may belong to another account). */
|
|
3294
|
+
async firstHeartbeat() {
|
|
3295
|
+
try {
|
|
3296
|
+
await this.heartbeat();
|
|
3297
|
+
} catch (e) {
|
|
3298
|
+
if (e instanceof HttpError && e.status === 404) {
|
|
3299
|
+
await this.identify(true);
|
|
3300
|
+
await this.heartbeat();
|
|
3301
|
+
return;
|
|
3302
|
+
}
|
|
3303
|
+
throw e;
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
3306
|
+
/**
|
|
3307
|
+
* Identify to Claw and send the first heartbeat, trying again (with the usual backoff) while the failure is
|
|
3308
|
+
* the kind that passes: no network yet (a laptop that woke before Wi-Fi), the identity service or Claw
|
|
3309
|
+
* answering 5xx or 429. Anything else (a refused credential, a missing scope) is a problem a retry will not
|
|
3310
|
+
* fix and ends the run with its message.
|
|
3311
|
+
*/
|
|
3312
|
+
async startup() {
|
|
3313
|
+
for (let attempt = 0; !this.ctl.signal.aborted; attempt++) {
|
|
3314
|
+
try {
|
|
3315
|
+
await this.identify();
|
|
3316
|
+
await this.firstHeartbeat();
|
|
3317
|
+
return "ok";
|
|
3318
|
+
} catch (e) {
|
|
3319
|
+
if (this.isRevocation(e)) {
|
|
3320
|
+
await this.onRevoked(e);
|
|
3321
|
+
return "revoked";
|
|
3322
|
+
}
|
|
3323
|
+
if (!isTransient(e)) throw e;
|
|
3324
|
+
this.d.log.warn("cannot reach Claw yet; will retry", { error: errMessage(e), attempt });
|
|
3325
|
+
await sleep(backoffMs(attempt, this.timing.backoffMinMs, this.timing.backoffMaxMs), this.ctl.signal);
|
|
3326
|
+
}
|
|
3327
|
+
}
|
|
3328
|
+
return "stopped";
|
|
3329
|
+
}
|
|
3330
|
+
// --- revocation and pause ----------------------------------------------------------
|
|
3331
|
+
/**
|
|
3332
|
+
* The owner disconnected this connector (or the identity service ended the sign-in): nothing may keep running.
|
|
3333
|
+
* No retry, no reconnect. A revoked connector row is also marked in connector.json, which is how `login` tells a
|
|
3334
|
+
* stored id that is dead from one a new sign-in should keep using; a revoked sign-in leaves the row alive, so it
|
|
3335
|
+
* is not marked. The id itself stays, so a restart cannot register a new connector with the same sign-in and
|
|
3336
|
+
* undo the owner's kill switch.
|
|
3337
|
+
*/
|
|
3338
|
+
async onRevoked(e) {
|
|
3339
|
+
if (this.revoked) return;
|
|
3340
|
+
this.revoked = true;
|
|
3341
|
+
const why = errMessage(e);
|
|
3342
|
+
this.d.log.error("revoked: stopping every agent", { why });
|
|
3343
|
+
this.d.audit.record("revoked", { why });
|
|
3344
|
+
if (e instanceof RevokedError && this.cid) {
|
|
3345
|
+
this.rowRevoked = true;
|
|
3346
|
+
try {
|
|
3347
|
+
this.writeIdentity();
|
|
3348
|
+
} catch (we) {
|
|
3349
|
+
this.d.log.warn("could not record the revocation in connector.json", { error: errMessage(we) });
|
|
3350
|
+
}
|
|
3351
|
+
}
|
|
3352
|
+
this.ctl.abort();
|
|
3353
|
+
this.streamCtl?.abort();
|
|
3354
|
+
await this.manager.killAll("revoked");
|
|
3355
|
+
}
|
|
3356
|
+
isRevocation(e) {
|
|
3357
|
+
return e instanceof RevokedError || e instanceof GrantRevokedError;
|
|
3358
|
+
}
|
|
3359
|
+
async pauseWatcher() {
|
|
3360
|
+
while (!this.ctl.signal.aborted) {
|
|
3361
|
+
const want = fs12.existsSync(pauseFile(this.d.dirs));
|
|
3362
|
+
if (want && !this.paused) {
|
|
3363
|
+
this.paused = true;
|
|
3364
|
+
this.d.audit.record("paused");
|
|
3365
|
+
this.d.log.warn("paused: stopping every agent");
|
|
3366
|
+
this.streamCtl?.abort();
|
|
3367
|
+
await this.manager.killAll("paused");
|
|
3368
|
+
} else if (!want && this.paused) {
|
|
3369
|
+
this.paused = false;
|
|
3370
|
+
this.d.audit.record("resumed");
|
|
3371
|
+
this.d.log.info("resumed");
|
|
3372
|
+
}
|
|
3373
|
+
await sleep(this.timing.pausePollMs, this.ctl.signal);
|
|
3374
|
+
}
|
|
3375
|
+
}
|
|
3376
|
+
// --- the loops -----------------------------------------------------------------------
|
|
3377
|
+
/** Post events in batches, one request at a time and in order, until everything is acknowledged. */
|
|
3378
|
+
async sender() {
|
|
3379
|
+
let failures = 0;
|
|
3380
|
+
while (!this.ctl.signal.aborted) {
|
|
3381
|
+
if (this.outbox.size === 0) {
|
|
3382
|
+
await new Promise((resolve) => {
|
|
3383
|
+
const done = () => {
|
|
3384
|
+
this.wakeSender = null;
|
|
3385
|
+
this.ctl.signal.removeEventListener("abort", done);
|
|
3386
|
+
resolve();
|
|
3387
|
+
};
|
|
3388
|
+
this.wakeSender = done;
|
|
3389
|
+
this.ctl.signal.addEventListener("abort", done, { once: true });
|
|
3390
|
+
});
|
|
3391
|
+
continue;
|
|
3392
|
+
}
|
|
3393
|
+
if (this.outbox.size < this.timing.batchMax) await sleep(this.timing.batchMs, this.ctl.signal);
|
|
3394
|
+
if (this.ctl.signal.aborted) break;
|
|
3395
|
+
const batch = this.outbox.pending(this.timing.batchMax);
|
|
3396
|
+
if (batch.length === 0) continue;
|
|
3397
|
+
try {
|
|
3398
|
+
const ack = await this.d.client.postEvents(this.cid, `b-${Date.now().toString(36)}-${randomUUID2().slice(0, 8)}`, batch);
|
|
3399
|
+
this.outbox.ack(ack.acked);
|
|
3400
|
+
for (const r of ack.rejected) {
|
|
3401
|
+
const ev = batch[r.index];
|
|
3402
|
+
if (!ev) continue;
|
|
3403
|
+
this.outbox.drop(ev.session_id, ev.seq);
|
|
3404
|
+
this.d.audit.record("event_rejected", { session_id: ev.session_id, seq: ev.seq, type: ev.type, reason: r.reason });
|
|
3405
|
+
}
|
|
3406
|
+
failures = 0;
|
|
3407
|
+
} catch (e) {
|
|
3408
|
+
if (this.isRevocation(e)) return void await this.onRevoked(e);
|
|
3409
|
+
failures++;
|
|
3410
|
+
this.d.log.warn("posting events failed; will retry", { error: errMessage(e), pending: this.outbox.size });
|
|
3411
|
+
await sleep(backoffMs(failures - 1, this.timing.backoffMinMs, this.timing.backoffMaxMs), this.ctl.signal);
|
|
3412
|
+
}
|
|
3413
|
+
}
|
|
3414
|
+
}
|
|
3415
|
+
async heartbeater() {
|
|
3416
|
+
while (!this.ctl.signal.aborted) {
|
|
3417
|
+
await new Promise((resolve) => {
|
|
3418
|
+
const done = () => {
|
|
3419
|
+
clearTimeout(t);
|
|
3420
|
+
this.wakeHeartbeat = null;
|
|
3421
|
+
this.ctl.signal.removeEventListener("abort", done);
|
|
3422
|
+
resolve();
|
|
3423
|
+
};
|
|
3424
|
+
const t = setTimeout(done, this.timing.heartbeatMs);
|
|
3425
|
+
this.wakeHeartbeat = done;
|
|
3426
|
+
this.ctl.signal.addEventListener("abort", done, { once: true });
|
|
3427
|
+
});
|
|
3428
|
+
if (this.ctl.signal.aborted) break;
|
|
3429
|
+
if (this.paused) continue;
|
|
3430
|
+
try {
|
|
3431
|
+
await this.heartbeat();
|
|
3432
|
+
this.writeIdentity();
|
|
3433
|
+
} catch (e) {
|
|
3434
|
+
if (this.isRevocation(e)) return void await this.onRevoked(e);
|
|
3435
|
+
this.d.log.warn("heartbeat failed", { error: errMessage(e) });
|
|
3436
|
+
}
|
|
3437
|
+
}
|
|
3438
|
+
}
|
|
3439
|
+
onFrame(f, reconnect) {
|
|
3440
|
+
switch (f.event) {
|
|
3441
|
+
case "ready":
|
|
3442
|
+
if (reconnect) this.manager.resendPending();
|
|
3443
|
+
this.wakeHeartbeat?.();
|
|
3444
|
+
return;
|
|
3445
|
+
case "revoked":
|
|
3446
|
+
throw new RevokedError();
|
|
3447
|
+
case "command":
|
|
3448
|
+
return this.onCommand(f);
|
|
3449
|
+
default:
|
|
3450
|
+
return;
|
|
3451
|
+
}
|
|
3452
|
+
}
|
|
3453
|
+
onCommand(f) {
|
|
3454
|
+
const seq = Number(f.id);
|
|
3455
|
+
let data;
|
|
3456
|
+
try {
|
|
3457
|
+
data = f.data === void 0 ? null : JSON.parse(f.data);
|
|
3458
|
+
} catch {
|
|
3459
|
+
data = null;
|
|
3460
|
+
}
|
|
3461
|
+
if (!Number.isSafeInteger(seq) || !isObject(data) || typeof data.cmd_id !== "string" || typeof data.type !== "string" || typeof data.session_id !== "string") {
|
|
3462
|
+
this.d.audit.record("command_dropped", { reason: "malformed_frame", id: f.id });
|
|
3463
|
+
return;
|
|
3464
|
+
}
|
|
3465
|
+
const cmd = data;
|
|
3466
|
+
if (this.ledger.seen(seq, cmd.cmd_id)) return;
|
|
3467
|
+
this.ledger.begin(seq, cmd.cmd_id);
|
|
3468
|
+
void this.manager.handle(cmd, () => {
|
|
3469
|
+
this.ledger.end(seq, cmd.cmd_id);
|
|
3470
|
+
try {
|
|
3471
|
+
this.writeIdentity();
|
|
3472
|
+
} catch {
|
|
3473
|
+
}
|
|
3474
|
+
});
|
|
3475
|
+
}
|
|
3476
|
+
async streamLoop() {
|
|
3477
|
+
let attempt = 0;
|
|
3478
|
+
let reconnect = false;
|
|
3479
|
+
while (!this.ctl.signal.aborted) {
|
|
3480
|
+
if (this.paused) {
|
|
3481
|
+
await sleep(this.timing.pausePollMs, this.ctl.signal);
|
|
3482
|
+
continue;
|
|
3483
|
+
}
|
|
3484
|
+
this.streamCtl = new AbortController();
|
|
3485
|
+
try {
|
|
3486
|
+
const sig = AbortSignal.any([this.ctl.signal, this.streamCtl.signal]);
|
|
3487
|
+
let got = false;
|
|
3488
|
+
for await (const f of this.d.client.commands(this.cid, { lastEventId: this.ledger.highestSeen || void 0, signal: sig, watchdogMs: this.timing.watchdogMs })) {
|
|
3489
|
+
if (!got) {
|
|
3490
|
+
got = true;
|
|
3491
|
+
attempt = 0;
|
|
3492
|
+
}
|
|
3493
|
+
this.onFrame(f, reconnect);
|
|
3494
|
+
if (f.event === "ready") reconnect = true;
|
|
3495
|
+
}
|
|
3496
|
+
} catch (e) {
|
|
3497
|
+
if (this.isRevocation(e)) return void await this.onRevoked(e);
|
|
3498
|
+
if (this.ctl.signal.aborted) break;
|
|
3499
|
+
if (e instanceof HttpError && e.status === 404) {
|
|
3500
|
+
try {
|
|
3501
|
+
await this.identify(true);
|
|
3502
|
+
await this.firstHeartbeat();
|
|
3503
|
+
} catch (again) {
|
|
3504
|
+
if (this.isRevocation(again)) return void await this.onRevoked(again);
|
|
3505
|
+
this.d.log.warn("could not register again", { error: errMessage(again) });
|
|
3506
|
+
}
|
|
3507
|
+
}
|
|
3508
|
+
this.d.log.warn("command stream lost; reconnecting", { error: errMessage(e), attempt });
|
|
3509
|
+
}
|
|
3510
|
+
if (this.ctl.signal.aborted) break;
|
|
3511
|
+
if (this.paused) continue;
|
|
3512
|
+
await sleep(backoffMs(attempt++, this.timing.backoffMinMs, this.timing.backoffMaxMs), this.ctl.signal);
|
|
3513
|
+
}
|
|
3514
|
+
}
|
|
3515
|
+
/** Run until stopped or revoked. Always leaves no adapter process behind. */
|
|
3516
|
+
async run() {
|
|
3517
|
+
fs12.mkdirSync(this.d.dirs.state, { recursive: true, mode: 448 });
|
|
3518
|
+
await this.registry.killAll();
|
|
3519
|
+
for (const p of this.store.takePending()) {
|
|
3520
|
+
this.d.audit.record("permission_orphaned", { session_id: p.session_id, action_id: p.action_id });
|
|
3521
|
+
this.emit(p.session_id, "permission_resolved", { actionId: p.action_id, optionId: null, outcome: "cancelled", by: "connector_restart" });
|
|
3522
|
+
}
|
|
3523
|
+
const pidf = pidFile(this.d.dirs);
|
|
3524
|
+
fs12.writeFileSync(pidf, JSON.stringify({ pid: process.pid, start: processStart(process.pid) ?? "" }), { mode: 384 });
|
|
3525
|
+
this.paused = fs12.existsSync(pauseFile(this.d.dirs));
|
|
3526
|
+
try {
|
|
3527
|
+
const up = await this.startup();
|
|
3528
|
+
if (up !== "ok") return up;
|
|
3529
|
+
this.d.audit.record("connector_online", {
|
|
3530
|
+
connector_id: this.cid,
|
|
3531
|
+
agents: this.d.config.agents.map((a) => a.id),
|
|
3532
|
+
paused: this.paused,
|
|
3533
|
+
unattended_agents: this.d.config.agents.filter((a) => a.unattended).map((a) => a.id)
|
|
3534
|
+
});
|
|
3535
|
+
await Promise.all([this.sender(), this.heartbeater(), this.streamLoop(), this.pauseWatcher()]);
|
|
3536
|
+
} finally {
|
|
3537
|
+
await this.manager.killAll(this.revoked ? "revoked" : "stopped").catch(() => void 0);
|
|
3538
|
+
fs12.rmSync(pidf, { force: true });
|
|
3539
|
+
}
|
|
3540
|
+
return this.revoked ? "revoked" : "stopped";
|
|
3541
|
+
}
|
|
3542
|
+
/** For tests: wait until every event is acknowledged. */
|
|
3543
|
+
async drained(timeoutMs = 5e3) {
|
|
3544
|
+
const end = Date.now() + timeoutMs;
|
|
3545
|
+
while (Date.now() < end) {
|
|
3546
|
+
if (this.outbox.size === 0) return true;
|
|
3547
|
+
await sleep(20);
|
|
3548
|
+
}
|
|
3549
|
+
return this.outbox.size === 0;
|
|
3550
|
+
}
|
|
3551
|
+
};
|
|
3552
|
+
|
|
3553
|
+
// src/version.ts
|
|
3554
|
+
var VERSION = "0.1.0";
|
|
3555
|
+
|
|
3556
|
+
// src/commands.ts
|
|
3557
|
+
var EXIT = { ok: 0, failure: 1, usage: 2, revoked: 3 };
|
|
3558
|
+
var HELP = `claw-connector ${VERSION}: connect the coding agents on this machine to Claw (outbound HTTPS only).
|
|
3559
|
+
|
|
3560
|
+
Usage: claw-connector <command> [options]
|
|
3561
|
+
|
|
3562
|
+
init [--approval ask|never | --approval AGENT=ask|never ...] [--no-hooks]
|
|
3563
|
+
write a sample agents.toml, and install the Claude Code session hooks (see "hooks"; --no-hooks
|
|
3564
|
+
skips that). Agents ask before every write and command by default;
|
|
3565
|
+
"--approval never" (or "--approval claude=never") lets phone sessions run like your
|
|
3566
|
+
terminal, with no per-step approval. Agents are: ${SAMPLE_AGENTS.join(", ")}.
|
|
3567
|
+
hooks install|uninstall|status [--config-dir DIR]
|
|
3568
|
+
the SessionStart/SessionEnd hooks that tell this connector which Claude Code sessions are open
|
|
3569
|
+
in a terminal; merged into DIR/settings.json (default: the claude-attach agents' config
|
|
3570
|
+
directories, else $CLAUDE_CONFIG_DIR or ~/.claude); only its own entries are ever changed
|
|
3571
|
+
login sign in (device flow); --server URL --identity-url URL [--name HOST]
|
|
3572
|
+
development only: --key <api key> (or CLAW_CONNECTOR_KEY)
|
|
3573
|
+
run connect and serve (Ctrl-C or SIGTERM stops it and every agent it started)
|
|
3574
|
+
status [--json] what is running, signed in, paused
|
|
3575
|
+
pause stop every agent now and stay offline until "resume"
|
|
3576
|
+
resume undo "pause"
|
|
3577
|
+
stop stop the running connector and every agent it started
|
|
3578
|
+
logout stop, revoke the sign-in and delete it
|
|
3579
|
+
--version, --help
|
|
3580
|
+
|
|
3581
|
+
Files: ${"$"}XDG_CONFIG_HOME/claw-connector (agents.toml, credentials.json) and ${"$"}XDG_STATE_HOME/claw-connector
|
|
3582
|
+
(audit.jsonl, logs, outbox); set CLAW_CONNECTOR_HOME to keep everything under one directory.
|
|
3583
|
+
`;
|
|
3584
|
+
function loadCfg(ctx) {
|
|
3585
|
+
return loadConfig(configFile(ctx.dirs), { hostname: ctx.hostname ?? os4.hostname() });
|
|
3586
|
+
}
|
|
3587
|
+
function serverUrl(ctx, flag, cfg, creds) {
|
|
3588
|
+
const v = flag ?? ctx.env.CLAW_URL ?? cfg?.server ?? creds?.server ?? null;
|
|
3589
|
+
return v ? secureUrl(v.trim(), "the Claw server URL") : null;
|
|
3590
|
+
}
|
|
3591
|
+
async function main(argv, ctx) {
|
|
3592
|
+
const [cmd, ...rest] = argv;
|
|
3593
|
+
try {
|
|
3594
|
+
switch (cmd) {
|
|
3595
|
+
case void 0:
|
|
3596
|
+
case "help":
|
|
3597
|
+
case "--help":
|
|
3598
|
+
case "-h":
|
|
3599
|
+
ctx.out(HELP);
|
|
3600
|
+
return cmd ? EXIT.ok : EXIT.usage;
|
|
3601
|
+
case "--version":
|
|
3602
|
+
case "-v":
|
|
3603
|
+
case "version":
|
|
3604
|
+
ctx.out(VERSION);
|
|
3605
|
+
return EXIT.ok;
|
|
3606
|
+
case "init":
|
|
3607
|
+
return await cmdInit(rest, ctx);
|
|
3608
|
+
case "hooks":
|
|
3609
|
+
return cmdHooks(rest, ctx);
|
|
3610
|
+
case "login":
|
|
3611
|
+
return await cmdLogin(rest, ctx);
|
|
3612
|
+
case "run":
|
|
3613
|
+
return await cmdRun(rest, ctx);
|
|
3614
|
+
case "status":
|
|
3615
|
+
return cmdStatus(rest, ctx);
|
|
3616
|
+
case "pause":
|
|
3617
|
+
return await cmdPause(ctx);
|
|
3618
|
+
case "resume":
|
|
3619
|
+
return cmdResume(ctx);
|
|
3620
|
+
case "stop":
|
|
3621
|
+
return await cmdStop(ctx);
|
|
3622
|
+
case "logout":
|
|
3623
|
+
return await cmdLogout(ctx);
|
|
3624
|
+
default:
|
|
3625
|
+
ctx.err(`unknown command ${JSON.stringify(cmd)}
|
|
3626
|
+
|
|
3627
|
+
${HELP}`);
|
|
3628
|
+
return EXIT.usage;
|
|
3629
|
+
}
|
|
3630
|
+
} catch (e) {
|
|
3631
|
+
if (e instanceof ConfigError || e instanceof CredentialsError || e instanceof DeviceFlowError || e instanceof UrlError) {
|
|
3632
|
+
ctx.err(e.message);
|
|
3633
|
+
return EXIT.failure;
|
|
3634
|
+
}
|
|
3635
|
+
ctx.err(`error: ${errMessage(e)}`);
|
|
3636
|
+
return EXIT.failure;
|
|
3637
|
+
}
|
|
3638
|
+
}
|
|
3639
|
+
function parseApproval(values) {
|
|
3640
|
+
const out = {};
|
|
3641
|
+
for (const v of values) {
|
|
3642
|
+
const eq = v.indexOf("=");
|
|
3643
|
+
const who = eq < 0 ? null : v.slice(0, eq);
|
|
3644
|
+
const how = eq < 0 ? v : v.slice(eq + 1);
|
|
3645
|
+
if (how !== "ask" && how !== "never") return `--approval takes ask or never (or AGENT=ask|never), not ${JSON.stringify(v)}`;
|
|
3646
|
+
const targets = who === null ? [...SAMPLE_AGENTS] : [who];
|
|
3647
|
+
for (const t of targets) {
|
|
3648
|
+
if (!SAMPLE_AGENTS.includes(t)) return `--approval: no sample agent ${JSON.stringify(t)} (agents are: ${SAMPLE_AGENTS.join(", ")})`;
|
|
3649
|
+
out[t] = how;
|
|
3650
|
+
}
|
|
3651
|
+
}
|
|
3652
|
+
return out;
|
|
3653
|
+
}
|
|
3654
|
+
async function cmdInit(args, ctx) {
|
|
3655
|
+
const { values } = parseArgs({ args, strict: true, options: { approval: { type: "string", multiple: true }, "no-hooks": { type: "boolean" } } });
|
|
3656
|
+
const approval = parseApproval(values.approval ?? []);
|
|
3657
|
+
if (typeof approval === "string") {
|
|
3658
|
+
ctx.err(approval);
|
|
3659
|
+
return EXIT.usage;
|
|
3660
|
+
}
|
|
3661
|
+
const file = configFile(ctx.dirs);
|
|
3662
|
+
fs13.mkdirSync(path14.dirname(file), { recursive: true, mode: 448 });
|
|
3663
|
+
try {
|
|
3664
|
+
fs13.writeFileSync(file, sampleConfig(approval), { mode: 384, flag: "wx" });
|
|
3665
|
+
ctx.out(`wrote ${file}: edit the roots (the directories an agent may work in), then run: claw-connector login`);
|
|
3666
|
+
const { unattended, parked } = sampleApprovals(approval);
|
|
3667
|
+
ctx.out(unattended.length ? `no per-step approval for: ${unattended.join(", ")}. Those agents write files and run commands without asking; the phone shows such a session as unattended. To go back, set approval = "ask" on the agent.` : `every agent in this file asks before it writes a file or runs a command. To let phone sessions run like your terminal instead: claw-connector init --approval never (on a new file), or set approval = "never" on an agent.`);
|
|
3668
|
+
if (parked.length) ctx.out(`${parked.join(", ")}: not enabled in this file (the block is commented out), so approval = "never" does nothing there until you uncomment the block.`);
|
|
3669
|
+
} catch (e) {
|
|
3670
|
+
if (e.code !== "EEXIST") throw e;
|
|
3671
|
+
ctx.out(`${file} already exists; left alone${values.approval?.length ? ` (to change an agent's approval, set approval = "ask" or "never" in its [[agents]] table)` : ""}`);
|
|
3672
|
+
}
|
|
3673
|
+
if (!values["no-hooks"]) {
|
|
3674
|
+
const dir = defaultClaudeDir(ctx);
|
|
3675
|
+
if (!fs13.existsSync(dir)) ctx.out(`no Claude Code directory at ${dir}; once you use Claude Code, run: claw-connector hooks install`);
|
|
3676
|
+
else installForInit(ctx, dir);
|
|
3677
|
+
}
|
|
3678
|
+
return EXIT.ok;
|
|
3679
|
+
}
|
|
3680
|
+
function installForInit(ctx, dir) {
|
|
3681
|
+
try {
|
|
3682
|
+
ctx.out(describeInstall(dir, installOne(ctx, dir).changed));
|
|
3683
|
+
} catch (e) {
|
|
3684
|
+
ctx.err(`hooks: ${errMessage(e)} (everything else is set up; fix it and run: claw-connector hooks install)`);
|
|
3685
|
+
}
|
|
3686
|
+
}
|
|
3687
|
+
var defaultClaudeDir = (ctx) => path14.resolve(ctx.env.CLAUDE_CONFIG_DIR?.trim() || path14.join(os4.homedir(), ".claude"));
|
|
3688
|
+
function claudeDirs(ctx, flag) {
|
|
3689
|
+
if (flag !== void 0) return [path14.resolve(flag)];
|
|
3690
|
+
let dirs = [];
|
|
3691
|
+
try {
|
|
3692
|
+
dirs = loadCfg(ctx).agents.filter((a) => a.attach).map((a) => path14.resolve(a.env.CLAUDE_CONFIG_DIR ?? ctx.env.CLAUDE_CONFIG_DIR?.trim() ?? path14.join(os4.homedir(), ".claude")));
|
|
3693
|
+
} catch {
|
|
3694
|
+
}
|
|
3695
|
+
return dirs.length ? [...new Set(dirs)] : [defaultClaudeDir(ctx)];
|
|
3696
|
+
}
|
|
3697
|
+
function hookScript(ctx) {
|
|
3698
|
+
const script = ctx.hookScript ?? fileURLToPath(new URL("./terminal-hook.js", import.meta.url));
|
|
3699
|
+
if (!fs13.existsSync(script)) throw new HooksError(`${script} does not exist (a source checkout needs "npm run build" first)`);
|
|
3700
|
+
return script;
|
|
3701
|
+
}
|
|
3702
|
+
function installOne(ctx, dir) {
|
|
3703
|
+
return installHooks(dir, ctx.node ?? process.execPath, hookScript(ctx));
|
|
3704
|
+
}
|
|
3705
|
+
var describeInstall = (dir, changed) => changed ? `installed the Claude Code session hooks in ${settingsFile(dir)}` : `the Claude Code session hooks are already in ${settingsFile(dir)}`;
|
|
3706
|
+
function cmdHooks(args, ctx) {
|
|
3707
|
+
const [sub, ...rest] = args;
|
|
3708
|
+
if (sub !== "install" && sub !== "uninstall" && sub !== "status") {
|
|
3709
|
+
ctx.err("usage: claw-connector hooks install|uninstall|status [--config-dir DIR]");
|
|
3710
|
+
return EXIT.usage;
|
|
3711
|
+
}
|
|
3712
|
+
const { values } = parseArgs({ args: rest, strict: true, options: { "config-dir": { type: "string" }, json: { type: "boolean" } } });
|
|
3713
|
+
const dirs = claudeDirs(ctx, values["config-dir"]);
|
|
3714
|
+
let code = EXIT.ok;
|
|
3715
|
+
const report = [];
|
|
3716
|
+
for (const dir of dirs) {
|
|
3717
|
+
try {
|
|
3718
|
+
if (sub === "install") {
|
|
3719
|
+
if (values["config-dir"] === void 0 && !fs13.existsSync(dir)) {
|
|
3720
|
+
ctx.err(`no Claude Code directory at ${dir}; pass --config-dir to name one`);
|
|
3721
|
+
code = EXIT.failure;
|
|
3722
|
+
continue;
|
|
3723
|
+
}
|
|
3724
|
+
ctx.out(describeInstall(dir, installOne(ctx, dir).changed));
|
|
3725
|
+
} else if (sub === "uninstall") {
|
|
3726
|
+
ctx.out(uninstallHooks(dir).changed ? `removed the Claude Code session hooks from ${settingsFile(dir)}` : `no session hooks of ours in ${settingsFile(dir)}`);
|
|
3727
|
+
} else {
|
|
3728
|
+
const st = hooksStatus(dir);
|
|
3729
|
+
report.push({ dir, ...st });
|
|
3730
|
+
if (!values.json) ctx.out(`${dir}: ${st.installed ? st.healthy ? "installed" : "installed, but broken" : st.events.length ? `partly installed (${st.events.join(", ")})` : "not installed"}${st.problem ? `; ${st.problem}` : ""}`);
|
|
3731
|
+
}
|
|
3732
|
+
} catch (e) {
|
|
3733
|
+
ctx.err(e instanceof HooksError ? e.message : `error: ${errMessage(e)}`);
|
|
3734
|
+
code = EXIT.failure;
|
|
3735
|
+
}
|
|
3736
|
+
}
|
|
3737
|
+
if (sub === "status" && values.json) ctx.out(JSON.stringify(report, null, 2));
|
|
3738
|
+
return code;
|
|
3739
|
+
}
|
|
3740
|
+
function forgetRevokedIdentity(ctx) {
|
|
3741
|
+
const file = identityFile(ctx.dirs);
|
|
3742
|
+
let revoked = false;
|
|
3743
|
+
try {
|
|
3744
|
+
const raw = JSON.parse(fs13.readFileSync(file, "utf8"));
|
|
3745
|
+
revoked = isObject(raw) && raw.revoked === true;
|
|
3746
|
+
} catch {
|
|
3747
|
+
}
|
|
3748
|
+
if (revoked) fs13.rmSync(file, { force: true });
|
|
3749
|
+
}
|
|
3750
|
+
async function cmdLogin(args, ctx) {
|
|
3751
|
+
const { values } = parseArgs({
|
|
3752
|
+
args,
|
|
3753
|
+
strict: true,
|
|
3754
|
+
options: { server: { type: "string" }, "identity-url": { type: "string" }, key: { type: "string" }, name: { type: "string" } }
|
|
3755
|
+
});
|
|
3756
|
+
let cfg = null;
|
|
3757
|
+
try {
|
|
3758
|
+
cfg = loadCfg(ctx);
|
|
3759
|
+
} catch (e) {
|
|
3760
|
+
if (!(e instanceof ConfigError)) throw e;
|
|
3761
|
+
}
|
|
3762
|
+
const server = serverUrl(ctx, values.server, cfg, null);
|
|
3763
|
+
if (!server) {
|
|
3764
|
+
ctx.err("login needs --server <Claw API URL> (or CLAW_URL, or server = in agents.toml)");
|
|
3765
|
+
return EXIT.usage;
|
|
3766
|
+
}
|
|
3767
|
+
const file = credentialsFile(ctx.dirs);
|
|
3768
|
+
const key = values.key ?? ctx.env.CLAW_CONNECTOR_KEY;
|
|
3769
|
+
if (key) {
|
|
3770
|
+
saveCredentials(file, { v: 1, server, kind: "key", key, saved_at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
3771
|
+
forgetRevokedIdentity(ctx);
|
|
3772
|
+
ctx.out("saved a development key. This is for trying things out: a device sign-in is the normal way (omit --key).");
|
|
3773
|
+
return EXIT.ok;
|
|
3774
|
+
}
|
|
3775
|
+
const identityRaw = (values["identity-url"] ?? ctx.env.CLAW_IDENTITY_URL ?? cfg?.identityUrl ?? "").trim();
|
|
3776
|
+
const identityUrl = identityRaw ? secureUrl(identityRaw, "the identity service URL") : "";
|
|
3777
|
+
if (!identityUrl) {
|
|
3778
|
+
ctx.err("login needs --identity-url <identity service URL> (or CLAW_IDENTITY_URL, or identity_url = in agents.toml)");
|
|
3779
|
+
return EXIT.usage;
|
|
3780
|
+
}
|
|
3781
|
+
const deviceName = values.name ?? cfg?.name ?? ctx.hostname ?? os4.hostname();
|
|
3782
|
+
const tok = await deviceLogin({ identityUrl, deviceName, fetch: ctx.fetch, print: ctx.out });
|
|
3783
|
+
saveCredentials(file, {
|
|
3784
|
+
v: 1,
|
|
3785
|
+
server,
|
|
3786
|
+
kind: "device",
|
|
3787
|
+
identity_url: identityUrl,
|
|
3788
|
+
client_id: DEFAULT_CLIENT_ID,
|
|
3789
|
+
refresh_token: tok.refresh_token,
|
|
3790
|
+
device_name: deviceName,
|
|
3791
|
+
saved_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
3792
|
+
});
|
|
3793
|
+
forgetRevokedIdentity(ctx);
|
|
3794
|
+
ctx.out(`signed in as ${deviceName}. Next: claw-connector run`);
|
|
3795
|
+
return EXIT.ok;
|
|
3796
|
+
}
|
|
3797
|
+
function runningPid(ctx) {
|
|
3798
|
+
try {
|
|
3799
|
+
const raw = JSON.parse(fs13.readFileSync(pidFile(ctx.dirs), "utf8"));
|
|
3800
|
+
if (isObject(raw) && typeof raw.pid === "number" && typeof raw.start === "string" && raw.start !== "" && processStart(raw.pid) === raw.start) return raw.pid;
|
|
3801
|
+
} catch {
|
|
3802
|
+
}
|
|
3803
|
+
return null;
|
|
3804
|
+
}
|
|
3805
|
+
async function cmdRun(_args, ctx) {
|
|
3806
|
+
const cfg = loadCfg(ctx);
|
|
3807
|
+
if (cfg.agents.length === 0) {
|
|
3808
|
+
ctx.err("agents.toml defines no agents; add an [[agents]] table");
|
|
3809
|
+
return EXIT.failure;
|
|
3810
|
+
}
|
|
3811
|
+
const creds = loadCredentials(credentialsFile(ctx.dirs));
|
|
3812
|
+
if (!creds) {
|
|
3813
|
+
ctx.err("not signed in: run claw-connector login");
|
|
3814
|
+
return EXIT.failure;
|
|
3815
|
+
}
|
|
3816
|
+
const running = runningPid(ctx);
|
|
3817
|
+
if (running !== null) {
|
|
3818
|
+
ctx.err(`already running (pid ${running}); use "claw-connector stop" first`);
|
|
3819
|
+
return EXIT.failure;
|
|
3820
|
+
}
|
|
3821
|
+
const server = serverUrl(ctx, void 0, cfg, creds);
|
|
3822
|
+
const tokens = providerFor(creds, (c) => saveCredentials(credentialsFile(ctx.dirs), c), { fetch: ctx.fetch });
|
|
3823
|
+
const client2 = new ClawClient({ server, tokens, fetch: ctx.fetch });
|
|
3824
|
+
const log = stderrLogger();
|
|
3825
|
+
const audit = fileAudit(auditFile(ctx.dirs));
|
|
3826
|
+
const connector2 = new Connector({ config: cfg, dirs: ctx.dirs, client: client2, server, audit, log, version: VERSION, timing: ctx.timing });
|
|
3827
|
+
ctx.onConnector?.(connector2);
|
|
3828
|
+
ctx.out(`connecting ${cfg.agents.map((a) => a.unattended ? `${a.id} (no per-step approval)` : a.id).join(", ")} to ${server} as "${cfg.name}"`);
|
|
3829
|
+
try {
|
|
3830
|
+
const result = await connector2.run();
|
|
3831
|
+
if (result === "revoked") {
|
|
3832
|
+
ctx.err("this connector was revoked: every agent was stopped. Run login again to connect it anew.");
|
|
3833
|
+
return EXIT.revoked;
|
|
3834
|
+
}
|
|
3835
|
+
return EXIT.ok;
|
|
3836
|
+
} catch (e) {
|
|
3837
|
+
if (e instanceof HttpError || e instanceof TransportError || e instanceof RevokedError || e instanceof GrantRevokedError) {
|
|
3838
|
+
ctx.err(`error: ${e.message}`);
|
|
3839
|
+
return EXIT.failure;
|
|
3840
|
+
}
|
|
3841
|
+
throw e;
|
|
3842
|
+
}
|
|
3843
|
+
}
|
|
3844
|
+
function cmdStatus(args, ctx) {
|
|
3845
|
+
const { values } = parseArgs({ args, strict: true, options: { json: { type: "boolean" } } });
|
|
3846
|
+
let creds = null;
|
|
3847
|
+
let credsProblem = null;
|
|
3848
|
+
try {
|
|
3849
|
+
creds = loadCredentials(credentialsFile(ctx.dirs));
|
|
3850
|
+
} catch (e) {
|
|
3851
|
+
credsProblem = errMessage(e);
|
|
3852
|
+
}
|
|
3853
|
+
let agents = [];
|
|
3854
|
+
let unattended = [];
|
|
3855
|
+
let configProblem = null;
|
|
3856
|
+
try {
|
|
3857
|
+
const cfg = loadCfg(ctx);
|
|
3858
|
+
agents = cfg.agents.map((a) => a.id);
|
|
3859
|
+
unattended = cfg.agents.filter((a) => a.unattended).map((a) => a.id);
|
|
3860
|
+
} catch (e) {
|
|
3861
|
+
configProblem = errMessage(e);
|
|
3862
|
+
}
|
|
3863
|
+
let connectorId = null;
|
|
3864
|
+
try {
|
|
3865
|
+
const raw = JSON.parse(fs13.readFileSync(identityFile(ctx.dirs), "utf8"));
|
|
3866
|
+
if (isObject(raw) && typeof raw.connector_id === "string") connectorId = raw.connector_id;
|
|
3867
|
+
} catch {
|
|
3868
|
+
}
|
|
3869
|
+
const reg = new ChildRegistry(childrenFile(ctx.dirs));
|
|
3870
|
+
const status = {
|
|
3871
|
+
version: VERSION,
|
|
3872
|
+
running: runningPid(ctx),
|
|
3873
|
+
paused: fs13.existsSync(pauseFile(ctx.dirs)),
|
|
3874
|
+
signed_in: creds ? { kind: creds.kind, server: creds.server, device_name: creds.device_name ?? null } : null,
|
|
3875
|
+
connector_id: connectorId,
|
|
3876
|
+
agents,
|
|
3877
|
+
/** Agents whose sessions start without asking before a write or a command. */
|
|
3878
|
+
unattended,
|
|
3879
|
+
adapters: reg.list().filter((r) => reg.isOurs(r)).map((r) => ({ pid: r.pid, agent: r.agent_id, session: r.session_id })),
|
|
3880
|
+
problems: [credsProblem, configProblem].filter((x) => !!x)
|
|
3881
|
+
};
|
|
3882
|
+
if (values.json) {
|
|
3883
|
+
ctx.out(JSON.stringify(status, null, 2));
|
|
3884
|
+
return EXIT.ok;
|
|
3885
|
+
}
|
|
3886
|
+
ctx.out(`connector: ${status.running ? `running (pid ${status.running})` : "not running"}${status.paused ? ", PAUSED" : ""}`);
|
|
3887
|
+
ctx.out(`signed in: ${creds ? `${creds.kind} to ${creds.server}` : "no"}`);
|
|
3888
|
+
ctx.out(`agents: ${agents.map((a) => unattended.includes(a) ? `${a} (no per-step approval)` : a).join(", ") || "none configured"}`);
|
|
3889
|
+
ctx.out(`adapters: ${status.adapters.length ? status.adapters.map((a) => `${a.agent} pid ${a.pid}`).join(", ") : "none running"}`);
|
|
3890
|
+
for (const p of status.problems) ctx.out(`problem: ${p}`);
|
|
3891
|
+
return EXIT.ok;
|
|
3892
|
+
}
|
|
3893
|
+
async function cmdPause(ctx) {
|
|
3894
|
+
fs13.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
|
|
3895
|
+
fs13.writeFileSync(pauseFile(ctx.dirs), (/* @__PURE__ */ new Date()).toISOString() + "\n", { mode: 384 });
|
|
3896
|
+
if (runningPid(ctx) === null) {
|
|
3897
|
+
const left = await new ChildRegistry(childrenFile(ctx.dirs)).killAll();
|
|
3898
|
+
ctx.out(`paused (the connector is not running${left.killed.length ? `; stopped ${left.killed.length} leftover agent process group(s)` : ""})`);
|
|
3899
|
+
} else {
|
|
3900
|
+
ctx.out("paused: the running connector stops every agent within a second and stays offline until: claw-connector resume");
|
|
3901
|
+
}
|
|
3902
|
+
return EXIT.ok;
|
|
3903
|
+
}
|
|
3904
|
+
function cmdResume(ctx) {
|
|
3905
|
+
fs13.rmSync(pauseFile(ctx.dirs), { force: true });
|
|
3906
|
+
ctx.out(runningPid(ctx) === null ? "resumed; start it with: claw-connector run" : "resumed: the running connector reconnects now");
|
|
3907
|
+
return EXIT.ok;
|
|
3908
|
+
}
|
|
3909
|
+
async function cmdStop(ctx, quiet = false) {
|
|
3910
|
+
const pid = runningPid(ctx);
|
|
3911
|
+
if (pid !== null) {
|
|
3912
|
+
process.kill(pid, "SIGTERM");
|
|
3913
|
+
const deadline = Date.now() + 15e3;
|
|
3914
|
+
while (Date.now() < deadline && isAlive(pid) && processStart(pid) !== null) await sleep(100);
|
|
3915
|
+
if (isAlive(pid) && runningPid(ctx) === pid) {
|
|
3916
|
+
process.kill(pid, "SIGKILL");
|
|
3917
|
+
if (!quiet) ctx.out(`the connector (pid ${pid}) did not stop in 15 s and was killed`);
|
|
3918
|
+
}
|
|
3919
|
+
}
|
|
3920
|
+
const left = await new ChildRegistry(childrenFile(ctx.dirs)).killAll();
|
|
3921
|
+
if (!quiet) ctx.out(pid === null ? `no connector running${left.killed.length ? `; stopped ${left.killed.length} leftover agent process group(s)` : ""}` : `stopped the connector${left.killed.length ? ` and ${left.killed.length} leftover agent process group(s)` : ""}`);
|
|
3922
|
+
return EXIT.ok;
|
|
3923
|
+
}
|
|
3924
|
+
async function cmdLogout(ctx) {
|
|
3925
|
+
await cmdStop(ctx, true);
|
|
3926
|
+
const file = credentialsFile(ctx.dirs);
|
|
3927
|
+
let creds = null;
|
|
3928
|
+
try {
|
|
3929
|
+
creds = loadCredentials(file);
|
|
3930
|
+
} catch {
|
|
3931
|
+
}
|
|
3932
|
+
if (creds?.kind === "device" && creds.identity_url && creds.refresh_token) {
|
|
3933
|
+
try {
|
|
3934
|
+
const f = ctx.fetch ?? fetch;
|
|
3935
|
+
await f(`${creds.identity_url}/oauth/revoke`, {
|
|
3936
|
+
method: "POST",
|
|
3937
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
3938
|
+
body: new URLSearchParams({ token: creds.refresh_token, token_type_hint: "refresh_token", client_id: creds.client_id ?? DEFAULT_CLIENT_ID }),
|
|
3939
|
+
signal: AbortSignal.timeout(1e4)
|
|
3940
|
+
});
|
|
3941
|
+
} catch (e) {
|
|
3942
|
+
ctx.err(`could not revoke the sign-in at the identity service (${errMessage(e)}); it was removed from this machine`);
|
|
3943
|
+
}
|
|
3944
|
+
}
|
|
3945
|
+
fs13.rmSync(file, { force: true });
|
|
3946
|
+
forgetRevokedIdentity(ctx);
|
|
3947
|
+
ctx.out("signed out");
|
|
3948
|
+
return EXIT.ok;
|
|
3949
|
+
}
|
|
3950
|
+
|
|
3951
|
+
// src/cli.ts
|
|
3952
|
+
var connector = null;
|
|
3953
|
+
var stopping = false;
|
|
3954
|
+
var onSignal = () => {
|
|
3955
|
+
if (stopping) process.exit(130);
|
|
3956
|
+
stopping = true;
|
|
3957
|
+
connector?.stop();
|
|
3958
|
+
};
|
|
3959
|
+
main(process.argv.slice(2), {
|
|
3960
|
+
dirs: resolveDirs(),
|
|
3961
|
+
env: process.env,
|
|
3962
|
+
out: (l) => process.stdout.write(l + "\n"),
|
|
3963
|
+
err: (l) => process.stderr.write(l + "\n"),
|
|
3964
|
+
onConnector: (c) => {
|
|
3965
|
+
connector = c;
|
|
3966
|
+
for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) process.on(sig, onSignal);
|
|
3967
|
+
}
|
|
3968
|
+
}).then((code) => process.exit(code), (e) => {
|
|
3969
|
+
process.stderr.write(`fatal: ${e instanceof Error ? e.stack : String(e)}
|
|
3970
|
+
`);
|
|
3971
|
+
process.exit(1);
|
|
3972
|
+
});
|