@primusclaw/connector 0.1.4 → 0.1.5
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/README.md +51 -15
- package/dist/cli.js +1241 -358
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,16 +1,297 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/commands.ts
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import
|
|
4
|
+
import fs18 from "node:fs";
|
|
5
|
+
import os7 from "node:os";
|
|
6
|
+
import path19 from "node:path";
|
|
7
7
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
8
8
|
import { parseArgs as parseArgs2 } from "node:util";
|
|
9
9
|
|
|
10
|
-
// src/
|
|
10
|
+
// src/adapter-install.ts
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
11
12
|
import fs from "node:fs";
|
|
12
13
|
import path from "node:path";
|
|
13
14
|
|
|
15
|
+
// src/util.ts
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
var sleep = (ms, signal) => new Promise((resolve) => {
|
|
18
|
+
if (signal?.aborted) return resolve();
|
|
19
|
+
const t = setTimeout(done, ms);
|
|
20
|
+
function done() {
|
|
21
|
+
signal?.removeEventListener("abort", done);
|
|
22
|
+
clearTimeout(t);
|
|
23
|
+
resolve();
|
|
24
|
+
}
|
|
25
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
26
|
+
});
|
|
27
|
+
var sha256 = (s) => createHash("sha256").update(s).digest("hex");
|
|
28
|
+
var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
29
|
+
var errMessage = (e) => e instanceof Error ? e.message : String(e);
|
|
30
|
+
function truncateBytes(text, max) {
|
|
31
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
32
|
+
if (bytes <= max) return { text, truncated: false, bytes };
|
|
33
|
+
let cut = Buffer.from(text, "utf8").subarray(0, max).toString("utf8");
|
|
34
|
+
if (cut.endsWith("\uFFFD")) cut = cut.slice(0, -1);
|
|
35
|
+
return { text: `${cut}
|
|
36
|
+
[truncated, ${bytes} bytes in all]`, truncated: true, bytes };
|
|
37
|
+
}
|
|
38
|
+
function backoffMs(attempt, minMs, maxMs, random = Math.random) {
|
|
39
|
+
const raw = Math.min(maxMs, minMs * 2 ** Math.min(attempt, 30));
|
|
40
|
+
return Math.round(raw * (0.5 + random() / 2));
|
|
41
|
+
}
|
|
42
|
+
var stderrLogger = (quiet = false) => {
|
|
43
|
+
const w = (level) => (msg, extra) => {
|
|
44
|
+
if (quiet && level === "info") return;
|
|
45
|
+
process.stderr.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${level} ${msg}${extra ? " " + JSON.stringify(extra) : ""}
|
|
46
|
+
`);
|
|
47
|
+
};
|
|
48
|
+
return { info: w("info"), warn: w("warn"), error: w("error") };
|
|
49
|
+
};
|
|
50
|
+
var UrlError = class extends Error {
|
|
51
|
+
};
|
|
52
|
+
function secureUrl(raw, what) {
|
|
53
|
+
let u;
|
|
54
|
+
try {
|
|
55
|
+
u = new URL(raw);
|
|
56
|
+
} catch {
|
|
57
|
+
throw new UrlError(`${what} is not a valid URL: ${JSON.stringify(raw.slice(0, 80))}`);
|
|
58
|
+
}
|
|
59
|
+
const loopback = u.hostname === "localhost" || u.hostname === "[::1]" || /^127(\.\d{1,3}){3}$/.test(u.hostname);
|
|
60
|
+
if (u.protocol !== "https:" && !(u.protocol === "http:" && loopback)) {
|
|
61
|
+
throw new UrlError(`${what} must be an https:// URL (http:// is accepted only for localhost), got ${u.protocol}//${u.host}`);
|
|
62
|
+
}
|
|
63
|
+
return raw.replace(/\/+$/, "");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// src/adapter-install.ts
|
|
67
|
+
var PINNED_RE = /^((?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*)@(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)$/;
|
|
68
|
+
var INSTALL_WAIT_MS = 5 * 6e4;
|
|
69
|
+
var INSTALL_TIMEOUT_MS = 15 * 6e4;
|
|
70
|
+
var RETRY_AFTER_MS = 10 * 6e4;
|
|
71
|
+
function pinnedPackage(cfg) {
|
|
72
|
+
const base = path.basename(cfg.command).replace(/\.(cmd|exe)$/i, "");
|
|
73
|
+
if (base !== "npx") return null;
|
|
74
|
+
const [flag, spec, ...rest] = cfg.args;
|
|
75
|
+
if (flag !== "-y" && flag !== "--yes" || typeof spec !== "string") return null;
|
|
76
|
+
const m = PINNED_RE.exec(spec);
|
|
77
|
+
if (!m) return null;
|
|
78
|
+
return { spec, name: m[1], version: m[2], rest };
|
|
79
|
+
}
|
|
80
|
+
function npmCommand(env) {
|
|
81
|
+
const exe = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
82
|
+
for (const d of (env.PATH ?? "").split(path.delimiter)) if (d && isExecutable(path.join(d, exe))) return exe;
|
|
83
|
+
const sibling = path.join(path.dirname(process.execPath), exe);
|
|
84
|
+
return isExecutable(sibling) ? sibling : exe;
|
|
85
|
+
}
|
|
86
|
+
function isExecutable(f) {
|
|
87
|
+
try {
|
|
88
|
+
fs.accessSync(f, fs.constants.X_OK);
|
|
89
|
+
return fs.statSync(f).isFile();
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
var npmInstaller = ({ dir, spec, env, timeoutMs }) => new Promise((resolve, reject) => {
|
|
95
|
+
const args = ["install", "--prefix", dir, "--no-save", "--no-package-lock", "--no-audit", "--no-fund", "--omit=dev", "--loglevel=error", spec];
|
|
96
|
+
let out = "";
|
|
97
|
+
const p = spawn(npmCommand(env), args, { cwd: dir, env, stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32", timeout: timeoutMs, killSignal: "SIGKILL" });
|
|
98
|
+
const keep = (d) => {
|
|
99
|
+
out = (out + d.toString("utf8")).slice(-2e3);
|
|
100
|
+
};
|
|
101
|
+
p.stdout.on("data", keep);
|
|
102
|
+
p.stderr.on("data", keep);
|
|
103
|
+
p.once("error", (e) => reject(new Error(`could not run npm: ${errMessage(e)}`)));
|
|
104
|
+
p.once("exit", (code, signal) => {
|
|
105
|
+
if (code === 0) resolve();
|
|
106
|
+
else reject(new Error(`npm install ${spec} ${signal ? `was killed (${signal})` : `exited ${code}`}${out.trim() ? `: ${out.trim().split("\n").slice(-3).join(" | ")}` : ""}`));
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
function binOf(pkgDir, pkg) {
|
|
110
|
+
let raw;
|
|
111
|
+
try {
|
|
112
|
+
raw = JSON.parse(fs.readFileSync(path.join(pkgDir, "package.json"), "utf8"));
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
if (!isObject(raw) || raw.version !== pkg.version) return null;
|
|
117
|
+
const short = pkg.name.split("/").pop();
|
|
118
|
+
let rel = null;
|
|
119
|
+
if (typeof raw.bin === "string") rel = raw.bin;
|
|
120
|
+
else if (isObject(raw.bin)) {
|
|
121
|
+
const entries = Object.entries(raw.bin);
|
|
122
|
+
rel = entries.length === 1 ? entries[0][1] : raw.bin[short];
|
|
123
|
+
}
|
|
124
|
+
if (typeof rel !== "string" || !rel) return null;
|
|
125
|
+
const file = path.resolve(pkgDir, rel);
|
|
126
|
+
if (!file.startsWith(pkgDir + path.sep)) return null;
|
|
127
|
+
try {
|
|
128
|
+
return fs.statSync(file).isFile() ? file : null;
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function runByNode(file) {
|
|
134
|
+
let head = "";
|
|
135
|
+
try {
|
|
136
|
+
const fd = fs.openSync(file, "r");
|
|
137
|
+
try {
|
|
138
|
+
const b = Buffer.alloc(128);
|
|
139
|
+
head = b.subarray(0, fs.readSync(fd, b, 0, 128, 0)).toString("utf8");
|
|
140
|
+
} finally {
|
|
141
|
+
fs.closeSync(fd);
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
if (head.startsWith("#!")) return /\bnode\b/.test(head.split("\n")[0]);
|
|
147
|
+
return /\.(c|m)?js$/.test(file);
|
|
148
|
+
}
|
|
149
|
+
var AdapterStore = class {
|
|
150
|
+
constructor(dir, o) {
|
|
151
|
+
this.dir = dir;
|
|
152
|
+
this.o = o;
|
|
153
|
+
this.installer = o.installer ?? npmInstaller;
|
|
154
|
+
}
|
|
155
|
+
dir;
|
|
156
|
+
o;
|
|
157
|
+
pending = /* @__PURE__ */ new Map();
|
|
158
|
+
failedAt = /* @__PURE__ */ new Map();
|
|
159
|
+
installer;
|
|
160
|
+
/** Where a package lives once installed. */
|
|
161
|
+
finalDir(pkg) {
|
|
162
|
+
return path.join(this.dir, `${pkg.name.replace("/", "+")}@${pkg.version}`);
|
|
163
|
+
}
|
|
164
|
+
/** The installed bin script, or null when there is no complete install. */
|
|
165
|
+
installed(pkg) {
|
|
166
|
+
return binOf(path.join(this.finalDir(pkg), "node_modules", ...pkg.name.split("/")), pkg);
|
|
167
|
+
}
|
|
168
|
+
/** Whether an install of this package is running in this process. */
|
|
169
|
+
installing(pkg) {
|
|
170
|
+
return this.pending.has(pkg.spec);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Install `pkg` unless it is there. Never throws: null when it could not be installed (the reason goes to `say`).
|
|
174
|
+
* One install per package at a time in this process; another process installing the same one is harmless.
|
|
175
|
+
*/
|
|
176
|
+
install(pkg, env, say = () => {
|
|
177
|
+
}) {
|
|
178
|
+
const have = this.installed(pkg);
|
|
179
|
+
if (have) return Promise.resolve(have);
|
|
180
|
+
const running = this.pending.get(pkg.spec);
|
|
181
|
+
if (running) return running;
|
|
182
|
+
const p = this.doInstall(pkg, env, say).finally(() => this.pending.delete(pkg.spec));
|
|
183
|
+
this.pending.set(pkg.spec, p);
|
|
184
|
+
return p;
|
|
185
|
+
}
|
|
186
|
+
async doInstall(pkg, env, say) {
|
|
187
|
+
const final = this.finalDir(pkg);
|
|
188
|
+
const started = Date.now();
|
|
189
|
+
let tmp = null;
|
|
190
|
+
try {
|
|
191
|
+
fs.mkdirSync(this.dir, { recursive: true, mode: 448 });
|
|
192
|
+
this.sweepAbandoned(pkg);
|
|
193
|
+
tmp = fs.mkdtempSync(path.join(this.dir, `.install-${pkg.name.replace("/", "+")}@${pkg.version}-`));
|
|
194
|
+
say(`installing ${pkg.spec} into ${final} (once; an adapter with its dependencies is a few hundred MB, so this can take a few minutes)`);
|
|
195
|
+
await this.installer({ dir: tmp, spec: pkg.spec, env, timeoutMs: INSTALL_TIMEOUT_MS });
|
|
196
|
+
if (!binOf(path.join(tmp, "node_modules", ...pkg.name.split("/")), pkg)) throw new Error(`npm reported success but ${pkg.spec} has no runnable bin in ${tmp}`);
|
|
197
|
+
if (!this.installed(pkg)) {
|
|
198
|
+
if (fs.existsSync(final)) fs.renameSync(final, `${final}.stale-${process.pid}-${Date.now()}`);
|
|
199
|
+
try {
|
|
200
|
+
fs.renameSync(tmp, final);
|
|
201
|
+
tmp = null;
|
|
202
|
+
} catch (e) {
|
|
203
|
+
if (!this.installed(pkg)) throw e;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
for (const f of fs.readdirSync(this.dir)) if (f.startsWith(`${path.basename(final)}.stale-`)) fs.rmSync(path.join(this.dir, f), { recursive: true, force: true });
|
|
207
|
+
const bin = this.installed(pkg);
|
|
208
|
+
if (!bin) throw new Error(`${pkg.spec} is not runnable after the install`);
|
|
209
|
+
this.failedAt.delete(pkg.spec);
|
|
210
|
+
say(`installed ${pkg.spec} in ${Math.round((Date.now() - started) / 1e3)} s`);
|
|
211
|
+
return bin;
|
|
212
|
+
} catch (e) {
|
|
213
|
+
this.failedAt.set(pkg.spec, this.now());
|
|
214
|
+
say(`could not install ${pkg.spec}: ${errMessage(e)}; sessions start it through npx instead (slower, and needs the registry each time)`);
|
|
215
|
+
return null;
|
|
216
|
+
} finally {
|
|
217
|
+
if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** Install directories a killed connector left behind (older than any install can take). */
|
|
221
|
+
sweepAbandoned(pkg) {
|
|
222
|
+
const prefix = `.install-${pkg.name.replace("/", "+")}@${pkg.version}-`;
|
|
223
|
+
for (const f of fs.readdirSync(this.dir)) {
|
|
224
|
+
if (!f.startsWith(prefix)) continue;
|
|
225
|
+
const p = path.join(this.dir, f);
|
|
226
|
+
try {
|
|
227
|
+
if (Date.now() - fs.statSync(p).mtimeMs > 2 * INSTALL_TIMEOUT_MS) fs.rmSync(p, { recursive: true, force: true });
|
|
228
|
+
} catch {
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
now() {
|
|
233
|
+
return (this.o.now ?? Date.now)();
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* How to start an agent's adapter for a session in `cwd`. A pinned package that is installed runs from here; one
|
|
237
|
+
* whose install is running is waited for (at most `waitMs`); otherwise npx runs as configured, in this directory.
|
|
238
|
+
*/
|
|
239
|
+
async launch(cfg, cwd, env) {
|
|
240
|
+
const pkg = pinnedPackage(cfg);
|
|
241
|
+
if (!pkg) return { command: cfg.command, args: cfg.args, cwd, installed: false };
|
|
242
|
+
let bin = this.installed(pkg);
|
|
243
|
+
if (!bin) {
|
|
244
|
+
const failed = this.failedAt.get(pkg.spec);
|
|
245
|
+
if (failed === void 0) {
|
|
246
|
+
const job = this.o.autoInstall ? this.install(pkg, env) : this.pending.get(pkg.spec);
|
|
247
|
+
if (job) bin = await raceTimeout(job, this.o.waitMs ?? INSTALL_WAIT_MS);
|
|
248
|
+
} else if (this.o.autoInstall && !this.installing(pkg) && this.now() - failed >= RETRY_AFTER_MS) {
|
|
249
|
+
void this.install(pkg, env);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (bin) return runByNode(bin) ? { command: process.execPath, args: [bin, ...pkg.rest], cwd, installed: true } : { command: bin, args: pkg.rest, cwd, installed: true };
|
|
253
|
+
fs.mkdirSync(this.dir, { recursive: true, mode: 448 });
|
|
254
|
+
return { command: cfg.command, args: cfg.args, cwd: this.dir, installed: false };
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
function raceTimeout(p, ms) {
|
|
258
|
+
let timer;
|
|
259
|
+
const t = new Promise((resolve) => {
|
|
260
|
+
timer = setTimeout(() => resolve(null), ms);
|
|
261
|
+
timer.unref?.();
|
|
262
|
+
});
|
|
263
|
+
return Promise.race([p, t]).finally(() => clearTimeout(timer));
|
|
264
|
+
}
|
|
265
|
+
var store = null;
|
|
266
|
+
function useAdapterStore(s) {
|
|
267
|
+
store = s;
|
|
268
|
+
}
|
|
269
|
+
function adapterStore() {
|
|
270
|
+
return store;
|
|
271
|
+
}
|
|
272
|
+
function pinnedOf(agents, base = process.env) {
|
|
273
|
+
const seen = /* @__PURE__ */ new Map();
|
|
274
|
+
for (const a of agents) {
|
|
275
|
+
const pkg = pinnedPackage(a);
|
|
276
|
+
if (pkg && !seen.has(pkg.spec)) seen.set(pkg.spec, { pkg, env: { ...base, ...a.env } });
|
|
277
|
+
}
|
|
278
|
+
return [...seen.values()];
|
|
279
|
+
}
|
|
280
|
+
async function installAll(s, agents, base, say) {
|
|
281
|
+
for (const { pkg, env } of pinnedOf(agents, base)) {
|
|
282
|
+
if (s.installed(pkg)) {
|
|
283
|
+
say(`${pkg.spec} is installed`);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
await s.install(pkg, env, say);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
var adaptersDir = (stateDir) => path.join(stateDir, "adapters");
|
|
290
|
+
|
|
291
|
+
// src/audit.ts
|
|
292
|
+
import fs2 from "node:fs";
|
|
293
|
+
import path2 from "node:path";
|
|
294
|
+
|
|
14
295
|
// src/redact.ts
|
|
15
296
|
var MASK = "[hidden]";
|
|
16
297
|
var B64 = "[A-Za-z0-9+/=_-]";
|
|
@@ -58,12 +339,12 @@ function redactDeep(value) {
|
|
|
58
339
|
|
|
59
340
|
// src/audit.ts
|
|
60
341
|
function fileAudit(file, now = Date.now) {
|
|
61
|
-
|
|
342
|
+
fs2.mkdirSync(path2.dirname(file), { recursive: true, mode: 448 });
|
|
62
343
|
return {
|
|
63
344
|
record(kind, detail = {}) {
|
|
64
345
|
const line = JSON.stringify({ ts: new Date(now()).toISOString(), ...redactDeep(detail), kind });
|
|
65
346
|
try {
|
|
66
|
-
|
|
347
|
+
fs2.appendFileSync(file, line + "\n", { mode: 384 });
|
|
67
348
|
} catch {
|
|
68
349
|
process.stderr.write(`audit write failed: ${kind}
|
|
69
350
|
`);
|
|
@@ -74,65 +355,12 @@ function fileAudit(file, now = Date.now) {
|
|
|
74
355
|
|
|
75
356
|
// src/children.ts
|
|
76
357
|
import { execFileSync } from "node:child_process";
|
|
77
|
-
import
|
|
78
|
-
import
|
|
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
|
|
358
|
+
import fs3 from "node:fs";
|
|
359
|
+
import path3 from "node:path";
|
|
132
360
|
function processStart(pid) {
|
|
133
361
|
try {
|
|
134
362
|
if (process.platform === "linux") {
|
|
135
|
-
const stat =
|
|
363
|
+
const stat = fs3.readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
136
364
|
const rest = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
137
365
|
return rest[19] ?? null;
|
|
138
366
|
}
|
|
@@ -157,10 +385,10 @@ var ChildRegistry = class {
|
|
|
157
385
|
file;
|
|
158
386
|
recs = /* @__PURE__ */ new Map();
|
|
159
387
|
save() {
|
|
160
|
-
|
|
388
|
+
fs3.mkdirSync(path3.dirname(this.file), { recursive: true, mode: 448 });
|
|
161
389
|
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
162
|
-
|
|
163
|
-
|
|
390
|
+
fs3.writeFileSync(tmp, JSON.stringify([...this.recs.values()]), { mode: 384 });
|
|
391
|
+
fs3.renameSync(tmp, this.file);
|
|
164
392
|
}
|
|
165
393
|
add(rec) {
|
|
166
394
|
const full = { ...rec, start: rec.start ?? processStart(rec.pid) ?? "", started_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
@@ -251,7 +479,7 @@ var ChildRegistry = class {
|
|
|
251
479
|
};
|
|
252
480
|
function readRecords(file) {
|
|
253
481
|
try {
|
|
254
|
-
const raw = JSON.parse(
|
|
482
|
+
const raw = JSON.parse(fs3.readFileSync(file, "utf8"));
|
|
255
483
|
return Array.isArray(raw) ? raw.filter((r) => Number.isInteger(r?.pid) && Number.isInteger(r?.pgid) && r.pgid > 1) : [];
|
|
256
484
|
} catch {
|
|
257
485
|
return [];
|
|
@@ -335,11 +563,11 @@ var RevokedError = class extends Error {
|
|
|
335
563
|
}
|
|
336
564
|
};
|
|
337
565
|
var HttpError = class extends Error {
|
|
338
|
-
constructor(status2, code,
|
|
339
|
-
super(`${
|
|
566
|
+
constructor(status2, code, path20) {
|
|
567
|
+
super(`${path20} answered ${status2} ${code}`);
|
|
340
568
|
this.status = status2;
|
|
341
569
|
this.code = code;
|
|
342
|
-
this.path =
|
|
570
|
+
this.path = path20;
|
|
343
571
|
}
|
|
344
572
|
status;
|
|
345
573
|
code;
|
|
@@ -363,8 +591,8 @@ var ClawClient = class {
|
|
|
363
591
|
...extra
|
|
364
592
|
};
|
|
365
593
|
}
|
|
366
|
-
async send(method,
|
|
367
|
-
const url = this.o.server +
|
|
594
|
+
async send(method, path20, body, signal) {
|
|
595
|
+
const url = this.o.server + path20;
|
|
368
596
|
for (let attempt = 0; ; attempt++) {
|
|
369
597
|
const headers = await this.headers(body === void 0 ? {} : { "content-type": "application/json" });
|
|
370
598
|
let res;
|
|
@@ -376,7 +604,7 @@ var ClawClient = class {
|
|
|
376
604
|
signal: signal ?? AbortSignal.timeout(this.o.requestTimeoutMs ?? 2e4)
|
|
377
605
|
});
|
|
378
606
|
} catch (e) {
|
|
379
|
-
throw new TransportError(`${method} ${
|
|
607
|
+
throw new TransportError(`${method} ${path20}: ${errMessage(e)}`);
|
|
380
608
|
}
|
|
381
609
|
if (res.status !== 401) return res;
|
|
382
610
|
const err = await errorCode(res);
|
|
@@ -385,12 +613,12 @@ var ClawClient = class {
|
|
|
385
613
|
this.o.tokens.invalidate();
|
|
386
614
|
continue;
|
|
387
615
|
}
|
|
388
|
-
throw new HttpError(401, err,
|
|
616
|
+
throw new HttpError(401, err, path20);
|
|
389
617
|
}
|
|
390
618
|
}
|
|
391
|
-
async json(method,
|
|
392
|
-
const res = await this.send(method,
|
|
393
|
-
if (!res.ok) throw new HttpError(res.status, await errorCode(res),
|
|
619
|
+
async json(method, path20, body) {
|
|
620
|
+
const res = await this.send(method, path20, body);
|
|
621
|
+
if (!res.ok) throw new HttpError(res.status, await errorCode(res), path20);
|
|
394
622
|
const parsed = await res.json().catch(() => null);
|
|
395
623
|
return isObject(parsed) && isObject(parsed.data) ? parsed.data : {};
|
|
396
624
|
}
|
|
@@ -439,21 +667,21 @@ var ClawClient = class {
|
|
|
439
667
|
};
|
|
440
668
|
try {
|
|
441
669
|
arm();
|
|
442
|
-
const
|
|
670
|
+
const path20 = `/v1/remote-agents/connectors/${cid}/commands`;
|
|
443
671
|
const headers = await this.headers({ accept: "text/event-stream", ...opts.lastEventId ? { "last-event-id": String(opts.lastEventId) } : {} });
|
|
444
672
|
let res;
|
|
445
673
|
try {
|
|
446
|
-
res = await this.f(this.o.server +
|
|
674
|
+
res = await this.f(this.o.server + path20, { headers, signal: stop.signal });
|
|
447
675
|
} catch (e) {
|
|
448
|
-
throw new TransportError(timedOut ? `no response for ${opts.watchdogMs} ms` : `GET ${
|
|
676
|
+
throw new TransportError(timedOut ? `no response for ${opts.watchdogMs} ms` : `GET ${path20}: ${errMessage(e)}`);
|
|
449
677
|
}
|
|
450
678
|
if (res.status === 401) {
|
|
451
679
|
const err = await errorCode(res);
|
|
452
680
|
if (err === "revoked") throw new RevokedError();
|
|
453
681
|
this.o.tokens.invalidate();
|
|
454
|
-
throw new HttpError(401, err,
|
|
682
|
+
throw new HttpError(401, err, path20);
|
|
455
683
|
}
|
|
456
|
-
if (!res.ok || !res.body) throw new HttpError(res.status, await errorCode(res),
|
|
684
|
+
if (!res.ok || !res.body) throw new HttpError(res.status, await errorCode(res), path20);
|
|
457
685
|
try {
|
|
458
686
|
for await (const frame of readSse(res.body, arm)) yield frame;
|
|
459
687
|
} catch (e) {
|
|
@@ -473,33 +701,33 @@ async function errorCode(res) {
|
|
|
473
701
|
}
|
|
474
702
|
|
|
475
703
|
// src/config.ts
|
|
476
|
-
import
|
|
704
|
+
import fs5 from "node:fs";
|
|
477
705
|
import os from "node:os";
|
|
478
|
-
import
|
|
706
|
+
import path5 from "node:path";
|
|
479
707
|
import { parse as parseToml } from "smol-toml";
|
|
480
708
|
|
|
481
709
|
// src/roots.ts
|
|
482
|
-
import
|
|
483
|
-
import
|
|
710
|
+
import fs4 from "node:fs";
|
|
711
|
+
import path4 from "node:path";
|
|
484
712
|
function realRoot(root) {
|
|
485
713
|
try {
|
|
486
|
-
return
|
|
714
|
+
return fs4.realpathSync.native(root);
|
|
487
715
|
} catch {
|
|
488
716
|
return null;
|
|
489
717
|
}
|
|
490
718
|
}
|
|
491
|
-
var inside = (child, root) => child === root || child.startsWith(root.endsWith(
|
|
719
|
+
var inside = (child, root) => child === root || child.startsWith(root.endsWith(path4.sep) ? root : root + path4.sep);
|
|
492
720
|
function checkCwd(cwd, roots) {
|
|
493
721
|
if (typeof cwd !== "string" || cwd === "" || cwd.includes("\0")) return { ok: false, reason: "invalid_cwd" };
|
|
494
|
-
if (!
|
|
722
|
+
if (!path4.isAbsolute(cwd)) return { ok: false, reason: "cwd_not_absolute" };
|
|
495
723
|
let real;
|
|
496
724
|
try {
|
|
497
|
-
real =
|
|
725
|
+
real = fs4.realpathSync.native(cwd);
|
|
498
726
|
} catch {
|
|
499
727
|
return { ok: false, reason: "cwd_not_found" };
|
|
500
728
|
}
|
|
501
729
|
try {
|
|
502
|
-
if (!
|
|
730
|
+
if (!fs4.statSync(real).isDirectory()) return { ok: false, reason: "cwd_not_directory" };
|
|
503
731
|
} catch {
|
|
504
732
|
return { ok: false, reason: "cwd_not_found" };
|
|
505
733
|
}
|
|
@@ -510,19 +738,19 @@ function checkCwd(cwd, roots) {
|
|
|
510
738
|
return { ok: false, reason: "cwd_outside_roots" };
|
|
511
739
|
}
|
|
512
740
|
function relativeInside(p, cwd) {
|
|
513
|
-
const abs =
|
|
741
|
+
const abs = path4.resolve(cwd, p);
|
|
514
742
|
let real;
|
|
515
743
|
try {
|
|
516
|
-
real =
|
|
744
|
+
real = fs4.realpathSync.native(abs);
|
|
517
745
|
} catch {
|
|
518
746
|
try {
|
|
519
|
-
real =
|
|
747
|
+
real = path4.join(fs4.realpathSync.native(path4.dirname(abs)), path4.basename(abs));
|
|
520
748
|
} catch {
|
|
521
749
|
return null;
|
|
522
750
|
}
|
|
523
751
|
}
|
|
524
752
|
if (!inside(real, cwd)) return null;
|
|
525
|
-
return
|
|
753
|
+
return path4.relative(cwd, real).split(path4.sep).join("/");
|
|
526
754
|
}
|
|
527
755
|
|
|
528
756
|
// src/config.ts
|
|
@@ -585,7 +813,7 @@ var strList = (v, what, problems) => {
|
|
|
585
813
|
}
|
|
586
814
|
return v;
|
|
587
815
|
};
|
|
588
|
-
var expandHome = (p) => p === "~" ? os.homedir() : p.startsWith("~/") ?
|
|
816
|
+
var expandHome = (p) => p === "~" ? os.homedir() : p.startsWith("~/") ? path5.join(os.homedir(), p.slice(2)) : p;
|
|
589
817
|
function parseConfig(text, defaults = {}) {
|
|
590
818
|
const problems = [];
|
|
591
819
|
let doc;
|
|
@@ -652,10 +880,10 @@ function parseConfig(text, defaults = {}) {
|
|
|
652
880
|
} else asking.add(askMode);
|
|
653
881
|
}
|
|
654
882
|
if (asking.has(askMode) && !safeModes.includes(askMode)) safeModes.push(askMode);
|
|
655
|
-
const roots = strList(raw.roots, `${label}.roots`, problems).map((r) =>
|
|
883
|
+
const roots = strList(raw.roots, `${label}.roots`, problems).map((r) => path5.resolve(expandHome(r)));
|
|
656
884
|
if (roots.length === 0) problems.push(`${label}: roots is required (an agent with no allowed directory is never offered)`);
|
|
657
885
|
for (const r of roots) {
|
|
658
|
-
if (r ===
|
|
886
|
+
if (r === path5.parse(r).root) problems.push(`${label}: a root may not be the filesystem root`);
|
|
659
887
|
else if (!realRoot(r)) problems.push(`${label}: root ${r} does not exist`);
|
|
660
888
|
}
|
|
661
889
|
const env = {};
|
|
@@ -696,9 +924,50 @@ function parseConfig(text, defaults = {}) {
|
|
|
696
924
|
attach
|
|
697
925
|
});
|
|
698
926
|
});
|
|
927
|
+
if (Array.isArray(rawAgents)) agents.push(...existingSessionAgents(rawAgents, agents, problems));
|
|
699
928
|
if (problems.length) throw new ConfigError(problems);
|
|
700
929
|
return { server, identityUrl, name, allowUnsafeModes, dropMessagePatterns, agents };
|
|
701
930
|
}
|
|
931
|
+
var EXISTING_SESSIONS_SUFFIX = "-sessions";
|
|
932
|
+
function existingSessionAgents(rawAgents, agents, problems) {
|
|
933
|
+
const out = [];
|
|
934
|
+
const configDir = (a) => path5.resolve(expandHome(a.env.CLAUDE_CONFIG_DIR ?? process.env.CLAUDE_CONFIG_DIR ?? path5.join(os.homedir(), ".claude")));
|
|
935
|
+
const ids = new Set(agents.map((a) => a.id));
|
|
936
|
+
for (const raw of rawAgents) {
|
|
937
|
+
if (!isObject(raw) || typeof raw.id !== "string") continue;
|
|
938
|
+
const flag = raw.existing_sessions;
|
|
939
|
+
if (flag !== void 0 && typeof flag !== "boolean") {
|
|
940
|
+
problems.push(`agent ${raw.id}: existing_sessions must be true or false`);
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
943
|
+
if (flag !== void 0 && raw.preset !== "claude") {
|
|
944
|
+
problems.push(`agent ${raw.id}: existing_sessions only applies to an agent made from the claude preset`);
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
if (raw.preset !== "claude" || flag === false) continue;
|
|
948
|
+
const src = agents.find((a) => a.id === raw.id);
|
|
949
|
+
if (!src) continue;
|
|
950
|
+
if ([...agents, ...out].some((a) => a.attach && configDir(a) === configDir(src))) continue;
|
|
951
|
+
const id = `${src.id.slice(0, 64 - EXISTING_SESSIONS_SUFFIX.length)}${EXISTING_SESSIONS_SUFFIX}`;
|
|
952
|
+
if (ids.has(id)) continue;
|
|
953
|
+
ids.add(id);
|
|
954
|
+
const name = src.name === PRESETS.claude.name ? PRESETS["claude-attach"].name : `${src.name} (existing sessions)`.slice(0, 128);
|
|
955
|
+
out.push({
|
|
956
|
+
...src,
|
|
957
|
+
id,
|
|
958
|
+
kind: PRESETS["claude-attach"].kind,
|
|
959
|
+
name,
|
|
960
|
+
args: [...src.args],
|
|
961
|
+
roots: [...src.roots],
|
|
962
|
+
env: { ...src.env },
|
|
963
|
+
safeModes: [...src.safeModes],
|
|
964
|
+
askingModes: [...src.askingModes],
|
|
965
|
+
autoAllow: [...src.autoAllow],
|
|
966
|
+
attach: { cli: "claude" }
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
return out;
|
|
970
|
+
}
|
|
702
971
|
function parseAutoAllow(raw, at, problems) {
|
|
703
972
|
if (raw === void 0) return [];
|
|
704
973
|
if (!Array.isArray(raw)) {
|
|
@@ -710,7 +979,7 @@ function parseAutoAllow(raw, at, problems) {
|
|
|
710
979
|
if (!isObject(r) || typeof r.kind !== "string" || typeof r.path_glob !== "string" || !r.kind || !r.path_glob) {
|
|
711
980
|
return void problems.push(`${at}[${i}] needs a kind and a path_glob`);
|
|
712
981
|
}
|
|
713
|
-
if (
|
|
982
|
+
if (path5.isAbsolute(r.path_glob) || r.path_glob.split("/").includes("..")) {
|
|
714
983
|
return void problems.push(`${at}[${i}].path_glob must be relative to the working directory and may not contain ..`);
|
|
715
984
|
}
|
|
716
985
|
rules.push({ kind: r.kind, pathGlob: r.path_glob });
|
|
@@ -720,7 +989,7 @@ function parseAutoAllow(raw, at, problems) {
|
|
|
720
989
|
function loadConfig(file, defaults = {}) {
|
|
721
990
|
let text;
|
|
722
991
|
try {
|
|
723
|
-
text =
|
|
992
|
+
text = fs5.readFileSync(file, "utf8");
|
|
724
993
|
} catch (e) {
|
|
725
994
|
if (e.code === "ENOENT") throw new ConfigError([`${file} does not exist; run "claw-connector init" to create one`]);
|
|
726
995
|
throw e;
|
|
@@ -767,8 +1036,8 @@ ${SAMPLE_AGENTS.map(agent).join("\n\n")}
|
|
|
767
1036
|
var SAMPLE_CONFIG = sampleConfig();
|
|
768
1037
|
|
|
769
1038
|
// src/credentials.ts
|
|
770
|
-
import
|
|
771
|
-
import
|
|
1039
|
+
import fs6 from "node:fs";
|
|
1040
|
+
import path6 from "node:path";
|
|
772
1041
|
var DEFAULT_CLIENT_ID = "claw-connector";
|
|
773
1042
|
var CONNECTOR_SCOPE = "claw.connector";
|
|
774
1043
|
var CredentialsError = class extends Error {
|
|
@@ -783,20 +1052,20 @@ var TransientAuthError = class extends Error {
|
|
|
783
1052
|
function loadCredentials(file) {
|
|
784
1053
|
let fd;
|
|
785
1054
|
try {
|
|
786
|
-
fd =
|
|
1055
|
+
fd = fs6.openSync(file, "r");
|
|
787
1056
|
} catch (e) {
|
|
788
1057
|
if (e.code === "ENOENT") return null;
|
|
789
1058
|
throw e;
|
|
790
1059
|
}
|
|
791
1060
|
let text;
|
|
792
1061
|
try {
|
|
793
|
-
const st =
|
|
1062
|
+
const st = fs6.fstatSync(fd);
|
|
794
1063
|
if (process.platform !== "win32" && (st.mode & 63) !== 0) {
|
|
795
1064
|
throw new CredentialsError(`${file} is readable by other users (mode ${(st.mode & 511).toString(8)}); run: chmod 600 ${file}`);
|
|
796
1065
|
}
|
|
797
|
-
text =
|
|
1066
|
+
text = fs6.readFileSync(fd, "utf8");
|
|
798
1067
|
} finally {
|
|
799
|
-
|
|
1068
|
+
fs6.closeSync(fd);
|
|
800
1069
|
}
|
|
801
1070
|
let raw;
|
|
802
1071
|
try {
|
|
@@ -810,11 +1079,11 @@ function loadCredentials(file) {
|
|
|
810
1079
|
return raw;
|
|
811
1080
|
}
|
|
812
1081
|
function saveCredentials(file, creds) {
|
|
813
|
-
|
|
1082
|
+
fs6.mkdirSync(path6.dirname(file), { recursive: true, mode: 448 });
|
|
814
1083
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
1084
|
+
fs6.writeFileSync(tmp, JSON.stringify(creds, null, 2) + "\n", { mode: 384 });
|
|
1085
|
+
fs6.chmodSync(tmp, 384);
|
|
1086
|
+
fs6.renameSync(tmp, file);
|
|
818
1087
|
}
|
|
819
1088
|
var KeyTokenProvider = class {
|
|
820
1089
|
constructor(key) {
|
|
@@ -1004,10 +1273,9 @@ async function deviceLogin(o) {
|
|
|
1004
1273
|
}
|
|
1005
1274
|
|
|
1006
1275
|
// src/claude-mode.ts
|
|
1007
|
-
import
|
|
1276
|
+
import fs7 from "node:fs";
|
|
1008
1277
|
import os2 from "node:os";
|
|
1009
|
-
import
|
|
1010
|
-
var isClaude = (a) => !a.attach && [a.command, ...a.args].some((s) => s.includes("claude-agent-acp"));
|
|
1278
|
+
import path7 from "node:path";
|
|
1011
1279
|
function managedSettingsFile(platform = process.platform) {
|
|
1012
1280
|
if (platform === "darwin") return "/Library/Application Support/ClaudeCode/managed-settings.json";
|
|
1013
1281
|
if (platform === "win32") return "C:\\Program Files\\ClaudeCode\\managed-settings.json";
|
|
@@ -1016,7 +1284,7 @@ function managedSettingsFile(platform = process.platform) {
|
|
|
1016
1284
|
function readSettings(file, unreadable) {
|
|
1017
1285
|
let text;
|
|
1018
1286
|
try {
|
|
1019
|
-
text =
|
|
1287
|
+
text = fs7.readFileSync(file, "utf8");
|
|
1020
1288
|
} catch (e) {
|
|
1021
1289
|
if (e.code !== "ENOENT") unreadable.push(file);
|
|
1022
1290
|
return null;
|
|
@@ -1029,67 +1297,63 @@ function readSettings(file, unreadable) {
|
|
|
1029
1297
|
return null;
|
|
1030
1298
|
}
|
|
1031
1299
|
}
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
let from = null;
|
|
1046
|
-
let disable;
|
|
1047
|
-
for (const [source, file] of files) {
|
|
1048
|
-
const s = readSettings(file, unreadable);
|
|
1049
|
-
const perms = s && isObject(s.permissions) ? s.permissions : null;
|
|
1050
|
-
if (!perms) continue;
|
|
1051
|
-
if (perms.defaultMode !== void 0) {
|
|
1052
|
-
raw = perms.defaultMode;
|
|
1053
|
-
from = [source, file];
|
|
1054
|
-
}
|
|
1055
|
-
if (perms.disableBypassPermissionsMode !== void 0) disable = perms.disableBypassPermissionsMode;
|
|
1056
|
-
}
|
|
1057
|
-
let bypass = raw === "bypassPermissions" && from !== null && from[0] !== "project" && from[0] !== "local";
|
|
1058
|
-
if (bypass) {
|
|
1059
|
-
const euid = o.euid === void 0 ? process.geteuid?.() ?? null : o.euid;
|
|
1060
|
-
if (euid === 0 && !o.env.IS_SANDBOX || disable === "disable") bypass = false;
|
|
1061
|
-
}
|
|
1062
|
-
return { bypass, file: bypass ? from[1] : null, unreadable };
|
|
1063
|
-
}
|
|
1064
|
-
function noAskWarnings(agents, env, opts = {}) {
|
|
1300
|
+
var runsClaudeAdapter = (a) => [a.command, ...a.args].some((s) => s.includes("claude-agent-acp"));
|
|
1301
|
+
var currentEuid = (euid) => euid === void 0 ? process.geteuid?.() ?? null : euid;
|
|
1302
|
+
function applyRootSandbox(agents, env, opts = {}) {
|
|
1303
|
+
if (currentEuid(opts.euid) !== 0 || env.IS_SANDBOX) return [];
|
|
1304
|
+
const out = [];
|
|
1305
|
+
for (const a of agents) {
|
|
1306
|
+
if (!runsClaudeAdapter(a) || !a.allowUnsafeModes || Object.hasOwn(a.env, "IS_SANDBOX")) continue;
|
|
1307
|
+
a.env = { ...a.env, IS_SANDBOX: "1" };
|
|
1308
|
+
out.push(`${a.id}: running as root, where Claude Code offers bypassPermissions only with IS_SANDBOX set; set IS_SANDBOX=1 for this agent's adapter because it runs without per-step approval`);
|
|
1309
|
+
}
|
|
1310
|
+
return out;
|
|
1311
|
+
}
|
|
1312
|
+
function bypassUnavailableWarnings(agents, env, opts = {}) {
|
|
1065
1313
|
const out = [];
|
|
1314
|
+
const euid = currentEuid(opts.euid);
|
|
1066
1315
|
for (const a of agents) {
|
|
1067
|
-
if (!
|
|
1068
|
-
const
|
|
1069
|
-
if (
|
|
1070
|
-
out.push(`${a.id}: sessions will be refused (
|
|
1316
|
+
if (!runsClaudeAdapter(a) || !a.allowUnsafeModes || a.askMode !== "bypassPermissions") continue;
|
|
1317
|
+
const merged = { ...env, ...a.env };
|
|
1318
|
+
if (euid === 0 && !merged.IS_SANDBOX) {
|
|
1319
|
+
out.push(`${a.id}: sessions will be refused (mode_unavailable): Claude Code offers bypassPermissions to root only with IS_SANDBOX set, and this agent's env sets IS_SANDBOX to an empty value; remove IS_SANDBOX from its env, or set approval = "ask"`);
|
|
1320
|
+
continue;
|
|
1321
|
+
}
|
|
1322
|
+
const home = merged.HOME?.trim() || os2.homedir();
|
|
1323
|
+
const configDir = merged.CLAUDE_CONFIG_DIR?.trim() || path7.join(home, ".claude");
|
|
1324
|
+
const unreadable = [];
|
|
1325
|
+
for (const file of [path7.join(configDir, "settings.json"), opts.managedFile ?? managedSettingsFile()]) {
|
|
1326
|
+
const s = readSettings(file, unreadable);
|
|
1327
|
+
const perms = s && isObject(s.permissions) ? s.permissions : null;
|
|
1328
|
+
if (perms?.disableBypassPermissionsMode === "disable") {
|
|
1329
|
+
out.push(`${a.id}: sessions will be refused (mode_unavailable): permissions.disableBypassPermissionsMode is "disable" in ${file}, so Claude Code does not offer bypassPermissions; remove that setting, or set approval = "ask" on this agent`);
|
|
1330
|
+
break;
|
|
1331
|
+
}
|
|
1071
1332
|
}
|
|
1072
|
-
for (const f of r.unreadable) out.push(`${a.id}: cannot tell whether Claude defaults to bypassPermissions: ${f} is not readable JSON`);
|
|
1073
1333
|
}
|
|
1074
1334
|
return out;
|
|
1075
1335
|
}
|
|
1336
|
+
function claudeSessionMeta(a) {
|
|
1337
|
+
if (!runsClaudeAdapter(a) || a.attach || a.allowUnsafeModes) return {};
|
|
1338
|
+
return { _meta: { claudeCode: { options: { allowDangerouslySkipPermissions: false } } } };
|
|
1339
|
+
}
|
|
1076
1340
|
|
|
1077
1341
|
// src/hooks.ts
|
|
1078
|
-
import
|
|
1079
|
-
import
|
|
1342
|
+
import fs9 from "node:fs";
|
|
1343
|
+
import path9 from "node:path";
|
|
1080
1344
|
|
|
1081
1345
|
// src/terminals.ts
|
|
1082
|
-
import
|
|
1083
|
-
import
|
|
1346
|
+
import fs8 from "node:fs";
|
|
1347
|
+
import path8 from "node:path";
|
|
1084
1348
|
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;
|
|
1085
1349
|
var TERMINALS_DIR = "claw-connector-terminals";
|
|
1086
|
-
var terminalsDir = (configDir) =>
|
|
1350
|
+
var terminalsDir = (configDir) => path8.join(configDir, TERMINALS_DIR);
|
|
1087
1351
|
var UNREADABLE_GRACE_MS = 6e4;
|
|
1088
1352
|
var MAX_STDIN = 1 << 20;
|
|
1089
1353
|
function localPidDomain() {
|
|
1090
1354
|
if (process.platform !== "linux") return null;
|
|
1091
1355
|
try {
|
|
1092
|
-
return `linux:${
|
|
1356
|
+
return `linux:${fs8.readFileSync("/etc/machine-id", "utf8").trim()}:${fs8.readlinkSync("/proc/self/ns/pid")}`;
|
|
1093
1357
|
} catch {
|
|
1094
1358
|
return null;
|
|
1095
1359
|
}
|
|
@@ -1120,7 +1384,7 @@ function readTerminals(configDir, localDomain, now = Date.now()) {
|
|
|
1120
1384
|
const dir = terminalsDir(configDir);
|
|
1121
1385
|
let names;
|
|
1122
1386
|
try {
|
|
1123
|
-
names =
|
|
1387
|
+
names = fs8.readdirSync(dir);
|
|
1124
1388
|
} catch (e) {
|
|
1125
1389
|
if (e.code === "ENOENT") return [];
|
|
1126
1390
|
throw e;
|
|
@@ -1128,15 +1392,15 @@ function readTerminals(configDir, localDomain, now = Date.now()) {
|
|
|
1128
1392
|
const out = [];
|
|
1129
1393
|
for (const name of names) {
|
|
1130
1394
|
if (!name.endsWith(".json")) continue;
|
|
1131
|
-
const file =
|
|
1395
|
+
const file = path8.join(dir, name);
|
|
1132
1396
|
let doc;
|
|
1133
1397
|
try {
|
|
1134
|
-
doc = JSON.parse(
|
|
1398
|
+
doc = JSON.parse(fs8.readFileSync(file, "utf8"));
|
|
1135
1399
|
} catch (e) {
|
|
1136
1400
|
if (e.code === "ENOENT") continue;
|
|
1137
1401
|
let age = Infinity;
|
|
1138
1402
|
try {
|
|
1139
|
-
age = now -
|
|
1403
|
+
age = now - fs8.statSync(file).mtimeMs;
|
|
1140
1404
|
} catch {
|
|
1141
1405
|
}
|
|
1142
1406
|
if (age < UNREADABLE_GRACE_MS) throw new Error(`${file} is unreadable (${e.message.split("\n")[0]})`);
|
|
@@ -1163,19 +1427,19 @@ var shellQuote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
|
1163
1427
|
function hookCommand(node, script, arg, configDir) {
|
|
1164
1428
|
return `${shellQuote(node)} ${shellQuote(script)} ${arg} --dir ${shellQuote(configDir)} ${MANAGED_MARK}`;
|
|
1165
1429
|
}
|
|
1166
|
-
var settingsFile = (configDir) =>
|
|
1430
|
+
var settingsFile = (configDir) => path9.join(configDir, "settings.json");
|
|
1167
1431
|
var isObject3 = (x) => typeof x === "object" && x !== null && !Array.isArray(x);
|
|
1168
1432
|
var isOurs = (h) => isObject3(h) && h.type === "command" && typeof h.command === "string" && h.command.endsWith(` ${MANAGED_MARK}`) && OURS_RE.test(h.command);
|
|
1169
1433
|
function load(configDir) {
|
|
1170
1434
|
const given = settingsFile(configDir);
|
|
1171
1435
|
let file = given;
|
|
1172
1436
|
try {
|
|
1173
|
-
file =
|
|
1437
|
+
file = fs9.realpathSync(given);
|
|
1174
1438
|
} catch {
|
|
1175
1439
|
}
|
|
1176
1440
|
let text;
|
|
1177
1441
|
try {
|
|
1178
|
-
text =
|
|
1442
|
+
text = fs9.readFileSync(file, "utf8");
|
|
1179
1443
|
} catch (e) {
|
|
1180
1444
|
if (e.code === "ENOENT") return { doc: {}, text: null, file };
|
|
1181
1445
|
throw new HooksError(`cannot read ${given}: ${e.message}`);
|
|
@@ -1193,18 +1457,18 @@ function load(configDir) {
|
|
|
1193
1457
|
function save(l, doc) {
|
|
1194
1458
|
const indent = l.text?.match(/^([ \t]+)"/m)?.[1] ?? " ";
|
|
1195
1459
|
const body = JSON.stringify(doc, null, indent) + "\n";
|
|
1196
|
-
|
|
1460
|
+
fs9.mkdirSync(path9.dirname(l.file), { recursive: true, mode: 448 });
|
|
1197
1461
|
let mode = 384;
|
|
1198
1462
|
try {
|
|
1199
|
-
mode =
|
|
1463
|
+
mode = fs9.statSync(l.file).mode & 511;
|
|
1200
1464
|
} catch {
|
|
1201
1465
|
}
|
|
1202
1466
|
const tmp = `${l.file}.claw-connector.${process.pid}.tmp`;
|
|
1203
|
-
|
|
1467
|
+
fs9.writeFileSync(tmp, body, { mode });
|
|
1204
1468
|
try {
|
|
1205
|
-
|
|
1469
|
+
fs9.renameSync(tmp, l.file);
|
|
1206
1470
|
} catch (e) {
|
|
1207
|
-
|
|
1471
|
+
fs9.rmSync(tmp, { force: true });
|
|
1208
1472
|
throw e;
|
|
1209
1473
|
}
|
|
1210
1474
|
}
|
|
@@ -1284,7 +1548,7 @@ function uninstallHooks(configDir) {
|
|
|
1284
1548
|
if (touchedAny && Object.keys(hooks).length === 0) delete doc.hooks;
|
|
1285
1549
|
}
|
|
1286
1550
|
if (touchedAny) save(l, doc);
|
|
1287
|
-
|
|
1551
|
+
fs9.rmSync(terminalsDir(configDir), { recursive: true, force: true });
|
|
1288
1552
|
return { changed: touchedAny, file: l.file };
|
|
1289
1553
|
}
|
|
1290
1554
|
function hooksStatus(configDir) {
|
|
@@ -1307,7 +1571,7 @@ function hooksStatus(configDir) {
|
|
|
1307
1571
|
const m = /^'((?:[^']|'\\'')*)' '((?:[^']|'\\'')*)' /.exec(String(x.command));
|
|
1308
1572
|
if (!m) continue;
|
|
1309
1573
|
for (const f of [m[1], m[2]].map((s) => s.replace(/'\\''/g, "'"))) {
|
|
1310
|
-
if (!
|
|
1574
|
+
if (!fs9.existsSync(f)) {
|
|
1311
1575
|
healthy = false;
|
|
1312
1576
|
problem ??= `${f} (named by the ${h.event} hook) does not exist; run: claw-connector hooks install`;
|
|
1313
1577
|
}
|
|
@@ -1318,19 +1582,20 @@ function hooksStatus(configDir) {
|
|
|
1318
1582
|
}
|
|
1319
1583
|
|
|
1320
1584
|
// src/connector.ts
|
|
1321
|
-
import
|
|
1585
|
+
import fs15 from "node:fs";
|
|
1322
1586
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1323
1587
|
|
|
1324
1588
|
// src/attach.ts
|
|
1325
1589
|
import { execFile, execFileSync as execFileSync2 } from "node:child_process";
|
|
1326
|
-
import
|
|
1590
|
+
import fs11 from "node:fs";
|
|
1327
1591
|
import os3 from "node:os";
|
|
1328
|
-
import
|
|
1592
|
+
import path11 from "node:path";
|
|
1593
|
+
import { StringDecoder } from "node:string_decoder";
|
|
1329
1594
|
|
|
1330
1595
|
// src/adapter.ts
|
|
1331
|
-
import { spawn } from "node:child_process";
|
|
1332
|
-
import
|
|
1333
|
-
import
|
|
1596
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
1597
|
+
import fs10 from "node:fs";
|
|
1598
|
+
import path10 from "node:path";
|
|
1334
1599
|
import { Readable, Writable } from "node:stream";
|
|
1335
1600
|
import * as acp from "@agentclientprotocol/sdk";
|
|
1336
1601
|
var AdapterProcess = class _AdapterProcess {
|
|
@@ -1346,20 +1611,22 @@ var AdapterProcess = class _AdapterProcess {
|
|
|
1346
1611
|
exited = false;
|
|
1347
1612
|
conn;
|
|
1348
1613
|
static async start(o) {
|
|
1349
|
-
|
|
1350
|
-
const
|
|
1351
|
-
const
|
|
1352
|
-
|
|
1353
|
-
|
|
1614
|
+
fs10.mkdirSync(path10.dirname(o.logFile), { recursive: true, mode: 448 });
|
|
1615
|
+
const env = { ...process.env, ...o.cfg.env };
|
|
1616
|
+
const run2 = await adapterStore()?.launch(o.cfg, o.cwd, env) ?? { command: o.cfg.command, args: o.cfg.args, cwd: o.cwd };
|
|
1617
|
+
const log = fs10.openSync(o.logFile, "a", 384);
|
|
1618
|
+
const child = spawn2(run2.command, run2.args, {
|
|
1619
|
+
cwd: run2.cwd,
|
|
1620
|
+
env,
|
|
1354
1621
|
stdio: ["pipe", "pipe", "pipe"],
|
|
1355
1622
|
// Its own process group, so the whole tree (an npx wrapper and the agent below it) can be stopped as one.
|
|
1356
1623
|
detached: true
|
|
1357
1624
|
});
|
|
1358
1625
|
await new Promise((resolve, reject) => {
|
|
1359
1626
|
child.once("spawn", () => resolve());
|
|
1360
|
-
child.once("error", (e) => reject(new Error(`could not start ${
|
|
1627
|
+
child.once("error", (e) => reject(new Error(`could not start ${run2.command}: ${errMessage(e)}`)));
|
|
1361
1628
|
}).catch((e) => {
|
|
1362
|
-
|
|
1629
|
+
fs10.closeSync(log);
|
|
1363
1630
|
throw e;
|
|
1364
1631
|
});
|
|
1365
1632
|
const pid = child.pid;
|
|
@@ -1369,7 +1636,7 @@ var AdapterProcess = class _AdapterProcess {
|
|
|
1369
1636
|
const flushLine = (line) => {
|
|
1370
1637
|
const text = redactText(line).text;
|
|
1371
1638
|
try {
|
|
1372
|
-
|
|
1639
|
+
fs10.writeSync(log, text + "\n");
|
|
1373
1640
|
} catch {
|
|
1374
1641
|
}
|
|
1375
1642
|
try {
|
|
@@ -1395,7 +1662,7 @@ var AdapterProcess = class _AdapterProcess {
|
|
|
1395
1662
|
tail = "";
|
|
1396
1663
|
}
|
|
1397
1664
|
try {
|
|
1398
|
-
|
|
1665
|
+
fs10.closeSync(log);
|
|
1399
1666
|
} catch {
|
|
1400
1667
|
}
|
|
1401
1668
|
});
|
|
@@ -1511,6 +1778,9 @@ var SESSION_REF_RE = SESSION_ID_RE;
|
|
|
1511
1778
|
var MAX_REPORTED = 12;
|
|
1512
1779
|
var MAX_LIST_PAGES = 8;
|
|
1513
1780
|
var MAX_TITLE = 80;
|
|
1781
|
+
var MAX_WAITING_FOR = 60;
|
|
1782
|
+
var UNKNOWN_RELIST_MS = 2e4;
|
|
1783
|
+
var MAX_WATCHED_PROJECT_DIRS = 200;
|
|
1514
1784
|
var MAX_SCANNED_PROJECT_DIRS = 2e3;
|
|
1515
1785
|
var UNREADABLE_GRACE_MS2 = 6e4;
|
|
1516
1786
|
var NO_HOOKS = {
|
|
@@ -1531,7 +1801,7 @@ var AttachSupport = class {
|
|
|
1531
1801
|
constructor(agent, d) {
|
|
1532
1802
|
this.agent = agent;
|
|
1533
1803
|
this.d = d;
|
|
1534
|
-
this.configDir =
|
|
1804
|
+
this.configDir = path11.resolve(agent.env.CLAUDE_CONFIG_DIR ?? d.env.CLAUDE_CONFIG_DIR ?? path11.join(os3.homedir(), ".claude"));
|
|
1535
1805
|
}
|
|
1536
1806
|
agent;
|
|
1537
1807
|
d;
|
|
@@ -1590,7 +1860,7 @@ var AttachSupport = class {
|
|
|
1590
1860
|
sessionId: `list-${this.agent.id}`,
|
|
1591
1861
|
cwd: this.agent.roots[0] ?? os3.homedir(),
|
|
1592
1862
|
hooks: NO_HOOKS,
|
|
1593
|
-
logFile:
|
|
1863
|
+
logFile: path11.join(this.d.logDir, `adapter-${this.agent.id}.log`),
|
|
1594
1864
|
version: this.d.version,
|
|
1595
1865
|
initTimeoutMs: 3e4
|
|
1596
1866
|
});
|
|
@@ -1649,13 +1919,108 @@ var AttachSupport = class {
|
|
|
1649
1919
|
} else if (this.now() - this.listedAt > this.d.catalogMs) void this.refreshCatalog();
|
|
1650
1920
|
const live = await within(this.liveEntries().catch(() => null), capMs, null);
|
|
1651
1921
|
const attached = this.d.attachedRefs();
|
|
1652
|
-
return this.listed.slice(0, MAX_REPORTED).map((l) =>
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
cwd: l.cwd,
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
})
|
|
1922
|
+
return this.listed.slice(0, MAX_REPORTED).map((l) => {
|
|
1923
|
+
const holders = live?.filter((e) => e.sessionId.toLowerCase() === l.ref) ?? [];
|
|
1924
|
+
const state = attached.has(l.ref) ? "attached" : live === null ? "unknown" : holders.length ? "live" : "idle";
|
|
1925
|
+
const out = { ref: l.ref, title: l.title, cwd: l.cwd, updated_at: Math.floor(l.updatedAt / 1e3), state };
|
|
1926
|
+
if (state === "live") Object.assign(out, liveActivity(holders));
|
|
1927
|
+
return out;
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1930
|
+
// --- noticing a change without starting anything ----------------------------------------
|
|
1931
|
+
lastPrint = null;
|
|
1932
|
+
lastUnknownList = 0;
|
|
1933
|
+
/**
|
|
1934
|
+
* A cheap look, with no process started: the names and modification times of Claude Code's session registry files (one
|
|
1935
|
+
* is written when a terminal opens a conversation, rewritten when it starts or finishes a turn or starts waiting, and
|
|
1936
|
+
* removed when it exits) and of its projects directory (a new working directory). True when something changed since
|
|
1937
|
+
* the previous look, so that the caller reports at once rather than at the next heartbeat. A live conversation inside
|
|
1938
|
+
* the roots whose transcript exists but which the list does not have yet (a terminal just opened it), or a new transcript
|
|
1939
|
+
* in a project folder inside the roots (one a terminal ran and already ended), is listed again first, at most every
|
|
1940
|
+
* {@link UNKNOWN_RELIST_MS} (a change inside that time is listed when it is up). The first look only learns what is there.
|
|
1941
|
+
*/
|
|
1942
|
+
async poll() {
|
|
1943
|
+
const print = this.fingerprint();
|
|
1944
|
+
const projects = this.projectsPrint();
|
|
1945
|
+
if (print === this.lastPrint && projects === this.lastProjects && !this.relistDue) return false;
|
|
1946
|
+
const first = this.lastPrint === null;
|
|
1947
|
+
const looked = print !== this.lastPrint || projects !== this.lastProjects;
|
|
1948
|
+
const newTranscript = !first && projects !== this.lastProjects;
|
|
1949
|
+
this.lastPrint = print;
|
|
1950
|
+
this.lastProjects = projects;
|
|
1951
|
+
let rows;
|
|
1952
|
+
try {
|
|
1953
|
+
rows = readRegistry(this.configDir, this.localDomain, this.now());
|
|
1954
|
+
} catch {
|
|
1955
|
+
rows = null;
|
|
1956
|
+
}
|
|
1957
|
+
if (looked && (newTranscript || rows !== null && this.unlistedLive(rows))) this.relistDue = true;
|
|
1958
|
+
let relisted = false;
|
|
1959
|
+
if (this.relistDue && this.now() - this.lastUnknownList >= UNKNOWN_RELIST_MS) {
|
|
1960
|
+
this.relistDue = false;
|
|
1961
|
+
this.lastUnknownList = this.now();
|
|
1962
|
+
relisted = await within(this.refreshCatalog().then(() => true), 6e4, false);
|
|
1963
|
+
}
|
|
1964
|
+
const summary = (rows === null ? "unreadable;" : "") + this.listed.slice(0, MAX_REPORTED).map((l) => {
|
|
1965
|
+
const holders = (rows ?? []).filter((e) => e.sessionId.toLowerCase() === l.ref);
|
|
1966
|
+
return `${l.ref}:${holders.length ? liveActivity(holders).activity ?? "live" : "-"}:${liveActivity(holders).waiting_for ?? ""}`;
|
|
1967
|
+
}).join(",");
|
|
1968
|
+
const changed = summary !== this.lastSummary;
|
|
1969
|
+
this.lastSummary = summary;
|
|
1970
|
+
return changed && (!first || relisted);
|
|
1971
|
+
}
|
|
1972
|
+
lastSummary = "";
|
|
1973
|
+
lastProjects = null;
|
|
1974
|
+
/** A change asked for a new list that the rate limit has held back; the next look makes it. */
|
|
1975
|
+
relistDue = false;
|
|
1976
|
+
/**
|
|
1977
|
+
* The project folders inside the roots (Claude Code names one after the working directory, every non-alphanumeric
|
|
1978
|
+
* turned into "-") with their modification times: one changes when a conversation is started in it. At most
|
|
1979
|
+
* {@link MAX_WATCHED_PROJECT_DIRS} of them, so a huge config directory is not walked every few seconds.
|
|
1980
|
+
*/
|
|
1981
|
+
projectsPrint() {
|
|
1982
|
+
const projects = path11.join(this.configDir, "projects");
|
|
1983
|
+
const prefixes = this.agent.roots.map((r) => r.replace(/[^A-Za-z0-9]/g, "-"));
|
|
1984
|
+
const parts = [];
|
|
1985
|
+
try {
|
|
1986
|
+
parts.push(`projects:${fs11.statSync(projects).mtimeMs}`);
|
|
1987
|
+
for (const name of fs11.readdirSync(projects).sort()) {
|
|
1988
|
+
if (!prefixes.some((p) => name.startsWith(p))) continue;
|
|
1989
|
+
if (parts.length > MAX_WATCHED_PROJECT_DIRS) break;
|
|
1990
|
+
try {
|
|
1991
|
+
parts.push(`${name}:${fs11.statSync(path11.join(projects, name)).mtimeMs}`);
|
|
1992
|
+
} catch {
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
} catch {
|
|
1996
|
+
parts.push("no-projects");
|
|
1997
|
+
}
|
|
1998
|
+
return parts.join("|");
|
|
1999
|
+
}
|
|
2000
|
+
fingerprint() {
|
|
2001
|
+
const parts = [];
|
|
2002
|
+
const dir = path11.join(this.configDir, "sessions");
|
|
2003
|
+
try {
|
|
2004
|
+
for (const name of fs11.readdirSync(dir).sort()) {
|
|
2005
|
+
if (!name.endsWith(".json")) continue;
|
|
2006
|
+
try {
|
|
2007
|
+
parts.push(`${name}:${fs11.statSync(path11.join(dir, name)).mtimeMs}`);
|
|
2008
|
+
} catch {
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
} catch {
|
|
2012
|
+
parts.push("no-sessions");
|
|
2013
|
+
}
|
|
2014
|
+
return parts.join("|");
|
|
2015
|
+
}
|
|
2016
|
+
/** Whether a process holds open a conversation in the roots, with a transcript on disk, that the last list did not have. */
|
|
2017
|
+
unlistedLive(rows) {
|
|
2018
|
+
return rows.some((e) => {
|
|
2019
|
+
const ref = e.sessionId.toLowerCase();
|
|
2020
|
+
if (e.foreign || !SESSION_REF_RE.test(ref) || !e.cwd || this.listed.some((l) => l.ref === ref)) return false;
|
|
2021
|
+
if (!checkCwd(e.cwd, this.agent.roots).ok) return false;
|
|
2022
|
+
return fs11.existsSync(path11.join(this.configDir, "projects", e.cwd.replace(/[^A-Za-z0-9]/g, "-"), `${ref}.jsonl`));
|
|
2023
|
+
});
|
|
1659
2024
|
}
|
|
1660
2025
|
// --- the twin check ----------------------------------------------------------------
|
|
1661
2026
|
/**
|
|
@@ -1684,7 +2049,7 @@ var AttachSupport = class {
|
|
|
1684
2049
|
const file = this.transcriptFile(want);
|
|
1685
2050
|
let age = null;
|
|
1686
2051
|
try {
|
|
1687
|
-
if (file) age = this.now() -
|
|
2052
|
+
if (file) age = this.now() - fs11.statSync(file).mtimeMs;
|
|
1688
2053
|
} catch {
|
|
1689
2054
|
}
|
|
1690
2055
|
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` };
|
|
@@ -1739,7 +2104,19 @@ var AttachSupport = class {
|
|
|
1739
2104
|
transcriptSize(ref) {
|
|
1740
2105
|
const file = SESSION_REF_RE.test(ref) ? this.transcriptFile(ref.toLowerCase()) : null;
|
|
1741
2106
|
try {
|
|
1742
|
-
return file ?
|
|
2107
|
+
return file ? fs11.statSync(file).size : null;
|
|
2108
|
+
} catch {
|
|
2109
|
+
return null;
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
/** Where the conversation's transcript is, or null when it cannot be found (yet). */
|
|
2113
|
+
transcriptPath(ref) {
|
|
2114
|
+
const file = SESSION_REF_RE.test(ref) ? this.transcriptFile(ref.toLowerCase()) : null;
|
|
2115
|
+
if (!file) return null;
|
|
2116
|
+
try {
|
|
2117
|
+
const root = fs11.realpathSync(path11.join(this.configDir, "projects"));
|
|
2118
|
+
const real = fs11.realpathSync(file);
|
|
2119
|
+
return real.startsWith(root + path11.sep) && real.endsWith(`${ref.toLowerCase()}.jsonl`) ? real : null;
|
|
1743
2120
|
} catch {
|
|
1744
2121
|
return null;
|
|
1745
2122
|
}
|
|
@@ -1747,24 +2124,24 @@ var AttachSupport = class {
|
|
|
1747
2124
|
/** `<config dir>/projects/<working directory with every non-alphanumeric turned into "-">/<id>.jsonl`, found by name when that guess misses. */
|
|
1748
2125
|
transcriptFile(ref) {
|
|
1749
2126
|
const cached = this.transcripts.get(ref);
|
|
1750
|
-
if (cached &&
|
|
2127
|
+
if (cached && fs11.existsSync(cached)) return cached;
|
|
1751
2128
|
this.transcripts.delete(ref);
|
|
1752
|
-
const projects =
|
|
2129
|
+
const projects = path11.join(this.configDir, "projects");
|
|
1753
2130
|
const remember = (f) => {
|
|
1754
2131
|
this.transcripts.set(ref, f);
|
|
1755
2132
|
return f;
|
|
1756
2133
|
};
|
|
1757
2134
|
const known = this.listed.find((l) => l.ref === ref);
|
|
1758
2135
|
if (known) {
|
|
1759
|
-
const guess =
|
|
1760
|
-
if (
|
|
2136
|
+
const guess = path11.join(projects, known.cwd.replace(/[^A-Za-z0-9]/g, "-"), `${ref}.jsonl`);
|
|
2137
|
+
if (fs11.existsSync(guess)) return remember(guess);
|
|
1761
2138
|
}
|
|
1762
2139
|
try {
|
|
1763
2140
|
let n = 0;
|
|
1764
|
-
for (const dir of
|
|
2141
|
+
for (const dir of fs11.readdirSync(projects)) {
|
|
1765
2142
|
if (++n > MAX_SCANNED_PROJECT_DIRS) break;
|
|
1766
|
-
const f =
|
|
1767
|
-
if (
|
|
2143
|
+
const f = path11.join(projects, dir, `${ref}.jsonl`);
|
|
2144
|
+
if (fs11.existsSync(f)) return remember(f);
|
|
1768
2145
|
}
|
|
1769
2146
|
} catch {
|
|
1770
2147
|
}
|
|
@@ -1804,30 +2181,37 @@ function parseAgentsJson(text) {
|
|
|
1804
2181
|
const out = [];
|
|
1805
2182
|
for (const x of raw) {
|
|
1806
2183
|
if (isObject(x) && typeof x.pid === "number" && Number.isInteger(x.pid) && typeof x.sessionId === "string") {
|
|
1807
|
-
out.push({
|
|
2184
|
+
out.push({
|
|
2185
|
+
pid: x.pid,
|
|
2186
|
+
sessionId: x.sessionId,
|
|
2187
|
+
status: typeof x.status === "string" ? x.status : null,
|
|
2188
|
+
foreign: false,
|
|
2189
|
+
waitingFor: typeof x.waitingFor === "string" ? x.waitingFor : null,
|
|
2190
|
+
cwd: typeof x.cwd === "string" ? x.cwd : null
|
|
2191
|
+
});
|
|
1808
2192
|
}
|
|
1809
2193
|
}
|
|
1810
2194
|
return out;
|
|
1811
2195
|
}
|
|
1812
2196
|
function readRegistry(configDir, localDomain, now = Date.now()) {
|
|
1813
|
-
const dir =
|
|
2197
|
+
const dir = path11.join(configDir, "sessions");
|
|
1814
2198
|
let names;
|
|
1815
2199
|
try {
|
|
1816
|
-
names =
|
|
2200
|
+
names = fs11.readdirSync(dir);
|
|
1817
2201
|
} catch (e) {
|
|
1818
2202
|
throw new TwinCheckError(`${dir}: ${errMessage(e)}`);
|
|
1819
2203
|
}
|
|
1820
2204
|
const out = [];
|
|
1821
2205
|
for (const name of names) {
|
|
1822
2206
|
if (!name.endsWith(".json")) continue;
|
|
1823
|
-
const file =
|
|
2207
|
+
const file = path11.join(dir, name);
|
|
1824
2208
|
let doc;
|
|
1825
2209
|
try {
|
|
1826
|
-
doc = JSON.parse(
|
|
2210
|
+
doc = JSON.parse(fs11.readFileSync(file, "utf8"));
|
|
1827
2211
|
} catch (e) {
|
|
1828
2212
|
let age = Infinity;
|
|
1829
2213
|
try {
|
|
1830
|
-
age = now -
|
|
2214
|
+
age = now - fs11.statSync(file).mtimeMs;
|
|
1831
2215
|
} catch {
|
|
1832
2216
|
}
|
|
1833
2217
|
if (age < UNREADABLE_GRACE_MS2) throw new TwinCheckError(`${file} is unreadable (${errMessage(e).split("\n")[0]})`);
|
|
@@ -1840,14 +2224,21 @@ function readRegistry(configDir, localDomain, now = Date.now()) {
|
|
|
1840
2224
|
if (!isAlive(doc.pid)) continue;
|
|
1841
2225
|
if (process.platform === "linux" && typeof doc.procStart === "string" && processStart(doc.pid) !== doc.procStart) continue;
|
|
1842
2226
|
}
|
|
1843
|
-
out.push({
|
|
2227
|
+
out.push({
|
|
2228
|
+
pid: doc.pid,
|
|
2229
|
+
sessionId: doc.sessionId,
|
|
2230
|
+
status: typeof doc.status === "string" ? doc.status : null,
|
|
2231
|
+
foreign,
|
|
2232
|
+
waitingFor: typeof doc.waitingFor === "string" ? doc.waitingFor : null,
|
|
2233
|
+
cwd: typeof doc.cwd === "string" ? doc.cwd : null
|
|
2234
|
+
});
|
|
1844
2235
|
}
|
|
1845
2236
|
return out;
|
|
1846
2237
|
}
|
|
1847
2238
|
function processGroup(pid) {
|
|
1848
2239
|
try {
|
|
1849
2240
|
if (process.platform === "linux") {
|
|
1850
|
-
const stat =
|
|
2241
|
+
const stat = fs11.readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
1851
2242
|
return Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[2]) || null;
|
|
1852
2243
|
}
|
|
1853
2244
|
return Number(execFileSync2("ps", ["-o", "pgid=", "-p", String(pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim()) || null;
|
|
@@ -1855,6 +2246,16 @@ function processGroup(pid) {
|
|
|
1855
2246
|
return null;
|
|
1856
2247
|
}
|
|
1857
2248
|
}
|
|
2249
|
+
function liveActivity(holders) {
|
|
2250
|
+
const waiting = holders.find((e) => e.status === "waiting");
|
|
2251
|
+
if (waiting) {
|
|
2252
|
+
const why = waiting.waitingFor ? cleanTitle(waiting.waitingFor).slice(0, MAX_WAITING_FOR) : "";
|
|
2253
|
+
return why ? { activity: "waiting", waiting_for: why } : { activity: "waiting" };
|
|
2254
|
+
}
|
|
2255
|
+
if (holders.some((e) => e.status === "busy")) return { activity: "working" };
|
|
2256
|
+
if (holders.some((e) => e.status === "idle")) return { activity: "idle" };
|
|
2257
|
+
return {};
|
|
2258
|
+
}
|
|
1858
2259
|
function cleanTitle(raw) {
|
|
1859
2260
|
const one = redactText(raw).text.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
|
|
1860
2261
|
return one.length > MAX_TITLE ? `${one.slice(0, MAX_TITLE - 1)}\u2026` : one;
|
|
@@ -1865,70 +2266,223 @@ function fitResumable(base, items, limit = REMOTE_AGENT_LIMITS.maxCapabilitiesBy
|
|
|
1865
2266
|
for (; ; ) {
|
|
1866
2267
|
const caps = { ...base, resumable: items.slice(0, n) };
|
|
1867
2268
|
if (size(caps) <= limit) return caps;
|
|
1868
|
-
if (n === 0)
|
|
2269
|
+
if (n === 0) {
|
|
2270
|
+
const { watch_live: _watch, ...rest } = base;
|
|
2271
|
+
return size(base) > limit && size(rest) < size(base) ? rest : base;
|
|
2272
|
+
}
|
|
1869
2273
|
n--;
|
|
1870
2274
|
}
|
|
1871
2275
|
}
|
|
2276
|
+
var MAX_TOOL_TITLE = 200;
|
|
2277
|
+
function transcriptUpdates(line) {
|
|
2278
|
+
let doc;
|
|
2279
|
+
try {
|
|
2280
|
+
doc = JSON.parse(line);
|
|
2281
|
+
} catch {
|
|
2282
|
+
return [];
|
|
2283
|
+
}
|
|
2284
|
+
if (!isObject(doc) || doc.isSidechain === true || doc.isMeta === true) return [];
|
|
2285
|
+
if (doc.type !== "user" && doc.type !== "assistant") return [];
|
|
2286
|
+
const msg = isObject(doc.message) ? doc.message : null;
|
|
2287
|
+
if (!msg) return [];
|
|
2288
|
+
const text = (type, t) => t.trim() ? [{ type, update: { sessionUpdate: type, content: { type: "text", text: t } } }] : [];
|
|
2289
|
+
if (typeof msg.content === "string") return text(doc.type === "user" ? "user_message_chunk" : "agent_message_chunk", msg.content);
|
|
2290
|
+
if (!Array.isArray(msg.content)) return [];
|
|
2291
|
+
const out = [];
|
|
2292
|
+
for (const block of msg.content) {
|
|
2293
|
+
if (!isObject(block)) continue;
|
|
2294
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
2295
|
+
out.push(...text(doc.type === "user" ? "user_message_chunk" : "agent_message_chunk", block.text));
|
|
2296
|
+
} else if (doc.type === "assistant" && block.type === "tool_use" && typeof block.id === "string") {
|
|
2297
|
+
out.push({ type: "tool_call", update: { sessionUpdate: "tool_call", toolCallId: block.id, title: toolTitle(block.name, block.input), kind: "other", status: "in_progress", rawInput: isObject(block.input) ? block.input : {} } });
|
|
2298
|
+
} else if (doc.type === "user" && block.type === "tool_result" && typeof block.tool_use_id === "string") {
|
|
2299
|
+
const result = resultText(block.content);
|
|
2300
|
+
out.push({ type: "tool_call_update", update: {
|
|
2301
|
+
sessionUpdate: "tool_call_update",
|
|
2302
|
+
toolCallId: block.tool_use_id,
|
|
2303
|
+
status: block.is_error === true ? "failed" : "completed",
|
|
2304
|
+
...result ? { content: [{ type: "content", content: { type: "text", text: result } }] } : {}
|
|
2305
|
+
} });
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
return out;
|
|
2309
|
+
}
|
|
2310
|
+
function toolTitle(name, input) {
|
|
2311
|
+
const tool = typeof name === "string" && name ? name : "Tool";
|
|
2312
|
+
const i = isObject(input) ? input : {};
|
|
2313
|
+
const what = [i.description, i.command, i.file_path, i.path, i.pattern, i.url].find((v) => typeof v === "string" && v.trim() !== "");
|
|
2314
|
+
return cleanTitleTo(what ? `${tool}: ${what}` : tool, MAX_TOOL_TITLE);
|
|
2315
|
+
}
|
|
2316
|
+
function resultText(content) {
|
|
2317
|
+
if (typeof content === "string") return content;
|
|
2318
|
+
if (!Array.isArray(content)) return "";
|
|
2319
|
+
return content.filter((c) => isObject(c) && c.type === "text" && typeof c.text === "string").map((c) => c.text).join("\n");
|
|
2320
|
+
}
|
|
2321
|
+
function cleanTitleTo(raw, max) {
|
|
2322
|
+
const one = raw.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
|
|
2323
|
+
return one.length > max ? `${one.slice(0, max - 1)}\u2026` : one;
|
|
2324
|
+
}
|
|
2325
|
+
var TAIL_BACKLOG_BYTES = 64 * 1024;
|
|
2326
|
+
var TAIL_READ_BYTES = 256 * 1024;
|
|
2327
|
+
var TAIL_MAX_LINE = 1024 * 1024;
|
|
2328
|
+
var TAIL_BACKLOG_UPDATES = 20;
|
|
2329
|
+
var TranscriptTail = class {
|
|
2330
|
+
constructor(locate) {
|
|
2331
|
+
this.locate = locate;
|
|
2332
|
+
}
|
|
2333
|
+
locate;
|
|
2334
|
+
offset = null;
|
|
2335
|
+
partial = "";
|
|
2336
|
+
skipping = false;
|
|
2337
|
+
/** A read can end inside a character; the decoder keeps its first bytes for the next one. */
|
|
2338
|
+
decoder = new StringDecoder("utf8");
|
|
2339
|
+
/** The last updates already in the transcript (at most {@link TAIL_BACKLOG_UPDATES}); from here on `poll()` reads what is new. */
|
|
2340
|
+
backlog() {
|
|
2341
|
+
const file = this.locate();
|
|
2342
|
+
if (!file) return [];
|
|
2343
|
+
let fd = null;
|
|
2344
|
+
try {
|
|
2345
|
+
fd = fs11.openSync(file, "r");
|
|
2346
|
+
const size = fs11.fstatSync(fd).size;
|
|
2347
|
+
const start = Math.max(0, size - TAIL_BACKLOG_BYTES);
|
|
2348
|
+
const buf = Buffer.alloc(size - start);
|
|
2349
|
+
const n = buf.length ? fs11.readSync(fd, buf, 0, buf.length, start) : 0;
|
|
2350
|
+
this.offset = start + n;
|
|
2351
|
+
this.decoder = new StringDecoder("utf8");
|
|
2352
|
+
let text = this.decoder.write(buf.subarray(0, n));
|
|
2353
|
+
if (start > 0) text = text.slice(text.indexOf("\n") + 1);
|
|
2354
|
+
const end = text.lastIndexOf("\n");
|
|
2355
|
+
this.partial = end === text.length - 1 ? "" : text.slice(end + 1);
|
|
2356
|
+
const updates = text.slice(0, end + 1).split("\n").flatMap((l) => l ? transcriptUpdates(l) : []);
|
|
2357
|
+
return updates.slice(-TAIL_BACKLOG_UPDATES);
|
|
2358
|
+
} catch {
|
|
2359
|
+
return [];
|
|
2360
|
+
} finally {
|
|
2361
|
+
if (fd !== null) fs11.closeSync(fd);
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
/** Start from the end without reading what is there (a watcher that was already following, after a restart). */
|
|
2365
|
+
fromEnd() {
|
|
2366
|
+
const file = this.locate();
|
|
2367
|
+
try {
|
|
2368
|
+
this.offset = file ? fs11.statSync(file).size : null;
|
|
2369
|
+
} catch {
|
|
2370
|
+
this.offset = null;
|
|
2371
|
+
}
|
|
2372
|
+
this.partial = "";
|
|
2373
|
+
this.decoder = new StringDecoder("utf8");
|
|
2374
|
+
}
|
|
2375
|
+
/** What was appended since the last look. */
|
|
2376
|
+
poll() {
|
|
2377
|
+
const file = this.locate();
|
|
2378
|
+
if (!file) return [];
|
|
2379
|
+
let fd = null;
|
|
2380
|
+
try {
|
|
2381
|
+
fd = fs11.openSync(file, "r");
|
|
2382
|
+
const size = fs11.fstatSync(fd).size;
|
|
2383
|
+
if (this.offset === null) this.offset = 0;
|
|
2384
|
+
if (size < this.offset) {
|
|
2385
|
+
this.offset = size;
|
|
2386
|
+
this.partial = "";
|
|
2387
|
+
this.decoder = new StringDecoder("utf8");
|
|
2388
|
+
return [];
|
|
2389
|
+
}
|
|
2390
|
+
if (size === this.offset) return [];
|
|
2391
|
+
const buf = Buffer.alloc(Math.min(TAIL_READ_BYTES, size - this.offset));
|
|
2392
|
+
const n = fs11.readSync(fd, buf, 0, buf.length, this.offset);
|
|
2393
|
+
this.offset += n;
|
|
2394
|
+
return this.take(this.decoder.write(buf.subarray(0, n)));
|
|
2395
|
+
} catch {
|
|
2396
|
+
return [];
|
|
2397
|
+
} finally {
|
|
2398
|
+
if (fd !== null) fs11.closeSync(fd);
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
take(chunk) {
|
|
2402
|
+
const text = this.partial + chunk;
|
|
2403
|
+
const end = text.lastIndexOf("\n");
|
|
2404
|
+
if (end < 0) {
|
|
2405
|
+
this.partial = text;
|
|
2406
|
+
if (this.partial.length > TAIL_MAX_LINE) {
|
|
2407
|
+
this.partial = "";
|
|
2408
|
+
this.skipping = true;
|
|
2409
|
+
}
|
|
2410
|
+
return [];
|
|
2411
|
+
}
|
|
2412
|
+
this.partial = text.slice(end + 1);
|
|
2413
|
+
const lines = text.slice(0, end).split("\n");
|
|
2414
|
+
if (this.skipping) {
|
|
2415
|
+
lines.shift();
|
|
2416
|
+
this.skipping = false;
|
|
2417
|
+
}
|
|
2418
|
+
if (this.partial.length > TAIL_MAX_LINE) {
|
|
2419
|
+
this.partial = "";
|
|
2420
|
+
this.skipping = true;
|
|
2421
|
+
}
|
|
2422
|
+
return lines.flatMap((l) => l ? transcriptUpdates(l) : []);
|
|
2423
|
+
}
|
|
2424
|
+
};
|
|
1872
2425
|
|
|
1873
2426
|
// src/health.ts
|
|
1874
|
-
import
|
|
1875
|
-
import
|
|
2427
|
+
import fs12 from "node:fs";
|
|
2428
|
+
import path13 from "node:path";
|
|
1876
2429
|
|
|
1877
2430
|
// src/paths.ts
|
|
1878
2431
|
import os4 from "node:os";
|
|
1879
|
-
import
|
|
2432
|
+
import path12 from "node:path";
|
|
1880
2433
|
function resolveDirs(env = process.env, home = os4.homedir()) {
|
|
1881
2434
|
const one = env.CLAW_CONNECTOR_HOME?.trim();
|
|
1882
|
-
if (one) return { config:
|
|
1883
|
-
const cfg = env.XDG_CONFIG_HOME?.trim() ||
|
|
1884
|
-
const st = env.XDG_STATE_HOME?.trim() ||
|
|
1885
|
-
return { config:
|
|
1886
|
-
}
|
|
1887
|
-
var configFile = (d) =>
|
|
1888
|
-
var credentialsFile = (d) =>
|
|
1889
|
-
var identityFile = (d) =>
|
|
1890
|
-
var sessionsFile = (d) =>
|
|
1891
|
-
var childrenFile = (d) =>
|
|
1892
|
-
var pidFile = (d) =>
|
|
1893
|
-
var pauseFile = (d) =>
|
|
1894
|
-
var
|
|
1895
|
-
var
|
|
1896
|
-
var
|
|
1897
|
-
var
|
|
1898
|
-
var
|
|
1899
|
-
var
|
|
1900
|
-
var
|
|
1901
|
-
var
|
|
1902
|
-
var
|
|
2435
|
+
if (one) return { config: path12.join(one, "config"), state: path12.join(one, "state") };
|
|
2436
|
+
const cfg = env.XDG_CONFIG_HOME?.trim() || path12.join(home, ".config");
|
|
2437
|
+
const st = env.XDG_STATE_HOME?.trim() || path12.join(home, ".local", "state");
|
|
2438
|
+
return { config: path12.join(cfg, "claw-connector"), state: path12.join(st, "claw-connector") };
|
|
2439
|
+
}
|
|
2440
|
+
var configFile = (d) => path12.join(d.config, "agents.toml");
|
|
2441
|
+
var credentialsFile = (d) => path12.join(d.config, "credentials.json");
|
|
2442
|
+
var identityFile = (d) => path12.join(d.state, "connector.json");
|
|
2443
|
+
var sessionsFile = (d) => path12.join(d.state, "sessions.json");
|
|
2444
|
+
var childrenFile = (d) => path12.join(d.state, "children.json");
|
|
2445
|
+
var pidFile = (d) => path12.join(d.state, "connector.pid");
|
|
2446
|
+
var pauseFile = (d) => path12.join(d.state, "paused");
|
|
2447
|
+
var stopRequestFile = (d) => path12.join(d.state, "stop-requested.json");
|
|
2448
|
+
var auditFile = (d) => path12.join(d.state, "audit.jsonl");
|
|
2449
|
+
var outboxDir = (d) => path12.join(d.state, "outbox");
|
|
2450
|
+
var logDir = (d) => path12.join(d.state, "logs");
|
|
2451
|
+
var disconnectsFile = (d) => path12.join(d.state, "disconnects.jsonl");
|
|
2452
|
+
var needsLoginFile = (d) => path12.join(d.state, "needs-login.json");
|
|
2453
|
+
var supervisorPidFile = (d) => path12.join(d.state, "supervisor.pid");
|
|
2454
|
+
var runLogFile = (d) => path12.join(d.state, "run.log");
|
|
2455
|
+
var keepaliveFile = (d) => path12.join(d.state, "keepalive.sh");
|
|
2456
|
+
var serviceFile = (d) => path12.join(d.state, "service.json");
|
|
1903
2457
|
|
|
1904
2458
|
// src/health.ts
|
|
1905
2459
|
var MAX_BYTES = 128 * 1024;
|
|
1906
2460
|
function recordDisconnect(dirs, d) {
|
|
1907
2461
|
const file = disconnectsFile(dirs);
|
|
1908
2462
|
try {
|
|
1909
|
-
|
|
1910
|
-
const fd =
|
|
2463
|
+
fs12.mkdirSync(path13.dirname(file), { recursive: true, mode: 448 });
|
|
2464
|
+
const fd = fs12.openSync(file, "a+", 384);
|
|
1911
2465
|
let lines = null;
|
|
1912
2466
|
try {
|
|
1913
|
-
|
|
1914
|
-
const size =
|
|
2467
|
+
fs12.writeSync(fd, JSON.stringify({ at: d.at ?? (/* @__PURE__ */ new Date()).toISOString(), reason: d.reason, ...d.detail ? { detail: d.detail.slice(0, 300) } : {} }) + "\n");
|
|
2468
|
+
const size = fs12.fstatSync(fd).size;
|
|
1915
2469
|
if (size > MAX_BYTES) {
|
|
1916
2470
|
const buf = Buffer.alloc(size);
|
|
1917
2471
|
let got = 0;
|
|
1918
2472
|
while (got < size) {
|
|
1919
|
-
const n =
|
|
2473
|
+
const n = fs12.readSync(fd, buf, got, size - got, got);
|
|
1920
2474
|
if (n === 0) break;
|
|
1921
2475
|
got += n;
|
|
1922
2476
|
}
|
|
1923
2477
|
lines = buf.subarray(0, got).toString("utf8").split("\n").filter(Boolean);
|
|
1924
2478
|
}
|
|
1925
2479
|
} finally {
|
|
1926
|
-
|
|
2480
|
+
fs12.closeSync(fd);
|
|
1927
2481
|
}
|
|
1928
2482
|
if (lines) {
|
|
1929
2483
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
1930
|
-
|
|
1931
|
-
|
|
2484
|
+
fs12.writeFileSync(tmp, lines.slice(Math.floor(lines.length / 2)).join("\n") + "\n", { mode: 384 });
|
|
2485
|
+
fs12.renameSync(tmp, file);
|
|
1932
2486
|
}
|
|
1933
2487
|
} catch {
|
|
1934
2488
|
}
|
|
@@ -1936,7 +2490,7 @@ function recordDisconnect(dirs, d) {
|
|
|
1936
2490
|
function recentDisconnects(dirs, n = 1) {
|
|
1937
2491
|
try {
|
|
1938
2492
|
const out = [];
|
|
1939
|
-
for (const line of
|
|
2493
|
+
for (const line of fs12.readFileSync(disconnectsFile(dirs), "utf8").split("\n").filter(Boolean).slice(-n)) {
|
|
1940
2494
|
try {
|
|
1941
2495
|
const v = JSON.parse(line);
|
|
1942
2496
|
if (isObject(v) && typeof v.at === "string" && typeof v.reason === "string") out.push({ at: v.at, reason: v.reason, ...typeof v.detail === "string" ? { detail: v.detail } : {} });
|
|
@@ -1950,14 +2504,14 @@ function recentDisconnects(dirs, n = 1) {
|
|
|
1950
2504
|
}
|
|
1951
2505
|
function markNeedsLogin(dirs, reason) {
|
|
1952
2506
|
try {
|
|
1953
|
-
|
|
1954
|
-
|
|
2507
|
+
fs12.mkdirSync(dirs.state, { recursive: true, mode: 448 });
|
|
2508
|
+
fs12.writeFileSync(needsLoginFile(dirs), JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString(), reason: reason.slice(0, 300) }), { mode: 384 });
|
|
1955
2509
|
} catch {
|
|
1956
2510
|
}
|
|
1957
2511
|
}
|
|
1958
2512
|
function needsLogin(dirs) {
|
|
1959
2513
|
try {
|
|
1960
|
-
const v = JSON.parse(
|
|
2514
|
+
const v = JSON.parse(fs12.readFileSync(needsLoginFile(dirs), "utf8"));
|
|
1961
2515
|
if (isObject(v) && typeof v.reason === "string") return { at: typeof v.at === "string" ? v.at : "", reason: v.reason };
|
|
1962
2516
|
return { at: "", reason: "unreadable needs-login.json" };
|
|
1963
2517
|
} catch (e) {
|
|
@@ -1965,11 +2519,11 @@ function needsLogin(dirs) {
|
|
|
1965
2519
|
}
|
|
1966
2520
|
}
|
|
1967
2521
|
function clearNeedsLogin(dirs) {
|
|
1968
|
-
|
|
2522
|
+
fs12.rmSync(needsLoginFile(dirs), { force: true });
|
|
1969
2523
|
}
|
|
1970
2524
|
|
|
1971
2525
|
// src/session.ts
|
|
1972
|
-
import
|
|
2526
|
+
import path14 from "node:path";
|
|
1973
2527
|
|
|
1974
2528
|
// src/permissions.ts
|
|
1975
2529
|
import { randomUUID } from "node:crypto";
|
|
@@ -2097,7 +2651,15 @@ var PermissionBook = class {
|
|
|
2097
2651
|
};
|
|
2098
2652
|
|
|
2099
2653
|
// src/session.ts
|
|
2100
|
-
var DEFAULT_SESSION_TIMING = {
|
|
2654
|
+
var DEFAULT_SESSION_TIMING = {
|
|
2655
|
+
idleMs: 30 * 6e4,
|
|
2656
|
+
permissionTtlMs: 10 * 6e4,
|
|
2657
|
+
modeConfirmMs: 3e3,
|
|
2658
|
+
twinWatchMs: 1e3,
|
|
2659
|
+
spectateMs: 1e3,
|
|
2660
|
+
spectateCheckMs: 5e3
|
|
2661
|
+
};
|
|
2662
|
+
var READ_ONLY_REASON = "read_only_live_session";
|
|
2101
2663
|
var SessionRefusal = class extends Error {
|
|
2102
2664
|
constructor(reason, message) {
|
|
2103
2665
|
super(message ?? reason);
|
|
@@ -2105,6 +2667,7 @@ var SessionRefusal = class extends Error {
|
|
|
2105
2667
|
}
|
|
2106
2668
|
reason;
|
|
2107
2669
|
};
|
|
2670
|
+
var BYPASS_HINT = ` (Claude Code offers it to root only with IS_SANDBOX set, and never when permissions.disableBypassPermissionsMode is "disable"; "claw-connector doctor" names the fix)`;
|
|
2108
2671
|
var MAX_TOOL_OUTPUT_BYTES = 8 * 1024;
|
|
2109
2672
|
var MAX_QUEUED_PROMPTS = 5;
|
|
2110
2673
|
var PERMISSION_WARNING_RE = /canUseTool will not be invoked/i;
|
|
@@ -2162,6 +2725,15 @@ var Session = class {
|
|
|
2162
2725
|
queue = [];
|
|
2163
2726
|
launching = null;
|
|
2164
2727
|
idle;
|
|
2728
|
+
/**
|
|
2729
|
+
* The conversation is open in a terminal: this session only reads its transcript and shows it (see {@link spectate}).
|
|
2730
|
+
* No adapter is ever started for it, and every command that would write is refused.
|
|
2731
|
+
*/
|
|
2732
|
+
readOnly = false;
|
|
2733
|
+
tail = null;
|
|
2734
|
+
tailTimer;
|
|
2735
|
+
lastTerminalCheck = 0;
|
|
2736
|
+
checkingTerminal = false;
|
|
2165
2737
|
book;
|
|
2166
2738
|
// --- helpers -----------------------------------------------------------------
|
|
2167
2739
|
emit(type, data) {
|
|
@@ -2220,6 +2792,11 @@ var Session = class {
|
|
|
2220
2792
|
if (requestedMode && !this.agent.safeModes.includes(requestedMode) && !this.agent.allowUnsafeModes) {
|
|
2221
2793
|
return this.refuse(new SessionRefusal("mode_not_allowed", `mode ${requestedMode} needs approval = "never" for this agent (or allow_unsafe_modes) on this machine`));
|
|
2222
2794
|
}
|
|
2795
|
+
const support = this.env.attach.get(this.agent.id);
|
|
2796
|
+
if (support && this.acpId) {
|
|
2797
|
+
const verdict = await support.check(this.acpId, { transcript: false });
|
|
2798
|
+
if (!verdict.ok && verdict.reason === "session_live") return this.spectate(support, title, verdict.detail);
|
|
2799
|
+
}
|
|
2223
2800
|
if (requestedMode) this.targetMode = requestedMode;
|
|
2224
2801
|
try {
|
|
2225
2802
|
await this.launch("resume");
|
|
@@ -2232,6 +2809,85 @@ var Session = class {
|
|
|
2232
2809
|
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 });
|
|
2233
2810
|
this.touch();
|
|
2234
2811
|
}
|
|
2812
|
+
// --- read-only: a conversation open in a terminal ------------------------------------
|
|
2813
|
+
/**
|
|
2814
|
+
* Show a conversation that a terminal has open without taking it over. Nothing is started: the transcript the terminal
|
|
2815
|
+
* writes is read from its end (`TranscriptTail`, read-only) and each new line goes out as the update an adapter would
|
|
2816
|
+
* have sent, scrubbed like every other event, marked `read_only`. What was there just before is sent first, marked
|
|
2817
|
+
* `replay`. Prompts, mode changes and decisions are refused (`read_only_live_session`) and never reach anything that
|
|
2818
|
+
* could write to the transcript. When the terminal no longer has the conversation open the session ends
|
|
2819
|
+
* (`terminal_closed`): taking it over then is a new session, through the full check.
|
|
2820
|
+
*/
|
|
2821
|
+
spectate(support, title, detail) {
|
|
2822
|
+
this.readOnly = true;
|
|
2823
|
+
this.tail = new TranscriptTail(() => this.acpId ? support.transcriptPath(this.acpId) : null);
|
|
2824
|
+
const backlog = this.tail.backlog();
|
|
2825
|
+
this.env.store.put(this.id, { agent_id: this.agent.id, acp_session_id: this.acpId, cwd: this.cwd, status: "active", mode: null, read_only: true, created_at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
2826
|
+
this.audit("session_spectating", { cwd: this.cwd, acp_session_id: this.acpId, detail: detail.slice(0, 300) });
|
|
2827
|
+
this.emit("session_started", {
|
|
2828
|
+
acp_session_id: this.acpId,
|
|
2829
|
+
agent_id: this.agent.id,
|
|
2830
|
+
kind: this.agent.kind,
|
|
2831
|
+
cwd: this.cwd,
|
|
2832
|
+
title,
|
|
2833
|
+
attached: true,
|
|
2834
|
+
read_only: true,
|
|
2835
|
+
read_only_reason: "session_live",
|
|
2836
|
+
modes: [],
|
|
2837
|
+
currentMode: null,
|
|
2838
|
+
unattended: false
|
|
2839
|
+
});
|
|
2840
|
+
this.announced = true;
|
|
2841
|
+
for (const u of backlog) this.emitTranscript(u, true);
|
|
2842
|
+
this.followTail(support);
|
|
2843
|
+
}
|
|
2844
|
+
/** After a restart: a session that was read-only is again, from the transcript's end (what was sent before is not sent twice). */
|
|
2845
|
+
restoreSpectate() {
|
|
2846
|
+
this.readOnly = true;
|
|
2847
|
+
const support = this.env.attach.get(this.agent.id);
|
|
2848
|
+
if (!support) return;
|
|
2849
|
+
this.tail = new TranscriptTail(() => this.acpId ? support.transcriptPath(this.acpId) : null);
|
|
2850
|
+
this.tail.fromEnd();
|
|
2851
|
+
this.followTail(support);
|
|
2852
|
+
}
|
|
2853
|
+
followTail(support) {
|
|
2854
|
+
this.lastTerminalCheck = Date.now();
|
|
2855
|
+
const tick = () => {
|
|
2856
|
+
if (this.closed || !this.tail) return;
|
|
2857
|
+
for (const u of this.tail.poll()) this.emitTranscript(u, false);
|
|
2858
|
+
if (!this.checkingTerminal && Date.now() - this.lastTerminalCheck >= this.env.timing.spectateCheckMs) void this.checkTerminal(support);
|
|
2859
|
+
};
|
|
2860
|
+
this.tailTimer = setInterval(tick, this.env.timing.spectateMs);
|
|
2861
|
+
this.tailTimer.unref?.();
|
|
2862
|
+
}
|
|
2863
|
+
/** Is the conversation still open somewhere else? The cheap look first; only a look that finds nothing asks every source. */
|
|
2864
|
+
async checkTerminal(support) {
|
|
2865
|
+
if (!this.acpId) return;
|
|
2866
|
+
this.checkingTerminal = true;
|
|
2867
|
+
try {
|
|
2868
|
+
this.lastTerminalCheck = Date.now();
|
|
2869
|
+
if (support.peek(this.acpId, null)?.twin) return;
|
|
2870
|
+
const verdict = await support.check(this.acpId, { transcript: false });
|
|
2871
|
+
if (verdict.ok && !this.closed) {
|
|
2872
|
+
for (const u of this.tail?.poll() ?? []) this.emitTranscript(u, false);
|
|
2873
|
+
await this.close("terminal_closed");
|
|
2874
|
+
}
|
|
2875
|
+
} finally {
|
|
2876
|
+
this.checkingTerminal = false;
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
emitTranscript(u, replay) {
|
|
2880
|
+
const text = u.type === "agent_message_chunk" ? String(u.update.content?.text ?? "") : "";
|
|
2881
|
+
if (text && this.env.config.dropMessagePatterns.some((re) => re.test(text))) return;
|
|
2882
|
+
const data = limitStrings(redactDeep({ sessionId: this.acpId, update: u.update }), MAX_TOOL_OUTPUT_BYTES);
|
|
2883
|
+
this.emit(u.type, { ...data, read_only: true, ...replay ? { replay: true } : {} });
|
|
2884
|
+
}
|
|
2885
|
+
/** The process is stopping: the transcript is no longer followed (the session itself stays recorded, read-only). */
|
|
2886
|
+
stopTail() {
|
|
2887
|
+
clearInterval(this.tailTimer);
|
|
2888
|
+
this.tailTimer = void 0;
|
|
2889
|
+
this.tail = null;
|
|
2890
|
+
}
|
|
2235
2891
|
/** Start (or restart) the adapter and bring the ACP session up in a verified mode. One at a time. */
|
|
2236
2892
|
launch(how) {
|
|
2237
2893
|
this.launching ??= this.guardedLaunch(how).finally(() => {
|
|
@@ -2315,7 +2971,7 @@ var Session = class {
|
|
|
2315
2971
|
sessionId: this.id,
|
|
2316
2972
|
cwd: this.cwd,
|
|
2317
2973
|
hooks: this.hooks(),
|
|
2318
|
-
logFile:
|
|
2974
|
+
logFile: path14.join(this.env.logDir, `adapter-${this.agent.id}.log`),
|
|
2319
2975
|
version: this.env.version
|
|
2320
2976
|
}).catch((e) => {
|
|
2321
2977
|
throw new SessionRefusal("adapter_start_failed", errMessage(e));
|
|
@@ -2328,7 +2984,7 @@ var Session = class {
|
|
|
2328
2984
|
this.modeSeen = [];
|
|
2329
2985
|
try {
|
|
2330
2986
|
if (how === "new") {
|
|
2331
|
-
const res = await adapter.request("session/new", { cwd: this.cwd, mcpServers: [] });
|
|
2987
|
+
const res = await adapter.request("session/new", { cwd: this.cwd, mcpServers: [], ...claudeSessionMeta(this.agent) });
|
|
2332
2988
|
if (typeof res.sessionId !== "string") throw new SessionRefusal("adapter_protocol", "session/new returned no session id");
|
|
2333
2989
|
this.acpId = res.sessionId;
|
|
2334
2990
|
this.absorb(res);
|
|
@@ -2336,11 +2992,11 @@ var Session = class {
|
|
|
2336
2992
|
if (!this.acpId) throw new SessionRefusal("adapter_cannot_resume", "no adapter session to resume");
|
|
2337
2993
|
const caps = adapter.caps.sessionCapabilities;
|
|
2338
2994
|
if (caps?.resume) {
|
|
2339
|
-
this.absorb(await adapter.request("session/resume", { sessionId: this.acpId, cwd: this.cwd, mcpServers: [] }));
|
|
2995
|
+
this.absorb(await adapter.request("session/resume", { sessionId: this.acpId, cwd: this.cwd, mcpServers: [], ...claudeSessionMeta(this.agent) }));
|
|
2340
2996
|
} else if (adapter.caps.loadSession) {
|
|
2341
2997
|
this.replaying = true;
|
|
2342
2998
|
try {
|
|
2343
|
-
this.absorb(await adapter.requestUnbounded("session/load", { sessionId: this.acpId, cwd: this.cwd, mcpServers: [] }));
|
|
2999
|
+
this.absorb(await adapter.requestUnbounded("session/load", { sessionId: this.acpId, cwd: this.cwd, mcpServers: [], ...claudeSessionMeta(this.agent) }));
|
|
2344
3000
|
} finally {
|
|
2345
3001
|
this.replaying = false;
|
|
2346
3002
|
}
|
|
@@ -2381,7 +3037,7 @@ var Session = class {
|
|
|
2381
3037
|
async enforceMode(mode) {
|
|
2382
3038
|
const a = this.adapter;
|
|
2383
3039
|
if (!a || !this.acpId) throw new SessionRefusal("adapter_gone");
|
|
2384
|
-
if (this.modes.length && !this.modes.some((m) => m.id === mode)) throw new SessionRefusal("mode_unavailable", `the adapter offers no mode ${mode}`);
|
|
3040
|
+
if (this.modes.length && !this.modes.some((m) => m.id === mode)) throw new SessionRefusal("mode_unavailable", `the adapter offers no mode ${mode}${mode === "bypassPermissions" ? BYPASS_HINT : ""}`);
|
|
2385
3041
|
this.enforcing = true;
|
|
2386
3042
|
this.modeSeen = [];
|
|
2387
3043
|
try {
|
|
@@ -2437,6 +3093,7 @@ var Session = class {
|
|
|
2437
3093
|
}
|
|
2438
3094
|
/** `set_mode`: a user-chosen mode, only from the allowed list, always read back. */
|
|
2439
3095
|
async setMode(modeId, cmdId) {
|
|
3096
|
+
if (this.readOnly) return this.rejectCommand("set_mode", cmdId, READ_ONLY_REASON);
|
|
2440
3097
|
if (!this.adapter) await this.ensureAdapter();
|
|
2441
3098
|
if (!this.modeAllowed(modeId)) return this.rejectCommand("set_mode", cmdId, "mode_not_allowed");
|
|
2442
3099
|
if (this.modes.length && !this.modes.some((m) => m.id === modeId)) return this.rejectCommand("set_mode", cmdId, "mode_unavailable");
|
|
@@ -2616,6 +3273,12 @@ var Session = class {
|
|
|
2616
3273
|
this.rejectCommand("prompt", cmdId, "session_closed", cmdSeq);
|
|
2617
3274
|
return started();
|
|
2618
3275
|
}
|
|
3276
|
+
if (this.readOnly) {
|
|
3277
|
+
this.audit("prompt_refused", { reason: READ_ONLY_REASON });
|
|
3278
|
+
this.rejectCommand("prompt", cmdId, READ_ONLY_REASON, cmdSeq);
|
|
3279
|
+
this.emit("turn_end", { stopReason: "error", error: READ_ONLY_REASON, read_only: true, cmd_id: cmdId, cmd_seq: cmdSeq });
|
|
3280
|
+
return started();
|
|
3281
|
+
}
|
|
2619
3282
|
if (this.queue.length >= MAX_QUEUED_PROMPTS) {
|
|
2620
3283
|
this.rejectCommand("prompt", cmdId, "too_many_queued_prompts", cmdSeq);
|
|
2621
3284
|
return started();
|
|
@@ -2732,6 +3395,10 @@ var Session = class {
|
|
|
2732
3395
|
/** `resume`: bring the session back. A live adapter is NOT asked to resume (that would reset its mode); the mode is re-asserted instead. */
|
|
2733
3396
|
async resume(cmdId) {
|
|
2734
3397
|
if (this.closed) return this.rejectCommand("resume", cmdId, "session_closed");
|
|
3398
|
+
if (this.readOnly) {
|
|
3399
|
+
this.emit("session_resumed", { acp_session_id: this.acpId, read_only: true, modes: [], currentMode: null, unattended: false });
|
|
3400
|
+
return;
|
|
3401
|
+
}
|
|
2735
3402
|
try {
|
|
2736
3403
|
if (this.adapter && !this.adapter.exited) await this.enforceMode(this.targetMode);
|
|
2737
3404
|
else await this.launch("resume");
|
|
@@ -2774,6 +3441,7 @@ var Session = class {
|
|
|
2774
3441
|
if (this.closed) return;
|
|
2775
3442
|
this.closed = true;
|
|
2776
3443
|
clearTimeout(this.idle);
|
|
3444
|
+
this.stopTail();
|
|
2777
3445
|
this.dropQueued();
|
|
2778
3446
|
for (const actionId of this.book.cancelAll()) this.emit("permission_resolved", { actionId, optionId: null, outcome: "cancelled", by: reason });
|
|
2779
3447
|
const a = this.adapter;
|
|
@@ -2916,6 +3584,10 @@ var SessionManager = class {
|
|
|
2916
3584
|
async sessionNew(cmd) {
|
|
2917
3585
|
const sid = cmd.session_id;
|
|
2918
3586
|
if (this.sessions.has(sid)) return;
|
|
3587
|
+
if (this.env.store.get(sid)?.read_only) {
|
|
3588
|
+
await this.lookup(sid);
|
|
3589
|
+
return;
|
|
3590
|
+
}
|
|
2919
3591
|
const agent = this.agent(cmd.agent_id);
|
|
2920
3592
|
if (!agent) {
|
|
2921
3593
|
this.env.audit.record("session_refused", { session_id: sid, reason: "unknown_agent", agent_id: String(cmd.agent_id).slice(0, 64) });
|
|
@@ -2963,7 +3635,7 @@ var SessionManager = class {
|
|
|
2963
3635
|
if (!support) return refuse("attach_unavailable");
|
|
2964
3636
|
const want = ref.toLowerCase();
|
|
2965
3637
|
const claim = `${agent.id}:${want}`;
|
|
2966
|
-
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");
|
|
3638
|
+
if (this.attaching.has(claim) || [...this.sessions.values()].some((s) => !s.closed && !s.readOnly && s.agent.id === agent.id && s.acpId?.toLowerCase() === want)) return refuse("already_attached");
|
|
2967
3639
|
if ([...this.sessions.values()].filter((s) => !s.closed).length + this.attaching.size >= MAX_ACTIVE_SESSIONS) return refuse("too_many_sessions");
|
|
2968
3640
|
this.attaching.add(claim);
|
|
2969
3641
|
try {
|
|
@@ -2983,7 +3655,7 @@ var SessionManager = class {
|
|
|
2983
3655
|
/** The ids of the existing sessions this connector holds open, by agent. */
|
|
2984
3656
|
attachedRefs(agentId) {
|
|
2985
3657
|
const out = /* @__PURE__ */ new Set();
|
|
2986
|
-
for (const s of this.sessions.values()) if (!s.closed && s.agent.id === agentId && s.acpId) out.add(s.acpId.toLowerCase());
|
|
3658
|
+
for (const s of this.sessions.values()) if (!s.closed && !s.readOnly && s.agent.id === agentId && s.acpId) out.add(s.acpId.toLowerCase());
|
|
2987
3659
|
return out;
|
|
2988
3660
|
}
|
|
2989
3661
|
/** A session this process knows, or one it made earlier (before a restart) and can bring back. */
|
|
@@ -3002,8 +3674,16 @@ var SessionManager = class {
|
|
|
3002
3674
|
}
|
|
3003
3675
|
const s = new Session(sid, agent, verdict.real, this.env, rec.acp_session_id, rec.mode);
|
|
3004
3676
|
this.sessions.set(sid, s);
|
|
3677
|
+
if (rec.read_only) s.restoreSpectate();
|
|
3005
3678
|
return s;
|
|
3006
3679
|
}
|
|
3680
|
+
/** At start: every session that was watching a terminal's conversation goes on watching it (and notices when it ends). */
|
|
3681
|
+
async restoreReadOnly() {
|
|
3682
|
+
for (const sid of this.env.store.ids()) {
|
|
3683
|
+
const rec = this.env.store.get(sid);
|
|
3684
|
+
if (rec?.read_only && rec.status === "active" && !this.sessions.has(sid)) await this.lookup(sid);
|
|
3685
|
+
}
|
|
3686
|
+
}
|
|
3007
3687
|
/** After the command stream reconnects: cards still waiting are shown again. */
|
|
3008
3688
|
resendPending() {
|
|
3009
3689
|
for (const s of this.sessions.values()) s.resendPending();
|
|
@@ -3016,6 +3696,7 @@ var SessionManager = class {
|
|
|
3016
3696
|
for (const s of this.sessions.values()) {
|
|
3017
3697
|
this.env.audit.record("session_killed", { session_id: s.id, reason });
|
|
3018
3698
|
s.dropQueued();
|
|
3699
|
+
s.stopTail();
|
|
3019
3700
|
}
|
|
3020
3701
|
await this.env.registry.killAll();
|
|
3021
3702
|
this.sessions.clear();
|
|
@@ -3023,8 +3704,8 @@ var SessionManager = class {
|
|
|
3023
3704
|
};
|
|
3024
3705
|
|
|
3025
3706
|
// src/outbox.ts
|
|
3026
|
-
import
|
|
3027
|
-
import
|
|
3707
|
+
import fs13 from "node:fs";
|
|
3708
|
+
import path15 from "node:path";
|
|
3028
3709
|
var DROPPABLE_TYPES = /* @__PURE__ */ new Set([
|
|
3029
3710
|
"agent_message_chunk",
|
|
3030
3711
|
"agent_thought_chunk",
|
|
@@ -3051,35 +3732,35 @@ var Outbox = class {
|
|
|
3051
3732
|
this.dir = opts.dir;
|
|
3052
3733
|
this.maxBytes = opts.maxBytesPerSession ?? 5 * 1024 * 1024;
|
|
3053
3734
|
this.now = opts.now ?? Date.now;
|
|
3054
|
-
|
|
3735
|
+
fs13.mkdirSync(this.dir, { recursive: true, mode: 448 });
|
|
3055
3736
|
this.load();
|
|
3056
3737
|
}
|
|
3057
|
-
eventsFile = (sid) =>
|
|
3058
|
-
metaFile = (sid) =>
|
|
3738
|
+
eventsFile = (sid) => path15.join(this.dir, `${safeName(sid)}.jsonl`);
|
|
3739
|
+
metaFile = (sid) => path15.join(this.dir, `${safeName(sid)}.meta.json`);
|
|
3059
3740
|
writeMeta(sid) {
|
|
3060
3741
|
const tmp = this.metaFile(sid) + ".tmp";
|
|
3061
|
-
|
|
3062
|
-
|
|
3742
|
+
fs13.writeFileSync(tmp, JSON.stringify(this.meta.get(sid)), { mode: 384 });
|
|
3743
|
+
fs13.renameSync(tmp, this.metaFile(sid));
|
|
3063
3744
|
}
|
|
3064
3745
|
/** Rebuild the queue from disk. The highest sequence number ever used is the larger of the meta file and the log itself. */
|
|
3065
3746
|
load() {
|
|
3066
3747
|
const loaded = [];
|
|
3067
3748
|
const bases = /* @__PURE__ */ new Set();
|
|
3068
|
-
for (const name of
|
|
3749
|
+
for (const name of fs13.readdirSync(this.dir)) {
|
|
3069
3750
|
if (name.endsWith(".meta.json")) bases.add(name.slice(0, -".meta.json".length));
|
|
3070
3751
|
else if (name.endsWith(".jsonl")) bases.add(name.slice(0, -".jsonl".length));
|
|
3071
3752
|
}
|
|
3072
3753
|
for (const base of bases) {
|
|
3073
3754
|
let meta = { lastSeq: 0, acked: 0 };
|
|
3074
3755
|
try {
|
|
3075
|
-
meta = JSON.parse(
|
|
3756
|
+
meta = JSON.parse(fs13.readFileSync(path15.join(this.dir, `${base}.meta.json`), "utf8"));
|
|
3076
3757
|
} catch {
|
|
3077
3758
|
}
|
|
3078
3759
|
let sid = null;
|
|
3079
|
-
const file =
|
|
3760
|
+
const file = path15.join(this.dir, `${base}.jsonl`);
|
|
3080
3761
|
let text = "";
|
|
3081
3762
|
try {
|
|
3082
|
-
text =
|
|
3763
|
+
text = fs13.readFileSync(file, "utf8");
|
|
3083
3764
|
} catch {
|
|
3084
3765
|
}
|
|
3085
3766
|
for (const line of text.split("\n")) {
|
|
@@ -3097,8 +3778,8 @@ var Outbox = class {
|
|
|
3097
3778
|
if (sid === null) {
|
|
3098
3779
|
sid = base;
|
|
3099
3780
|
try {
|
|
3100
|
-
if (this.now() -
|
|
3101
|
-
for (const f of [`${base}.meta.json`, `${base}.jsonl`])
|
|
3781
|
+
if (this.now() - fs13.statSync(path15.join(this.dir, `${base}.meta.json`)).mtimeMs > TOMBSTONE_MS) {
|
|
3782
|
+
for (const f of [`${base}.meta.json`, `${base}.jsonl`]) fs13.rmSync(path15.join(this.dir, f), { force: true });
|
|
3102
3783
|
continue;
|
|
3103
3784
|
}
|
|
3104
3785
|
} catch {
|
|
@@ -3120,7 +3801,7 @@ var Outbox = class {
|
|
|
3120
3801
|
meta.lastSeq = ev.seq;
|
|
3121
3802
|
this.meta.set(sessionId, meta);
|
|
3122
3803
|
const line = JSON.stringify(ev);
|
|
3123
|
-
|
|
3804
|
+
fs13.appendFileSync(this.eventsFile(sessionId), line + "\n", { mode: 384 });
|
|
3124
3805
|
if (ev.seq === 1) this.writeMeta(sessionId);
|
|
3125
3806
|
this.queue.push(ev);
|
|
3126
3807
|
this.bytes.set(sessionId, (this.bytes.get(sessionId) ?? 0) + line.length);
|
|
@@ -3149,8 +3830,8 @@ var Outbox = class {
|
|
|
3149
3830
|
this.writeMeta(sessionId);
|
|
3150
3831
|
const mine = this.queue.filter((e) => e.session_id === sessionId);
|
|
3151
3832
|
const tmp = this.eventsFile(sessionId) + ".tmp";
|
|
3152
|
-
|
|
3153
|
-
|
|
3833
|
+
fs13.writeFileSync(tmp, mine.map((e) => JSON.stringify(e) + "\n").join(""), { mode: 384 });
|
|
3834
|
+
fs13.renameSync(tmp, this.eventsFile(sessionId));
|
|
3154
3835
|
}
|
|
3155
3836
|
/** The oldest events not yet acknowledged, in the order they were appended. */
|
|
3156
3837
|
pending(max) {
|
|
@@ -3188,7 +3869,7 @@ var Outbox = class {
|
|
|
3188
3869
|
this.writeMeta(sid);
|
|
3189
3870
|
const left = this.queue.filter((e) => e.session_id === sid);
|
|
3190
3871
|
if (left.length === 0) {
|
|
3191
|
-
|
|
3872
|
+
fs13.writeFileSync(this.eventsFile(sid), "", { mode: 384 });
|
|
3192
3873
|
this.bytes.set(sid, 0);
|
|
3193
3874
|
if (this.closed.has(sid)) this.forget(sid);
|
|
3194
3875
|
} else if (left.length < this.queue.length / 2 || meta.acked > 1e3) {
|
|
@@ -3207,7 +3888,7 @@ var Outbox = class {
|
|
|
3207
3888
|
* repeat. What stays is the small meta file, a tombstone, removed after {@link TOMBSTONE_MS}.
|
|
3208
3889
|
*/
|
|
3209
3890
|
forget(sid) {
|
|
3210
|
-
|
|
3891
|
+
fs13.rmSync(this.eventsFile(sid), { force: true });
|
|
3211
3892
|
if (this.meta.has(sid)) this.writeMeta(sid);
|
|
3212
3893
|
this.bytes.delete(sid);
|
|
3213
3894
|
this.closed.delete(sid);
|
|
@@ -3215,19 +3896,19 @@ var Outbox = class {
|
|
|
3215
3896
|
};
|
|
3216
3897
|
|
|
3217
3898
|
// src/session-store.ts
|
|
3218
|
-
import
|
|
3219
|
-
import
|
|
3899
|
+
import fs14 from "node:fs";
|
|
3900
|
+
import path16 from "node:path";
|
|
3220
3901
|
var SessionStore = class {
|
|
3221
3902
|
constructor(file) {
|
|
3222
3903
|
this.file = file;
|
|
3223
3904
|
this.pendingFile = `${file}.pending`;
|
|
3224
3905
|
try {
|
|
3225
|
-
const raw = JSON.parse(
|
|
3906
|
+
const raw = JSON.parse(fs14.readFileSync(this.pendingFile, "utf8"));
|
|
3226
3907
|
if (Array.isArray(raw)) this.pending = raw.filter((p) => p && typeof p.session_id === "string" && typeof p.action_id === "string");
|
|
3227
3908
|
} catch {
|
|
3228
3909
|
}
|
|
3229
3910
|
try {
|
|
3230
|
-
const raw = JSON.parse(
|
|
3911
|
+
const raw = JSON.parse(fs14.readFileSync(file, "utf8"));
|
|
3231
3912
|
if (raw && typeof raw === "object" && !Array.isArray(raw)) this.recs = raw;
|
|
3232
3913
|
} catch {
|
|
3233
3914
|
}
|
|
@@ -3270,23 +3951,24 @@ var SessionStore = class {
|
|
|
3270
3951
|
return out;
|
|
3271
3952
|
}
|
|
3272
3953
|
savePending() {
|
|
3273
|
-
|
|
3954
|
+
fs14.mkdirSync(path16.dirname(this.pendingFile), { recursive: true, mode: 448 });
|
|
3274
3955
|
const tmp = `${this.pendingFile}.${process.pid}.tmp`;
|
|
3275
|
-
|
|
3276
|
-
|
|
3956
|
+
fs14.writeFileSync(tmp, JSON.stringify(this.pending), { mode: 384 });
|
|
3957
|
+
fs14.renameSync(tmp, this.pendingFile);
|
|
3277
3958
|
}
|
|
3278
3959
|
ids() {
|
|
3279
3960
|
return Object.keys(this.recs);
|
|
3280
3961
|
}
|
|
3281
3962
|
save() {
|
|
3282
|
-
|
|
3963
|
+
fs14.mkdirSync(path16.dirname(this.file), { recursive: true, mode: 448 });
|
|
3283
3964
|
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
3284
|
-
|
|
3285
|
-
|
|
3965
|
+
fs14.writeFileSync(tmp, JSON.stringify(this.recs), { mode: 384 });
|
|
3966
|
+
fs14.renameSync(tmp, this.file);
|
|
3286
3967
|
}
|
|
3287
3968
|
};
|
|
3288
3969
|
|
|
3289
3970
|
// src/connector.ts
|
|
3971
|
+
var RESTART_RECONNECT_GAP_MS = 5e3;
|
|
3290
3972
|
var DEFAULT_TIMING = {
|
|
3291
3973
|
...DEFAULT_SESSION_TIMING,
|
|
3292
3974
|
heartbeatMs: REMOTE_AGENT_TIMING.heartbeatMs,
|
|
@@ -3297,6 +3979,7 @@ var DEFAULT_TIMING = {
|
|
|
3297
3979
|
backoffMaxMs: 6e4,
|
|
3298
3980
|
pausePollMs: 1e3,
|
|
3299
3981
|
catalogMs: 12e4,
|
|
3982
|
+
sessionsWatchMs: 1e4,
|
|
3300
3983
|
attachCliMs: 8e3,
|
|
3301
3984
|
recentWriteMs: 1e4,
|
|
3302
3985
|
authFailLimit: 4,
|
|
@@ -3423,8 +4106,16 @@ var Connector = class {
|
|
|
3423
4106
|
}
|
|
3424
4107
|
async descriptors() {
|
|
3425
4108
|
return Promise.all(this.d.config.agents.map(async (a) => {
|
|
3426
|
-
const base = { ask_mode: a.askMode, modes: a.safeModes, roots: a.roots.slice(0, 8), protocol: 1, unattended: a.unattended, unattended_allowed: a.allowUnsafeModes };
|
|
3427
4109
|
const support = this.attach.get(a.id);
|
|
4110
|
+
const base = {
|
|
4111
|
+
ask_mode: a.askMode,
|
|
4112
|
+
modes: a.safeModes,
|
|
4113
|
+
roots: a.roots.slice(0, 8),
|
|
4114
|
+
protocol: 1,
|
|
4115
|
+
unattended: a.unattended,
|
|
4116
|
+
unattended_allowed: a.allowUnsafeModes,
|
|
4117
|
+
...support ? { watch_live: true } : {}
|
|
4118
|
+
};
|
|
3428
4119
|
const capabilities = support ? fitResumable(base, await support.resumable()) : base;
|
|
3429
4120
|
return { agent_id: a.id, kind: a.kind, name: a.name, capabilities };
|
|
3430
4121
|
}));
|
|
@@ -3432,7 +4123,7 @@ var Connector = class {
|
|
|
3432
4123
|
// --- identity -------------------------------------------------------------------
|
|
3433
4124
|
readIdentity() {
|
|
3434
4125
|
try {
|
|
3435
|
-
const raw = JSON.parse(
|
|
4126
|
+
const raw = JSON.parse(fs15.readFileSync(identityFile(this.d.dirs), "utf8"));
|
|
3436
4127
|
if (isObject(raw) && raw.server === this.d.server && typeof raw.connector_id === "string") {
|
|
3437
4128
|
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") : [] };
|
|
3438
4129
|
}
|
|
@@ -3443,9 +4134,9 @@ var Connector = class {
|
|
|
3443
4134
|
writeIdentity() {
|
|
3444
4135
|
const st = { server: this.d.server, connector_id: this.cid, cursor: this.ledger.cursor, handled: this.ledger.recentIds, ...this.rowRevoked ? { revoked: true } : {} };
|
|
3445
4136
|
const file = identityFile(this.d.dirs);
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
4137
|
+
fs15.mkdirSync(this.d.dirs.state, { recursive: true, mode: 448 });
|
|
4138
|
+
fs15.writeFileSync(`${file}.tmp`, JSON.stringify(st), { mode: 384 });
|
|
4139
|
+
fs15.renameSync(`${file}.tmp`, file);
|
|
3449
4140
|
}
|
|
3450
4141
|
/** Use the connector id this machine already has, or register a new one. */
|
|
3451
4142
|
async identify(forceNew = false) {
|
|
@@ -3552,7 +4243,7 @@ var Connector = class {
|
|
|
3552
4243
|
}
|
|
3553
4244
|
async pauseWatcher() {
|
|
3554
4245
|
while (!this.ctl.signal.aborted) {
|
|
3555
|
-
const want =
|
|
4246
|
+
const want = fs15.existsSync(pauseFile(this.d.dirs));
|
|
3556
4247
|
if (want && !this.paused) {
|
|
3557
4248
|
this.paused = true;
|
|
3558
4249
|
this.d.audit.record("paused");
|
|
@@ -3606,6 +4297,22 @@ var Connector = class {
|
|
|
3606
4297
|
}
|
|
3607
4298
|
}
|
|
3608
4299
|
}
|
|
4300
|
+
/** Report a change in the conversations on this machine (see {@link ConnectorTiming.sessionsWatchMs}) without waiting for the heartbeat. */
|
|
4301
|
+
async sessionsWatcher() {
|
|
4302
|
+
if (this.attach.size === 0) return;
|
|
4303
|
+
while (!this.ctl.signal.aborted) {
|
|
4304
|
+
let changed = false;
|
|
4305
|
+
for (const support of this.attach.values()) {
|
|
4306
|
+
try {
|
|
4307
|
+
if (await support.poll()) changed = true;
|
|
4308
|
+
} catch (e) {
|
|
4309
|
+
this.d.log.warn("looking for changed sessions failed", { agent: support.agent.id, error: errMessage(e) });
|
|
4310
|
+
}
|
|
4311
|
+
}
|
|
4312
|
+
if (changed && !this.paused) this.wakeHeartbeat?.();
|
|
4313
|
+
await sleep(this.timing.sessionsWatchMs, this.ctl.signal);
|
|
4314
|
+
}
|
|
4315
|
+
}
|
|
3609
4316
|
async heartbeater() {
|
|
3610
4317
|
while (!this.ctl.signal.aborted) {
|
|
3611
4318
|
await new Promise((resolve) => {
|
|
@@ -3673,6 +4380,7 @@ var Connector = class {
|
|
|
3673
4380
|
async streamLoop() {
|
|
3674
4381
|
let attempt = 0;
|
|
3675
4382
|
let reconnect = false;
|
|
4383
|
+
let lastRestartAt = -Infinity;
|
|
3676
4384
|
while (!this.ctl.signal.aborted) {
|
|
3677
4385
|
if (this.paused) {
|
|
3678
4386
|
await sleep(this.timing.pausePollMs, this.ctl.signal);
|
|
@@ -3683,6 +4391,7 @@ var Connector = class {
|
|
|
3683
4391
|
const sig = AbortSignal.any([this.ctl.signal, this.streamCtl.signal]);
|
|
3684
4392
|
let got = false;
|
|
3685
4393
|
this.lastFrame = "";
|
|
4394
|
+
let restart = false;
|
|
3686
4395
|
for await (const f of this.d.client.commands(this.cid, { lastEventId: this.ledger.highestSeen || void 0, signal: sig, watchdogMs: this.timing.watchdogMs })) {
|
|
3687
4396
|
if (!got) {
|
|
3688
4397
|
got = true;
|
|
@@ -3691,11 +4400,20 @@ var Connector = class {
|
|
|
3691
4400
|
}
|
|
3692
4401
|
this.onFrame(f, reconnect);
|
|
3693
4402
|
if (f.event === "ready") reconnect = true;
|
|
4403
|
+
if (f.event === "reconnect") {
|
|
4404
|
+
restart = true;
|
|
4405
|
+
break;
|
|
4406
|
+
}
|
|
3694
4407
|
}
|
|
3695
4408
|
if (!this.ctl.signal.aborted && !this.paused) {
|
|
3696
|
-
const why = this.lastFrame === "superseded" || this.lastFrame === "token_expired" ? this.lastFrame : "the server closed the stream (a Claw release or restart)";
|
|
4409
|
+
const why = restart ? "restart" : this.lastFrame === "superseded" || this.lastFrame === "token_expired" ? this.lastFrame : "the server closed the stream (a Claw release or restart)";
|
|
3697
4410
|
this.d.log.info("command stream ended by the server; reconnecting", { why });
|
|
3698
4411
|
recordDisconnect(this.d.dirs, { reason: "server_closed", detail: why });
|
|
4412
|
+
if (restart && Date.now() - lastRestartAt >= RESTART_RECONNECT_GAP_MS) {
|
|
4413
|
+
lastRestartAt = Date.now();
|
|
4414
|
+
attempt = 0;
|
|
4415
|
+
continue;
|
|
4416
|
+
}
|
|
3699
4417
|
}
|
|
3700
4418
|
} catch (e) {
|
|
3701
4419
|
if (this.isRevocation(e)) return void await this.onRevoked(e);
|
|
@@ -3720,7 +4438,7 @@ var Connector = class {
|
|
|
3720
4438
|
}
|
|
3721
4439
|
/** Run until stopped or revoked. Always leaves no adapter process behind. */
|
|
3722
4440
|
async run() {
|
|
3723
|
-
|
|
4441
|
+
fs15.mkdirSync(this.d.dirs.state, { recursive: true, mode: 448 });
|
|
3724
4442
|
await this.registry.killAll();
|
|
3725
4443
|
for (const p of this.store.takePending()) {
|
|
3726
4444
|
this.d.audit.record("permission_orphaned", { session_id: p.session_id, action_id: p.action_id });
|
|
@@ -3729,32 +4447,33 @@ var Connector = class {
|
|
|
3729
4447
|
const pidf = pidFile(this.d.dirs);
|
|
3730
4448
|
let leftover = false;
|
|
3731
4449
|
try {
|
|
3732
|
-
|
|
4450
|
+
fs15.readFileSync(pidf);
|
|
3733
4451
|
leftover = true;
|
|
3734
4452
|
} catch {
|
|
3735
4453
|
}
|
|
3736
4454
|
if (leftover) {
|
|
3737
4455
|
let last;
|
|
3738
4456
|
try {
|
|
3739
|
-
last =
|
|
4457
|
+
last = fs15.statSync(identityFile(this.d.dirs)).mtime.toISOString();
|
|
3740
4458
|
} catch {
|
|
3741
4459
|
}
|
|
3742
4460
|
recordDisconnect(this.d.dirs, { reason: "process_died", detail: `the previous run ended without shutting down (killed, crashed, or the machine restarted)${last ? `; last heartbeat ${last}` : ""}`, ...last ? { at: last } : {} });
|
|
3743
4461
|
}
|
|
3744
|
-
|
|
3745
|
-
this.paused =
|
|
4462
|
+
fs15.writeFileSync(pidf, JSON.stringify({ pid: process.pid, start: processStart(process.pid) ?? "" }), { mode: 384 });
|
|
4463
|
+
this.paused = fs15.existsSync(pauseFile(this.d.dirs));
|
|
3746
4464
|
let result = "stopped";
|
|
3747
4465
|
let failure = "";
|
|
3748
4466
|
try {
|
|
3749
4467
|
const up = await this.startup();
|
|
3750
4468
|
if (up !== "ok") return result = up;
|
|
4469
|
+
await this.manager.restoreReadOnly();
|
|
3751
4470
|
this.d.audit.record("connector_online", {
|
|
3752
4471
|
connector_id: this.cid,
|
|
3753
4472
|
agents: this.d.config.agents.map((a) => a.id),
|
|
3754
4473
|
paused: this.paused,
|
|
3755
4474
|
unattended_agents: this.d.config.agents.filter((a) => a.unattended).map((a) => a.id)
|
|
3756
4475
|
});
|
|
3757
|
-
await Promise.all([this.sender(), this.heartbeater(), this.streamLoop(), this.pauseWatcher()]);
|
|
4476
|
+
await Promise.all([this.sender(), this.heartbeater(), this.streamLoop(), this.pauseWatcher(), this.sessionsWatcher()]);
|
|
3758
4477
|
result = this.revoked ? "revoked" : this.authFailed ? "auth_failed" : "stopped";
|
|
3759
4478
|
} catch (e) {
|
|
3760
4479
|
failure = errMessage(e);
|
|
@@ -3763,7 +4482,7 @@ var Connector = class {
|
|
|
3763
4482
|
await this.manager.killAll(this.revoked ? "revoked" : "stopped").catch(() => void 0);
|
|
3764
4483
|
const reason = this.revoked ? "revoked" : this.authFailed ? "auth_failed" : failure ? "failed" : this.paused ? "stopped_while_paused" : result;
|
|
3765
4484
|
recordDisconnect(this.d.dirs, { reason, detail: this.authWhy || failure || void 0 });
|
|
3766
|
-
|
|
4485
|
+
fs15.rmSync(pidf, { force: true });
|
|
3767
4486
|
}
|
|
3768
4487
|
return result;
|
|
3769
4488
|
}
|
|
@@ -3779,10 +4498,10 @@ var Connector = class {
|
|
|
3779
4498
|
};
|
|
3780
4499
|
|
|
3781
4500
|
// src/service.ts
|
|
3782
|
-
import { spawn as
|
|
3783
|
-
import
|
|
4501
|
+
import { spawn as spawn3, spawnSync } from "node:child_process";
|
|
4502
|
+
import fs16 from "node:fs";
|
|
3784
4503
|
import os5 from "node:os";
|
|
3785
|
-
import
|
|
4504
|
+
import path17 from "node:path";
|
|
3786
4505
|
import { fileURLToPath } from "node:url";
|
|
3787
4506
|
import { parseArgs } from "node:util";
|
|
3788
4507
|
var SUPERVISORS = ["systemd", "launchd", "script"];
|
|
@@ -3821,11 +4540,11 @@ function deps(ctx) {
|
|
|
3821
4540
|
}),
|
|
3822
4541
|
node: d.node ?? ctx.node ?? process.execPath,
|
|
3823
4542
|
cli: d.cli ?? fileURLToPath(import.meta.url),
|
|
3824
|
-
systemdBooted: d.systemdBooted ?? (() =>
|
|
4543
|
+
systemdBooted: d.systemdBooted ?? (() => fs16.existsSync("/run/systemd/system"))
|
|
3825
4544
|
};
|
|
3826
4545
|
}
|
|
3827
|
-
var systemdUnitPath = (ctx, d) =>
|
|
3828
|
-
var launchdPlistPath = (d) =>
|
|
4546
|
+
var systemdUnitPath = (ctx, d) => path17.join(ctx.env.XDG_CONFIG_HOME?.trim() || path17.join(d.home, ".config"), "systemd", "user", UNIT);
|
|
4547
|
+
var launchdPlistPath = (d) => path17.join(d.home, "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
3829
4548
|
function detectSupervisor(d) {
|
|
3830
4549
|
if (d.platform === "darwin") return "launchd";
|
|
3831
4550
|
if (!d.systemdBooted()) return "script";
|
|
@@ -3875,12 +4594,14 @@ StartLimitIntervalSec=0
|
|
|
3875
4594
|
[Service]
|
|
3876
4595
|
Type=simple
|
|
3877
4596
|
ExecStart=${sdQuote(d.node)} ${sdQuote(d.cli)} run --supervised
|
|
4597
|
+
ExecStop=${sdQuote(d.node)} ${sdQuote(d.cli)} stop
|
|
3878
4598
|
${vars.map(([k, v]) => `Environment=${sdEnv(`${k}=${v}`)}`).join("\n")}
|
|
3879
4599
|
Restart=on-failure
|
|
3880
4600
|
RestartSec=${RESTART_SEC}
|
|
3881
4601
|
# 78: revoked, sign-in refused, or no usable config. Restarting cannot help; "claw-connector login" can.
|
|
3882
4602
|
RestartPreventExitStatus=${NEEDS_PERSON}
|
|
3883
|
-
# "claw-connector stop" and "systemctl --user stop" both end it cleanly (exit 0), which is not
|
|
4603
|
+
# "claw-connector stop" and "systemctl --user stop" (through ExecStop) both end it cleanly (exit 0), which is not
|
|
4604
|
+
# restarted. Any other SIGTERM ends a supervised run with exit 75, which is.
|
|
3884
4605
|
KillMode=mixed
|
|
3885
4606
|
TimeoutStopSec=20
|
|
3886
4607
|
|
|
@@ -3924,7 +4645,7 @@ exec ${sh(d.node)} ${sh(d.cli)} service ensure
|
|
|
3924
4645
|
}
|
|
3925
4646
|
function readPidRec(file) {
|
|
3926
4647
|
try {
|
|
3927
|
-
const raw = JSON.parse(
|
|
4648
|
+
const raw = JSON.parse(fs16.readFileSync(file, "utf8"));
|
|
3928
4649
|
if (isObject(raw) && typeof raw.pid === "number" && typeof raw.start === "string" && raw.start !== "") return { pid: raw.pid, start: raw.start };
|
|
3929
4650
|
} catch {
|
|
3930
4651
|
}
|
|
@@ -3944,8 +4665,8 @@ async function supervise(ctx, o = {}) {
|
|
|
3944
4665
|
ctx.err(`a supervisor is already running (pid ${other})`);
|
|
3945
4666
|
return 1;
|
|
3946
4667
|
}
|
|
3947
|
-
|
|
3948
|
-
|
|
4668
|
+
fs16.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
|
|
4669
|
+
fs16.writeFileSync(pidf, JSON.stringify({ pid: process.pid, start: processStart(process.pid) ?? "" }), { mode: 384 });
|
|
3949
4670
|
let stopping2 = false;
|
|
3950
4671
|
let child = null;
|
|
3951
4672
|
const wake = new AbortController();
|
|
@@ -3960,7 +4681,7 @@ async function supervise(ctx, o = {}) {
|
|
|
3960
4681
|
o.signal?.addEventListener("abort", () => onSig("SIGTERM"), { once: true });
|
|
3961
4682
|
try {
|
|
3962
4683
|
for (let round = 0; !stopping2; round++) {
|
|
3963
|
-
child =
|
|
4684
|
+
child = spawn3(cmd, args, { stdio: ["ignore", "inherit", "inherit"], env: ctx.env });
|
|
3964
4685
|
const c = child;
|
|
3965
4686
|
const { code, signal } = await new Promise((resolve) => {
|
|
3966
4687
|
c.once("exit", (code2, signal2) => resolve({ code: code2, signal: signal2 }));
|
|
@@ -3989,7 +4710,7 @@ async function supervise(ctx, o = {}) {
|
|
|
3989
4710
|
} finally {
|
|
3990
4711
|
for (const [s, h] of handlers) process.off(s, h);
|
|
3991
4712
|
const rec = readPidRec(pidf);
|
|
3992
|
-
if (rec?.pid === process.pid)
|
|
4713
|
+
if (rec?.pid === process.pid) fs16.rmSync(pidf, { force: true });
|
|
3993
4714
|
}
|
|
3994
4715
|
}
|
|
3995
4716
|
async function cmdSupervise(_args, ctx) {
|
|
@@ -4011,14 +4732,14 @@ async function ensure(ctx, d, h) {
|
|
|
4011
4732
|
ctx.out(`the connector is running (pid ${run2}) without the supervisor; leaving it`);
|
|
4012
4733
|
return 0;
|
|
4013
4734
|
}
|
|
4014
|
-
|
|
4015
|
-
const log =
|
|
4735
|
+
fs16.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
|
|
4736
|
+
const log = fs16.openSync(runLogFile(ctx.dirs), "a", 384);
|
|
4016
4737
|
try {
|
|
4017
|
-
const p =
|
|
4738
|
+
const p = spawn3(d.node, [d.cli, "supervise"], { detached: true, cwd: d.home, stdio: ["ignore", log, log], env: ctx.env });
|
|
4018
4739
|
p.unref();
|
|
4019
4740
|
ctx.out(`started the supervisor (pid ${p.pid}); log: ${runLogFile(ctx.dirs)}`);
|
|
4020
4741
|
} finally {
|
|
4021
|
-
|
|
4742
|
+
fs16.closeSync(log);
|
|
4022
4743
|
}
|
|
4023
4744
|
return 0;
|
|
4024
4745
|
}
|
|
@@ -4048,7 +4769,7 @@ async function cmdService(args, ctx, h) {
|
|
|
4048
4769
|
}
|
|
4049
4770
|
function installedSupervisor(ctx) {
|
|
4050
4771
|
try {
|
|
4051
|
-
const raw = JSON.parse(
|
|
4772
|
+
const raw = JSON.parse(fs16.readFileSync(serviceFile(ctx.dirs), "utf8"));
|
|
4052
4773
|
if (isObject(raw) && typeof raw.supervisor === "string" && SUPERVISORS.includes(raw.supervisor)) return raw.supervisor;
|
|
4053
4774
|
} catch {
|
|
4054
4775
|
}
|
|
@@ -4069,10 +4790,10 @@ function startAfterLogin(ctx, h) {
|
|
|
4069
4790
|
ctx.out(r.code === 0 ? `started the ${sup} service again` : `could not start the ${sup} service (${r.out || `exit ${r.code}`}); run: claw-connector service install`);
|
|
4070
4791
|
}
|
|
4071
4792
|
function writeFile600(file, body, mode = 384) {
|
|
4072
|
-
|
|
4793
|
+
fs16.mkdirSync(path17.dirname(file), { recursive: true, mode: 448 });
|
|
4073
4794
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
4074
|
-
|
|
4075
|
-
|
|
4795
|
+
fs16.writeFileSync(tmp, body, { mode });
|
|
4796
|
+
fs16.renameSync(tmp, file);
|
|
4076
4797
|
}
|
|
4077
4798
|
async function install(ctx, d, h, sup, extra) {
|
|
4078
4799
|
const problem = h.preflight(ctx);
|
|
@@ -4080,7 +4801,7 @@ async function install(ctx, d, h, sup, extra) {
|
|
|
4080
4801
|
ctx.err(`cannot install the service yet: ${problem}`);
|
|
4081
4802
|
return NEEDS_PERSON;
|
|
4082
4803
|
}
|
|
4083
|
-
if (!
|
|
4804
|
+
if (!fs16.existsSync(d.cli)) {
|
|
4084
4805
|
ctx.err(`${d.cli} does not exist (a source checkout needs "npm run build" first)`);
|
|
4085
4806
|
return 1;
|
|
4086
4807
|
}
|
|
@@ -4144,19 +4865,19 @@ function recordInstalled(ctx, sup) {
|
|
|
4144
4865
|
writeFile600(serviceFile(ctx.dirs), JSON.stringify({ supervisor: sup, installed_at: (/* @__PURE__ */ new Date()).toISOString() }));
|
|
4145
4866
|
}
|
|
4146
4867
|
async function uninstall(ctx, d, sup) {
|
|
4147
|
-
|
|
4868
|
+
fs16.rmSync(serviceFile(ctx.dirs), { force: true });
|
|
4148
4869
|
if (sup === "systemd") {
|
|
4149
4870
|
const unit = systemdUnitPath(ctx, d);
|
|
4150
4871
|
d.exec("systemctl", ["--user", "disable", "--now", UNIT]);
|
|
4151
|
-
const had =
|
|
4152
|
-
|
|
4872
|
+
const had = fs16.existsSync(unit);
|
|
4873
|
+
fs16.rmSync(unit, { force: true });
|
|
4153
4874
|
d.exec("systemctl", ["--user", "daemon-reload"]);
|
|
4154
4875
|
ctx.out(had ? `stopped and removed ${unit}` : `no unit at ${unit}`);
|
|
4155
4876
|
} else if (sup === "launchd") {
|
|
4156
4877
|
const plist = launchdPlistPath(d);
|
|
4157
4878
|
d.exec("launchctl", ["bootout", `gui/${d.uid}/${LABEL}`]);
|
|
4158
|
-
const had =
|
|
4159
|
-
|
|
4879
|
+
const had = fs16.existsSync(plist);
|
|
4880
|
+
fs16.rmSync(plist, { force: true });
|
|
4160
4881
|
ctx.out(had ? `stopped and removed ${plist}` : `no LaunchAgent at ${plist}`);
|
|
4161
4882
|
} else {
|
|
4162
4883
|
const pid = supervisorPid(ctx);
|
|
@@ -4166,8 +4887,8 @@ async function uninstall(ctx, d, sup) {
|
|
|
4166
4887
|
while (Date.now() < end && isAlive(pid) && processStart(pid) !== null) await sleep(100);
|
|
4167
4888
|
}
|
|
4168
4889
|
const script = keepaliveFile(ctx.dirs);
|
|
4169
|
-
const had =
|
|
4170
|
-
|
|
4890
|
+
const had = fs16.existsSync(script);
|
|
4891
|
+
fs16.rmSync(script, { force: true });
|
|
4171
4892
|
ctx.out(`${pid !== null ? `stopped the supervisor (pid ${pid}) and the connector; ` : ""}${had ? `removed ${script}` : "no keep-alive script"}. Remove the @reboot and */5 lines that call it.`);
|
|
4172
4893
|
}
|
|
4173
4894
|
return 0;
|
|
@@ -4200,15 +4921,101 @@ function status(ctx, d, h, json) {
|
|
|
4200
4921
|
}
|
|
4201
4922
|
|
|
4202
4923
|
// src/version.ts
|
|
4203
|
-
var VERSION = "0.1.
|
|
4924
|
+
var VERSION = "0.1.5";
|
|
4925
|
+
|
|
4926
|
+
// src/probe.ts
|
|
4927
|
+
import fs17 from "node:fs";
|
|
4928
|
+
import os6 from "node:os";
|
|
4929
|
+
import path18 from "node:path";
|
|
4930
|
+
var BYPASS_FIX = `Claude Code offers bypassPermissions to root only with IS_SANDBOX set (the connector sets it for an agent with approval = "never" unless its env names IS_SANDBOX), and never when permissions.disableBypassPermissionsMode is "disable" in its settings; fix that, or set approval = "ask" on this agent`;
|
|
4931
|
+
function modesOf(res) {
|
|
4932
|
+
const m = isObject(res.modes) ? res.modes : null;
|
|
4933
|
+
if (m && Array.isArray(m.availableModes)) return m.availableModes.filter((x) => isObject(x) && typeof x.id === "string").map((x) => x.id);
|
|
4934
|
+
const opt = Array.isArray(res.configOptions) ? res.configOptions.find((o) => isObject(o) && o.category === "mode") : null;
|
|
4935
|
+
if (isObject(opt) && Array.isArray(opt.options)) return opt.options.filter((x) => isObject(x) && typeof x.value === "string").map((x) => x.value);
|
|
4936
|
+
return null;
|
|
4937
|
+
}
|
|
4938
|
+
var probeDir = (a) => a.roots.find((r) => {
|
|
4939
|
+
try {
|
|
4940
|
+
return fs17.statSync(r).isDirectory();
|
|
4941
|
+
} catch {
|
|
4942
|
+
return false;
|
|
4943
|
+
}
|
|
4944
|
+
}) ?? os6.homedir();
|
|
4945
|
+
async function probeAgent(a, o) {
|
|
4946
|
+
const cwd = probeDir(a);
|
|
4947
|
+
const out = { agent: a.id, cwd, ok: false, modes: null, problems: [], notes: [] };
|
|
4948
|
+
if (!a.roots.includes(cwd)) out.notes.push(`no root of this agent exists yet (${a.roots.join(", ")}); probed in ${cwd}`);
|
|
4949
|
+
const tmp = fs17.mkdtempSync(path18.join(os6.tmpdir(), "claw-probe-"));
|
|
4950
|
+
const registry = new ChildRegistry(path18.join(tmp, "children.json"));
|
|
4951
|
+
const timeoutMs = o.timeoutMs ?? 12e4;
|
|
4952
|
+
let adapter = null;
|
|
4953
|
+
try {
|
|
4954
|
+
adapter = await AdapterProcess.start({
|
|
4955
|
+
cfg: a,
|
|
4956
|
+
registry,
|
|
4957
|
+
sessionId: `probe-${a.id}`,
|
|
4958
|
+
cwd,
|
|
4959
|
+
logFile: path18.join(o.logDir, `adapter-${a.id}.log`),
|
|
4960
|
+
version: o.version,
|
|
4961
|
+
initTimeoutMs: timeoutMs,
|
|
4962
|
+
hooks: {
|
|
4963
|
+
onUpdate: () => {
|
|
4964
|
+
},
|
|
4965
|
+
onNotification: () => {
|
|
4966
|
+
},
|
|
4967
|
+
onStderr: () => {
|
|
4968
|
+
},
|
|
4969
|
+
onExit: () => {
|
|
4970
|
+
},
|
|
4971
|
+
// No prompt is sent, so nothing should ask; anything that does is refused.
|
|
4972
|
+
onPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
|
4973
|
+
onExtension: async () => ({})
|
|
4974
|
+
}
|
|
4975
|
+
});
|
|
4976
|
+
const res = await adapter.request("session/new", { cwd, mcpServers: [], ...claudeSessionMeta(a) }, timeoutMs);
|
|
4977
|
+
out.modes = modesOf(res);
|
|
4978
|
+
if (out.modes === null || out.modes.length === 0) {
|
|
4979
|
+
out.notes.push("the adapter lists no modes; the connector sets the ask mode and reads it back when a session opens");
|
|
4980
|
+
out.ok = true;
|
|
4981
|
+
return out;
|
|
4982
|
+
}
|
|
4983
|
+
if (!out.modes.includes(a.askMode)) {
|
|
4984
|
+
out.problems.push(`sessions will be refused (mode_unavailable): this agent's sessions run in ${a.askMode}${a.unattended ? ` (approval = "never")` : ""}, but the adapter offers only ${out.modes.join(", ")}${a.askMode === "bypassPermissions" ? `. ${BYPASS_FIX}` : ""}`);
|
|
4985
|
+
}
|
|
4986
|
+
const missing = a.safeModes.filter((m) => m !== a.askMode && !out.modes.includes(m));
|
|
4987
|
+
if (missing.length) out.notes.push(`safe_modes lists ${missing.join(", ")}, which the adapter does not offer here; a phone that picks one is refused`);
|
|
4988
|
+
out.ok = out.problems.length === 0;
|
|
4989
|
+
} catch (e) {
|
|
4990
|
+
out.problems.push(`the adapter did not open a session: ${errMessage(e)} (its log: ${path18.join(o.logDir, `adapter-${a.id}.log`)})`);
|
|
4991
|
+
} finally {
|
|
4992
|
+
await adapter?.kill().catch(() => void 0);
|
|
4993
|
+
fs17.rmSync(tmp, { recursive: true, force: true });
|
|
4994
|
+
}
|
|
4995
|
+
return out;
|
|
4996
|
+
}
|
|
4997
|
+
var probeAgents = async (agents, o) => {
|
|
4998
|
+
const out = [];
|
|
4999
|
+
for (const a of agents) out.push(await probeAgent(a, o));
|
|
5000
|
+
return out;
|
|
5001
|
+
};
|
|
5002
|
+
function probeLines(results) {
|
|
5003
|
+
const lines = [];
|
|
5004
|
+
for (const r of results) {
|
|
5005
|
+
lines.push(`${r.agent}: ${r.ok ? "ok" : "PROBLEM"}${r.modes ? ` (modes: ${r.modes.join(", ")})` : ""}`);
|
|
5006
|
+
for (const p of r.problems) lines.push(` problem: ${p}`);
|
|
5007
|
+
for (const n of r.notes) lines.push(` note: ${n}`);
|
|
5008
|
+
}
|
|
5009
|
+
return lines;
|
|
5010
|
+
}
|
|
4204
5011
|
|
|
4205
5012
|
// src/commands.ts
|
|
4206
|
-
var EXIT = { ok: 0, failure: 1, usage: 2, needsPerson: 78, revoked: 78 };
|
|
5013
|
+
var EXIT = { ok: 0, failure: 1, usage: 2, terminated: 75, needsPerson: 78, revoked: 78 };
|
|
4207
5014
|
var HELP = `claw-connector ${VERSION}: connect the coding agents on this machine to Claw (outbound HTTPS only).
|
|
4208
5015
|
|
|
4209
5016
|
Usage: claw-connector <command> [options]
|
|
4210
5017
|
|
|
4211
|
-
init [--approval ask|never | --approval AGENT=ask|never ...] [--no-hooks]
|
|
5018
|
+
init [--approval ask|never | --approval AGENT=ask|never ...] [--no-hooks] [--no-probe]
|
|
4212
5019
|
write a sample agents.toml, and install the Claude Code session hooks (see "hooks"; --no-hooks
|
|
4213
5020
|
skips that). Agents ask before every write and command by default;
|
|
4214
5021
|
"--approval never" (or "--approval claude=never") lets phone sessions run like your
|
|
@@ -4226,6 +5033,9 @@ Usage: claw-connector <command> [options]
|
|
|
4226
5033
|
after exit 78. systemd --user on Linux, launchd on macOS; "script" (containers without
|
|
4227
5034
|
systemd) writes a keep-alive script for a startup hook and a 5-minute cron line
|
|
4228
5035
|
status [--json] what is running, signed in, paused, the last disconnect and why
|
|
5036
|
+
doctor [--agent ID] [--json]
|
|
5037
|
+
start each agent's adapter, open a session (no prompt) and check that it offers the
|
|
5038
|
+
modes agents.toml needs, so a phone session is not refused; init runs it too
|
|
4229
5039
|
pause stop every agent now and stay offline until "resume"
|
|
4230
5040
|
resume undo "pause"
|
|
4231
5041
|
stop stop the running connector and every agent it started
|
|
@@ -4235,16 +5045,19 @@ Usage: claw-connector <command> [options]
|
|
|
4235
5045
|
Files: ${"$"}XDG_CONFIG_HOME/claw-connector (agents.toml, credentials.json) and ${"$"}XDG_STATE_HOME/claw-connector
|
|
4236
5046
|
(audit.jsonl, logs, outbox); set CLAW_CONNECTOR_HOME to keep everything under one directory.
|
|
4237
5047
|
`;
|
|
4238
|
-
function loadCfg(ctx) {
|
|
4239
|
-
|
|
5048
|
+
function loadCfg(ctx, notes = []) {
|
|
5049
|
+
const cfg = loadConfig(configFile(ctx.dirs), { hostname: ctx.hostname ?? os7.hostname() });
|
|
5050
|
+
notes.push(...applyRootSandbox(cfg.agents, ctx.env, { euid: ctx.euid }));
|
|
5051
|
+
return cfg;
|
|
4240
5052
|
}
|
|
4241
|
-
var noAsk = (ctx, cfg) =>
|
|
5053
|
+
var noAsk = (ctx, cfg) => bypassUnavailableWarnings(cfg.agents, ctx.env, { managedFile: ctx.claudeManagedSettings, euid: ctx.euid });
|
|
4242
5054
|
function serverUrl(ctx, flag, cfg, creds) {
|
|
4243
5055
|
const v = flag ?? ctx.env.CLAW_URL ?? cfg?.server ?? creds?.server ?? null;
|
|
4244
5056
|
return v ? secureUrl(v.trim(), "the Claw server URL") : null;
|
|
4245
5057
|
}
|
|
4246
5058
|
async function main(argv, ctx) {
|
|
4247
5059
|
const [cmd, ...rest] = argv;
|
|
5060
|
+
useAdapterStore(new AdapterStore(adaptersDir(ctx.dirs.state), { autoInstall: ctx.installAdapters === true }));
|
|
4248
5061
|
try {
|
|
4249
5062
|
switch (cmd) {
|
|
4250
5063
|
case void 0:
|
|
@@ -4269,11 +5082,14 @@ async function main(argv, ctx) {
|
|
|
4269
5082
|
return code === EXIT.needsPerson && ctx.env.CLAW_CONNECTOR_SUPERVISOR === "launchd" ? EXIT.ok : code;
|
|
4270
5083
|
}
|
|
4271
5084
|
case "service":
|
|
5085
|
+
if (rest[0] === "install") await installAdapters(ctx, false);
|
|
4272
5086
|
return await cmdService(rest, ctx, serviceHelpers);
|
|
4273
5087
|
case "supervise":
|
|
4274
5088
|
return await cmdSupervise(rest, ctx);
|
|
4275
5089
|
case "status":
|
|
4276
5090
|
return cmdStatus(rest, ctx);
|
|
5091
|
+
case "doctor":
|
|
5092
|
+
return await cmdDoctor(rest, ctx);
|
|
4277
5093
|
case "pause":
|
|
4278
5094
|
return await cmdPause(ctx);
|
|
4279
5095
|
case "resume":
|
|
@@ -4313,16 +5129,16 @@ function parseApproval(values) {
|
|
|
4313
5129
|
return out;
|
|
4314
5130
|
}
|
|
4315
5131
|
async function cmdInit(args, ctx) {
|
|
4316
|
-
const { values } = parseArgs2({ args, strict: true, options: { approval: { type: "string", multiple: true }, "no-hooks": { type: "boolean" } } });
|
|
5132
|
+
const { values } = parseArgs2({ args, strict: true, options: { approval: { type: "string", multiple: true }, "no-hooks": { type: "boolean" }, "no-probe": { type: "boolean" } } });
|
|
4317
5133
|
const approval = parseApproval(values.approval ?? []);
|
|
4318
5134
|
if (typeof approval === "string") {
|
|
4319
5135
|
ctx.err(approval);
|
|
4320
5136
|
return EXIT.usage;
|
|
4321
5137
|
}
|
|
4322
5138
|
const file = configFile(ctx.dirs);
|
|
4323
|
-
|
|
5139
|
+
fs18.mkdirSync(path19.dirname(file), { recursive: true, mode: 448 });
|
|
4324
5140
|
try {
|
|
4325
|
-
|
|
5141
|
+
fs18.writeFileSync(file, sampleConfig(approval), { mode: 384, flag: "wx" });
|
|
4326
5142
|
ctx.out(`wrote ${file}: edit the roots (the directories an agent may work in), then run: claw-connector login`);
|
|
4327
5143
|
const { unattended, parked } = sampleApprovals(approval);
|
|
4328
5144
|
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.`);
|
|
@@ -4333,11 +5149,54 @@ async function cmdInit(args, ctx) {
|
|
|
4333
5149
|
}
|
|
4334
5150
|
if (!values["no-hooks"]) {
|
|
4335
5151
|
const dir = defaultClaudeDir(ctx);
|
|
4336
|
-
if (!
|
|
5152
|
+
if (!fs18.existsSync(dir)) ctx.out(`no Claude Code directory at ${dir}; once you use Claude Code, run: claw-connector hooks install`);
|
|
4337
5153
|
else installForInit(ctx, dir);
|
|
4338
5154
|
}
|
|
5155
|
+
await installAdapters(ctx, true);
|
|
5156
|
+
if (ctx.probe && !values["no-probe"]) {
|
|
5157
|
+
try {
|
|
5158
|
+
const cfg = loadCfg(ctx);
|
|
5159
|
+
ctx.out("checking each agent's adapter (claw-connector doctor; skip with --no-probe):");
|
|
5160
|
+
for (const l of probeLines(await ctx.probe(cfg.agents, { logDir: logDir(ctx.dirs), version: VERSION }))) ctx.out(l);
|
|
5161
|
+
} catch (e) {
|
|
5162
|
+
ctx.out(`could not check the agents yet: ${errMessage(e)}; after editing agents.toml run: claw-connector doctor`);
|
|
5163
|
+
}
|
|
5164
|
+
}
|
|
4339
5165
|
return EXIT.ok;
|
|
4340
5166
|
}
|
|
5167
|
+
async function cmdDoctor(args, ctx) {
|
|
5168
|
+
const { values } = parseArgs2({ args, strict: true, options: { agent: { type: "string" }, json: { type: "boolean" } } });
|
|
5169
|
+
const notes = [];
|
|
5170
|
+
const cfg = loadCfg(ctx, notes);
|
|
5171
|
+
const agents = values.agent ? cfg.agents.filter((a) => a.id === values.agent) : cfg.agents;
|
|
5172
|
+
if (agents.length === 0) {
|
|
5173
|
+
ctx.err(values.agent ? `no agent ${JSON.stringify(values.agent)} in agents.toml` : "agents.toml defines no agents; add an [[agents]] table");
|
|
5174
|
+
return EXIT.failure;
|
|
5175
|
+
}
|
|
5176
|
+
const warnings = noAsk(ctx, { ...cfg, agents });
|
|
5177
|
+
const results = await (ctx.probe ?? probeAgents)(agents, { logDir: logDir(ctx.dirs), version: VERSION });
|
|
5178
|
+
const ok = results.every((r) => r.ok);
|
|
5179
|
+
if (values.json) {
|
|
5180
|
+
ctx.out(JSON.stringify({ version: VERSION, ok, notes, warnings, agents: results }, null, 2));
|
|
5181
|
+
} else {
|
|
5182
|
+
for (const n of notes) ctx.out(`note: ${n}`);
|
|
5183
|
+
for (const w of warnings) ctx.out(`warning: ${w}`);
|
|
5184
|
+
for (const l of probeLines(results)) ctx.out(l);
|
|
5185
|
+
}
|
|
5186
|
+
return ok ? EXIT.ok : EXIT.failure;
|
|
5187
|
+
}
|
|
5188
|
+
async function installAdapters(ctx, initFallback) {
|
|
5189
|
+
const store2 = adapterStore();
|
|
5190
|
+
if (!store2 || !ctx.installAdapters) return;
|
|
5191
|
+
let agents;
|
|
5192
|
+
try {
|
|
5193
|
+
agents = loadCfg(ctx).agents;
|
|
5194
|
+
} catch {
|
|
5195
|
+
if (!initFallback) return;
|
|
5196
|
+
agents = SAMPLE_ACTIVE.map((id) => ({ command: PRESETS[id].command, args: PRESETS[id].args, env: {} }));
|
|
5197
|
+
}
|
|
5198
|
+
await installAll(store2, agents, ctx.env, (l) => ctx.out(`adapters: ${l}`));
|
|
5199
|
+
}
|
|
4341
5200
|
function installForInit(ctx, dir) {
|
|
4342
5201
|
try {
|
|
4343
5202
|
ctx.out(describeInstall(dir, installOne(ctx, dir).changed));
|
|
@@ -4345,19 +5204,19 @@ function installForInit(ctx, dir) {
|
|
|
4345
5204
|
ctx.err(`hooks: ${errMessage(e)} (everything else is set up; fix it and run: claw-connector hooks install)`);
|
|
4346
5205
|
}
|
|
4347
5206
|
}
|
|
4348
|
-
var defaultClaudeDir = (ctx) =>
|
|
5207
|
+
var defaultClaudeDir = (ctx) => path19.resolve(ctx.env.CLAUDE_CONFIG_DIR?.trim() || path19.join(os7.homedir(), ".claude"));
|
|
4349
5208
|
function claudeDirs(ctx, flag) {
|
|
4350
|
-
if (flag !== void 0) return [
|
|
5209
|
+
if (flag !== void 0) return [path19.resolve(flag)];
|
|
4351
5210
|
let dirs = [];
|
|
4352
5211
|
try {
|
|
4353
|
-
dirs = loadCfg(ctx).agents.filter((a) => a.attach).map((a) =>
|
|
5212
|
+
dirs = loadCfg(ctx).agents.filter((a) => a.attach).map((a) => path19.resolve(a.env.CLAUDE_CONFIG_DIR ?? ctx.env.CLAUDE_CONFIG_DIR?.trim() ?? path19.join(os7.homedir(), ".claude")));
|
|
4354
5213
|
} catch {
|
|
4355
5214
|
}
|
|
4356
5215
|
return dirs.length ? [...new Set(dirs)] : [defaultClaudeDir(ctx)];
|
|
4357
5216
|
}
|
|
4358
5217
|
function hookScript(ctx) {
|
|
4359
5218
|
const script = ctx.hookScript ?? fileURLToPath2(new URL("./terminal-hook.js", import.meta.url));
|
|
4360
|
-
if (!
|
|
5219
|
+
if (!fs18.existsSync(script)) throw new HooksError(`${script} does not exist (a source checkout needs "npm run build" first)`);
|
|
4361
5220
|
return script;
|
|
4362
5221
|
}
|
|
4363
5222
|
function installOne(ctx, dir) {
|
|
@@ -4377,7 +5236,7 @@ function cmdHooks(args, ctx) {
|
|
|
4377
5236
|
for (const dir of dirs) {
|
|
4378
5237
|
try {
|
|
4379
5238
|
if (sub === "install") {
|
|
4380
|
-
if (values["config-dir"] === void 0 && !
|
|
5239
|
+
if (values["config-dir"] === void 0 && !fs18.existsSync(dir)) {
|
|
4381
5240
|
ctx.err(`no Claude Code directory at ${dir}; pass --config-dir to name one`);
|
|
4382
5241
|
code = EXIT.failure;
|
|
4383
5242
|
continue;
|
|
@@ -4402,11 +5261,11 @@ function forgetRevokedIdentity(ctx) {
|
|
|
4402
5261
|
const file = identityFile(ctx.dirs);
|
|
4403
5262
|
let revoked = false;
|
|
4404
5263
|
try {
|
|
4405
|
-
const raw = JSON.parse(
|
|
5264
|
+
const raw = JSON.parse(fs18.readFileSync(file, "utf8"));
|
|
4406
5265
|
revoked = isObject(raw) && raw.revoked === true;
|
|
4407
5266
|
} catch {
|
|
4408
5267
|
}
|
|
4409
|
-
if (revoked)
|
|
5268
|
+
if (revoked) fs18.rmSync(file, { force: true });
|
|
4410
5269
|
}
|
|
4411
5270
|
async function cmdLogin(args, ctx) {
|
|
4412
5271
|
const { values } = parseArgs2({
|
|
@@ -4441,7 +5300,7 @@ async function cmdLogin(args, ctx) {
|
|
|
4441
5300
|
ctx.err("login needs --identity-url <identity service URL> (or CLAW_IDENTITY_URL, or identity_url = in agents.toml)");
|
|
4442
5301
|
return EXIT.usage;
|
|
4443
5302
|
}
|
|
4444
|
-
const deviceName = values.name ?? cfg?.name ?? ctx.hostname ??
|
|
5303
|
+
const deviceName = values.name ?? cfg?.name ?? ctx.hostname ?? os7.hostname();
|
|
4445
5304
|
const tok = await deviceLogin({ identityUrl, deviceName, fetch: ctx.fetch, print: ctx.out });
|
|
4446
5305
|
saveCredentials(file, {
|
|
4447
5306
|
v: 1,
|
|
@@ -4461,14 +5320,14 @@ async function cmdLogin(args, ctx) {
|
|
|
4461
5320
|
}
|
|
4462
5321
|
function runningPid(ctx) {
|
|
4463
5322
|
try {
|
|
4464
|
-
const raw = JSON.parse(
|
|
5323
|
+
const raw = JSON.parse(fs18.readFileSync(pidFile(ctx.dirs), "utf8"));
|
|
4465
5324
|
if (isObject(raw) && typeof raw.pid === "number" && typeof raw.start === "string" && raw.start !== "" && processStart(raw.pid) === raw.start) return raw.pid;
|
|
4466
5325
|
} catch {
|
|
4467
5326
|
}
|
|
4468
5327
|
return null;
|
|
4469
5328
|
}
|
|
4470
5329
|
async function cmdRun(args, ctx) {
|
|
4471
|
-
parseArgs2({ args, strict: true, options: { supervised: { type: "boolean" } } });
|
|
5330
|
+
const { values: runFlags } = parseArgs2({ args, strict: true, options: { supervised: { type: "boolean" } } });
|
|
4472
5331
|
{
|
|
4473
5332
|
const nl = needsLogin(ctx.dirs);
|
|
4474
5333
|
if (nl) {
|
|
@@ -4478,8 +5337,9 @@ async function cmdRun(args, ctx) {
|
|
|
4478
5337
|
}
|
|
4479
5338
|
let cfg;
|
|
4480
5339
|
let creds;
|
|
5340
|
+
const notes = [];
|
|
4481
5341
|
try {
|
|
4482
|
-
cfg = loadCfg(ctx);
|
|
5342
|
+
cfg = loadCfg(ctx, notes);
|
|
4483
5343
|
creds = loadCredentials(credentialsFile(ctx.dirs));
|
|
4484
5344
|
} catch (e) {
|
|
4485
5345
|
if (e instanceof ConfigError || e instanceof CredentialsError) {
|
|
@@ -4507,8 +5367,10 @@ async function cmdRun(args, ctx) {
|
|
|
4507
5367
|
const log = stderrLogger();
|
|
4508
5368
|
const audit = fileAudit(auditFile(ctx.dirs));
|
|
4509
5369
|
const connector2 = new Connector({ config: cfg, dirs: ctx.dirs, client: client2, server, audit, log, version: VERSION, timing: ctx.timing });
|
|
5370
|
+
if (ctx.installAdapters) for (const { pkg, env } of pinnedOf(cfg.agents, ctx.env)) void adapterStore()?.install(pkg, env, (l) => ctx.out(`adapters: ${l}`));
|
|
4510
5371
|
ctx.onConnector?.(connector2);
|
|
4511
5372
|
ctx.out(`connecting ${cfg.agents.map((a) => a.unattended ? `${a.id} (no per-step approval)` : a.id).join(", ")} to ${server} as "${cfg.name}"`);
|
|
5373
|
+
for (const n of notes) ctx.out(`note: ${n}`);
|
|
4512
5374
|
for (const w of noAsk(ctx, cfg)) ctx.out(`warning: ${w}`);
|
|
4513
5375
|
try {
|
|
4514
5376
|
const result = await connector2.run();
|
|
@@ -4522,6 +5384,10 @@ async function cmdRun(args, ctx) {
|
|
|
4522
5384
|
ctx.err(`Claw keeps refusing this sign-in (${connector2.authFailure}): every agent was stopped. Run login again.`);
|
|
4523
5385
|
return EXIT.needsPerson;
|
|
4524
5386
|
}
|
|
5387
|
+
if (runFlags.supervised && !takeStopRequest(ctx)) {
|
|
5388
|
+
ctx.err(`${(/* @__PURE__ */ new Date()).toISOString()} stopped by a signal that did not come from "claw-connector stop"; exit ${EXIT.terminated}, so the supervisor starts it again`);
|
|
5389
|
+
return EXIT.terminated;
|
|
5390
|
+
}
|
|
4525
5391
|
return EXIT.ok;
|
|
4526
5392
|
} catch (e) {
|
|
4527
5393
|
if (isAuthRefusal(e) || e instanceof RevokedError || e instanceof GrantRevokedError || e instanceof CredentialsError) {
|
|
@@ -4559,7 +5425,7 @@ function cmdStatus(args, ctx) {
|
|
|
4559
5425
|
}
|
|
4560
5426
|
let connectorId = null;
|
|
4561
5427
|
try {
|
|
4562
|
-
const raw = JSON.parse(
|
|
5428
|
+
const raw = JSON.parse(fs18.readFileSync(identityFile(ctx.dirs), "utf8"));
|
|
4563
5429
|
if (isObject(raw) && typeof raw.connector_id === "string") connectorId = raw.connector_id;
|
|
4564
5430
|
} catch {
|
|
4565
5431
|
}
|
|
@@ -4567,7 +5433,7 @@ function cmdStatus(args, ctx) {
|
|
|
4567
5433
|
const status2 = {
|
|
4568
5434
|
version: VERSION,
|
|
4569
5435
|
running: runningPid(ctx),
|
|
4570
|
-
paused:
|
|
5436
|
+
paused: fs18.existsSync(pauseFile(ctx.dirs)),
|
|
4571
5437
|
signed_in: creds ? { kind: creds.kind, server: creds.server, device_name: creds.device_name ?? null } : null,
|
|
4572
5438
|
connector_id: connectorId,
|
|
4573
5439
|
agents,
|
|
@@ -4596,8 +5462,8 @@ function cmdStatus(args, ctx) {
|
|
|
4596
5462
|
return EXIT.ok;
|
|
4597
5463
|
}
|
|
4598
5464
|
async function cmdPause(ctx) {
|
|
4599
|
-
|
|
4600
|
-
|
|
5465
|
+
fs18.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
|
|
5466
|
+
fs18.writeFileSync(pauseFile(ctx.dirs), (/* @__PURE__ */ new Date()).toISOString() + "\n", { mode: 384 });
|
|
4601
5467
|
if (runningPid(ctx) === null) {
|
|
4602
5468
|
const left = await new ChildRegistry(childrenFile(ctx.dirs)).killAll();
|
|
4603
5469
|
ctx.out(`paused (the connector is not running${left.killed.length ? `; stopped ${left.killed.length} leftover agent process group(s)` : ""})`);
|
|
@@ -4607,13 +5473,15 @@ async function cmdPause(ctx) {
|
|
|
4607
5473
|
return EXIT.ok;
|
|
4608
5474
|
}
|
|
4609
5475
|
function cmdResume(ctx) {
|
|
4610
|
-
|
|
5476
|
+
fs18.rmSync(pauseFile(ctx.dirs), { force: true });
|
|
4611
5477
|
ctx.out(runningPid(ctx) === null ? "resumed; start it with: claw-connector run" : "resumed: the running connector reconnects now");
|
|
4612
5478
|
return EXIT.ok;
|
|
4613
5479
|
}
|
|
4614
5480
|
async function cmdStop(ctx, quiet = false) {
|
|
4615
5481
|
const pid = runningPid(ctx);
|
|
4616
5482
|
if (pid !== null) {
|
|
5483
|
+
fs18.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
|
|
5484
|
+
fs18.writeFileSync(stopRequestFile(ctx.dirs), JSON.stringify({ pid, start: processStart(pid) ?? "", at: (/* @__PURE__ */ new Date()).toISOString() }), { mode: 384 });
|
|
4617
5485
|
process.kill(pid, "SIGTERM");
|
|
4618
5486
|
const deadline = Date.now() + 15e3;
|
|
4619
5487
|
while (Date.now() < deadline && isAlive(pid) && processStart(pid) !== null) await sleep(100);
|
|
@@ -4621,14 +5489,27 @@ async function cmdStop(ctx, quiet = false) {
|
|
|
4621
5489
|
process.kill(pid, "SIGKILL");
|
|
4622
5490
|
if (!quiet) ctx.out(`the connector (pid ${pid}) did not stop in 15 s and was killed`);
|
|
4623
5491
|
}
|
|
5492
|
+
fs18.rmSync(stopRequestFile(ctx.dirs), { force: true });
|
|
4624
5493
|
}
|
|
4625
5494
|
const left = await new ChildRegistry(childrenFile(ctx.dirs)).killAll();
|
|
4626
5495
|
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)` : ""}`);
|
|
4627
5496
|
return EXIT.ok;
|
|
4628
5497
|
}
|
|
5498
|
+
function takeStopRequest(ctx) {
|
|
5499
|
+
const file = stopRequestFile(ctx.dirs);
|
|
5500
|
+
let mine = false;
|
|
5501
|
+
try {
|
|
5502
|
+
const raw = JSON.parse(fs18.readFileSync(file, "utf8"));
|
|
5503
|
+
mine = isObject(raw) && raw.pid === process.pid && raw.start === (processStart(process.pid) ?? "");
|
|
5504
|
+
} catch {
|
|
5505
|
+
return false;
|
|
5506
|
+
}
|
|
5507
|
+
if (mine) fs18.rmSync(file, { force: true });
|
|
5508
|
+
return mine;
|
|
5509
|
+
}
|
|
4629
5510
|
function storedConnector(ctx) {
|
|
4630
5511
|
try {
|
|
4631
|
-
const raw = JSON.parse(
|
|
5512
|
+
const raw = JSON.parse(fs18.readFileSync(identityFile(ctx.dirs), "utf8"));
|
|
4632
5513
|
if (isObject(raw) && typeof raw.server === "string" && typeof raw.connector_id === "string") {
|
|
4633
5514
|
return { server: raw.server, connector_id: raw.connector_id, revoked: raw.revoked === true };
|
|
4634
5515
|
}
|
|
@@ -4700,8 +5581,8 @@ async function cmdLogout(ctx) {
|
|
|
4700
5581
|
ctx.err(`could not revoke the sign-in at the identity service (${errMessage(e)}); it was removed from this machine`);
|
|
4701
5582
|
}
|
|
4702
5583
|
}
|
|
4703
|
-
|
|
4704
|
-
if (rowGone)
|
|
5584
|
+
fs18.rmSync(file, { force: true });
|
|
5585
|
+
if (rowGone) fs18.rmSync(identityFile(ctx.dirs), { force: true });
|
|
4705
5586
|
else forgetRevokedIdentity(ctx);
|
|
4706
5587
|
if (row && notRevoked !== null) {
|
|
4707
5588
|
ctx.err(`signed out on this machine, but connector ${row.connector_id} was NOT revoked at Claw (${notRevoked}).`);
|
|
@@ -4742,6 +5623,8 @@ main(process.argv.slice(2), {
|
|
|
4742
5623
|
env: process.env,
|
|
4743
5624
|
out: (l) => process.stdout.write(l + "\n"),
|
|
4744
5625
|
err: (l) => process.stderr.write(l + "\n"),
|
|
5626
|
+
probe: probeAgents,
|
|
5627
|
+
installAdapters: true,
|
|
4745
5628
|
onConnector: (c) => {
|
|
4746
5629
|
connector = c;
|
|
4747
5630
|
for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) process.on(sig, onSignal);
|