@primusclaw/connector 0.1.4 → 0.1.6
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 +155 -38
- package/dist/cli.js +2668 -457
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,16 +1,300 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
5
|
+
|
|
3
6
|
// src/commands.ts
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
+
import fs19 from "node:fs";
|
|
8
|
+
import os9 from "node:os";
|
|
9
|
+
import path21 from "node:path";
|
|
7
10
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
8
11
|
import { parseArgs as parseArgs2 } from "node:util";
|
|
9
12
|
|
|
10
|
-
// src/
|
|
13
|
+
// src/adapter-install.ts
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
11
15
|
import fs from "node:fs";
|
|
12
16
|
import path from "node:path";
|
|
13
17
|
|
|
18
|
+
// src/util.ts
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
var sleep = (ms, signal) => new Promise((resolve) => {
|
|
21
|
+
if (signal?.aborted) return resolve();
|
|
22
|
+
const t = setTimeout(done, ms);
|
|
23
|
+
function done() {
|
|
24
|
+
signal?.removeEventListener("abort", done);
|
|
25
|
+
clearTimeout(t);
|
|
26
|
+
resolve();
|
|
27
|
+
}
|
|
28
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
29
|
+
});
|
|
30
|
+
var sha256 = (s) => createHash("sha256").update(s).digest("hex");
|
|
31
|
+
var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
32
|
+
var errMessage = (e) => e instanceof Error ? e.message : String(e);
|
|
33
|
+
function truncateBytes(text, max) {
|
|
34
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
35
|
+
if (bytes <= max) return { text, truncated: false, bytes };
|
|
36
|
+
let cut = Buffer.from(text, "utf8").subarray(0, max).toString("utf8");
|
|
37
|
+
if (cut.endsWith("\uFFFD")) cut = cut.slice(0, -1);
|
|
38
|
+
return { text: `${cut}
|
|
39
|
+
[truncated, ${bytes} bytes in all]`, truncated: true, bytes };
|
|
40
|
+
}
|
|
41
|
+
function backoffMs(attempt, minMs, maxMs, random = Math.random) {
|
|
42
|
+
const raw = Math.min(maxMs, minMs * 2 ** Math.min(attempt, 30));
|
|
43
|
+
return Math.round(raw * (0.5 + random() / 2));
|
|
44
|
+
}
|
|
45
|
+
var stderrLogger = (quiet = false) => {
|
|
46
|
+
const w = (level) => (msg, extra) => {
|
|
47
|
+
if (quiet && level === "info") return;
|
|
48
|
+
process.stderr.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${level} ${msg}${extra ? " " + JSON.stringify(extra) : ""}
|
|
49
|
+
`);
|
|
50
|
+
};
|
|
51
|
+
return { info: w("info"), warn: w("warn"), error: w("error") };
|
|
52
|
+
};
|
|
53
|
+
var UrlError = class extends Error {
|
|
54
|
+
};
|
|
55
|
+
function secureUrl(raw, what) {
|
|
56
|
+
let u;
|
|
57
|
+
try {
|
|
58
|
+
u = new URL(raw);
|
|
59
|
+
} catch {
|
|
60
|
+
throw new UrlError(`${what} is not a valid URL: ${JSON.stringify(raw.slice(0, 80))}`);
|
|
61
|
+
}
|
|
62
|
+
const loopback = u.hostname === "localhost" || u.hostname === "[::1]" || /^127(\.\d{1,3}){3}$/.test(u.hostname);
|
|
63
|
+
if (u.protocol !== "https:" && !(u.protocol === "http:" && loopback)) {
|
|
64
|
+
throw new UrlError(`${what} must be an https:// URL (http:// is accepted only for localhost), got ${u.protocol}//${u.host}`);
|
|
65
|
+
}
|
|
66
|
+
return raw.replace(/\/+$/, "");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// src/adapter-install.ts
|
|
70
|
+
var PINNED_RE = /^((?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*)@(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)$/;
|
|
71
|
+
var INSTALL_WAIT_MS = 5 * 6e4;
|
|
72
|
+
var INSTALL_TIMEOUT_MS = 15 * 6e4;
|
|
73
|
+
var RETRY_AFTER_MS = 10 * 6e4;
|
|
74
|
+
function pinnedPackage(cfg) {
|
|
75
|
+
const base = path.basename(cfg.command).replace(/\.(cmd|exe)$/i, "");
|
|
76
|
+
if (base !== "npx") return null;
|
|
77
|
+
const [flag, spec, ...rest] = cfg.args;
|
|
78
|
+
if (flag !== "-y" && flag !== "--yes" || typeof spec !== "string") return null;
|
|
79
|
+
const m = PINNED_RE.exec(spec);
|
|
80
|
+
if (!m) return null;
|
|
81
|
+
return { spec, name: m[1], version: m[2], rest };
|
|
82
|
+
}
|
|
83
|
+
function npmCommand(env) {
|
|
84
|
+
const exe = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
85
|
+
for (const d of (env.PATH ?? "").split(path.delimiter)) if (d && isExecutable(path.join(d, exe))) return exe;
|
|
86
|
+
const sibling = path.join(path.dirname(process.execPath), exe);
|
|
87
|
+
return isExecutable(sibling) ? sibling : exe;
|
|
88
|
+
}
|
|
89
|
+
function isExecutable(f) {
|
|
90
|
+
try {
|
|
91
|
+
fs.accessSync(f, fs.constants.X_OK);
|
|
92
|
+
return fs.statSync(f).isFile();
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
var npmInstaller = ({ dir, spec, env, timeoutMs }) => new Promise((resolve, reject) => {
|
|
98
|
+
const args = ["install", "--prefix", dir, "--no-save", "--no-package-lock", "--no-audit", "--no-fund", "--omit=dev", "--loglevel=error", spec];
|
|
99
|
+
let out = "";
|
|
100
|
+
const p = spawn(npmCommand(env), args, { cwd: dir, env, stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32", timeout: timeoutMs, killSignal: "SIGKILL" });
|
|
101
|
+
const keep = (d) => {
|
|
102
|
+
out = (out + d.toString("utf8")).slice(-2e3);
|
|
103
|
+
};
|
|
104
|
+
p.stdout.on("data", keep);
|
|
105
|
+
p.stderr.on("data", keep);
|
|
106
|
+
p.once("error", (e) => reject(new Error(`could not run npm: ${errMessage(e)}`)));
|
|
107
|
+
p.once("exit", (code, signal) => {
|
|
108
|
+
if (code === 0) resolve();
|
|
109
|
+
else reject(new Error(`npm install ${spec} ${signal ? `was killed (${signal})` : `exited ${code}`}${out.trim() ? `: ${out.trim().split("\n").slice(-3).join(" | ")}` : ""}`));
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
function binOf(pkgDir, pkg) {
|
|
113
|
+
let raw;
|
|
114
|
+
try {
|
|
115
|
+
raw = JSON.parse(fs.readFileSync(path.join(pkgDir, "package.json"), "utf8"));
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
if (!isObject(raw) || raw.version !== pkg.version) return null;
|
|
120
|
+
const short = pkg.name.split("/").pop();
|
|
121
|
+
let rel = null;
|
|
122
|
+
if (typeof raw.bin === "string") rel = raw.bin;
|
|
123
|
+
else if (isObject(raw.bin)) {
|
|
124
|
+
const entries = Object.entries(raw.bin);
|
|
125
|
+
rel = entries.length === 1 ? entries[0][1] : raw.bin[short];
|
|
126
|
+
}
|
|
127
|
+
if (typeof rel !== "string" || !rel) return null;
|
|
128
|
+
const file = path.resolve(pkgDir, rel);
|
|
129
|
+
if (!file.startsWith(pkgDir + path.sep)) return null;
|
|
130
|
+
try {
|
|
131
|
+
return fs.statSync(file).isFile() ? file : null;
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function runByNode(file) {
|
|
137
|
+
let head = "";
|
|
138
|
+
try {
|
|
139
|
+
const fd = fs.openSync(file, "r");
|
|
140
|
+
try {
|
|
141
|
+
const b = Buffer.alloc(128);
|
|
142
|
+
head = b.subarray(0, fs.readSync(fd, b, 0, 128, 0)).toString("utf8");
|
|
143
|
+
} finally {
|
|
144
|
+
fs.closeSync(fd);
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
if (head.startsWith("#!")) return /\bnode\b/.test(head.split("\n")[0]);
|
|
150
|
+
return /\.(c|m)?js$/.test(file);
|
|
151
|
+
}
|
|
152
|
+
var AdapterStore = class {
|
|
153
|
+
constructor(dir, o) {
|
|
154
|
+
this.dir = dir;
|
|
155
|
+
this.o = o;
|
|
156
|
+
this.installer = o.installer ?? npmInstaller;
|
|
157
|
+
}
|
|
158
|
+
dir;
|
|
159
|
+
o;
|
|
160
|
+
pending = /* @__PURE__ */ new Map();
|
|
161
|
+
failedAt = /* @__PURE__ */ new Map();
|
|
162
|
+
installer;
|
|
163
|
+
/** Where a package lives once installed. */
|
|
164
|
+
finalDir(pkg) {
|
|
165
|
+
return path.join(this.dir, `${pkg.name.replace("/", "+")}@${pkg.version}`);
|
|
166
|
+
}
|
|
167
|
+
/** The installed bin script, or null when there is no complete install. */
|
|
168
|
+
installed(pkg) {
|
|
169
|
+
return binOf(path.join(this.finalDir(pkg), "node_modules", ...pkg.name.split("/")), pkg);
|
|
170
|
+
}
|
|
171
|
+
/** Whether an install of this package is running in this process. */
|
|
172
|
+
installing(pkg) {
|
|
173
|
+
return this.pending.has(pkg.spec);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Install `pkg` unless it is there. Never throws: null when it could not be installed (the reason goes to `say`).
|
|
177
|
+
* One install per package at a time in this process; another process installing the same one is harmless.
|
|
178
|
+
*/
|
|
179
|
+
install(pkg, env, say = () => {
|
|
180
|
+
}) {
|
|
181
|
+
const have = this.installed(pkg);
|
|
182
|
+
if (have) return Promise.resolve(have);
|
|
183
|
+
const running = this.pending.get(pkg.spec);
|
|
184
|
+
if (running) return running;
|
|
185
|
+
const p = this.doInstall(pkg, env, say).finally(() => this.pending.delete(pkg.spec));
|
|
186
|
+
this.pending.set(pkg.spec, p);
|
|
187
|
+
return p;
|
|
188
|
+
}
|
|
189
|
+
async doInstall(pkg, env, say) {
|
|
190
|
+
const final = this.finalDir(pkg);
|
|
191
|
+
const started = Date.now();
|
|
192
|
+
let tmp = null;
|
|
193
|
+
try {
|
|
194
|
+
fs.mkdirSync(this.dir, { recursive: true, mode: 448 });
|
|
195
|
+
this.sweepAbandoned(pkg);
|
|
196
|
+
tmp = fs.mkdtempSync(path.join(this.dir, `.install-${pkg.name.replace("/", "+")}@${pkg.version}-`));
|
|
197
|
+
say(`installing ${pkg.spec} into ${final} (once; an adapter with its dependencies is a few hundred MB, so this can take a few minutes)`);
|
|
198
|
+
await this.installer({ dir: tmp, spec: pkg.spec, env, timeoutMs: INSTALL_TIMEOUT_MS });
|
|
199
|
+
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}`);
|
|
200
|
+
if (!this.installed(pkg)) {
|
|
201
|
+
if (fs.existsSync(final)) fs.renameSync(final, `${final}.stale-${process.pid}-${Date.now()}`);
|
|
202
|
+
try {
|
|
203
|
+
fs.renameSync(tmp, final);
|
|
204
|
+
tmp = null;
|
|
205
|
+
} catch (e) {
|
|
206
|
+
if (!this.installed(pkg)) throw e;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
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 });
|
|
210
|
+
const bin = this.installed(pkg);
|
|
211
|
+
if (!bin) throw new Error(`${pkg.spec} is not runnable after the install`);
|
|
212
|
+
this.failedAt.delete(pkg.spec);
|
|
213
|
+
say(`installed ${pkg.spec} in ${Math.round((Date.now() - started) / 1e3)} s`);
|
|
214
|
+
return bin;
|
|
215
|
+
} catch (e) {
|
|
216
|
+
this.failedAt.set(pkg.spec, this.now());
|
|
217
|
+
say(`could not install ${pkg.spec}: ${errMessage(e)}; sessions start it through npx instead (slower, and needs the registry each time)`);
|
|
218
|
+
return null;
|
|
219
|
+
} finally {
|
|
220
|
+
if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/** Install directories a killed connector left behind (older than any install can take). */
|
|
224
|
+
sweepAbandoned(pkg) {
|
|
225
|
+
const prefix = `.install-${pkg.name.replace("/", "+")}@${pkg.version}-`;
|
|
226
|
+
for (const f of fs.readdirSync(this.dir)) {
|
|
227
|
+
if (!f.startsWith(prefix)) continue;
|
|
228
|
+
const p = path.join(this.dir, f);
|
|
229
|
+
try {
|
|
230
|
+
if (Date.now() - fs.statSync(p).mtimeMs > 2 * INSTALL_TIMEOUT_MS) fs.rmSync(p, { recursive: true, force: true });
|
|
231
|
+
} catch {
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
now() {
|
|
236
|
+
return (this.o.now ?? Date.now)();
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* How to start an agent's adapter for a session in `cwd`. A pinned package that is installed runs from here; one
|
|
240
|
+
* whose install is running is waited for (at most `waitMs`); otherwise npx runs as configured, in this directory.
|
|
241
|
+
*/
|
|
242
|
+
async launch(cfg, cwd, env) {
|
|
243
|
+
const pkg = pinnedPackage(cfg);
|
|
244
|
+
if (!pkg) return { command: cfg.command, args: cfg.args, cwd, installed: false };
|
|
245
|
+
let bin = this.installed(pkg);
|
|
246
|
+
if (!bin) {
|
|
247
|
+
const failed = this.failedAt.get(pkg.spec);
|
|
248
|
+
if (failed === void 0) {
|
|
249
|
+
const job = this.o.autoInstall ? this.install(pkg, env) : this.pending.get(pkg.spec);
|
|
250
|
+
if (job) bin = await raceTimeout(job, this.o.waitMs ?? INSTALL_WAIT_MS);
|
|
251
|
+
} else if (this.o.autoInstall && !this.installing(pkg) && this.now() - failed >= RETRY_AFTER_MS) {
|
|
252
|
+
void this.install(pkg, env);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (bin) return runByNode(bin) ? { command: process.execPath, args: [bin, ...pkg.rest], cwd, installed: true } : { command: bin, args: pkg.rest, cwd, installed: true };
|
|
256
|
+
fs.mkdirSync(this.dir, { recursive: true, mode: 448 });
|
|
257
|
+
return { command: cfg.command, args: cfg.args, cwd: this.dir, installed: false };
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
function raceTimeout(p, ms) {
|
|
261
|
+
let timer;
|
|
262
|
+
const t = new Promise((resolve) => {
|
|
263
|
+
timer = setTimeout(() => resolve(null), ms);
|
|
264
|
+
timer.unref?.();
|
|
265
|
+
});
|
|
266
|
+
return Promise.race([p, t]).finally(() => clearTimeout(timer));
|
|
267
|
+
}
|
|
268
|
+
var store = null;
|
|
269
|
+
function useAdapterStore(s) {
|
|
270
|
+
store = s;
|
|
271
|
+
}
|
|
272
|
+
function adapterStore() {
|
|
273
|
+
return store;
|
|
274
|
+
}
|
|
275
|
+
function pinnedOf(agents, base = process.env) {
|
|
276
|
+
const seen = /* @__PURE__ */ new Map();
|
|
277
|
+
for (const a of agents) {
|
|
278
|
+
const pkg = pinnedPackage(a);
|
|
279
|
+
if (pkg && !seen.has(pkg.spec)) seen.set(pkg.spec, { pkg, env: { ...base, ...a.env } });
|
|
280
|
+
}
|
|
281
|
+
return [...seen.values()];
|
|
282
|
+
}
|
|
283
|
+
async function installAll(s, agents, base, say) {
|
|
284
|
+
for (const { pkg, env } of pinnedOf(agents, base)) {
|
|
285
|
+
if (s.installed(pkg)) {
|
|
286
|
+
say(`${pkg.spec} is installed`);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
await s.install(pkg, env, say);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
var adaptersDir = (stateDir) => path.join(stateDir, "adapters");
|
|
293
|
+
|
|
294
|
+
// src/audit.ts
|
|
295
|
+
import fs2 from "node:fs";
|
|
296
|
+
import path2 from "node:path";
|
|
297
|
+
|
|
14
298
|
// src/redact.ts
|
|
15
299
|
var MASK = "[hidden]";
|
|
16
300
|
var B64 = "[A-Za-z0-9+/=_-]";
|
|
@@ -58,12 +342,12 @@ function redactDeep(value) {
|
|
|
58
342
|
|
|
59
343
|
// src/audit.ts
|
|
60
344
|
function fileAudit(file, now = Date.now) {
|
|
61
|
-
|
|
345
|
+
fs2.mkdirSync(path2.dirname(file), { recursive: true, mode: 448 });
|
|
62
346
|
return {
|
|
63
347
|
record(kind, detail = {}) {
|
|
64
348
|
const line = JSON.stringify({ ts: new Date(now()).toISOString(), ...redactDeep(detail), kind });
|
|
65
349
|
try {
|
|
66
|
-
|
|
350
|
+
fs2.appendFileSync(file, line + "\n", { mode: 384 });
|
|
67
351
|
} catch {
|
|
68
352
|
process.stderr.write(`audit write failed: ${kind}
|
|
69
353
|
`);
|
|
@@ -74,65 +358,12 @@ function fileAudit(file, now = Date.now) {
|
|
|
74
358
|
|
|
75
359
|
// src/children.ts
|
|
76
360
|
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
|
|
361
|
+
import fs3 from "node:fs";
|
|
362
|
+
import path3 from "node:path";
|
|
132
363
|
function processStart(pid) {
|
|
133
364
|
try {
|
|
134
365
|
if (process.platform === "linux") {
|
|
135
|
-
const stat =
|
|
366
|
+
const stat = fs3.readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
136
367
|
const rest = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
137
368
|
return rest[19] ?? null;
|
|
138
369
|
}
|
|
@@ -157,10 +388,10 @@ var ChildRegistry = class {
|
|
|
157
388
|
file;
|
|
158
389
|
recs = /* @__PURE__ */ new Map();
|
|
159
390
|
save() {
|
|
160
|
-
|
|
391
|
+
fs3.mkdirSync(path3.dirname(this.file), { recursive: true, mode: 448 });
|
|
161
392
|
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
162
|
-
|
|
163
|
-
|
|
393
|
+
fs3.writeFileSync(tmp, JSON.stringify([...this.recs.values()]), { mode: 384 });
|
|
394
|
+
fs3.renameSync(tmp, this.file);
|
|
164
395
|
}
|
|
165
396
|
add(rec) {
|
|
166
397
|
const full = { ...rec, start: rec.start ?? processStart(rec.pid) ?? "", started_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
@@ -251,7 +482,7 @@ var ChildRegistry = class {
|
|
|
251
482
|
};
|
|
252
483
|
function readRecords(file) {
|
|
253
484
|
try {
|
|
254
|
-
const raw = JSON.parse(
|
|
485
|
+
const raw = JSON.parse(fs3.readFileSync(file, "utf8"));
|
|
255
486
|
return Array.isArray(raw) ? raw.filter((r) => Number.isInteger(r?.pid) && Number.isInteger(r?.pgid) && r.pgid > 1) : [];
|
|
256
487
|
} catch {
|
|
257
488
|
return [];
|
|
@@ -335,11 +566,11 @@ var RevokedError = class extends Error {
|
|
|
335
566
|
}
|
|
336
567
|
};
|
|
337
568
|
var HttpError = class extends Error {
|
|
338
|
-
constructor(status2, code,
|
|
339
|
-
super(`${
|
|
569
|
+
constructor(status2, code, path22) {
|
|
570
|
+
super(`${path22} answered ${status2} ${code}`);
|
|
340
571
|
this.status = status2;
|
|
341
572
|
this.code = code;
|
|
342
|
-
this.path =
|
|
573
|
+
this.path = path22;
|
|
343
574
|
}
|
|
344
575
|
status;
|
|
345
576
|
code;
|
|
@@ -363,8 +594,8 @@ var ClawClient = class {
|
|
|
363
594
|
...extra
|
|
364
595
|
};
|
|
365
596
|
}
|
|
366
|
-
async send(method,
|
|
367
|
-
const url = this.o.server +
|
|
597
|
+
async send(method, path22, body, signal) {
|
|
598
|
+
const url = this.o.server + path22;
|
|
368
599
|
for (let attempt = 0; ; attempt++) {
|
|
369
600
|
const headers = await this.headers(body === void 0 ? {} : { "content-type": "application/json" });
|
|
370
601
|
let res;
|
|
@@ -376,7 +607,7 @@ var ClawClient = class {
|
|
|
376
607
|
signal: signal ?? AbortSignal.timeout(this.o.requestTimeoutMs ?? 2e4)
|
|
377
608
|
});
|
|
378
609
|
} catch (e) {
|
|
379
|
-
throw new TransportError(`${method} ${
|
|
610
|
+
throw new TransportError(`${method} ${path22}: ${errMessage(e)}`);
|
|
380
611
|
}
|
|
381
612
|
if (res.status !== 401) return res;
|
|
382
613
|
const err = await errorCode(res);
|
|
@@ -385,12 +616,12 @@ var ClawClient = class {
|
|
|
385
616
|
this.o.tokens.invalidate();
|
|
386
617
|
continue;
|
|
387
618
|
}
|
|
388
|
-
throw new HttpError(401, err,
|
|
619
|
+
throw new HttpError(401, err, path22);
|
|
389
620
|
}
|
|
390
621
|
}
|
|
391
|
-
async json(method,
|
|
392
|
-
const res = await this.send(method,
|
|
393
|
-
if (!res.ok) throw new HttpError(res.status, await errorCode(res),
|
|
622
|
+
async json(method, path22, body) {
|
|
623
|
+
const res = await this.send(method, path22, body);
|
|
624
|
+
if (!res.ok) throw new HttpError(res.status, await errorCode(res), path22);
|
|
394
625
|
const parsed = await res.json().catch(() => null);
|
|
395
626
|
return isObject(parsed) && isObject(parsed.data) ? parsed.data : {};
|
|
396
627
|
}
|
|
@@ -439,21 +670,21 @@ var ClawClient = class {
|
|
|
439
670
|
};
|
|
440
671
|
try {
|
|
441
672
|
arm();
|
|
442
|
-
const
|
|
673
|
+
const path22 = `/v1/remote-agents/connectors/${cid}/commands`;
|
|
443
674
|
const headers = await this.headers({ accept: "text/event-stream", ...opts.lastEventId ? { "last-event-id": String(opts.lastEventId) } : {} });
|
|
444
675
|
let res;
|
|
445
676
|
try {
|
|
446
|
-
res = await this.f(this.o.server +
|
|
677
|
+
res = await this.f(this.o.server + path22, { headers, signal: stop.signal });
|
|
447
678
|
} catch (e) {
|
|
448
|
-
throw new TransportError(timedOut ? `no response for ${opts.watchdogMs} ms` : `GET ${
|
|
679
|
+
throw new TransportError(timedOut ? `no response for ${opts.watchdogMs} ms` : `GET ${path22}: ${errMessage(e)}`);
|
|
449
680
|
}
|
|
450
681
|
if (res.status === 401) {
|
|
451
682
|
const err = await errorCode(res);
|
|
452
683
|
if (err === "revoked") throw new RevokedError();
|
|
453
684
|
this.o.tokens.invalidate();
|
|
454
|
-
throw new HttpError(401, err,
|
|
685
|
+
throw new HttpError(401, err, path22);
|
|
455
686
|
}
|
|
456
|
-
if (!res.ok || !res.body) throw new HttpError(res.status, await errorCode(res),
|
|
687
|
+
if (!res.ok || !res.body) throw new HttpError(res.status, await errorCode(res), path22);
|
|
457
688
|
try {
|
|
458
689
|
for await (const frame of readSse(res.body, arm)) yield frame;
|
|
459
690
|
} catch (e) {
|
|
@@ -477,55 +708,6 @@ import fs4 from "node:fs";
|
|
|
477
708
|
import os from "node:os";
|
|
478
709
|
import path4 from "node:path";
|
|
479
710
|
import { parse as parseToml } from "smol-toml";
|
|
480
|
-
|
|
481
|
-
// src/roots.ts
|
|
482
|
-
import fs3 from "node:fs";
|
|
483
|
-
import path3 from "node:path";
|
|
484
|
-
function realRoot(root) {
|
|
485
|
-
try {
|
|
486
|
-
return fs3.realpathSync.native(root);
|
|
487
|
-
} catch {
|
|
488
|
-
return null;
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
var inside = (child, root) => child === root || child.startsWith(root.endsWith(path3.sep) ? root : root + path3.sep);
|
|
492
|
-
function checkCwd(cwd, roots) {
|
|
493
|
-
if (typeof cwd !== "string" || cwd === "" || cwd.includes("\0")) return { ok: false, reason: "invalid_cwd" };
|
|
494
|
-
if (!path3.isAbsolute(cwd)) return { ok: false, reason: "cwd_not_absolute" };
|
|
495
|
-
let real;
|
|
496
|
-
try {
|
|
497
|
-
real = fs3.realpathSync.native(cwd);
|
|
498
|
-
} catch {
|
|
499
|
-
return { ok: false, reason: "cwd_not_found" };
|
|
500
|
-
}
|
|
501
|
-
try {
|
|
502
|
-
if (!fs3.statSync(real).isDirectory()) return { ok: false, reason: "cwd_not_directory" };
|
|
503
|
-
} catch {
|
|
504
|
-
return { ok: false, reason: "cwd_not_found" };
|
|
505
|
-
}
|
|
506
|
-
for (const root of roots) {
|
|
507
|
-
const r = realRoot(root);
|
|
508
|
-
if (r && inside(real, r)) return { ok: true, real };
|
|
509
|
-
}
|
|
510
|
-
return { ok: false, reason: "cwd_outside_roots" };
|
|
511
|
-
}
|
|
512
|
-
function relativeInside(p, cwd) {
|
|
513
|
-
const abs = path3.resolve(cwd, p);
|
|
514
|
-
let real;
|
|
515
|
-
try {
|
|
516
|
-
real = fs3.realpathSync.native(abs);
|
|
517
|
-
} catch {
|
|
518
|
-
try {
|
|
519
|
-
real = path3.join(fs3.realpathSync.native(path3.dirname(abs)), path3.basename(abs));
|
|
520
|
-
} catch {
|
|
521
|
-
return null;
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
if (!inside(real, cwd)) return null;
|
|
525
|
-
return path3.relative(cwd, real).split(path3.sep).join("/");
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
// src/config.ts
|
|
529
711
|
var ConfigError = class extends Error {
|
|
530
712
|
constructor(problems) {
|
|
531
713
|
super(`agents.toml: ${problems.join("; ")}`);
|
|
@@ -541,7 +723,8 @@ var PRESETS = {
|
|
|
541
723
|
args: ["-y", "@agentclientprotocol/claude-agent-acp@0.99.0"],
|
|
542
724
|
askMode: "default",
|
|
543
725
|
safeModes: ["default", "plan"],
|
|
544
|
-
unattended: { askMode: "bypassPermissions" }
|
|
726
|
+
unattended: { askMode: "bypassPermissions" },
|
|
727
|
+
readOnly: ["plan"]
|
|
545
728
|
},
|
|
546
729
|
// Existing Claude Code sessions, taken over from the phone. The kind is the opaque label a chat client picks an icon
|
|
547
730
|
// from; it says what the entry is for, not which product serves it. Same adapter and same ask mode as `claude`.
|
|
@@ -553,7 +736,8 @@ var PRESETS = {
|
|
|
553
736
|
askMode: "default",
|
|
554
737
|
safeModes: ["default", "plan"],
|
|
555
738
|
attach: true,
|
|
556
|
-
unattended: { askMode: "bypassPermissions" }
|
|
739
|
+
unattended: { askMode: "bypassPermissions" },
|
|
740
|
+
readOnly: ["plan"]
|
|
557
741
|
},
|
|
558
742
|
codex: {
|
|
559
743
|
kind: "codex",
|
|
@@ -571,7 +755,8 @@ var PRESETS = {
|
|
|
571
755
|
args: ["acp"],
|
|
572
756
|
askMode: "ask",
|
|
573
757
|
safeModes: ["ask", "plan"],
|
|
574
|
-
unattended: { askMode: "agent", args: ["--force", "acp"] }
|
|
758
|
+
unattended: { askMode: "agent", args: ["--force", "acp"] },
|
|
759
|
+
readOnly: ["ask", "plan"]
|
|
575
760
|
}
|
|
576
761
|
};
|
|
577
762
|
var ID_RE = /^[A-Za-z0-9._-]{1,64}$/;
|
|
@@ -656,7 +841,6 @@ function parseConfig(text, defaults = {}) {
|
|
|
656
841
|
if (roots.length === 0) problems.push(`${label}: roots is required (an agent with no allowed directory is never offered)`);
|
|
657
842
|
for (const r of roots) {
|
|
658
843
|
if (r === path4.parse(r).root) problems.push(`${label}: a root may not be the filesystem root`);
|
|
659
|
-
else if (!realRoot(r)) problems.push(`${label}: root ${r} does not exist`);
|
|
660
844
|
}
|
|
661
845
|
const env = {};
|
|
662
846
|
if (raw.env !== void 0) {
|
|
@@ -692,13 +876,56 @@ function parseConfig(text, defaults = {}) {
|
|
|
692
876
|
askingModes: [...asking],
|
|
693
877
|
allowUnsafeModes: allowUnsafe,
|
|
694
878
|
unattended: !asking.has(askMode),
|
|
879
|
+
// What the preset measured; a custom agent declares nothing here.
|
|
880
|
+
readOnlyModes: [...preset?.readOnly ?? []],
|
|
695
881
|
autoAllow: [...globalAllow, ...own],
|
|
696
882
|
attach
|
|
697
883
|
});
|
|
698
884
|
});
|
|
885
|
+
if (Array.isArray(rawAgents)) agents.push(...existingSessionAgents(rawAgents, agents, problems));
|
|
699
886
|
if (problems.length) throw new ConfigError(problems);
|
|
700
887
|
return { server, identityUrl, name, allowUnsafeModes, dropMessagePatterns, agents };
|
|
701
888
|
}
|
|
889
|
+
var EXISTING_SESSIONS_SUFFIX = "-sessions";
|
|
890
|
+
function existingSessionAgents(rawAgents, agents, problems) {
|
|
891
|
+
const out = [];
|
|
892
|
+
const configDir = (a) => path4.resolve(expandHome(a.env.CLAUDE_CONFIG_DIR ?? process.env.CLAUDE_CONFIG_DIR ?? path4.join(os.homedir(), ".claude")));
|
|
893
|
+
const ids = new Set(agents.map((a) => a.id));
|
|
894
|
+
for (const raw of rawAgents) {
|
|
895
|
+
if (!isObject(raw) || typeof raw.id !== "string") continue;
|
|
896
|
+
const flag = raw.existing_sessions;
|
|
897
|
+
if (flag !== void 0 && typeof flag !== "boolean") {
|
|
898
|
+
problems.push(`agent ${raw.id}: existing_sessions must be true or false`);
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
if (flag !== void 0 && raw.preset !== "claude") {
|
|
902
|
+
problems.push(`agent ${raw.id}: existing_sessions only applies to an agent made from the claude preset`);
|
|
903
|
+
continue;
|
|
904
|
+
}
|
|
905
|
+
if (raw.preset !== "claude" || flag === false) continue;
|
|
906
|
+
const src = agents.find((a) => a.id === raw.id);
|
|
907
|
+
if (!src) continue;
|
|
908
|
+
if ([...agents, ...out].some((a) => a.attach && configDir(a) === configDir(src))) continue;
|
|
909
|
+
const id = `${src.id.slice(0, 64 - EXISTING_SESSIONS_SUFFIX.length)}${EXISTING_SESSIONS_SUFFIX}`;
|
|
910
|
+
if (ids.has(id)) continue;
|
|
911
|
+
ids.add(id);
|
|
912
|
+
const name = src.name === PRESETS.claude.name ? PRESETS["claude-attach"].name : `${src.name} (existing sessions)`.slice(0, 128);
|
|
913
|
+
out.push({
|
|
914
|
+
...src,
|
|
915
|
+
id,
|
|
916
|
+
kind: PRESETS["claude-attach"].kind,
|
|
917
|
+
name,
|
|
918
|
+
args: [...src.args],
|
|
919
|
+
roots: [...src.roots],
|
|
920
|
+
env: { ...src.env },
|
|
921
|
+
safeModes: [...src.safeModes],
|
|
922
|
+
askingModes: [...src.askingModes],
|
|
923
|
+
autoAllow: [...src.autoAllow],
|
|
924
|
+
attach: { cli: "claude" }
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
return out;
|
|
928
|
+
}
|
|
702
929
|
function parseAutoAllow(raw, at, problems) {
|
|
703
930
|
if (raw === void 0) return [];
|
|
704
931
|
if (!Array.isArray(raw)) {
|
|
@@ -734,7 +961,9 @@ function sampleApprovals(approval) {
|
|
|
734
961
|
return { unattended: never.filter((id) => SAMPLE_ACTIVE.includes(id)), parked: never.filter((id) => !SAMPLE_ACTIVE.includes(id)) };
|
|
735
962
|
}
|
|
736
963
|
var SAMPLE_NOTES = {
|
|
737
|
-
"claude-attach": "# Take over a Claude Code conversation that already exists on this machine (exit it in the terminal first)."
|
|
964
|
+
"claude-attach": "# Take over a Claude Code conversation that already exists on this machine (exit it in the terminal first).",
|
|
965
|
+
cursor: `# Cursor asks before it runs a command but cannot ask before it edits a file, so with approval "ask" its sessions are
|
|
966
|
+
# read-only (ask and plan modes): they answer questions and change nothing. To let it write code, set approval = "never".`
|
|
738
967
|
};
|
|
739
968
|
var NEVER_NOTE = `# No per-step approval: this agent runs like it does in your terminal. A phone session writes files and runs
|
|
740
969
|
# commands without asking, and the phone shows it as unattended. To go back to asking, set it to "ask".`;
|
|
@@ -1007,7 +1236,6 @@ async function deviceLogin(o) {
|
|
|
1007
1236
|
import fs6 from "node:fs";
|
|
1008
1237
|
import os2 from "node:os";
|
|
1009
1238
|
import path6 from "node:path";
|
|
1010
|
-
var isClaude = (a) => !a.attach && [a.command, ...a.args].some((s) => s.includes("claude-agent-acp"));
|
|
1011
1239
|
function managedSettingsFile(platform = process.platform) {
|
|
1012
1240
|
if (platform === "darwin") return "/Library/Application Support/ClaudeCode/managed-settings.json";
|
|
1013
1241
|
if (platform === "win32") return "C:\\Program Files\\ClaudeCode\\managed-settings.json";
|
|
@@ -1029,50 +1257,46 @@ function readSettings(file, unreadable) {
|
|
|
1029
1257
|
return null;
|
|
1030
1258
|
}
|
|
1031
1259
|
}
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
["user", path6.join(configDir, "settings.json")],
|
|
1037
|
-
...o.cwd === null ? [] : [
|
|
1038
|
-
["project", path6.join(o.cwd, ".claude", "settings.json")],
|
|
1039
|
-
["local", path6.join(o.cwd, ".claude", "settings.local.json")]
|
|
1040
|
-
],
|
|
1041
|
-
["managed", o.managedFile ?? managedSettingsFile()]
|
|
1042
|
-
];
|
|
1043
|
-
const unreadable = [];
|
|
1044
|
-
let raw;
|
|
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 = {}) {
|
|
1260
|
+
var runsClaudeAdapter = (a) => [a.command, ...a.args].some((s) => s.includes("claude-agent-acp"));
|
|
1261
|
+
var currentEuid = (euid) => euid === void 0 ? process.geteuid?.() ?? null : euid;
|
|
1262
|
+
function applyRootSandbox(agents, env, opts = {}) {
|
|
1263
|
+
if (currentEuid(opts.euid) !== 0 || env.IS_SANDBOX) return [];
|
|
1065
1264
|
const out = [];
|
|
1066
1265
|
for (const a of agents) {
|
|
1067
|
-
if (!
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1266
|
+
if (!runsClaudeAdapter(a) || !a.allowUnsafeModes || Object.hasOwn(a.env, "IS_SANDBOX")) continue;
|
|
1267
|
+
a.env = { ...a.env, IS_SANDBOX: "1" };
|
|
1268
|
+
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`);
|
|
1269
|
+
}
|
|
1270
|
+
return out;
|
|
1271
|
+
}
|
|
1272
|
+
function bypassUnavailableWarnings(agents, env, opts = {}) {
|
|
1273
|
+
const out = [];
|
|
1274
|
+
const euid = currentEuid(opts.euid);
|
|
1275
|
+
for (const a of agents) {
|
|
1276
|
+
if (!runsClaudeAdapter(a) || !a.allowUnsafeModes || a.askMode !== "bypassPermissions") continue;
|
|
1277
|
+
const merged = { ...env, ...a.env };
|
|
1278
|
+
if (euid === 0 && !merged.IS_SANDBOX) {
|
|
1279
|
+
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"`);
|
|
1280
|
+
continue;
|
|
1281
|
+
}
|
|
1282
|
+
const home = merged.HOME?.trim() || os2.homedir();
|
|
1283
|
+
const configDir = merged.CLAUDE_CONFIG_DIR?.trim() || path6.join(home, ".claude");
|
|
1284
|
+
const unreadable = [];
|
|
1285
|
+
for (const file of [path6.join(configDir, "settings.json"), opts.managedFile ?? managedSettingsFile()]) {
|
|
1286
|
+
const s = readSettings(file, unreadable);
|
|
1287
|
+
const perms = s && isObject(s.permissions) ? s.permissions : null;
|
|
1288
|
+
if (perms?.disableBypassPermissionsMode === "disable") {
|
|
1289
|
+
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`);
|
|
1290
|
+
break;
|
|
1291
|
+
}
|
|
1071
1292
|
}
|
|
1072
|
-
for (const f of r.unreadable) out.push(`${a.id}: cannot tell whether Claude defaults to bypassPermissions: ${f} is not readable JSON`);
|
|
1073
1293
|
}
|
|
1074
1294
|
return out;
|
|
1075
1295
|
}
|
|
1296
|
+
function claudeSessionMeta(a) {
|
|
1297
|
+
if (!runsClaudeAdapter(a) || a.attach || a.allowUnsafeModes) return {};
|
|
1298
|
+
return { _meta: { claudeCode: { options: { allowDangerouslySkipPermissions: false } } } };
|
|
1299
|
+
}
|
|
1076
1300
|
|
|
1077
1301
|
// src/hooks.ts
|
|
1078
1302
|
import fs8 from "node:fs";
|
|
@@ -1318,21 +1542,23 @@ function hooksStatus(configDir) {
|
|
|
1318
1542
|
}
|
|
1319
1543
|
|
|
1320
1544
|
// src/connector.ts
|
|
1321
|
-
import
|
|
1545
|
+
import fs16 from "node:fs";
|
|
1322
1546
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1323
1547
|
|
|
1324
1548
|
// src/attach.ts
|
|
1325
1549
|
import { execFile, execFileSync as execFileSync2 } from "node:child_process";
|
|
1326
|
-
import
|
|
1550
|
+
import fs11 from "node:fs";
|
|
1327
1551
|
import os3 from "node:os";
|
|
1328
|
-
import
|
|
1552
|
+
import path11 from "node:path";
|
|
1553
|
+
import { StringDecoder } from "node:string_decoder";
|
|
1329
1554
|
|
|
1330
1555
|
// src/adapter.ts
|
|
1331
|
-
import { spawn } from "node:child_process";
|
|
1556
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
1332
1557
|
import fs9 from "node:fs";
|
|
1333
1558
|
import path9 from "node:path";
|
|
1334
1559
|
import { Readable, Writable } from "node:stream";
|
|
1335
1560
|
import * as acp from "@agentclientprotocol/sdk";
|
|
1561
|
+
var AUTH_STATUS_METHOD = "_auth/status_update";
|
|
1336
1562
|
var AdapterProcess = class _AdapterProcess {
|
|
1337
1563
|
constructor(child, pid, registry) {
|
|
1338
1564
|
this.child = child;
|
|
@@ -1343,21 +1569,28 @@ var AdapterProcess = class _AdapterProcess {
|
|
|
1343
1569
|
pid;
|
|
1344
1570
|
registry;
|
|
1345
1571
|
caps = {};
|
|
1572
|
+
/** The ways to sign in the adapter offered at `initialize` (`authMethods`), for `authenticate`. */
|
|
1573
|
+
authMethods = [];
|
|
1574
|
+
/** The last sign-in it reported, if it reports one; see {@link waitAuthStatus}. */
|
|
1575
|
+
authStatus = null;
|
|
1346
1576
|
exited = false;
|
|
1347
1577
|
conn;
|
|
1578
|
+
authWaiters = [];
|
|
1348
1579
|
static async start(o) {
|
|
1349
1580
|
fs9.mkdirSync(path9.dirname(o.logFile), { recursive: true, mode: 448 });
|
|
1581
|
+
const env = { ...process.env, ...o.cfg.env };
|
|
1582
|
+
const run2 = await adapterStore()?.launch(o.cfg, o.cwd, env) ?? { command: o.cfg.command, args: o.cfg.args, cwd: o.cwd };
|
|
1350
1583
|
const log = fs9.openSync(o.logFile, "a", 384);
|
|
1351
|
-
const child =
|
|
1352
|
-
cwd:
|
|
1353
|
-
env
|
|
1584
|
+
const child = spawn2(run2.command, run2.args, {
|
|
1585
|
+
cwd: run2.cwd,
|
|
1586
|
+
env,
|
|
1354
1587
|
stdio: ["pipe", "pipe", "pipe"],
|
|
1355
1588
|
// Its own process group, so the whole tree (an npx wrapper and the agent below it) can be stopped as one.
|
|
1356
1589
|
detached: true
|
|
1357
1590
|
});
|
|
1358
1591
|
await new Promise((resolve, reject) => {
|
|
1359
1592
|
child.once("spawn", () => resolve());
|
|
1360
|
-
child.once("error", (e) => reject(new Error(`could not start ${
|
|
1593
|
+
child.once("error", (e) => reject(new Error(`could not start ${run2.command}: ${errMessage(e)}`)));
|
|
1361
1594
|
}).catch((e) => {
|
|
1362
1595
|
fs9.closeSync(log);
|
|
1363
1596
|
throw e;
|
|
@@ -1413,7 +1646,12 @@ var AdapterProcess = class _AdapterProcess {
|
|
|
1413
1646
|
if (ctx.method.startsWith("cursor/")) return await o.hooks.onExtension(ctx.method, ctx.params ?? {});
|
|
1414
1647
|
throw acp.RequestError.methodNotFound(ctx.method);
|
|
1415
1648
|
}).onNotification("*", async (ctx) => {
|
|
1416
|
-
|
|
1649
|
+
const params = ctx.params ?? {};
|
|
1650
|
+
if (ctx.method === AUTH_STATUS_METHOD && params.authStatus && typeof params.authStatus.kind === "string") {
|
|
1651
|
+
a.authStatus = { kind: params.authStatus.kind, ...typeof params.authStatus.label === "string" ? { label: params.authStatus.label } : {} };
|
|
1652
|
+
for (const w of a.authWaiters.splice(0)) w();
|
|
1653
|
+
}
|
|
1654
|
+
o.hooks.onNotification(ctx.method, params);
|
|
1417
1655
|
});
|
|
1418
1656
|
a.conn = app.connect(acp.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout)));
|
|
1419
1657
|
try {
|
|
@@ -1423,12 +1661,46 @@ var AdapterProcess = class _AdapterProcess {
|
|
|
1423
1661
|
"initialize"
|
|
1424
1662
|
);
|
|
1425
1663
|
a.caps = init.agentCapabilities ?? {};
|
|
1664
|
+
const methods2 = init.authMethods;
|
|
1665
|
+
a.authMethods = Array.isArray(methods2) ? methods2.filter((m) => typeof m === "object" && m !== null) : [];
|
|
1426
1666
|
} catch (e) {
|
|
1427
1667
|
await a.kill();
|
|
1428
1668
|
throw e;
|
|
1429
1669
|
}
|
|
1430
1670
|
return a;
|
|
1431
1671
|
}
|
|
1672
|
+
/** Whether the adapter says it reports its sign-in (`agentCapabilities._meta.authStatus`). */
|
|
1673
|
+
get reportsAuth() {
|
|
1674
|
+
const meta = this.caps._meta;
|
|
1675
|
+
return !!meta && typeof meta === "object" && meta.authStatus !== void 0;
|
|
1676
|
+
}
|
|
1677
|
+
/**
|
|
1678
|
+
* The sign-in the adapter reports, waiting up to `ms` for its first report (claude-agent-acp sends it shortly after
|
|
1679
|
+
* `initialize`, and again for each new session). Null when it reports nothing in that time: not known, not "none".
|
|
1680
|
+
*/
|
|
1681
|
+
async waitAuthStatus(ms) {
|
|
1682
|
+
if (this.authStatus || !this.reportsAuth || ms <= 0) return this.authStatus;
|
|
1683
|
+
await new Promise((resolve) => {
|
|
1684
|
+
const done = () => {
|
|
1685
|
+
clearTimeout(t);
|
|
1686
|
+
const i = this.authWaiters.indexOf(done);
|
|
1687
|
+
if (i >= 0) this.authWaiters.splice(i, 1);
|
|
1688
|
+
resolve();
|
|
1689
|
+
};
|
|
1690
|
+
const t = setTimeout(done, ms);
|
|
1691
|
+
t.unref?.();
|
|
1692
|
+
this.authWaiters.push(done);
|
|
1693
|
+
});
|
|
1694
|
+
return this.authStatus;
|
|
1695
|
+
}
|
|
1696
|
+
/** The id of an `authenticate` method that signs in with an API key the adapter reads from its own environment. */
|
|
1697
|
+
apiKeyMethod() {
|
|
1698
|
+
for (const m of this.authMethods) {
|
|
1699
|
+
const id = typeof m.id === "string" ? m.id : typeof m.methodId === "string" ? m.methodId : null;
|
|
1700
|
+
if (id === "api-key") return id;
|
|
1701
|
+
}
|
|
1702
|
+
return null;
|
|
1703
|
+
}
|
|
1432
1704
|
request(method, params, timeoutMs = 6e4) {
|
|
1433
1705
|
return withTimeout(this.conn.agent.request(method, params), timeoutMs, method);
|
|
1434
1706
|
}
|
|
@@ -1504,6 +1776,155 @@ function isRemoteCommandType(v) {
|
|
|
1504
1776
|
return typeof v === "string" && REMOTE_COMMAND_TYPES.includes(v);
|
|
1505
1777
|
}
|
|
1506
1778
|
|
|
1779
|
+
// src/roots.ts
|
|
1780
|
+
import fs10 from "node:fs";
|
|
1781
|
+
import path10 from "node:path";
|
|
1782
|
+
function realRoot(root) {
|
|
1783
|
+
try {
|
|
1784
|
+
return fs10.realpathSync.native(root);
|
|
1785
|
+
} catch {
|
|
1786
|
+
return null;
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
var inside = (child, root) => child === root || child.startsWith(root.endsWith(path10.sep) ? root : root + path10.sep);
|
|
1790
|
+
function checkCwd(cwd, roots) {
|
|
1791
|
+
if (typeof cwd !== "string" || cwd === "" || cwd.includes("\0")) return { ok: false, reason: "invalid_cwd" };
|
|
1792
|
+
if (!path10.isAbsolute(cwd)) return { ok: false, reason: "cwd_not_absolute" };
|
|
1793
|
+
let real;
|
|
1794
|
+
try {
|
|
1795
|
+
real = fs10.realpathSync.native(cwd);
|
|
1796
|
+
} catch {
|
|
1797
|
+
return { ok: false, reason: "cwd_not_found" };
|
|
1798
|
+
}
|
|
1799
|
+
try {
|
|
1800
|
+
if (!fs10.statSync(real).isDirectory()) return { ok: false, reason: "cwd_not_directory" };
|
|
1801
|
+
} catch {
|
|
1802
|
+
return { ok: false, reason: "cwd_not_found" };
|
|
1803
|
+
}
|
|
1804
|
+
for (const root of roots) {
|
|
1805
|
+
const r = realRoot(root);
|
|
1806
|
+
if (r && inside(real, r)) return { ok: true, real };
|
|
1807
|
+
}
|
|
1808
|
+
return { ok: false, reason: "cwd_outside_roots" };
|
|
1809
|
+
}
|
|
1810
|
+
var stalled = /* @__PURE__ */ new Set();
|
|
1811
|
+
var Stalled = class extends Error {
|
|
1812
|
+
};
|
|
1813
|
+
async function bounded(what, f, ms) {
|
|
1814
|
+
if (stalled.has(what)) throw new Stalled(what);
|
|
1815
|
+
let timer;
|
|
1816
|
+
const call = f();
|
|
1817
|
+
const late = new Promise((_, reject) => {
|
|
1818
|
+
timer = setTimeout(() => reject(new Stalled(what)), ms);
|
|
1819
|
+
timer.unref?.();
|
|
1820
|
+
});
|
|
1821
|
+
try {
|
|
1822
|
+
return await Promise.race([call, late]);
|
|
1823
|
+
} catch (e) {
|
|
1824
|
+
if (e instanceof Stalled) {
|
|
1825
|
+
stalled.add(what);
|
|
1826
|
+
void call.then(() => stalled.delete(what), () => stalled.delete(what));
|
|
1827
|
+
}
|
|
1828
|
+
throw e;
|
|
1829
|
+
} finally {
|
|
1830
|
+
clearTimeout(timer);
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
var realpathOf = (p, ms, f = fs10.promises) => bounded(p, () => f.realpath(p), ms);
|
|
1834
|
+
var isDir = async (p, ms, f = fs10.promises) => {
|
|
1835
|
+
try {
|
|
1836
|
+
return (await bounded(`stat:${p}`, () => f.stat(p), ms)).isDirectory();
|
|
1837
|
+
} catch (e) {
|
|
1838
|
+
if (e instanceof Stalled) throw e;
|
|
1839
|
+
return false;
|
|
1840
|
+
}
|
|
1841
|
+
};
|
|
1842
|
+
async function resolveCwd(cwd, roots, o) {
|
|
1843
|
+
const ms = o.timeoutMs ?? 1e4;
|
|
1844
|
+
if (typeof cwd !== "string" || cwd.trim() === "" || cwd.includes("\0")) return { ok: false, reason: "invalid_cwd" };
|
|
1845
|
+
const typed = cwd.trim();
|
|
1846
|
+
let candidates;
|
|
1847
|
+
if (typed === "~" || typed.startsWith("~/")) candidates = [path10.join(o.home, typed.slice(2))];
|
|
1848
|
+
else if (path10.isAbsolute(typed)) candidates = [typed];
|
|
1849
|
+
else if (typed.startsWith("~")) return { ok: false, reason: "cwd_not_absolute" };
|
|
1850
|
+
else {
|
|
1851
|
+
const clean = path10.normalize(typed).replace(/\/+$/, "");
|
|
1852
|
+
if (clean === ".." || clean.startsWith("../")) return { ok: false, reason: "cwd_outside_roots" };
|
|
1853
|
+
candidates = [.../* @__PURE__ */ new Set([
|
|
1854
|
+
...roots.map((r) => path10.join(r, clean)),
|
|
1855
|
+
...roots.filter((r) => path10.basename(r) === clean)
|
|
1856
|
+
])];
|
|
1857
|
+
}
|
|
1858
|
+
const realRoots = [];
|
|
1859
|
+
const down = [];
|
|
1860
|
+
await Promise.all(roots.map(async (r) => {
|
|
1861
|
+
try {
|
|
1862
|
+
realRoots.push(await realpathOf(r, ms, o.fs));
|
|
1863
|
+
} catch {
|
|
1864
|
+
down.push(path10.resolve(r));
|
|
1865
|
+
}
|
|
1866
|
+
}));
|
|
1867
|
+
const underDown = (p) => down.some((r) => inside(path10.resolve(p), r));
|
|
1868
|
+
const hits = /* @__PURE__ */ new Map();
|
|
1869
|
+
let unavailable = false;
|
|
1870
|
+
for (const c of candidates) {
|
|
1871
|
+
if (underDown(c)) {
|
|
1872
|
+
unavailable = true;
|
|
1873
|
+
continue;
|
|
1874
|
+
}
|
|
1875
|
+
let real;
|
|
1876
|
+
try {
|
|
1877
|
+
real = await realpathOf(c, ms, o.fs);
|
|
1878
|
+
} catch (e) {
|
|
1879
|
+
if (e instanceof Stalled) unavailable = true;
|
|
1880
|
+
continue;
|
|
1881
|
+
}
|
|
1882
|
+
let dir;
|
|
1883
|
+
try {
|
|
1884
|
+
dir = await isDir(real, ms, o.fs);
|
|
1885
|
+
} catch {
|
|
1886
|
+
unavailable = true;
|
|
1887
|
+
continue;
|
|
1888
|
+
}
|
|
1889
|
+
if (!dir) {
|
|
1890
|
+
if (candidates.length === 1) return { ok: false, reason: "cwd_not_directory" };
|
|
1891
|
+
continue;
|
|
1892
|
+
}
|
|
1893
|
+
if (realRoots.some((r) => inside(real, r))) hits.set(real, true);
|
|
1894
|
+
else if (candidates.length === 1) return { ok: false, reason: "cwd_outside_roots" };
|
|
1895
|
+
}
|
|
1896
|
+
if (hits.size > 1) return { ok: false, reason: "cwd_ambiguous" };
|
|
1897
|
+
if (hits.size === 1) return { ok: true, real: [...hits.keys()][0] };
|
|
1898
|
+
return { ok: false, reason: unavailable ? "root_unavailable" : "cwd_not_found" };
|
|
1899
|
+
}
|
|
1900
|
+
async function rootProblems(agents, timeoutMs = 1e4) {
|
|
1901
|
+
const out = [];
|
|
1902
|
+
await Promise.all(agents.flatMap((a) => a.roots.map(async (r) => {
|
|
1903
|
+
try {
|
|
1904
|
+
const real = await realpathOf(r, timeoutMs);
|
|
1905
|
+
if (!await isDir(real, timeoutMs)) out.push(`${a.id}: root ${r} is not a directory; sessions there are refused`);
|
|
1906
|
+
} catch (e) {
|
|
1907
|
+
out.push(e instanceof Stalled ? `${a.id}: root ${r} does not answer (a stalled network mount?); its sessions are refused (root_unavailable) until it does` : `${a.id}: root ${r} does not exist (yet); its sessions are refused until it does (create it, or fix roots in agents.toml)`);
|
|
1908
|
+
}
|
|
1909
|
+
})));
|
|
1910
|
+
return out.sort();
|
|
1911
|
+
}
|
|
1912
|
+
function relativeInside(p, cwd) {
|
|
1913
|
+
const abs = path10.resolve(cwd, p);
|
|
1914
|
+
let real;
|
|
1915
|
+
try {
|
|
1916
|
+
real = fs10.realpathSync.native(abs);
|
|
1917
|
+
} catch {
|
|
1918
|
+
try {
|
|
1919
|
+
real = path10.join(fs10.realpathSync.native(path10.dirname(abs)), path10.basename(abs));
|
|
1920
|
+
} catch {
|
|
1921
|
+
return null;
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
if (!inside(real, cwd)) return null;
|
|
1925
|
+
return path10.relative(cwd, real).split(path10.sep).join("/");
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1507
1928
|
// src/attach.ts
|
|
1508
1929
|
var TwinCheckError = class extends Error {
|
|
1509
1930
|
};
|
|
@@ -1511,6 +1932,9 @@ var SESSION_REF_RE = SESSION_ID_RE;
|
|
|
1511
1932
|
var MAX_REPORTED = 12;
|
|
1512
1933
|
var MAX_LIST_PAGES = 8;
|
|
1513
1934
|
var MAX_TITLE = 80;
|
|
1935
|
+
var MAX_WAITING_FOR = 60;
|
|
1936
|
+
var UNKNOWN_RELIST_MS = 2e4;
|
|
1937
|
+
var MAX_WATCHED_PROJECT_DIRS = 200;
|
|
1514
1938
|
var MAX_SCANNED_PROJECT_DIRS = 2e3;
|
|
1515
1939
|
var UNREADABLE_GRACE_MS2 = 6e4;
|
|
1516
1940
|
var NO_HOOKS = {
|
|
@@ -1531,7 +1955,7 @@ var AttachSupport = class {
|
|
|
1531
1955
|
constructor(agent, d) {
|
|
1532
1956
|
this.agent = agent;
|
|
1533
1957
|
this.d = d;
|
|
1534
|
-
this.configDir =
|
|
1958
|
+
this.configDir = path11.resolve(agent.env.CLAUDE_CONFIG_DIR ?? d.env.CLAUDE_CONFIG_DIR ?? path11.join(os3.homedir(), ".claude"));
|
|
1535
1959
|
}
|
|
1536
1960
|
agent;
|
|
1537
1961
|
d;
|
|
@@ -1580,6 +2004,14 @@ var AttachSupport = class {
|
|
|
1580
2004
|
});
|
|
1581
2005
|
return this.refreshing;
|
|
1582
2006
|
}
|
|
2007
|
+
/** Where the listing adapter starts: the first root that is there now (a root may be missing), else the home directory. */
|
|
2008
|
+
async listingDir() {
|
|
2009
|
+
for (const r of this.agent.roots) {
|
|
2010
|
+
const v = await resolveCwd(r, [r], { home: os3.homedir(), timeoutMs: 5e3 });
|
|
2011
|
+
if (v.ok) return v.real;
|
|
2012
|
+
}
|
|
2013
|
+
return os3.homedir();
|
|
2014
|
+
}
|
|
1583
2015
|
async doRefresh() {
|
|
1584
2016
|
let adapter = null;
|
|
1585
2017
|
this.noteHooks();
|
|
@@ -1588,9 +2020,9 @@ var AttachSupport = class {
|
|
|
1588
2020
|
cfg: this.agent,
|
|
1589
2021
|
registry: this.d.registry,
|
|
1590
2022
|
sessionId: `list-${this.agent.id}`,
|
|
1591
|
-
cwd: this.
|
|
2023
|
+
cwd: await this.listingDir(),
|
|
1592
2024
|
hooks: NO_HOOKS,
|
|
1593
|
-
logFile:
|
|
2025
|
+
logFile: path11.join(this.d.logDir, `adapter-${this.agent.id}.log`),
|
|
1594
2026
|
version: this.d.version,
|
|
1595
2027
|
initTimeoutMs: 3e4
|
|
1596
2028
|
});
|
|
@@ -1602,7 +2034,8 @@ var AttachSupport = class {
|
|
|
1602
2034
|
const res = await adapter.request("session/list", cursor ? { cursor } : {}, 3e4);
|
|
1603
2035
|
for (const s of Array.isArray(res.sessions) ? res.sessions : []) {
|
|
1604
2036
|
if (!isObject(s) || typeof s.sessionId !== "string" || !SESSION_REF_RE.test(s.sessionId) || typeof s.cwd !== "string") continue;
|
|
1605
|
-
|
|
2037
|
+
if (!path11.isAbsolute(s.cwd)) continue;
|
|
2038
|
+
const where = await resolveCwd(s.cwd, this.agent.roots, { home: os3.homedir(), timeoutMs: 5e3 });
|
|
1606
2039
|
if (!where.ok) continue;
|
|
1607
2040
|
const at = typeof s.updatedAt === "string" ? Date.parse(s.updatedAt) : NaN;
|
|
1608
2041
|
found.set(s.sessionId.toLowerCase(), {
|
|
@@ -1649,13 +2082,108 @@ var AttachSupport = class {
|
|
|
1649
2082
|
} else if (this.now() - this.listedAt > this.d.catalogMs) void this.refreshCatalog();
|
|
1650
2083
|
const live = await within(this.liveEntries().catch(() => null), capMs, null);
|
|
1651
2084
|
const attached = this.d.attachedRefs();
|
|
1652
|
-
return this.listed.slice(0, MAX_REPORTED).map((l) =>
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
cwd: l.cwd,
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
})
|
|
2085
|
+
return this.listed.slice(0, MAX_REPORTED).map((l) => {
|
|
2086
|
+
const holders = live?.filter((e) => e.sessionId.toLowerCase() === l.ref) ?? [];
|
|
2087
|
+
const state = attached.has(l.ref) ? "attached" : live === null ? "unknown" : holders.length ? "live" : "idle";
|
|
2088
|
+
const out = { ref: l.ref, title: l.title, cwd: l.cwd, updated_at: Math.floor(l.updatedAt / 1e3), state };
|
|
2089
|
+
if (state === "live") Object.assign(out, liveActivity(holders));
|
|
2090
|
+
return out;
|
|
2091
|
+
});
|
|
2092
|
+
}
|
|
2093
|
+
// --- noticing a change without starting anything ----------------------------------------
|
|
2094
|
+
lastPrint = null;
|
|
2095
|
+
lastUnknownList = 0;
|
|
2096
|
+
/**
|
|
2097
|
+
* A cheap look, with no process started: the names and modification times of Claude Code's session registry files (one
|
|
2098
|
+
* is written when a terminal opens a conversation, rewritten when it starts or finishes a turn or starts waiting, and
|
|
2099
|
+
* removed when it exits) and of its projects directory (a new working directory). True when something changed since
|
|
2100
|
+
* the previous look, so that the caller reports at once rather than at the next heartbeat. A live conversation inside
|
|
2101
|
+
* the roots whose transcript exists but which the list does not have yet (a terminal just opened it), or a new transcript
|
|
2102
|
+
* in a project folder inside the roots (one a terminal ran and already ended), is listed again first, at most every
|
|
2103
|
+
* {@link UNKNOWN_RELIST_MS} (a change inside that time is listed when it is up). The first look only learns what is there.
|
|
2104
|
+
*/
|
|
2105
|
+
async poll() {
|
|
2106
|
+
const print = this.fingerprint();
|
|
2107
|
+
const projects = this.projectsPrint();
|
|
2108
|
+
if (print === this.lastPrint && projects === this.lastProjects && !this.relistDue) return false;
|
|
2109
|
+
const first = this.lastPrint === null;
|
|
2110
|
+
const looked = print !== this.lastPrint || projects !== this.lastProjects;
|
|
2111
|
+
const newTranscript = !first && projects !== this.lastProjects;
|
|
2112
|
+
this.lastPrint = print;
|
|
2113
|
+
this.lastProjects = projects;
|
|
2114
|
+
let rows;
|
|
2115
|
+
try {
|
|
2116
|
+
rows = readRegistry(this.configDir, this.localDomain, this.now());
|
|
2117
|
+
} catch {
|
|
2118
|
+
rows = null;
|
|
2119
|
+
}
|
|
2120
|
+
if (looked && (newTranscript || rows !== null && this.unlistedLive(rows))) this.relistDue = true;
|
|
2121
|
+
let relisted = false;
|
|
2122
|
+
if (this.relistDue && this.now() - this.lastUnknownList >= UNKNOWN_RELIST_MS) {
|
|
2123
|
+
this.relistDue = false;
|
|
2124
|
+
this.lastUnknownList = this.now();
|
|
2125
|
+
relisted = await within(this.refreshCatalog().then(() => true), 6e4, false);
|
|
2126
|
+
}
|
|
2127
|
+
const summary = (rows === null ? "unreadable;" : "") + this.listed.slice(0, MAX_REPORTED).map((l) => {
|
|
2128
|
+
const holders = (rows ?? []).filter((e) => e.sessionId.toLowerCase() === l.ref);
|
|
2129
|
+
return `${l.ref}:${holders.length ? liveActivity(holders).activity ?? "live" : "-"}:${liveActivity(holders).waiting_for ?? ""}`;
|
|
2130
|
+
}).join(",");
|
|
2131
|
+
const changed = summary !== this.lastSummary;
|
|
2132
|
+
this.lastSummary = summary;
|
|
2133
|
+
return changed && (!first || relisted);
|
|
2134
|
+
}
|
|
2135
|
+
lastSummary = "";
|
|
2136
|
+
lastProjects = null;
|
|
2137
|
+
/** A change asked for a new list that the rate limit has held back; the next look makes it. */
|
|
2138
|
+
relistDue = false;
|
|
2139
|
+
/**
|
|
2140
|
+
* The project folders inside the roots (Claude Code names one after the working directory, every non-alphanumeric
|
|
2141
|
+
* turned into "-") with their modification times: one changes when a conversation is started in it. At most
|
|
2142
|
+
* {@link MAX_WATCHED_PROJECT_DIRS} of them, so a huge config directory is not walked every few seconds.
|
|
2143
|
+
*/
|
|
2144
|
+
projectsPrint() {
|
|
2145
|
+
const projects = path11.join(this.configDir, "projects");
|
|
2146
|
+
const prefixes = this.agent.roots.map((r) => r.replace(/[^A-Za-z0-9]/g, "-"));
|
|
2147
|
+
const parts = [];
|
|
2148
|
+
try {
|
|
2149
|
+
parts.push(`projects:${fs11.statSync(projects).mtimeMs}`);
|
|
2150
|
+
for (const name of fs11.readdirSync(projects).sort()) {
|
|
2151
|
+
if (!prefixes.some((p) => name.startsWith(p))) continue;
|
|
2152
|
+
if (parts.length > MAX_WATCHED_PROJECT_DIRS) break;
|
|
2153
|
+
try {
|
|
2154
|
+
parts.push(`${name}:${fs11.statSync(path11.join(projects, name)).mtimeMs}`);
|
|
2155
|
+
} catch {
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
} catch {
|
|
2159
|
+
parts.push("no-projects");
|
|
2160
|
+
}
|
|
2161
|
+
return parts.join("|");
|
|
2162
|
+
}
|
|
2163
|
+
fingerprint() {
|
|
2164
|
+
const parts = [];
|
|
2165
|
+
const dir = path11.join(this.configDir, "sessions");
|
|
2166
|
+
try {
|
|
2167
|
+
for (const name of fs11.readdirSync(dir).sort()) {
|
|
2168
|
+
if (!name.endsWith(".json")) continue;
|
|
2169
|
+
try {
|
|
2170
|
+
parts.push(`${name}:${fs11.statSync(path11.join(dir, name)).mtimeMs}`);
|
|
2171
|
+
} catch {
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
} catch {
|
|
2175
|
+
parts.push("no-sessions");
|
|
2176
|
+
}
|
|
2177
|
+
return parts.join("|");
|
|
2178
|
+
}
|
|
2179
|
+
/** Whether a process holds open a conversation in the roots, with a transcript on disk, that the last list did not have. */
|
|
2180
|
+
unlistedLive(rows) {
|
|
2181
|
+
return rows.some((e) => {
|
|
2182
|
+
const ref = e.sessionId.toLowerCase();
|
|
2183
|
+
if (e.foreign || !SESSION_REF_RE.test(ref) || !e.cwd || this.listed.some((l) => l.ref === ref)) return false;
|
|
2184
|
+
if (!checkCwd(e.cwd, this.agent.roots).ok) return false;
|
|
2185
|
+
return fs11.existsSync(path11.join(this.configDir, "projects", e.cwd.replace(/[^A-Za-z0-9]/g, "-"), `${ref}.jsonl`));
|
|
2186
|
+
});
|
|
1659
2187
|
}
|
|
1660
2188
|
// --- the twin check ----------------------------------------------------------------
|
|
1661
2189
|
/**
|
|
@@ -1684,7 +2212,7 @@ var AttachSupport = class {
|
|
|
1684
2212
|
const file = this.transcriptFile(want);
|
|
1685
2213
|
let age = null;
|
|
1686
2214
|
try {
|
|
1687
|
-
if (file) age = this.now() -
|
|
2215
|
+
if (file) age = this.now() - fs11.statSync(file).mtimeMs;
|
|
1688
2216
|
} catch {
|
|
1689
2217
|
}
|
|
1690
2218
|
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 +2267,19 @@ var AttachSupport = class {
|
|
|
1739
2267
|
transcriptSize(ref) {
|
|
1740
2268
|
const file = SESSION_REF_RE.test(ref) ? this.transcriptFile(ref.toLowerCase()) : null;
|
|
1741
2269
|
try {
|
|
1742
|
-
return file ?
|
|
2270
|
+
return file ? fs11.statSync(file).size : null;
|
|
2271
|
+
} catch {
|
|
2272
|
+
return null;
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
/** Where the conversation's transcript is, or null when it cannot be found (yet). */
|
|
2276
|
+
transcriptPath(ref) {
|
|
2277
|
+
const file = SESSION_REF_RE.test(ref) ? this.transcriptFile(ref.toLowerCase()) : null;
|
|
2278
|
+
if (!file) return null;
|
|
2279
|
+
try {
|
|
2280
|
+
const root = fs11.realpathSync(path11.join(this.configDir, "projects"));
|
|
2281
|
+
const real = fs11.realpathSync(file);
|
|
2282
|
+
return real.startsWith(root + path11.sep) && real.endsWith(`${ref.toLowerCase()}.jsonl`) ? real : null;
|
|
1743
2283
|
} catch {
|
|
1744
2284
|
return null;
|
|
1745
2285
|
}
|
|
@@ -1747,24 +2287,24 @@ var AttachSupport = class {
|
|
|
1747
2287
|
/** `<config dir>/projects/<working directory with every non-alphanumeric turned into "-">/<id>.jsonl`, found by name when that guess misses. */
|
|
1748
2288
|
transcriptFile(ref) {
|
|
1749
2289
|
const cached = this.transcripts.get(ref);
|
|
1750
|
-
if (cached &&
|
|
2290
|
+
if (cached && fs11.existsSync(cached)) return cached;
|
|
1751
2291
|
this.transcripts.delete(ref);
|
|
1752
|
-
const projects =
|
|
2292
|
+
const projects = path11.join(this.configDir, "projects");
|
|
1753
2293
|
const remember = (f) => {
|
|
1754
2294
|
this.transcripts.set(ref, f);
|
|
1755
2295
|
return f;
|
|
1756
2296
|
};
|
|
1757
2297
|
const known = this.listed.find((l) => l.ref === ref);
|
|
1758
2298
|
if (known) {
|
|
1759
|
-
const guess =
|
|
1760
|
-
if (
|
|
2299
|
+
const guess = path11.join(projects, known.cwd.replace(/[^A-Za-z0-9]/g, "-"), `${ref}.jsonl`);
|
|
2300
|
+
if (fs11.existsSync(guess)) return remember(guess);
|
|
1761
2301
|
}
|
|
1762
2302
|
try {
|
|
1763
2303
|
let n = 0;
|
|
1764
|
-
for (const dir of
|
|
2304
|
+
for (const dir of fs11.readdirSync(projects)) {
|
|
1765
2305
|
if (++n > MAX_SCANNED_PROJECT_DIRS) break;
|
|
1766
|
-
const f =
|
|
1767
|
-
if (
|
|
2306
|
+
const f = path11.join(projects, dir, `${ref}.jsonl`);
|
|
2307
|
+
if (fs11.existsSync(f)) return remember(f);
|
|
1768
2308
|
}
|
|
1769
2309
|
} catch {
|
|
1770
2310
|
}
|
|
@@ -1804,30 +2344,37 @@ function parseAgentsJson(text) {
|
|
|
1804
2344
|
const out = [];
|
|
1805
2345
|
for (const x of raw) {
|
|
1806
2346
|
if (isObject(x) && typeof x.pid === "number" && Number.isInteger(x.pid) && typeof x.sessionId === "string") {
|
|
1807
|
-
out.push({
|
|
2347
|
+
out.push({
|
|
2348
|
+
pid: x.pid,
|
|
2349
|
+
sessionId: x.sessionId,
|
|
2350
|
+
status: typeof x.status === "string" ? x.status : null,
|
|
2351
|
+
foreign: false,
|
|
2352
|
+
waitingFor: typeof x.waitingFor === "string" ? x.waitingFor : null,
|
|
2353
|
+
cwd: typeof x.cwd === "string" ? x.cwd : null
|
|
2354
|
+
});
|
|
1808
2355
|
}
|
|
1809
2356
|
}
|
|
1810
2357
|
return out;
|
|
1811
2358
|
}
|
|
1812
2359
|
function readRegistry(configDir, localDomain, now = Date.now()) {
|
|
1813
|
-
const dir =
|
|
2360
|
+
const dir = path11.join(configDir, "sessions");
|
|
1814
2361
|
let names;
|
|
1815
2362
|
try {
|
|
1816
|
-
names =
|
|
2363
|
+
names = fs11.readdirSync(dir);
|
|
1817
2364
|
} catch (e) {
|
|
1818
2365
|
throw new TwinCheckError(`${dir}: ${errMessage(e)}`);
|
|
1819
2366
|
}
|
|
1820
2367
|
const out = [];
|
|
1821
2368
|
for (const name of names) {
|
|
1822
2369
|
if (!name.endsWith(".json")) continue;
|
|
1823
|
-
const file =
|
|
2370
|
+
const file = path11.join(dir, name);
|
|
1824
2371
|
let doc;
|
|
1825
2372
|
try {
|
|
1826
|
-
doc = JSON.parse(
|
|
2373
|
+
doc = JSON.parse(fs11.readFileSync(file, "utf8"));
|
|
1827
2374
|
} catch (e) {
|
|
1828
2375
|
let age = Infinity;
|
|
1829
2376
|
try {
|
|
1830
|
-
age = now -
|
|
2377
|
+
age = now - fs11.statSync(file).mtimeMs;
|
|
1831
2378
|
} catch {
|
|
1832
2379
|
}
|
|
1833
2380
|
if (age < UNREADABLE_GRACE_MS2) throw new TwinCheckError(`${file} is unreadable (${errMessage(e).split("\n")[0]})`);
|
|
@@ -1840,14 +2387,21 @@ function readRegistry(configDir, localDomain, now = Date.now()) {
|
|
|
1840
2387
|
if (!isAlive(doc.pid)) continue;
|
|
1841
2388
|
if (process.platform === "linux" && typeof doc.procStart === "string" && processStart(doc.pid) !== doc.procStart) continue;
|
|
1842
2389
|
}
|
|
1843
|
-
out.push({
|
|
2390
|
+
out.push({
|
|
2391
|
+
pid: doc.pid,
|
|
2392
|
+
sessionId: doc.sessionId,
|
|
2393
|
+
status: typeof doc.status === "string" ? doc.status : null,
|
|
2394
|
+
foreign,
|
|
2395
|
+
waitingFor: typeof doc.waitingFor === "string" ? doc.waitingFor : null,
|
|
2396
|
+
cwd: typeof doc.cwd === "string" ? doc.cwd : null
|
|
2397
|
+
});
|
|
1844
2398
|
}
|
|
1845
2399
|
return out;
|
|
1846
2400
|
}
|
|
1847
2401
|
function processGroup(pid) {
|
|
1848
2402
|
try {
|
|
1849
2403
|
if (process.platform === "linux") {
|
|
1850
|
-
const stat =
|
|
2404
|
+
const stat = fs11.readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
1851
2405
|
return Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[2]) || null;
|
|
1852
2406
|
}
|
|
1853
2407
|
return Number(execFileSync2("ps", ["-o", "pgid=", "-p", String(pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim()) || null;
|
|
@@ -1855,6 +2409,16 @@ function processGroup(pid) {
|
|
|
1855
2409
|
return null;
|
|
1856
2410
|
}
|
|
1857
2411
|
}
|
|
2412
|
+
function liveActivity(holders) {
|
|
2413
|
+
const waiting = holders.find((e) => e.status === "waiting");
|
|
2414
|
+
if (waiting) {
|
|
2415
|
+
const why = waiting.waitingFor ? cleanTitle(waiting.waitingFor).slice(0, MAX_WAITING_FOR) : "";
|
|
2416
|
+
return why ? { activity: "waiting", waiting_for: why } : { activity: "waiting" };
|
|
2417
|
+
}
|
|
2418
|
+
if (holders.some((e) => e.status === "busy")) return { activity: "working" };
|
|
2419
|
+
if (holders.some((e) => e.status === "idle")) return { activity: "idle" };
|
|
2420
|
+
return {};
|
|
2421
|
+
}
|
|
1858
2422
|
function cleanTitle(raw) {
|
|
1859
2423
|
const one = redactText(raw).text.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
|
|
1860
2424
|
return one.length > MAX_TITLE ? `${one.slice(0, MAX_TITLE - 1)}\u2026` : one;
|
|
@@ -1865,70 +2429,227 @@ function fitResumable(base, items, limit = REMOTE_AGENT_LIMITS.maxCapabilitiesBy
|
|
|
1865
2429
|
for (; ; ) {
|
|
1866
2430
|
const caps = { ...base, resumable: items.slice(0, n) };
|
|
1867
2431
|
if (size(caps) <= limit) return caps;
|
|
1868
|
-
if (n === 0)
|
|
2432
|
+
if (n === 0) {
|
|
2433
|
+
const { watch_live: _watch, ...rest } = base;
|
|
2434
|
+
return size(base) > limit && size(rest) < size(base) ? rest : base;
|
|
2435
|
+
}
|
|
1869
2436
|
n--;
|
|
1870
2437
|
}
|
|
1871
2438
|
}
|
|
2439
|
+
var MAX_TOOL_TITLE = 200;
|
|
2440
|
+
function transcriptUpdates(line) {
|
|
2441
|
+
let doc;
|
|
2442
|
+
try {
|
|
2443
|
+
doc = JSON.parse(line);
|
|
2444
|
+
} catch {
|
|
2445
|
+
return [];
|
|
2446
|
+
}
|
|
2447
|
+
if (!isObject(doc) || doc.isSidechain === true || doc.isMeta === true) return [];
|
|
2448
|
+
if (doc.type !== "user" && doc.type !== "assistant") return [];
|
|
2449
|
+
const msg = isObject(doc.message) ? doc.message : null;
|
|
2450
|
+
if (!msg) return [];
|
|
2451
|
+
const text = (type, t) => t.trim() ? [{ type, update: { sessionUpdate: type, content: { type: "text", text: t } } }] : [];
|
|
2452
|
+
if (typeof msg.content === "string") return text(doc.type === "user" ? "user_message_chunk" : "agent_message_chunk", msg.content);
|
|
2453
|
+
if (!Array.isArray(msg.content)) return [];
|
|
2454
|
+
const out = [];
|
|
2455
|
+
for (const block of msg.content) {
|
|
2456
|
+
if (!isObject(block)) continue;
|
|
2457
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
2458
|
+
out.push(...text(doc.type === "user" ? "user_message_chunk" : "agent_message_chunk", block.text));
|
|
2459
|
+
} else if (doc.type === "assistant" && block.type === "tool_use" && typeof block.id === "string") {
|
|
2460
|
+
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 : {} } });
|
|
2461
|
+
} else if (doc.type === "user" && block.type === "tool_result" && typeof block.tool_use_id === "string") {
|
|
2462
|
+
const result = resultText(block.content);
|
|
2463
|
+
out.push({ type: "tool_call_update", update: {
|
|
2464
|
+
sessionUpdate: "tool_call_update",
|
|
2465
|
+
toolCallId: block.tool_use_id,
|
|
2466
|
+
status: block.is_error === true ? "failed" : "completed",
|
|
2467
|
+
...result ? { content: [{ type: "content", content: { type: "text", text: result } }] } : {}
|
|
2468
|
+
} });
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
return out;
|
|
2472
|
+
}
|
|
2473
|
+
function toolTitle(name, input) {
|
|
2474
|
+
const tool = typeof name === "string" && name ? name : "Tool";
|
|
2475
|
+
const i = isObject(input) ? input : {};
|
|
2476
|
+
const what = [i.description, i.command, i.file_path, i.path, i.pattern, i.url].find((v) => typeof v === "string" && v.trim() !== "");
|
|
2477
|
+
return cleanTitleTo(what ? `${tool}: ${what}` : tool, MAX_TOOL_TITLE);
|
|
2478
|
+
}
|
|
2479
|
+
function resultText(content) {
|
|
2480
|
+
if (typeof content === "string") return content;
|
|
2481
|
+
if (!Array.isArray(content)) return "";
|
|
2482
|
+
return content.filter((c) => isObject(c) && c.type === "text" && typeof c.text === "string").map((c) => c.text).join("\n");
|
|
2483
|
+
}
|
|
2484
|
+
function cleanTitleTo(raw, max) {
|
|
2485
|
+
const one = raw.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
|
|
2486
|
+
return one.length > max ? `${one.slice(0, max - 1)}\u2026` : one;
|
|
2487
|
+
}
|
|
2488
|
+
var TAIL_BACKLOG_BYTES = 64 * 1024;
|
|
2489
|
+
var TAIL_READ_BYTES = 256 * 1024;
|
|
2490
|
+
var TAIL_MAX_LINE = 1024 * 1024;
|
|
2491
|
+
var TAIL_BACKLOG_UPDATES = 20;
|
|
2492
|
+
var TranscriptTail = class {
|
|
2493
|
+
constructor(locate) {
|
|
2494
|
+
this.locate = locate;
|
|
2495
|
+
}
|
|
2496
|
+
locate;
|
|
2497
|
+
offset = null;
|
|
2498
|
+
partial = "";
|
|
2499
|
+
skipping = false;
|
|
2500
|
+
/** A read can end inside a character; the decoder keeps its first bytes for the next one. */
|
|
2501
|
+
decoder = new StringDecoder("utf8");
|
|
2502
|
+
/** The last updates already in the transcript (at most {@link TAIL_BACKLOG_UPDATES}); from here on `poll()` reads what is new. */
|
|
2503
|
+
backlog() {
|
|
2504
|
+
const file = this.locate();
|
|
2505
|
+
if (!file) return [];
|
|
2506
|
+
let fd = null;
|
|
2507
|
+
try {
|
|
2508
|
+
fd = fs11.openSync(file, "r");
|
|
2509
|
+
const size = fs11.fstatSync(fd).size;
|
|
2510
|
+
const start = Math.max(0, size - TAIL_BACKLOG_BYTES);
|
|
2511
|
+
const buf = Buffer.alloc(size - start);
|
|
2512
|
+
const n = buf.length ? fs11.readSync(fd, buf, 0, buf.length, start) : 0;
|
|
2513
|
+
this.offset = start + n;
|
|
2514
|
+
this.decoder = new StringDecoder("utf8");
|
|
2515
|
+
let text = this.decoder.write(buf.subarray(0, n));
|
|
2516
|
+
if (start > 0) text = text.slice(text.indexOf("\n") + 1);
|
|
2517
|
+
const end = text.lastIndexOf("\n");
|
|
2518
|
+
this.partial = end === text.length - 1 ? "" : text.slice(end + 1);
|
|
2519
|
+
const updates = text.slice(0, end + 1).split("\n").flatMap((l) => l ? transcriptUpdates(l) : []);
|
|
2520
|
+
return updates.slice(-TAIL_BACKLOG_UPDATES);
|
|
2521
|
+
} catch {
|
|
2522
|
+
return [];
|
|
2523
|
+
} finally {
|
|
2524
|
+
if (fd !== null) fs11.closeSync(fd);
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
/** Start from the end without reading what is there (a watcher that was already following, after a restart). */
|
|
2528
|
+
fromEnd() {
|
|
2529
|
+
const file = this.locate();
|
|
2530
|
+
try {
|
|
2531
|
+
this.offset = file ? fs11.statSync(file).size : null;
|
|
2532
|
+
} catch {
|
|
2533
|
+
this.offset = null;
|
|
2534
|
+
}
|
|
2535
|
+
this.partial = "";
|
|
2536
|
+
this.decoder = new StringDecoder("utf8");
|
|
2537
|
+
}
|
|
2538
|
+
/** What was appended since the last look. */
|
|
2539
|
+
poll() {
|
|
2540
|
+
const file = this.locate();
|
|
2541
|
+
if (!file) return [];
|
|
2542
|
+
let fd = null;
|
|
2543
|
+
try {
|
|
2544
|
+
fd = fs11.openSync(file, "r");
|
|
2545
|
+
const size = fs11.fstatSync(fd).size;
|
|
2546
|
+
if (this.offset === null) this.offset = 0;
|
|
2547
|
+
if (size < this.offset) {
|
|
2548
|
+
this.offset = size;
|
|
2549
|
+
this.partial = "";
|
|
2550
|
+
this.decoder = new StringDecoder("utf8");
|
|
2551
|
+
return [];
|
|
2552
|
+
}
|
|
2553
|
+
if (size === this.offset) return [];
|
|
2554
|
+
const buf = Buffer.alloc(Math.min(TAIL_READ_BYTES, size - this.offset));
|
|
2555
|
+
const n = fs11.readSync(fd, buf, 0, buf.length, this.offset);
|
|
2556
|
+
this.offset += n;
|
|
2557
|
+
return this.take(this.decoder.write(buf.subarray(0, n)));
|
|
2558
|
+
} catch {
|
|
2559
|
+
return [];
|
|
2560
|
+
} finally {
|
|
2561
|
+
if (fd !== null) fs11.closeSync(fd);
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2564
|
+
take(chunk) {
|
|
2565
|
+
const text = this.partial + chunk;
|
|
2566
|
+
const end = text.lastIndexOf("\n");
|
|
2567
|
+
if (end < 0) {
|
|
2568
|
+
this.partial = text;
|
|
2569
|
+
if (this.partial.length > TAIL_MAX_LINE) {
|
|
2570
|
+
this.partial = "";
|
|
2571
|
+
this.skipping = true;
|
|
2572
|
+
}
|
|
2573
|
+
return [];
|
|
2574
|
+
}
|
|
2575
|
+
this.partial = text.slice(end + 1);
|
|
2576
|
+
const lines = text.slice(0, end).split("\n");
|
|
2577
|
+
if (this.skipping) {
|
|
2578
|
+
lines.shift();
|
|
2579
|
+
this.skipping = false;
|
|
2580
|
+
}
|
|
2581
|
+
if (this.partial.length > TAIL_MAX_LINE) {
|
|
2582
|
+
this.partial = "";
|
|
2583
|
+
this.skipping = true;
|
|
2584
|
+
}
|
|
2585
|
+
return lines.flatMap((l) => l ? transcriptUpdates(l) : []);
|
|
2586
|
+
}
|
|
2587
|
+
};
|
|
1872
2588
|
|
|
1873
2589
|
// src/health.ts
|
|
1874
|
-
import
|
|
1875
|
-
import
|
|
2590
|
+
import fs12 from "node:fs";
|
|
2591
|
+
import path13 from "node:path";
|
|
1876
2592
|
|
|
1877
2593
|
// src/paths.ts
|
|
1878
2594
|
import os4 from "node:os";
|
|
1879
|
-
import
|
|
2595
|
+
import path12 from "node:path";
|
|
1880
2596
|
function resolveDirs(env = process.env, home = os4.homedir()) {
|
|
1881
2597
|
const one = env.CLAW_CONNECTOR_HOME?.trim();
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
const
|
|
1885
|
-
|
|
1886
|
-
}
|
|
1887
|
-
|
|
1888
|
-
var
|
|
1889
|
-
var
|
|
1890
|
-
var
|
|
1891
|
-
var
|
|
1892
|
-
var
|
|
1893
|
-
var
|
|
1894
|
-
var
|
|
1895
|
-
var
|
|
1896
|
-
var
|
|
1897
|
-
var
|
|
1898
|
-
var
|
|
1899
|
-
var
|
|
1900
|
-
var
|
|
1901
|
-
var
|
|
1902
|
-
var
|
|
2598
|
+
const own = env.CLAW_CONNECTOR_CONFIG_DIR?.trim();
|
|
2599
|
+
if (one) return { config: own ? path12.resolve(own) : path12.join(one, "config"), state: path12.join(one, "state") };
|
|
2600
|
+
const cfg = env.XDG_CONFIG_HOME?.trim() || path12.join(home, ".config");
|
|
2601
|
+
const st = env.XDG_STATE_HOME?.trim() || path12.join(home, ".local", "state");
|
|
2602
|
+
return { config: own ? path12.resolve(own) : path12.join(cfg, "claw-connector"), state: path12.join(st, "claw-connector") };
|
|
2603
|
+
}
|
|
2604
|
+
var configFile = (d) => path12.join(d.config, "agents.toml");
|
|
2605
|
+
var credentialsFile = (d) => path12.join(d.config, "credentials.json");
|
|
2606
|
+
var serviceEnvFile = (d) => path12.join(d.config, "service-env.json");
|
|
2607
|
+
var identityFile = (d) => path12.join(d.state, "connector.json");
|
|
2608
|
+
var sessionsFile = (d) => path12.join(d.state, "sessions.json");
|
|
2609
|
+
var childrenFile = (d) => path12.join(d.state, "children.json");
|
|
2610
|
+
var pidFile = (d) => path12.join(d.state, "connector.pid");
|
|
2611
|
+
var pauseFile = (d) => path12.join(d.state, "paused");
|
|
2612
|
+
var stopRequestFile = (d) => path12.join(d.state, "stop-requested.json");
|
|
2613
|
+
var auditFile = (d) => path12.join(d.state, "audit.jsonl");
|
|
2614
|
+
var outboxDir = (d) => path12.join(d.state, "outbox");
|
|
2615
|
+
var logDir = (d) => path12.join(d.state, "logs");
|
|
2616
|
+
var disconnectsFile = (d) => path12.join(d.state, "disconnects.jsonl");
|
|
2617
|
+
var needsLoginFile = (d) => path12.join(d.state, "needs-login.json");
|
|
2618
|
+
var supervisorPidFile = (d) => path12.join(d.state, "supervisor.pid");
|
|
2619
|
+
var runLogFile = (d) => path12.join(d.state, "run.log");
|
|
2620
|
+
var keepaliveFile = (d) => path12.join(d.state, "keepalive.sh");
|
|
2621
|
+
var serviceFile = (d) => path12.join(d.state, "service.json");
|
|
2622
|
+
var doctorFile = (d) => path12.join(d.state, "doctor.json");
|
|
2623
|
+
var keepaliveStampFile = (d) => path12.join(d.state, "keepalive-last");
|
|
1903
2624
|
|
|
1904
2625
|
// src/health.ts
|
|
1905
2626
|
var MAX_BYTES = 128 * 1024;
|
|
1906
2627
|
function recordDisconnect(dirs, d) {
|
|
1907
2628
|
const file = disconnectsFile(dirs);
|
|
1908
2629
|
try {
|
|
1909
|
-
|
|
1910
|
-
const fd =
|
|
2630
|
+
fs12.mkdirSync(path13.dirname(file), { recursive: true, mode: 448 });
|
|
2631
|
+
const fd = fs12.openSync(file, "a+", 384);
|
|
1911
2632
|
let lines = null;
|
|
1912
2633
|
try {
|
|
1913
|
-
|
|
1914
|
-
const size =
|
|
2634
|
+
fs12.writeSync(fd, JSON.stringify({ at: d.at ?? (/* @__PURE__ */ new Date()).toISOString(), reason: d.reason, ...d.detail ? { detail: d.detail.slice(0, 300) } : {} }) + "\n");
|
|
2635
|
+
const size = fs12.fstatSync(fd).size;
|
|
1915
2636
|
if (size > MAX_BYTES) {
|
|
1916
2637
|
const buf = Buffer.alloc(size);
|
|
1917
2638
|
let got = 0;
|
|
1918
2639
|
while (got < size) {
|
|
1919
|
-
const n =
|
|
2640
|
+
const n = fs12.readSync(fd, buf, got, size - got, got);
|
|
1920
2641
|
if (n === 0) break;
|
|
1921
2642
|
got += n;
|
|
1922
2643
|
}
|
|
1923
2644
|
lines = buf.subarray(0, got).toString("utf8").split("\n").filter(Boolean);
|
|
1924
2645
|
}
|
|
1925
2646
|
} finally {
|
|
1926
|
-
|
|
2647
|
+
fs12.closeSync(fd);
|
|
1927
2648
|
}
|
|
1928
2649
|
if (lines) {
|
|
1929
2650
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
1930
|
-
|
|
1931
|
-
|
|
2651
|
+
fs12.writeFileSync(tmp, lines.slice(Math.floor(lines.length / 2)).join("\n") + "\n", { mode: 384 });
|
|
2652
|
+
fs12.renameSync(tmp, file);
|
|
1932
2653
|
}
|
|
1933
2654
|
} catch {
|
|
1934
2655
|
}
|
|
@@ -1936,7 +2657,7 @@ function recordDisconnect(dirs, d) {
|
|
|
1936
2657
|
function recentDisconnects(dirs, n = 1) {
|
|
1937
2658
|
try {
|
|
1938
2659
|
const out = [];
|
|
1939
|
-
for (const line of
|
|
2660
|
+
for (const line of fs12.readFileSync(disconnectsFile(dirs), "utf8").split("\n").filter(Boolean).slice(-n)) {
|
|
1940
2661
|
try {
|
|
1941
2662
|
const v = JSON.parse(line);
|
|
1942
2663
|
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 +2671,14 @@ function recentDisconnects(dirs, n = 1) {
|
|
|
1950
2671
|
}
|
|
1951
2672
|
function markNeedsLogin(dirs, reason) {
|
|
1952
2673
|
try {
|
|
1953
|
-
|
|
1954
|
-
|
|
2674
|
+
fs12.mkdirSync(dirs.state, { recursive: true, mode: 448 });
|
|
2675
|
+
fs12.writeFileSync(needsLoginFile(dirs), JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString(), reason: reason.slice(0, 300) }), { mode: 384 });
|
|
1955
2676
|
} catch {
|
|
1956
2677
|
}
|
|
1957
2678
|
}
|
|
1958
2679
|
function needsLogin(dirs) {
|
|
1959
2680
|
try {
|
|
1960
|
-
const v = JSON.parse(
|
|
2681
|
+
const v = JSON.parse(fs12.readFileSync(needsLoginFile(dirs), "utf8"));
|
|
1961
2682
|
if (isObject(v) && typeof v.reason === "string") return { at: typeof v.at === "string" ? v.at : "", reason: v.reason };
|
|
1962
2683
|
return { at: "", reason: "unreadable needs-login.json" };
|
|
1963
2684
|
} catch (e) {
|
|
@@ -1965,11 +2686,74 @@ function needsLogin(dirs) {
|
|
|
1965
2686
|
}
|
|
1966
2687
|
}
|
|
1967
2688
|
function clearNeedsLogin(dirs) {
|
|
1968
|
-
|
|
2689
|
+
fs12.rmSync(needsLoginFile(dirs), { force: true });
|
|
1969
2690
|
}
|
|
1970
2691
|
|
|
1971
2692
|
// src/session.ts
|
|
1972
|
-
import
|
|
2693
|
+
import path15 from "node:path";
|
|
2694
|
+
|
|
2695
|
+
// src/hints.ts
|
|
2696
|
+
import path14 from "node:path";
|
|
2697
|
+
function agentFamily(a) {
|
|
2698
|
+
const words = [a.command, ...a.args].map((s) => path14.basename(s));
|
|
2699
|
+
if (words.some((s) => s.includes("claude-agent-acp") || s.includes("claude-code-acp"))) return "claude";
|
|
2700
|
+
if (words.some((s) => s.includes("codex-acp"))) return "codex";
|
|
2701
|
+
const cmd = path14.basename(a.command);
|
|
2702
|
+
if (cmd === "agent" || cmd === "cursor-agent") return "cursor";
|
|
2703
|
+
if (a.kind === "claude" || a.kind === "attach") return "claude";
|
|
2704
|
+
if (a.kind === "codex" || a.kind === "cursor") return a.kind;
|
|
2705
|
+
return "other";
|
|
2706
|
+
}
|
|
2707
|
+
var SIGN_IN = {
|
|
2708
|
+
claude: `Claude Code is not signed in on that machine: run "claude" there and sign in with /login, or set ANTHROPIC_API_KEY (or your gateway's ANTHROPIC_AUTH_TOKEN) for this agent in agents.toml under [agents.env]; a service installed from a shell that has the variable keeps it too`,
|
|
2709
|
+
codex: `Codex is not signed in on that machine: run "codex login" there (or "codex login --with-api-key"), or set OPENAI_API_KEY for this agent in agents.toml under [agents.env]`,
|
|
2710
|
+
cursor: `Cursor is not signed in on that machine: run "agent login" there, or set CURSOR_API_KEY for this agent in agents.toml under [agents.env]`,
|
|
2711
|
+
other: `the agent is not signed in on that machine: sign it in there, then try again`
|
|
2712
|
+
};
|
|
2713
|
+
var DOCTOR = `run "claw-connector doctor" on that machine; it names the fix`;
|
|
2714
|
+
var HINTS = {
|
|
2715
|
+
invalid_cwd: "give the folder to work in: a full path, ~/path, or the name of a folder inside one of this agent's roots",
|
|
2716
|
+
cwd_not_absolute: "give the folder as a full path, as ~/path, or as the name of a folder inside one of this agent's roots",
|
|
2717
|
+
cwd_not_found: "that folder does not exist on that machine: pick an existing folder inside one of this agent's roots, or create it there first",
|
|
2718
|
+
cwd_not_directory: "that is a file, not a folder: pick a folder inside one of this agent's roots",
|
|
2719
|
+
cwd_ambiguous: "more than one of this agent's roots has a folder by that name: give its full path",
|
|
2720
|
+
cwd_outside_roots: "this agent may only work inside its roots (agents.toml on that machine): pick a folder inside one of them, or add the folder to roots there and restart the connector",
|
|
2721
|
+
cwd_mismatch: "that conversation belongs to another folder: take it over without naming a folder, or name the one it was started in",
|
|
2722
|
+
root_unavailable: "the folder's disk is not answering on that machine (a network mount that is down or not mounted yet): try again in a minute, or check the mount there",
|
|
2723
|
+
unknown_agent: "that machine no longer offers this agent: check agents.toml there, then restart the connector",
|
|
2724
|
+
too_many_sessions: "that machine already runs the most sessions it allows at once (10): close one, then try again",
|
|
2725
|
+
mode_not_allowed: `that mode does not ask before each step, and this agent may use such a mode only with approval = "never" in agents.toml on that machine`,
|
|
2726
|
+
mode_unavailable: `the agent does not offer the mode this machine requires for it: ${DOCTOR}`,
|
|
2727
|
+
mode_not_enforced: `the agent would not run in a mode that asks before each step (often because Claude's own settings default to bypassPermissions): ${DOCTOR}, or set approval = "never" for this agent there`,
|
|
2728
|
+
mode_not_confirmed: `the agent did not confirm the mode this machine requires: ${DOCTOR}`,
|
|
2729
|
+
mode_mismatch: `the agent switched to a mode other than the one this machine requires: ${DOCTOR}`,
|
|
2730
|
+
adapter_start_failed: `the agent's program could not start on that machine (not installed, not on the service's PATH, or the download failed): ${DOCTOR}`,
|
|
2731
|
+
adapter_error: `the agent failed while opening the session: ${DOCTOR}; the adapter's log is in the connector's logs directory there`,
|
|
2732
|
+
adapter_protocol: `the agent answered in a way the connector does not understand: ${DOCTOR}, and update the agent's adapter`,
|
|
2733
|
+
adapter_cannot_resume: "this agent cannot reopen an earlier session: start a new one",
|
|
2734
|
+
adapter_gone: "the agent stopped while the session was opening: try again",
|
|
2735
|
+
resume_not_supported: "this agent starts new conversations only: start a new session instead of continuing one",
|
|
2736
|
+
resume_ref_required: "this agent only takes over conversations that already exist on that machine: pick one from its list, or use the agent that starts new ones",
|
|
2737
|
+
invalid_resume_ref: "that is not a conversation this machine lists: pick one from its list",
|
|
2738
|
+
attach_unavailable: "taking over conversations is not available for this agent on that machine: start a new session instead",
|
|
2739
|
+
already_attached: "that conversation is already open from the chat: use that session",
|
|
2740
|
+
unknown_session: "that machine no longer has this conversation (or it is outside the agent's roots): pick another one from its list",
|
|
2741
|
+
catalog_unavailable: `that machine could not list its conversations just now: try again; if it keeps failing, ${DOCTOR}`,
|
|
2742
|
+
session_live: "that conversation is open in a terminal on that machine: exit it there (/exit), then try again",
|
|
2743
|
+
recently_written: "that conversation was written to a moment ago: wait a few seconds, then try again",
|
|
2744
|
+
twin_check_failed: "the connector could not check whether a terminal holds that conversation open: try again in a moment",
|
|
2745
|
+
too_many_queued_prompts: "five messages are already waiting for this session: wait for the agent to finish one",
|
|
2746
|
+
text_required: "the message was empty",
|
|
2747
|
+
session_closed: "the session has already ended: start a new one"
|
|
2748
|
+
};
|
|
2749
|
+
function hintFor(reason, agent) {
|
|
2750
|
+
if (reason === "agent_auth_required") return SIGN_IN[agent ? agentFamily(agent) : "other"];
|
|
2751
|
+
return HINTS[reason] ?? null;
|
|
2752
|
+
}
|
|
2753
|
+
function withHint(data, agent) {
|
|
2754
|
+
const hint = hintFor(data.reason, agent);
|
|
2755
|
+
return hint ? { ...data, hint } : data;
|
|
2756
|
+
}
|
|
1973
2757
|
|
|
1974
2758
|
// src/permissions.ts
|
|
1975
2759
|
import { randomUUID } from "node:crypto";
|
|
@@ -2097,7 +2881,17 @@ var PermissionBook = class {
|
|
|
2097
2881
|
};
|
|
2098
2882
|
|
|
2099
2883
|
// src/session.ts
|
|
2100
|
-
var DEFAULT_SESSION_TIMING = {
|
|
2884
|
+
var DEFAULT_SESSION_TIMING = {
|
|
2885
|
+
idleMs: 30 * 6e4,
|
|
2886
|
+
permissionTtlMs: 10 * 6e4,
|
|
2887
|
+
modeConfirmMs: 3e3,
|
|
2888
|
+
twinWatchMs: 1e3,
|
|
2889
|
+
spectateMs: 1e3,
|
|
2890
|
+
spectateCheckMs: 5e3,
|
|
2891
|
+
rootCheckMs: 1e4,
|
|
2892
|
+
authWaitMs: 3e3
|
|
2893
|
+
};
|
|
2894
|
+
var READ_ONLY_REASON = "read_only_live_session";
|
|
2101
2895
|
var SessionRefusal = class extends Error {
|
|
2102
2896
|
constructor(reason, message) {
|
|
2103
2897
|
super(message ?? reason);
|
|
@@ -2105,6 +2899,7 @@ var SessionRefusal = class extends Error {
|
|
|
2105
2899
|
}
|
|
2106
2900
|
reason;
|
|
2107
2901
|
};
|
|
2902
|
+
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
2903
|
var MAX_TOOL_OUTPUT_BYTES = 8 * 1024;
|
|
2109
2904
|
var MAX_QUEUED_PROMPTS = 5;
|
|
2110
2905
|
var PERMISSION_WARNING_RE = /canUseTool will not be invoked/i;
|
|
@@ -2159,9 +2954,25 @@ var Session = class {
|
|
|
2159
2954
|
*/
|
|
2160
2955
|
twinSeen = false;
|
|
2161
2956
|
turnRunning = false;
|
|
2957
|
+
/**
|
|
2958
|
+
* The connector is going away (stopped, or restarted by its supervisor) and will come back. What this process cannot
|
|
2959
|
+
* finish is left recorded for the next run instead of being ended here: the running turn (no `turn_end`), its waiting
|
|
2960
|
+
* permission requests (still pending), and the prompts queued behind it (not counted as handled, so Claw delivers them
|
|
2961
|
+
* again). See {@link SessionManager.recover}.
|
|
2962
|
+
*/
|
|
2963
|
+
stopping = false;
|
|
2162
2964
|
queue = [];
|
|
2163
2965
|
launching = null;
|
|
2164
2966
|
idle;
|
|
2967
|
+
/**
|
|
2968
|
+
* The conversation is open in a terminal: this session only reads its transcript and shows it (see {@link spectate}).
|
|
2969
|
+
* No adapter is ever started for it, and every command that would write is refused.
|
|
2970
|
+
*/
|
|
2971
|
+
readOnly = false;
|
|
2972
|
+
tail = null;
|
|
2973
|
+
tailTimer;
|
|
2974
|
+
lastTerminalCheck = 0;
|
|
2975
|
+
checkingTerminal = false;
|
|
2165
2976
|
book;
|
|
2166
2977
|
// --- helpers -----------------------------------------------------------------
|
|
2167
2978
|
emit(type, data) {
|
|
@@ -2191,7 +3002,7 @@ var Session = class {
|
|
|
2191
3002
|
return this.noAskWarning || this.currentMode !== null && !this.agent.askingModes.includes(this.currentMode);
|
|
2192
3003
|
}
|
|
2193
3004
|
shownModes() {
|
|
2194
|
-
return this.modes.filter((m) => this.modeAllowed(m.id)).map((m) => ({ ...m, unattended: !this.agent.askingModes.includes(m.id) }));
|
|
3005
|
+
return this.modes.filter((m) => this.modeAllowed(m.id)).map((m) => ({ ...m, unattended: !this.agent.askingModes.includes(m.id), ...this.agent.readOnlyModes.includes(m.id) ? { read_only: true } : {} }));
|
|
2195
3006
|
}
|
|
2196
3007
|
// --- starting ----------------------------------------------------------------
|
|
2197
3008
|
/** `session.new`: start the adapter, create the ACP session, lock the mode. A refusal closes the session with a reason. */
|
|
@@ -2220,6 +3031,11 @@ var Session = class {
|
|
|
2220
3031
|
if (requestedMode && !this.agent.safeModes.includes(requestedMode) && !this.agent.allowUnsafeModes) {
|
|
2221
3032
|
return this.refuse(new SessionRefusal("mode_not_allowed", `mode ${requestedMode} needs approval = "never" for this agent (or allow_unsafe_modes) on this machine`));
|
|
2222
3033
|
}
|
|
3034
|
+
const support = this.env.attach.get(this.agent.id);
|
|
3035
|
+
if (support && this.acpId) {
|
|
3036
|
+
const verdict = await support.check(this.acpId, { transcript: false });
|
|
3037
|
+
if (!verdict.ok && verdict.reason === "session_live") return this.spectate(support, title, verdict.detail);
|
|
3038
|
+
}
|
|
2223
3039
|
if (requestedMode) this.targetMode = requestedMode;
|
|
2224
3040
|
try {
|
|
2225
3041
|
await this.launch("resume");
|
|
@@ -2232,6 +3048,85 @@ var Session = class {
|
|
|
2232
3048
|
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
3049
|
this.touch();
|
|
2234
3050
|
}
|
|
3051
|
+
// --- read-only: a conversation open in a terminal ------------------------------------
|
|
3052
|
+
/**
|
|
3053
|
+
* Show a conversation that a terminal has open without taking it over. Nothing is started: the transcript the terminal
|
|
3054
|
+
* writes is read from its end (`TranscriptTail`, read-only) and each new line goes out as the update an adapter would
|
|
3055
|
+
* have sent, scrubbed like every other event, marked `read_only`. What was there just before is sent first, marked
|
|
3056
|
+
* `replay`. Prompts, mode changes and decisions are refused (`read_only_live_session`) and never reach anything that
|
|
3057
|
+
* could write to the transcript. When the terminal no longer has the conversation open the session ends
|
|
3058
|
+
* (`terminal_closed`): taking it over then is a new session, through the full check.
|
|
3059
|
+
*/
|
|
3060
|
+
spectate(support, title, detail) {
|
|
3061
|
+
this.readOnly = true;
|
|
3062
|
+
this.tail = new TranscriptTail(() => this.acpId ? support.transcriptPath(this.acpId) : null);
|
|
3063
|
+
const backlog = this.tail.backlog();
|
|
3064
|
+
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() });
|
|
3065
|
+
this.audit("session_spectating", { cwd: this.cwd, acp_session_id: this.acpId, detail: detail.slice(0, 300) });
|
|
3066
|
+
this.emit("session_started", {
|
|
3067
|
+
acp_session_id: this.acpId,
|
|
3068
|
+
agent_id: this.agent.id,
|
|
3069
|
+
kind: this.agent.kind,
|
|
3070
|
+
cwd: this.cwd,
|
|
3071
|
+
title,
|
|
3072
|
+
attached: true,
|
|
3073
|
+
read_only: true,
|
|
3074
|
+
read_only_reason: "session_live",
|
|
3075
|
+
modes: [],
|
|
3076
|
+
currentMode: null,
|
|
3077
|
+
unattended: false
|
|
3078
|
+
});
|
|
3079
|
+
this.announced = true;
|
|
3080
|
+
for (const u of backlog) this.emitTranscript(u, true);
|
|
3081
|
+
this.followTail(support);
|
|
3082
|
+
}
|
|
3083
|
+
/** After a restart: a session that was read-only is again, from the transcript's end (what was sent before is not sent twice). */
|
|
3084
|
+
restoreSpectate() {
|
|
3085
|
+
this.readOnly = true;
|
|
3086
|
+
const support = this.env.attach.get(this.agent.id);
|
|
3087
|
+
if (!support) return;
|
|
3088
|
+
this.tail = new TranscriptTail(() => this.acpId ? support.transcriptPath(this.acpId) : null);
|
|
3089
|
+
this.tail.fromEnd();
|
|
3090
|
+
this.followTail(support);
|
|
3091
|
+
}
|
|
3092
|
+
followTail(support) {
|
|
3093
|
+
this.lastTerminalCheck = Date.now();
|
|
3094
|
+
const tick = () => {
|
|
3095
|
+
if (this.closed || !this.tail) return;
|
|
3096
|
+
for (const u of this.tail.poll()) this.emitTranscript(u, false);
|
|
3097
|
+
if (!this.checkingTerminal && Date.now() - this.lastTerminalCheck >= this.env.timing.spectateCheckMs) void this.checkTerminal(support);
|
|
3098
|
+
};
|
|
3099
|
+
this.tailTimer = setInterval(tick, this.env.timing.spectateMs);
|
|
3100
|
+
this.tailTimer.unref?.();
|
|
3101
|
+
}
|
|
3102
|
+
/** Is the conversation still open somewhere else? The cheap look first; only a look that finds nothing asks every source. */
|
|
3103
|
+
async checkTerminal(support) {
|
|
3104
|
+
if (!this.acpId) return;
|
|
3105
|
+
this.checkingTerminal = true;
|
|
3106
|
+
try {
|
|
3107
|
+
this.lastTerminalCheck = Date.now();
|
|
3108
|
+
if (support.peek(this.acpId, null)?.twin) return;
|
|
3109
|
+
const verdict = await support.check(this.acpId, { transcript: false });
|
|
3110
|
+
if (verdict.ok && !this.closed) {
|
|
3111
|
+
for (const u of this.tail?.poll() ?? []) this.emitTranscript(u, false);
|
|
3112
|
+
await this.close("terminal_closed");
|
|
3113
|
+
}
|
|
3114
|
+
} finally {
|
|
3115
|
+
this.checkingTerminal = false;
|
|
3116
|
+
}
|
|
3117
|
+
}
|
|
3118
|
+
emitTranscript(u, replay) {
|
|
3119
|
+
const text = u.type === "agent_message_chunk" ? String(u.update.content?.text ?? "") : "";
|
|
3120
|
+
if (text && this.env.config.dropMessagePatterns.some((re) => re.test(text))) return;
|
|
3121
|
+
const data = limitStrings(redactDeep({ sessionId: this.acpId, update: u.update }), MAX_TOOL_OUTPUT_BYTES);
|
|
3122
|
+
this.emit(u.type, { ...data, read_only: true, ...replay ? { replay: true } : {} });
|
|
3123
|
+
}
|
|
3124
|
+
/** The process is stopping: the transcript is no longer followed (the session itself stays recorded, read-only). */
|
|
3125
|
+
stopTail() {
|
|
3126
|
+
clearInterval(this.tailTimer);
|
|
3127
|
+
this.tailTimer = void 0;
|
|
3128
|
+
this.tail = null;
|
|
3129
|
+
}
|
|
2235
3130
|
/** Start (or restart) the adapter and bring the ACP session up in a verified mode. One at a time. */
|
|
2236
3131
|
launch(how) {
|
|
2237
3132
|
this.launching ??= this.guardedLaunch(how).finally(() => {
|
|
@@ -2315,7 +3210,7 @@ var Session = class {
|
|
|
2315
3210
|
sessionId: this.id,
|
|
2316
3211
|
cwd: this.cwd,
|
|
2317
3212
|
hooks: this.hooks(),
|
|
2318
|
-
logFile:
|
|
3213
|
+
logFile: path15.join(this.env.logDir, `adapter-${this.agent.id}.log`),
|
|
2319
3214
|
version: this.env.version
|
|
2320
3215
|
}).catch((e) => {
|
|
2321
3216
|
throw new SessionRefusal("adapter_start_failed", errMessage(e));
|
|
@@ -2328,19 +3223,20 @@ var Session = class {
|
|
|
2328
3223
|
this.modeSeen = [];
|
|
2329
3224
|
try {
|
|
2330
3225
|
if (how === "new") {
|
|
2331
|
-
const res = await adapter.request("session/new", { cwd: this.cwd, mcpServers: [] });
|
|
3226
|
+
const res = await this.signedIn(adapter, () => adapter.request("session/new", { cwd: this.cwd, mcpServers: [], ...claudeSessionMeta(this.agent) }));
|
|
2332
3227
|
if (typeof res.sessionId !== "string") throw new SessionRefusal("adapter_protocol", "session/new returned no session id");
|
|
2333
3228
|
this.acpId = res.sessionId;
|
|
2334
3229
|
this.absorb(res);
|
|
3230
|
+
await this.refuseSignedOut(adapter);
|
|
2335
3231
|
} else {
|
|
2336
3232
|
if (!this.acpId) throw new SessionRefusal("adapter_cannot_resume", "no adapter session to resume");
|
|
2337
3233
|
const caps = adapter.caps.sessionCapabilities;
|
|
2338
3234
|
if (caps?.resume) {
|
|
2339
|
-
this.absorb(await adapter.request("session/resume", { sessionId: this.acpId, cwd: this.cwd, mcpServers: [] }));
|
|
3235
|
+
this.absorb(await this.signedIn(adapter, () => adapter.request("session/resume", { sessionId: this.acpId, cwd: this.cwd, mcpServers: [], ...claudeSessionMeta(this.agent) })));
|
|
2340
3236
|
} else if (adapter.caps.loadSession) {
|
|
2341
3237
|
this.replaying = true;
|
|
2342
3238
|
try {
|
|
2343
|
-
this.absorb(await adapter.requestUnbounded("session/load", { sessionId: this.acpId, cwd: this.cwd, mcpServers: [] }));
|
|
3239
|
+
this.absorb(await this.signedIn(adapter, () => adapter.requestUnbounded("session/load", { sessionId: this.acpId, cwd: this.cwd, mcpServers: [], ...claudeSessionMeta(this.agent) })));
|
|
2344
3240
|
} finally {
|
|
2345
3241
|
this.replaying = false;
|
|
2346
3242
|
}
|
|
@@ -2355,6 +3251,43 @@ var Session = class {
|
|
|
2355
3251
|
throw e instanceof SessionRefusal ? e : mapAdapterError(e);
|
|
2356
3252
|
}
|
|
2357
3253
|
}
|
|
3254
|
+
/**
|
|
3255
|
+
* `open` (a `session/new`), signing the adapter in first when it needs that and can do it by itself: an adapter that
|
|
3256
|
+
* answers "Authentication required" and offers an `api-key` method, while its environment holds a key it reads
|
|
3257
|
+
* (codex-acp: OPENAI_API_KEY or CODEX_API_KEY), is told to `authenticate` with it and asked once more. Without this a
|
|
3258
|
+
* Codex agent set up with only an API key never opened a session. The key itself is never sent: the adapter reads
|
|
3259
|
+
* its own environment. (codex-acp then keeps the key in its auth.json, as `codex login --with-api-key` does.)
|
|
3260
|
+
*/
|
|
3261
|
+
async signedIn(adapter, open) {
|
|
3262
|
+
try {
|
|
3263
|
+
return await open();
|
|
3264
|
+
} catch (e) {
|
|
3265
|
+
const method = adapter.apiKeyMethod();
|
|
3266
|
+
if (mapAdapterError(e).reason !== "agent_auth_required" || !method || !this.hasApiKey()) throw e;
|
|
3267
|
+
this.audit("agent_authenticate", { method });
|
|
3268
|
+
try {
|
|
3269
|
+
await adapter.request("authenticate", { methodId: method });
|
|
3270
|
+
} catch (ae) {
|
|
3271
|
+
throw new SessionRefusal("agent_auth_required", `authenticate ${method} failed: ${errMessage(ae)}`);
|
|
3272
|
+
}
|
|
3273
|
+
return await open();
|
|
3274
|
+
}
|
|
3275
|
+
}
|
|
3276
|
+
/** Whether the adapter's environment (the connector's, with the agent's `[agents.env]` over it) holds an OpenAI/Codex key. */
|
|
3277
|
+
hasApiKey() {
|
|
3278
|
+
const env = { ...process.env, ...this.agent.env };
|
|
3279
|
+
return ["CODEX_API_KEY", "OPENAI_API_KEY"].some((k) => typeof env[k] === "string" && env[k].trim() !== "");
|
|
3280
|
+
}
|
|
3281
|
+
/**
|
|
3282
|
+
* An adapter that reports its sign-in (claude-agent-acp) and says there is none: the session would open, say it is
|
|
3283
|
+
* ready and fail at its first message with "Authentication required". A new session is refused now instead, with the
|
|
3284
|
+
* fix. A session that already exists (resumed after an idle recycle or a restart) is not: its prompts say why and it
|
|
3285
|
+
* stays, so it carries on once someone signs in on the machine.
|
|
3286
|
+
*/
|
|
3287
|
+
async refuseSignedOut(adapter) {
|
|
3288
|
+
const st = await adapter.waitAuthStatus(this.env.timing.authWaitMs);
|
|
3289
|
+
if (st?.kind === "none") throw new SessionRefusal("agent_auth_required", `the agent reports that it is not signed in${st.label ? ` (${st.label})` : ""}`);
|
|
3290
|
+
}
|
|
2358
3291
|
/** Take the modes and the mode config option out of a `session/new|resume|load` answer. */
|
|
2359
3292
|
absorb(res) {
|
|
2360
3293
|
const modes = res.modes;
|
|
@@ -2381,7 +3314,7 @@ var Session = class {
|
|
|
2381
3314
|
async enforceMode(mode) {
|
|
2382
3315
|
const a = this.adapter;
|
|
2383
3316
|
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}`);
|
|
3317
|
+
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
3318
|
this.enforcing = true;
|
|
2386
3319
|
this.modeSeen = [];
|
|
2387
3320
|
try {
|
|
@@ -2437,6 +3370,7 @@ var Session = class {
|
|
|
2437
3370
|
}
|
|
2438
3371
|
/** `set_mode`: a user-chosen mode, only from the allowed list, always read back. */
|
|
2439
3372
|
async setMode(modeId, cmdId) {
|
|
3373
|
+
if (this.readOnly) return this.rejectCommand("set_mode", cmdId, READ_ONLY_REASON);
|
|
2440
3374
|
if (!this.adapter) await this.ensureAdapter();
|
|
2441
3375
|
if (!this.modeAllowed(modeId)) return this.rejectCommand("set_mode", cmdId, "mode_not_allowed");
|
|
2442
3376
|
if (this.modes.length && !this.modes.some((m) => m.id === modeId)) return this.rejectCommand("set_mode", cmdId, "mode_unavailable");
|
|
@@ -2489,7 +3423,7 @@ var Session = class {
|
|
|
2489
3423
|
this.adapter = null;
|
|
2490
3424
|
const hadTurn = this.turnRunning;
|
|
2491
3425
|
const cancelled = this.book.cancelAll();
|
|
2492
|
-
for (const actionId of cancelled) this.emit("permission_resolved", { actionId, optionId: null, outcome: "cancelled", by: "adapter_exit" });
|
|
3426
|
+
if (!this.stopping) for (const actionId of cancelled) this.emit("permission_resolved", { actionId, optionId: null, outcome: "cancelled", by: "adapter_exit" });
|
|
2493
3427
|
if (!this.closed) this.audit("adapter_exited", { code: info.code, signal: info.signal, during_turn: hadTurn });
|
|
2494
3428
|
}
|
|
2495
3429
|
onUpdate(update, params) {
|
|
@@ -2538,16 +3472,20 @@ var Session = class {
|
|
|
2538
3472
|
kind: str(toolCall.kind, 64),
|
|
2539
3473
|
input: boundedInput(toolCall.rawInput),
|
|
2540
3474
|
toolCallId: str(toolCall.toolCallId, 128)
|
|
2541
|
-
}, (optionId) => optionId === null ? { outcome: { outcome: "cancelled" } } : { outcome: { outcome: "selected", optionId } });
|
|
3475
|
+
}, (optionId) => optionId === null ? { outcome: { outcome: "cancelled" } } : { outcome: { outcome: "selected", optionId } }, true);
|
|
2542
3476
|
}
|
|
2543
3477
|
/** Put a question on the phone and wait for the answer, a cancel or the timeout. `shape` turns the answer into what the adapter expects. */
|
|
2544
|
-
askPerson(options, info, shape) {
|
|
3478
|
+
askPerson(options, info, shape, permission = false) {
|
|
2545
3479
|
return new Promise((resolve) => {
|
|
2546
3480
|
const p = this.book.open(options, (actionId) => redactDeep({ actionId, ...info, options, timeout_ms: this.book.ttl }), (optionId) => {
|
|
2547
|
-
|
|
3481
|
+
if (!this.stopping) {
|
|
3482
|
+
this.env.store.removePending(this.id, p.actionId);
|
|
3483
|
+
if (this.book.size === 0) this.env.store.markWaiting(this.id, false);
|
|
3484
|
+
}
|
|
2548
3485
|
resolve(shape(optionId));
|
|
2549
3486
|
});
|
|
2550
3487
|
this.env.store.addPending(this.id, p.actionId);
|
|
3488
|
+
this.env.store.markWaiting(this.id, true, permission);
|
|
2551
3489
|
this.audit("permission_requested", { action_id: p.actionId, tool: info.tool, tool_kind: info.kind, options: options.map((o) => o.optionId) });
|
|
2552
3490
|
this.emit("permissionRequest", p.payload);
|
|
2553
3491
|
});
|
|
@@ -2616,6 +3554,12 @@ var Session = class {
|
|
|
2616
3554
|
this.rejectCommand("prompt", cmdId, "session_closed", cmdSeq);
|
|
2617
3555
|
return started();
|
|
2618
3556
|
}
|
|
3557
|
+
if (this.readOnly) {
|
|
3558
|
+
this.audit("prompt_refused", { reason: READ_ONLY_REASON });
|
|
3559
|
+
this.rejectCommand("prompt", cmdId, READ_ONLY_REASON, cmdSeq);
|
|
3560
|
+
this.emit("turn_end", { stopReason: "error", error: READ_ONLY_REASON, read_only: true, cmd_id: cmdId, cmd_seq: cmdSeq });
|
|
3561
|
+
return started();
|
|
3562
|
+
}
|
|
2619
3563
|
if (this.queue.length >= MAX_QUEUED_PROMPTS) {
|
|
2620
3564
|
this.rejectCommand("prompt", cmdId, "too_many_queued_prompts", cmdSeq);
|
|
2621
3565
|
return started();
|
|
@@ -2623,11 +3567,16 @@ var Session = class {
|
|
|
2623
3567
|
this.queue.push({ text, cmdId, cmdSeq, started });
|
|
2624
3568
|
if (!this.turnRunning) void this.pump();
|
|
2625
3569
|
}
|
|
2626
|
-
/** Prompts that will now never run (the
|
|
2627
|
-
dropQueued() {
|
|
3570
|
+
/** Prompts that will now never run here: they count as handled, unless (`keep`) the connector is only restarting and the next run should get them again. */
|
|
3571
|
+
dropQueued(keep = false) {
|
|
2628
3572
|
const q = this.queue;
|
|
2629
3573
|
this.queue = [];
|
|
2630
|
-
for (const item of q) item.started();
|
|
3574
|
+
if (!keep) for (const item of q) item.started();
|
|
3575
|
+
}
|
|
3576
|
+
/** The connector is about to stop its adapters and will come back: see {@link stopping}. */
|
|
3577
|
+
shutdown() {
|
|
3578
|
+
this.stopping = true;
|
|
3579
|
+
this.dropQueued(true);
|
|
2631
3580
|
}
|
|
2632
3581
|
async pump() {
|
|
2633
3582
|
if (this.turnRunning) return;
|
|
@@ -2651,6 +3600,7 @@ var Session = class {
|
|
|
2651
3600
|
this.audit("prompt_refused", { reason: refused.reason, detail: refused.detail.slice(0, 300) });
|
|
2652
3601
|
this.rejectCommand("prompt", cmdId, refused.reason, cmdSeq);
|
|
2653
3602
|
this.emit("turn_end", { stopReason: "error", error: refused.reason, cmd_id: cmdId, cmd_seq: cmdSeq });
|
|
3603
|
+
this.env.store.endTurn(this.id);
|
|
2654
3604
|
return;
|
|
2655
3605
|
}
|
|
2656
3606
|
}
|
|
@@ -2658,7 +3608,11 @@ var Session = class {
|
|
|
2658
3608
|
await this.ensureAdapter();
|
|
2659
3609
|
} catch (e) {
|
|
2660
3610
|
started();
|
|
2661
|
-
|
|
3611
|
+
const reason = e instanceof SessionRefusal ? e.reason : "adapter_unavailable";
|
|
3612
|
+
const hint = hintFor(reason, this.agent);
|
|
3613
|
+
this.emit("turn_end", { stopReason: "error", error: reason, ...hint ? { hint } : {}, cmd_id: cmdId, cmd_seq: cmdSeq });
|
|
3614
|
+
this.env.store.endTurn(this.id);
|
|
3615
|
+
if (reason === "agent_auth_required") return;
|
|
2662
3616
|
return this.refuse(e);
|
|
2663
3617
|
}
|
|
2664
3618
|
const a = this.adapter;
|
|
@@ -2667,13 +3621,22 @@ var Session = class {
|
|
|
2667
3621
|
const watch = support ? this.watchTwins(support, this.acpId) : null;
|
|
2668
3622
|
try {
|
|
2669
3623
|
const reply = a.requestUnbounded("session/prompt", { sessionId: this.acpId, prompt: [{ type: "text", text }] });
|
|
3624
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3625
|
+
this.env.store.beginTurn({ session_id: this.id, cmd_id: cmdId, cmd_seq: cmdSeq, started_at: now });
|
|
3626
|
+
this.env.store.patch(this.id, { used_at: now });
|
|
2670
3627
|
started();
|
|
2671
3628
|
const res = await reply;
|
|
2672
3629
|
const usage = isObject(res.usage) ? res.usage : void 0;
|
|
2673
|
-
this.
|
|
3630
|
+
this.endTurn({ stopReason: typeof res.stopReason === "string" ? res.stopReason : "end_turn", ...usage ? { usage } : {}, cmd_id: cmdId, cmd_seq: cmdSeq });
|
|
2674
3631
|
} catch (e) {
|
|
2675
|
-
this.audit("turn_failed", { error: errMessage(e) });
|
|
2676
|
-
|
|
3632
|
+
if (!this.stopping) this.audit("turn_failed", { error: errMessage(e) });
|
|
3633
|
+
const r = mapAdapterError(e);
|
|
3634
|
+
if (r.reason === "agent_auth_required") {
|
|
3635
|
+
this.endTurn({ stopReason: "error", error: r.reason, message: errMessage(e).slice(0, 300), hint: hintFor(r.reason, this.agent), cmd_id: cmdId, cmd_seq: cmdSeq });
|
|
3636
|
+
if (this.adapter === a) await this.dropAdapter();
|
|
3637
|
+
} else {
|
|
3638
|
+
this.endTurn({ stopReason: "error", error: errMessage(e).slice(0, 300), cmd_id: cmdId, cmd_seq: cmdSeq });
|
|
3639
|
+
}
|
|
2677
3640
|
} finally {
|
|
2678
3641
|
this.markTranscript();
|
|
2679
3642
|
if (watch) {
|
|
@@ -2718,6 +3681,12 @@ var Session = class {
|
|
|
2718
3681
|
}
|
|
2719
3682
|
return null;
|
|
2720
3683
|
}
|
|
3684
|
+
/** The turn is over: say so and forget it. Not while the connector is going away: the next run ends or continues it. */
|
|
3685
|
+
endTurn(data) {
|
|
3686
|
+
if (this.stopping) return;
|
|
3687
|
+
this.emit("turn_end", data);
|
|
3688
|
+
this.env.store.endTurn(this.id);
|
|
3689
|
+
}
|
|
2721
3690
|
/** What size the transcript is as this connector leaves it: after it starts the session and after every turn. */
|
|
2722
3691
|
markTranscript() {
|
|
2723
3692
|
if (this.agent.attach && this.acpId) this.transcriptMark = this.env.attach.get(this.agent.id)?.transcriptSize(this.acpId) ?? null;
|
|
@@ -2732,10 +3701,15 @@ var Session = class {
|
|
|
2732
3701
|
/** `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
3702
|
async resume(cmdId) {
|
|
2734
3703
|
if (this.closed) return this.rejectCommand("resume", cmdId, "session_closed");
|
|
3704
|
+
if (this.readOnly) {
|
|
3705
|
+
this.emit("session_resumed", { acp_session_id: this.acpId, read_only: true, modes: [], currentMode: null, unattended: false });
|
|
3706
|
+
return;
|
|
3707
|
+
}
|
|
2735
3708
|
try {
|
|
2736
3709
|
if (this.adapter && !this.adapter.exited) await this.enforceMode(this.targetMode);
|
|
2737
3710
|
else await this.launch("resume");
|
|
2738
3711
|
} catch (e) {
|
|
3712
|
+
if (e instanceof SessionRefusal && e.reason === "agent_auth_required") return this.rejectCommand("resume", cmdId, e.reason);
|
|
2739
3713
|
return this.refuse(e);
|
|
2740
3714
|
}
|
|
2741
3715
|
this.audit("session_resumed", { mode: this.currentMode, unattended: this.unattended });
|
|
@@ -2774,6 +3748,7 @@ var Session = class {
|
|
|
2774
3748
|
if (this.closed) return;
|
|
2775
3749
|
this.closed = true;
|
|
2776
3750
|
clearTimeout(this.idle);
|
|
3751
|
+
this.stopTail();
|
|
2777
3752
|
this.dropQueued();
|
|
2778
3753
|
for (const actionId of this.book.cancelAll()) this.emit("permission_resolved", { actionId, optionId: null, outcome: "cancelled", by: reason });
|
|
2779
3754
|
const a = this.adapter;
|
|
@@ -2781,7 +3756,7 @@ var Session = class {
|
|
|
2781
3756
|
await this.dropAdapter();
|
|
2782
3757
|
this.env.store.patch(this.id, { status: "closed" });
|
|
2783
3758
|
this.audit("session_closed", { reason });
|
|
2784
|
-
this.emit("session_closed", { reason });
|
|
3759
|
+
this.emit("session_closed", withHint({ reason }, this.agent));
|
|
2785
3760
|
}
|
|
2786
3761
|
/** A session that cannot be opened (or kept safe) is closed with the reason, never left half-open. */
|
|
2787
3762
|
async refuse(e) {
|
|
@@ -2793,7 +3768,7 @@ var Session = class {
|
|
|
2793
3768
|
/** A command refused; a prompt's refusal names it by `cmd_seq` too, as its turn_end would. */
|
|
2794
3769
|
rejectCommand(type, cmdId, reason, cmdSeq = null) {
|
|
2795
3770
|
this.audit("command_rejected", { type, cmd_id: cmdId, reason });
|
|
2796
|
-
this.emit("command_rejected", { type, cmd_id: cmdId, reason, ...cmdSeq !== null ? { cmd_seq: cmdSeq } : {} });
|
|
3771
|
+
this.emit("command_rejected", withHint({ type, cmd_id: cmdId, reason, ...cmdSeq !== null ? { cmd_seq: cmdSeq } : {} }, this.agent));
|
|
2797
3772
|
}
|
|
2798
3773
|
};
|
|
2799
3774
|
function mapAdapterError(e) {
|
|
@@ -2818,7 +3793,10 @@ function limitStrings(value, max) {
|
|
|
2818
3793
|
}
|
|
2819
3794
|
|
|
2820
3795
|
// src/manager.ts
|
|
3796
|
+
import os5 from "node:os";
|
|
2821
3797
|
var MAX_ACTIVE_SESSIONS = 10;
|
|
3798
|
+
var RECOVER_NOTICE_MS = 4 * 36e5;
|
|
3799
|
+
var CONTINUE_PROMPT = "[claw-connector] The connector on this machine restarted while you were waiting for my permission, so that request was cancelled. Continue the task from where you left off, and ask again for anything that still needs my permission.";
|
|
2822
3800
|
var cmdSeqOf = (cmd) => Number.isSafeInteger(cmd.cmd_seq) ? cmd.cmd_seq : null;
|
|
2823
3801
|
var SessionManager = class {
|
|
2824
3802
|
constructor(env) {
|
|
@@ -2832,6 +3810,15 @@ var SessionManager = class {
|
|
|
2832
3810
|
agent(id) {
|
|
2833
3811
|
return typeof id === "string" ? this.env.config.agents.find((a) => a.id === id) : void 0;
|
|
2834
3812
|
}
|
|
3813
|
+
/** The folder a phone asked for, as this agent would see it: `~` is the home its adapter runs with. */
|
|
3814
|
+
where(cwd, agent) {
|
|
3815
|
+
return resolveCwd(typeof cwd === "string" ? cwd : "", agent.roots, { home: agent.env.HOME?.trim() || os5.homedir(), timeoutMs: this.env.timing.rootCheckMs });
|
|
3816
|
+
}
|
|
3817
|
+
/** A session refused before it had a process: audited, and closed with its reason and what to do about it. */
|
|
3818
|
+
refuseNew(sid, reason, agent, extra = {}) {
|
|
3819
|
+
this.env.audit.record("session_refused", { session_id: sid, ...agent ? { agent_id: agent.id } : {}, reason, ...extra });
|
|
3820
|
+
this.env.emit(sid, "session_closed", withHint({ reason }, agent));
|
|
3821
|
+
}
|
|
2835
3822
|
/**
|
|
2836
3823
|
* Commands of one session run one after another; sessions run side by side.
|
|
2837
3824
|
* `handled` is called exactly once, when the command counts as carried out: for most that is when the
|
|
@@ -2881,8 +3868,20 @@ var SessionManager = class {
|
|
|
2881
3868
|
return false;
|
|
2882
3869
|
}
|
|
2883
3870
|
const s = await this.lookup(sid);
|
|
2884
|
-
if (
|
|
2885
|
-
this.env.emit(sid, "session_closed", { reason: "unknown_session" });
|
|
3871
|
+
if (s === null) {
|
|
3872
|
+
this.env.emit(sid, "session_closed", { reason: "unknown_session", hint: "the session has already ended on that machine: start a new one" });
|
|
3873
|
+
return false;
|
|
3874
|
+
}
|
|
3875
|
+
if (!(s instanceof Session)) {
|
|
3876
|
+
if (type === "close") {
|
|
3877
|
+
this.env.store.patch(sid, { status: "closed" });
|
|
3878
|
+
this.env.audit.record("session_closed", { session_id: sid, reason: "closed" });
|
|
3879
|
+
this.env.emit(sid, "session_closed", { reason: "closed" });
|
|
3880
|
+
return false;
|
|
3881
|
+
}
|
|
3882
|
+
const seq = cmdSeqOf(cmd);
|
|
3883
|
+
this.env.audit.record("command_rejected", { type, cmd_id: cmdId, reason: s.unavailable });
|
|
3884
|
+
this.env.emit(sid, "command_rejected", withHint({ type, cmd_id: cmdId, reason: s.unavailable, ...seq !== null ? { cmd_seq: seq } : {} }, s.agent));
|
|
2886
3885
|
return false;
|
|
2887
3886
|
}
|
|
2888
3887
|
switch (type) {
|
|
@@ -2916,31 +3915,20 @@ var SessionManager = class {
|
|
|
2916
3915
|
async sessionNew(cmd) {
|
|
2917
3916
|
const sid = cmd.session_id;
|
|
2918
3917
|
if (this.sessions.has(sid)) return;
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
this.env.audit.record("session_refused", { session_id: sid, reason: "unknown_agent", agent_id: String(cmd.agent_id).slice(0, 64) });
|
|
2922
|
-
this.env.emit(sid, "session_closed", { reason: "unknown_agent" });
|
|
3918
|
+
if (this.env.store.get(sid)?.read_only) {
|
|
3919
|
+
await this.lookup(sid);
|
|
2923
3920
|
return;
|
|
2924
3921
|
}
|
|
3922
|
+
const agent = this.agent(cmd.agent_id);
|
|
3923
|
+
if (!agent) return this.refuseNew(sid, "unknown_agent", null, { agent_id: String(cmd.agent_id).slice(0, 64) });
|
|
2925
3924
|
const ref = cmd.resume_ref;
|
|
2926
3925
|
if (agent.attach) return this.attachNew(cmd, agent);
|
|
2927
|
-
if (ref !== void 0 && ref !== null)
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
}
|
|
2932
|
-
const verdict = checkCwd(typeof cmd.cwd === "string" ? cmd.cwd : "", agent.roots);
|
|
2933
|
-
if (!verdict.ok) {
|
|
2934
|
-
this.env.audit.record("session_refused", { session_id: sid, agent_id: agent.id, reason: verdict.reason, cwd: String(cmd.cwd).slice(0, 300) });
|
|
2935
|
-
this.env.emit(sid, "session_closed", { reason: verdict.reason });
|
|
2936
|
-
return;
|
|
2937
|
-
}
|
|
3926
|
+
if (ref !== void 0 && ref !== null) return this.refuseNew(sid, "resume_not_supported", agent);
|
|
3927
|
+
const verdict = await this.where(cmd.cwd, agent);
|
|
3928
|
+
if (!verdict.ok) return this.refuseNew(sid, verdict.reason, agent, { cwd: String(cmd.cwd).slice(0, 300) });
|
|
3929
|
+
if (this.sessions.has(sid)) return;
|
|
2938
3930
|
const active = [...this.sessions.values()].filter((s2) => !s2.closed).length;
|
|
2939
|
-
if (active >= MAX_ACTIVE_SESSIONS)
|
|
2940
|
-
this.env.audit.record("session_refused", { session_id: sid, agent_id: agent.id, reason: "too_many_sessions" });
|
|
2941
|
-
this.env.emit(sid, "session_closed", { reason: "too_many_sessions" });
|
|
2942
|
-
return;
|
|
2943
|
-
}
|
|
3931
|
+
if (active >= MAX_ACTIVE_SESSIONS) return this.refuseNew(sid, "too_many_sessions", agent);
|
|
2944
3932
|
const s = new Session(sid, agent, verdict.real, this.env);
|
|
2945
3933
|
this.sessions.set(sid, s);
|
|
2946
3934
|
await s.create(typeof cmd.mode === "string" ? cmd.mode : null);
|
|
@@ -2952,10 +3940,7 @@ var SessionManager = class {
|
|
|
2952
3940
|
*/
|
|
2953
3941
|
async attachNew(cmd, agent) {
|
|
2954
3942
|
const sid = cmd.session_id;
|
|
2955
|
-
const refuse = (reason, extra = {}) =>
|
|
2956
|
-
this.env.audit.record("session_refused", { session_id: sid, agent_id: agent.id, reason, ...extra });
|
|
2957
|
-
this.env.emit(sid, "session_closed", { reason });
|
|
2958
|
-
};
|
|
3943
|
+
const refuse = (reason, extra = {}) => this.refuseNew(sid, reason, agent, extra);
|
|
2959
3944
|
const ref = typeof cmd.resume_ref === "string" ? cmd.resume_ref : "";
|
|
2960
3945
|
if (!ref) return refuse("resume_ref_required");
|
|
2961
3946
|
if (!SESSION_REF_RE.test(ref)) return refuse("invalid_resume_ref");
|
|
@@ -2963,13 +3948,13 @@ var SessionManager = class {
|
|
|
2963
3948
|
if (!support) return refuse("attach_unavailable");
|
|
2964
3949
|
const want = ref.toLowerCase();
|
|
2965
3950
|
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");
|
|
3951
|
+
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
3952
|
if ([...this.sessions.values()].filter((s) => !s.closed).length + this.attaching.size >= MAX_ACTIVE_SESSIONS) return refuse("too_many_sessions");
|
|
2968
3953
|
this.attaching.add(claim);
|
|
2969
3954
|
try {
|
|
2970
3955
|
const found = await support.resolve(want);
|
|
2971
3956
|
if (!found.ok) return refuse(found.reason);
|
|
2972
|
-
const asked =
|
|
3957
|
+
const asked = await this.where(cmd.cwd, agent);
|
|
2973
3958
|
if (!asked.ok) return refuse(asked.reason, { cwd: String(cmd.cwd).slice(0, 300) });
|
|
2974
3959
|
if (asked.real !== found.item.cwd) return refuse("cwd_mismatch", { cwd: String(cmd.cwd).slice(0, 300) });
|
|
2975
3960
|
const s = new Session(sid, agent, found.item.cwd, this.env, found.item.ref);
|
|
@@ -2983,27 +3968,40 @@ var SessionManager = class {
|
|
|
2983
3968
|
/** The ids of the existing sessions this connector holds open, by agent. */
|
|
2984
3969
|
attachedRefs(agentId) {
|
|
2985
3970
|
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());
|
|
3971
|
+
for (const s of this.sessions.values()) if (!s.closed && !s.readOnly && s.agent.id === agentId && s.acpId) out.add(s.acpId.toLowerCase());
|
|
2987
3972
|
return out;
|
|
2988
3973
|
}
|
|
2989
|
-
/**
|
|
3974
|
+
/**
|
|
3975
|
+
* A session this process knows, or one it made earlier (before a restart) and can bring back; `unavailable` when its
|
|
3976
|
+
* folder's disk does not answer now (the session is kept for later).
|
|
3977
|
+
*/
|
|
2990
3978
|
async lookup(sid) {
|
|
2991
3979
|
const have = this.sessions.get(sid);
|
|
2992
3980
|
if (have) return have;
|
|
2993
3981
|
const rec = this.env.store.get(sid);
|
|
2994
3982
|
if (!rec || rec.status !== "active") return null;
|
|
2995
3983
|
const agent = this.agent(rec.agent_id);
|
|
2996
|
-
const verdict = agent ?
|
|
3984
|
+
const verdict = agent ? await this.where(rec.cwd, agent) : null;
|
|
2997
3985
|
if (!agent || !verdict?.ok) {
|
|
3986
|
+
if (agent && verdict && !verdict.ok && verdict.reason === "root_unavailable") return { unavailable: verdict.reason, agent };
|
|
2998
3987
|
this.env.store.patch(sid, { status: "closed" });
|
|
2999
|
-
this.
|
|
3000
|
-
this.env.emit(sid, "session_closed", { reason: agent ? "cwd_outside_roots" : "unknown_agent" });
|
|
3988
|
+
this.refuseNew(sid, agent ? verdict && !verdict.ok ? verdict.reason : "cwd_outside_roots" : "unknown_agent", agent ?? null);
|
|
3001
3989
|
return null;
|
|
3002
3990
|
}
|
|
3991
|
+
const raced = this.sessions.get(sid);
|
|
3992
|
+
if (raced) return raced;
|
|
3003
3993
|
const s = new Session(sid, agent, verdict.real, this.env, rec.acp_session_id, rec.mode);
|
|
3004
3994
|
this.sessions.set(sid, s);
|
|
3995
|
+
if (rec.read_only) s.restoreSpectate();
|
|
3005
3996
|
return s;
|
|
3006
3997
|
}
|
|
3998
|
+
/** At start: every session that was watching a terminal's conversation goes on watching it (and notices when it ends). */
|
|
3999
|
+
async restoreReadOnly() {
|
|
4000
|
+
for (const sid of this.env.store.ids()) {
|
|
4001
|
+
const rec = this.env.store.get(sid);
|
|
4002
|
+
if (rec?.read_only && rec.status === "active" && !this.sessions.has(sid)) await this.lookup(sid);
|
|
4003
|
+
}
|
|
4004
|
+
}
|
|
3007
4005
|
/** After the command stream reconnects: cards still waiting are shown again. */
|
|
3008
4006
|
resendPending() {
|
|
3009
4007
|
for (const s of this.sessions.values()) s.resendPending();
|
|
@@ -3011,11 +4009,62 @@ var SessionManager = class {
|
|
|
3011
4009
|
get pendingPermissions() {
|
|
3012
4010
|
return [...this.sessions.values()].reduce((n, s) => n + s.pendingCount, 0);
|
|
3013
4011
|
}
|
|
3014
|
-
/**
|
|
3015
|
-
|
|
4012
|
+
/**
|
|
4013
|
+
* After a restart (a crash, a kill, a supervisor's restart, an upgrade): every turn the previous run left running is
|
|
4014
|
+
* ended or carried on, and every session used lately is told it is back (`session_recovered`), once.
|
|
4015
|
+
*
|
|
4016
|
+
* A turn whose agent was waiting for a person's permission for a tool call, cut off no longer ago than a card lives, in a
|
|
4017
|
+
* session of an agent that always asks, goes on with {@link CONTINUE_PROMPT} under the same prompt (`cmd_id`,
|
|
4018
|
+
* `cmd_seq`): the agent asks again, so the card comes back, and the prompt's own `turn_end` still ends it. Not while
|
|
4019
|
+
* the kill switch is on (`mayContinue`). Any other turn ends with `turn_end` `error: "connector_restarted"`, naming its
|
|
4020
|
+
* prompt, so the phone stops waiting for it; whether the agent finished what it was doing is in the conversation, which
|
|
4021
|
+
* the next message resumes.
|
|
4022
|
+
*/
|
|
4023
|
+
recover(o) {
|
|
4024
|
+
const now = o.now ?? Date.now();
|
|
4025
|
+
const told = /* @__PURE__ */ new Set();
|
|
4026
|
+
for (const t of this.env.store.listTurns()) {
|
|
4027
|
+
const sid = t.session_id;
|
|
4028
|
+
const rec = this.env.store.get(sid);
|
|
4029
|
+
const active = rec?.status === "active";
|
|
4030
|
+
const agent = rec ? this.agent(rec.agent_id) : void 0;
|
|
4031
|
+
const asks = agent !== void 0 && !agent.allowUnsafeModes && rec?.mode !== null && rec?.mode !== void 0 && agent.askingModes.includes(rec.mode);
|
|
4032
|
+
const waiting = Boolean(t.waiting_permission);
|
|
4033
|
+
const recent = o.downForMs !== null && o.downForMs <= this.env.timing.permissionTtlMs;
|
|
4034
|
+
if (o.mayContinue && active && asks && waiting && recent) {
|
|
4035
|
+
this.env.audit.record("turn_continued", { session_id: sid, cmd_id: t.cmd_id, cmd_seq: t.cmd_seq, down_ms: o.downForMs });
|
|
4036
|
+
this.env.emit(sid, "session_recovered", { reason: "connector_restart", interrupted: true, continuing: true, cmd_id: t.cmd_id, cmd_seq: t.cmd_seq });
|
|
4037
|
+
void this.handle({ cmd_id: t.cmd_id ?? `continue-${t.cmd_seq ?? sid}`, type: "prompt", session_id: sid, text: CONTINUE_PROMPT, ...t.cmd_seq !== null ? { cmd_seq: t.cmd_seq } : {} }, () => {
|
|
4038
|
+
if (this.env.store.turn(sid)?.started_at === t.started_at) this.env.store.endTurn(sid);
|
|
4039
|
+
});
|
|
4040
|
+
} else {
|
|
4041
|
+
this.env.audit.record("turn_interrupted", { session_id: sid, cmd_id: t.cmd_id, cmd_seq: t.cmd_seq, waiting, down_ms: o.downForMs });
|
|
4042
|
+
if (active) this.env.emit(sid, "session_recovered", { reason: "connector_restart", interrupted: true, continuing: false, cmd_id: t.cmd_id, cmd_seq: t.cmd_seq });
|
|
4043
|
+
this.env.emit(sid, "turn_end", { stopReason: "error", error: "connector_restarted", cmd_id: t.cmd_id, cmd_seq: t.cmd_seq });
|
|
4044
|
+
this.env.store.endTurn(sid);
|
|
4045
|
+
}
|
|
4046
|
+
told.add(sid);
|
|
4047
|
+
}
|
|
4048
|
+
for (const sid of this.env.store.ids()) {
|
|
4049
|
+
const rec = this.env.store.get(sid);
|
|
4050
|
+
if (told.has(sid) || rec?.status !== "active" || !rec.used_at) continue;
|
|
4051
|
+
const used = Date.parse(rec.used_at);
|
|
4052
|
+
if (!Number.isFinite(used) || now - used > RECOVER_NOTICE_MS) continue;
|
|
4053
|
+
this.env.emit(sid, "session_recovered", { reason: "connector_restart", interrupted: false, continuing: false });
|
|
4054
|
+
}
|
|
4055
|
+
}
|
|
4056
|
+
/**
|
|
4057
|
+
* Every adapter process, now. Sessions stay recorded and can be resumed. `restarting`: the connector is only going away
|
|
4058
|
+
* for a while (stopped, or restarted by its supervisor); what is in flight is left for the next run ({@link recover})
|
|
4059
|
+
* instead of being ended, and queued prompts are delivered again. Otherwise (the kill switch: revoked, paused) every
|
|
4060
|
+
* turn ends here and queued prompts count as handled.
|
|
4061
|
+
*/
|
|
4062
|
+
async killAll(reason, restarting = false) {
|
|
3016
4063
|
for (const s of this.sessions.values()) {
|
|
3017
4064
|
this.env.audit.record("session_killed", { session_id: s.id, reason });
|
|
3018
|
-
s.
|
|
4065
|
+
if (restarting) s.shutdown();
|
|
4066
|
+
else s.dropQueued();
|
|
4067
|
+
s.stopTail();
|
|
3019
4068
|
}
|
|
3020
4069
|
await this.env.registry.killAll();
|
|
3021
4070
|
this.sessions.clear();
|
|
@@ -3023,8 +4072,8 @@ var SessionManager = class {
|
|
|
3023
4072
|
};
|
|
3024
4073
|
|
|
3025
4074
|
// src/outbox.ts
|
|
3026
|
-
import
|
|
3027
|
-
import
|
|
4075
|
+
import fs13 from "node:fs";
|
|
4076
|
+
import path16 from "node:path";
|
|
3028
4077
|
var DROPPABLE_TYPES = /* @__PURE__ */ new Set([
|
|
3029
4078
|
"agent_message_chunk",
|
|
3030
4079
|
"agent_thought_chunk",
|
|
@@ -3051,35 +4100,35 @@ var Outbox = class {
|
|
|
3051
4100
|
this.dir = opts.dir;
|
|
3052
4101
|
this.maxBytes = opts.maxBytesPerSession ?? 5 * 1024 * 1024;
|
|
3053
4102
|
this.now = opts.now ?? Date.now;
|
|
3054
|
-
|
|
4103
|
+
fs13.mkdirSync(this.dir, { recursive: true, mode: 448 });
|
|
3055
4104
|
this.load();
|
|
3056
4105
|
}
|
|
3057
|
-
eventsFile = (sid) =>
|
|
3058
|
-
metaFile = (sid) =>
|
|
4106
|
+
eventsFile = (sid) => path16.join(this.dir, `${safeName(sid)}.jsonl`);
|
|
4107
|
+
metaFile = (sid) => path16.join(this.dir, `${safeName(sid)}.meta.json`);
|
|
3059
4108
|
writeMeta(sid) {
|
|
3060
4109
|
const tmp = this.metaFile(sid) + ".tmp";
|
|
3061
|
-
|
|
3062
|
-
|
|
4110
|
+
fs13.writeFileSync(tmp, JSON.stringify(this.meta.get(sid)), { mode: 384 });
|
|
4111
|
+
fs13.renameSync(tmp, this.metaFile(sid));
|
|
3063
4112
|
}
|
|
3064
4113
|
/** Rebuild the queue from disk. The highest sequence number ever used is the larger of the meta file and the log itself. */
|
|
3065
4114
|
load() {
|
|
3066
4115
|
const loaded = [];
|
|
3067
4116
|
const bases = /* @__PURE__ */ new Set();
|
|
3068
|
-
for (const name of
|
|
4117
|
+
for (const name of fs13.readdirSync(this.dir)) {
|
|
3069
4118
|
if (name.endsWith(".meta.json")) bases.add(name.slice(0, -".meta.json".length));
|
|
3070
4119
|
else if (name.endsWith(".jsonl")) bases.add(name.slice(0, -".jsonl".length));
|
|
3071
4120
|
}
|
|
3072
4121
|
for (const base of bases) {
|
|
3073
4122
|
let meta = { lastSeq: 0, acked: 0 };
|
|
3074
4123
|
try {
|
|
3075
|
-
meta = JSON.parse(
|
|
4124
|
+
meta = JSON.parse(fs13.readFileSync(path16.join(this.dir, `${base}.meta.json`), "utf8"));
|
|
3076
4125
|
} catch {
|
|
3077
4126
|
}
|
|
3078
4127
|
let sid = null;
|
|
3079
|
-
const file =
|
|
4128
|
+
const file = path16.join(this.dir, `${base}.jsonl`);
|
|
3080
4129
|
let text = "";
|
|
3081
4130
|
try {
|
|
3082
|
-
text =
|
|
4131
|
+
text = fs13.readFileSync(file, "utf8");
|
|
3083
4132
|
} catch {
|
|
3084
4133
|
}
|
|
3085
4134
|
for (const line of text.split("\n")) {
|
|
@@ -3097,8 +4146,8 @@ var Outbox = class {
|
|
|
3097
4146
|
if (sid === null) {
|
|
3098
4147
|
sid = base;
|
|
3099
4148
|
try {
|
|
3100
|
-
if (this.now() -
|
|
3101
|
-
for (const f of [`${base}.meta.json`, `${base}.jsonl`])
|
|
4149
|
+
if (this.now() - fs13.statSync(path16.join(this.dir, `${base}.meta.json`)).mtimeMs > TOMBSTONE_MS) {
|
|
4150
|
+
for (const f of [`${base}.meta.json`, `${base}.jsonl`]) fs13.rmSync(path16.join(this.dir, f), { force: true });
|
|
3102
4151
|
continue;
|
|
3103
4152
|
}
|
|
3104
4153
|
} catch {
|
|
@@ -3120,7 +4169,7 @@ var Outbox = class {
|
|
|
3120
4169
|
meta.lastSeq = ev.seq;
|
|
3121
4170
|
this.meta.set(sessionId, meta);
|
|
3122
4171
|
const line = JSON.stringify(ev);
|
|
3123
|
-
|
|
4172
|
+
fs13.appendFileSync(this.eventsFile(sessionId), line + "\n", { mode: 384 });
|
|
3124
4173
|
if (ev.seq === 1) this.writeMeta(sessionId);
|
|
3125
4174
|
this.queue.push(ev);
|
|
3126
4175
|
this.bytes.set(sessionId, (this.bytes.get(sessionId) ?? 0) + line.length);
|
|
@@ -3149,8 +4198,8 @@ var Outbox = class {
|
|
|
3149
4198
|
this.writeMeta(sessionId);
|
|
3150
4199
|
const mine = this.queue.filter((e) => e.session_id === sessionId);
|
|
3151
4200
|
const tmp = this.eventsFile(sessionId) + ".tmp";
|
|
3152
|
-
|
|
3153
|
-
|
|
4201
|
+
fs13.writeFileSync(tmp, mine.map((e) => JSON.stringify(e) + "\n").join(""), { mode: 384 });
|
|
4202
|
+
fs13.renameSync(tmp, this.eventsFile(sessionId));
|
|
3154
4203
|
}
|
|
3155
4204
|
/** The oldest events not yet acknowledged, in the order they were appended. */
|
|
3156
4205
|
pending(max) {
|
|
@@ -3188,7 +4237,7 @@ var Outbox = class {
|
|
|
3188
4237
|
this.writeMeta(sid);
|
|
3189
4238
|
const left = this.queue.filter((e) => e.session_id === sid);
|
|
3190
4239
|
if (left.length === 0) {
|
|
3191
|
-
|
|
4240
|
+
fs13.writeFileSync(this.eventsFile(sid), "", { mode: 384 });
|
|
3192
4241
|
this.bytes.set(sid, 0);
|
|
3193
4242
|
if (this.closed.has(sid)) this.forget(sid);
|
|
3194
4243
|
} else if (left.length < this.queue.length / 2 || meta.acked > 1e3) {
|
|
@@ -3207,7 +4256,7 @@ var Outbox = class {
|
|
|
3207
4256
|
* repeat. What stays is the small meta file, a tombstone, removed after {@link TOMBSTONE_MS}.
|
|
3208
4257
|
*/
|
|
3209
4258
|
forget(sid) {
|
|
3210
|
-
|
|
4259
|
+
fs13.rmSync(this.eventsFile(sid), { force: true });
|
|
3211
4260
|
if (this.meta.has(sid)) this.writeMeta(sid);
|
|
3212
4261
|
this.bytes.delete(sid);
|
|
3213
4262
|
this.closed.delete(sid);
|
|
@@ -3215,19 +4264,27 @@ var Outbox = class {
|
|
|
3215
4264
|
};
|
|
3216
4265
|
|
|
3217
4266
|
// src/session-store.ts
|
|
3218
|
-
import
|
|
3219
|
-
import
|
|
4267
|
+
import fs14 from "node:fs";
|
|
4268
|
+
import path17 from "node:path";
|
|
3220
4269
|
var SessionStore = class {
|
|
3221
4270
|
constructor(file) {
|
|
3222
4271
|
this.file = file;
|
|
3223
4272
|
this.pendingFile = `${file}.pending`;
|
|
4273
|
+
this.turnsFile = `${file}.turns`;
|
|
4274
|
+
try {
|
|
4275
|
+
const raw = JSON.parse(fs14.readFileSync(this.turnsFile, "utf8"));
|
|
4276
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
4277
|
+
for (const [k, t] of Object.entries(raw)) if (t && typeof t.session_id === "string") this.turns[k] = t;
|
|
4278
|
+
}
|
|
4279
|
+
} catch {
|
|
4280
|
+
}
|
|
3224
4281
|
try {
|
|
3225
|
-
const raw = JSON.parse(
|
|
4282
|
+
const raw = JSON.parse(fs14.readFileSync(this.pendingFile, "utf8"));
|
|
3226
4283
|
if (Array.isArray(raw)) this.pending = raw.filter((p) => p && typeof p.session_id === "string" && typeof p.action_id === "string");
|
|
3227
4284
|
} catch {
|
|
3228
4285
|
}
|
|
3229
4286
|
try {
|
|
3230
|
-
const raw = JSON.parse(
|
|
4287
|
+
const raw = JSON.parse(fs14.readFileSync(file, "utf8"));
|
|
3231
4288
|
if (raw && typeof raw === "object" && !Array.isArray(raw)) this.recs = raw;
|
|
3232
4289
|
} catch {
|
|
3233
4290
|
}
|
|
@@ -3236,6 +4293,8 @@ var SessionStore = class {
|
|
|
3236
4293
|
recs = {};
|
|
3237
4294
|
pending = [];
|
|
3238
4295
|
pendingFile;
|
|
4296
|
+
turns = {};
|
|
4297
|
+
turnsFile;
|
|
3239
4298
|
get(id) {
|
|
3240
4299
|
return this.recs[id];
|
|
3241
4300
|
}
|
|
@@ -3257,36 +4316,509 @@ var SessionStore = class {
|
|
|
3257
4316
|
this.pending.push({ session_id: sessionId, action_id: actionId });
|
|
3258
4317
|
this.savePending();
|
|
3259
4318
|
}
|
|
3260
|
-
removePending(sessionId, actionId) {
|
|
3261
|
-
const before = this.pending.length;
|
|
3262
|
-
this.pending = this.pending.filter((p) => !(p.session_id === sessionId && p.action_id === actionId));
|
|
3263
|
-
if (this.pending.length !== before) this.savePending();
|
|
4319
|
+
removePending(sessionId, actionId) {
|
|
4320
|
+
const before = this.pending.length;
|
|
4321
|
+
this.pending = this.pending.filter((p) => !(p.session_id === sessionId && p.action_id === actionId));
|
|
4322
|
+
if (this.pending.length !== before) this.savePending();
|
|
4323
|
+
}
|
|
4324
|
+
/** Return what was pending and forget it. */
|
|
4325
|
+
takePending() {
|
|
4326
|
+
const out = this.pending;
|
|
4327
|
+
this.pending = [];
|
|
4328
|
+
if (out.length) this.savePending();
|
|
4329
|
+
return out;
|
|
4330
|
+
}
|
|
4331
|
+
savePending() {
|
|
4332
|
+
fs14.mkdirSync(path17.dirname(this.pendingFile), { recursive: true, mode: 448 });
|
|
4333
|
+
const tmp = `${this.pendingFile}.${process.pid}.tmp`;
|
|
4334
|
+
fs14.writeFileSync(tmp, JSON.stringify(this.pending), { mode: 384 });
|
|
4335
|
+
fs14.renameSync(tmp, this.pendingFile);
|
|
4336
|
+
}
|
|
4337
|
+
/**
|
|
4338
|
+
* The turn a session's agent is running. It lives in the adapter's memory, so a connector that dies (or is stopped)
|
|
4339
|
+
* mid-turn would leave the prompt open for ever: the next run reads these and ends or continues each one.
|
|
4340
|
+
*/
|
|
4341
|
+
beginTurn(t) {
|
|
4342
|
+
this.turns[t.session_id] = t;
|
|
4343
|
+
this.saveTurns();
|
|
4344
|
+
}
|
|
4345
|
+
/** The turn's agent is waiting for a person (`true`; `permission`: a tool call's permission request), or no longer (`false`). */
|
|
4346
|
+
markWaiting(sessionId, waiting, permission = false) {
|
|
4347
|
+
const t = this.turns[sessionId];
|
|
4348
|
+
if (!t) return;
|
|
4349
|
+
const next = { ...t, waiting, waiting_permission: waiting && (permission || Boolean(t.waiting_permission)) };
|
|
4350
|
+
if (Boolean(t.waiting) === next.waiting && Boolean(t.waiting_permission) === next.waiting_permission) return;
|
|
4351
|
+
this.turns[sessionId] = next;
|
|
4352
|
+
this.saveTurns();
|
|
4353
|
+
}
|
|
4354
|
+
turn(sessionId) {
|
|
4355
|
+
return this.turns[sessionId];
|
|
4356
|
+
}
|
|
4357
|
+
endTurn(sessionId) {
|
|
4358
|
+
if (!(sessionId in this.turns)) return;
|
|
4359
|
+
delete this.turns[sessionId];
|
|
4360
|
+
this.saveTurns();
|
|
4361
|
+
}
|
|
4362
|
+
/** The turns recorded as running. Each stays until it is ended ({@link endTurn}) or a new turn of its session replaces it. */
|
|
4363
|
+
listTurns() {
|
|
4364
|
+
return Object.values(this.turns).map((t) => ({ ...t }));
|
|
4365
|
+
}
|
|
4366
|
+
saveTurns() {
|
|
4367
|
+
fs14.mkdirSync(path17.dirname(this.turnsFile), { recursive: true, mode: 448 });
|
|
4368
|
+
const tmp = `${this.turnsFile}.${process.pid}.tmp`;
|
|
4369
|
+
fs14.writeFileSync(tmp, JSON.stringify(this.turns), { mode: 384 });
|
|
4370
|
+
fs14.renameSync(tmp, this.turnsFile);
|
|
4371
|
+
}
|
|
4372
|
+
ids() {
|
|
4373
|
+
return Object.keys(this.recs);
|
|
4374
|
+
}
|
|
4375
|
+
save() {
|
|
4376
|
+
fs14.mkdirSync(path17.dirname(this.file), { recursive: true, mode: 448 });
|
|
4377
|
+
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
4378
|
+
fs14.writeFileSync(tmp, JSON.stringify(this.recs), { mode: 384 });
|
|
4379
|
+
fs14.renameSync(tmp, this.file);
|
|
4380
|
+
}
|
|
4381
|
+
};
|
|
4382
|
+
|
|
4383
|
+
// src/doctor.ts
|
|
4384
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
4385
|
+
import fs15 from "node:fs";
|
|
4386
|
+
import fsp from "node:fs/promises";
|
|
4387
|
+
import os6 from "node:os";
|
|
4388
|
+
import path18 from "node:path";
|
|
4389
|
+
import { parse as parseToml2 } from "smol-toml";
|
|
4390
|
+
var FS_TIMEOUT_MS = 5e3;
|
|
4391
|
+
async function within2(p, ms = FS_TIMEOUT_MS) {
|
|
4392
|
+
let t;
|
|
4393
|
+
const late = new Promise((resolve) => {
|
|
4394
|
+
t = setTimeout(() => resolve(null), ms);
|
|
4395
|
+
t.unref?.();
|
|
4396
|
+
});
|
|
4397
|
+
p.catch(() => void 0);
|
|
4398
|
+
try {
|
|
4399
|
+
return await Promise.race([p, late]);
|
|
4400
|
+
} finally {
|
|
4401
|
+
clearTimeout(t);
|
|
4402
|
+
}
|
|
4403
|
+
}
|
|
4404
|
+
var errnoOf = async (p) => within2(p.then(() => "", (e) => e.code ?? "EIO"));
|
|
4405
|
+
var fileExists = async (f) => await errnoOf(fsp.access(f)) === "";
|
|
4406
|
+
async function readText(f) {
|
|
4407
|
+
return within2(fsp.readFile(f, "utf8").catch(() => null));
|
|
4408
|
+
}
|
|
4409
|
+
async function readJson(file) {
|
|
4410
|
+
const t = await readText(file);
|
|
4411
|
+
if (t === null) return null;
|
|
4412
|
+
try {
|
|
4413
|
+
const v = JSON.parse(t);
|
|
4414
|
+
return isObject(v) ? v : null;
|
|
4415
|
+
} catch {
|
|
4416
|
+
return null;
|
|
4417
|
+
}
|
|
4418
|
+
}
|
|
4419
|
+
var NODE_MIN_MAJOR = 22;
|
|
4420
|
+
var RECHECK_MS = 15 * 6e4;
|
|
4421
|
+
var KEEPALIVE_STALE_MS = 15 * 6e4;
|
|
4422
|
+
var PROBE_TIMEOUT_MS = 15e3;
|
|
4423
|
+
var PIPE_GRACE_MS = 500;
|
|
4424
|
+
var MAX_REPORTED2 = 6;
|
|
4425
|
+
function runProbe(cmd, args, env, signal, timeoutMs = PROBE_TIMEOUT_MS, input, ownGroup = true) {
|
|
4426
|
+
return new Promise((resolve) => {
|
|
4427
|
+
if (signal?.aborted) return resolve({ code: null, out: "stopped" });
|
|
4428
|
+
let out = "";
|
|
4429
|
+
let child;
|
|
4430
|
+
try {
|
|
4431
|
+
child = spawn3(cmd, args, { env, cwd: os6.homedir(), detached: ownGroup, stdio: [input === void 0 ? "ignore" : "pipe", "pipe", "pipe"], windowsHide: true });
|
|
4432
|
+
} catch (e) {
|
|
4433
|
+
return resolve({ code: null, out: errMessage(e) });
|
|
4434
|
+
}
|
|
4435
|
+
if (input !== void 0) {
|
|
4436
|
+
child.stdin?.on("error", () => void 0);
|
|
4437
|
+
child.stdin?.end(input);
|
|
4438
|
+
}
|
|
4439
|
+
const take = (b) => {
|
|
4440
|
+
if (out.length < 256 * 1024) out += b.toString("utf8");
|
|
4441
|
+
};
|
|
4442
|
+
child.stdout?.on("data", take);
|
|
4443
|
+
child.stderr?.on("data", take);
|
|
4444
|
+
const killGroup = () => {
|
|
4445
|
+
try {
|
|
4446
|
+
if (child.pid) process.kill(ownGroup ? -child.pid : child.pid, "SIGKILL");
|
|
4447
|
+
} catch {
|
|
4448
|
+
}
|
|
4449
|
+
};
|
|
4450
|
+
let settled = false;
|
|
4451
|
+
const finish = (code) => {
|
|
4452
|
+
if (settled) return;
|
|
4453
|
+
settled = true;
|
|
4454
|
+
clearTimeout(timer);
|
|
4455
|
+
signal?.removeEventListener("abort", onAbort);
|
|
4456
|
+
killGroup();
|
|
4457
|
+
child.stdout?.destroy();
|
|
4458
|
+
child.stderr?.destroy();
|
|
4459
|
+
resolve({ code, out: out.trim() });
|
|
4460
|
+
};
|
|
4461
|
+
const onAbort = () => finish(null);
|
|
4462
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
4463
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
4464
|
+
child.once("error", (e) => {
|
|
4465
|
+
out = errMessage(e);
|
|
4466
|
+
finish(null);
|
|
4467
|
+
});
|
|
4468
|
+
const ended = (st) => new Promise((res) => {
|
|
4469
|
+
if (!st || st.readableEnded) res();
|
|
4470
|
+
else {
|
|
4471
|
+
st.once("end", () => res());
|
|
4472
|
+
st.once("close", () => res());
|
|
4473
|
+
}
|
|
4474
|
+
});
|
|
4475
|
+
const drained = Promise.all([ended(child.stdout), ended(child.stderr)]);
|
|
4476
|
+
child.once("exit", (code) => {
|
|
4477
|
+
const grace = setTimeout(() => finish(code), PIPE_GRACE_MS);
|
|
4478
|
+
void drained.then(() => {
|
|
4479
|
+
clearTimeout(grace);
|
|
4480
|
+
finish(code);
|
|
4481
|
+
});
|
|
4482
|
+
});
|
|
4483
|
+
});
|
|
4484
|
+
}
|
|
4485
|
+
function defaultDeps(env, over = {}) {
|
|
4486
|
+
const signal = over.signal;
|
|
4487
|
+
const ownGroup = over.ownGroup ?? true;
|
|
4488
|
+
const run2 = (cmd, args, e) => runProbe(cmd, args, e, signal, PROBE_TIMEOUT_MS, void 0, ownGroup);
|
|
4489
|
+
return {
|
|
4490
|
+
env,
|
|
4491
|
+
platform: process.platform,
|
|
4492
|
+
home: os6.homedir(),
|
|
4493
|
+
user: env.USER || (() => {
|
|
4494
|
+
try {
|
|
4495
|
+
return os6.userInfo().username;
|
|
4496
|
+
} catch {
|
|
4497
|
+
return "";
|
|
4498
|
+
}
|
|
4499
|
+
})(),
|
|
4500
|
+
nodeVersion: process.versions.node,
|
|
4501
|
+
uid: process.getuid?.() ?? null,
|
|
4502
|
+
now: () => Date.now(),
|
|
4503
|
+
probe: run2,
|
|
4504
|
+
crontab: async () => {
|
|
4505
|
+
const r = await run2("crontab", ["-l"], env);
|
|
4506
|
+
return r.code === 0 ? r.out : null;
|
|
4507
|
+
},
|
|
4508
|
+
exec: (cmd, args) => run2(cmd, args, env),
|
|
4509
|
+
writable: async (dir) => {
|
|
4510
|
+
const code = await errnoOf(fsp.access(dir, fs15.constants.W_OK));
|
|
4511
|
+
return code === "" ? null : code ?? "ETIMEDOUT";
|
|
4512
|
+
},
|
|
4513
|
+
get: async (url) => {
|
|
4514
|
+
try {
|
|
4515
|
+
const r = await fetch(url, { signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(8e3)]) : AbortSignal.timeout(8e3), redirect: "manual" });
|
|
4516
|
+
await r.body?.cancel().catch(() => void 0);
|
|
4517
|
+
return { status: r.status };
|
|
4518
|
+
} catch (e) {
|
|
4519
|
+
const cause = e.cause;
|
|
4520
|
+
return { error: cause instanceof Error ? `${errMessage(e)} (${cause.message})` : errMessage(e) };
|
|
4521
|
+
}
|
|
4522
|
+
},
|
|
4523
|
+
...over
|
|
4524
|
+
};
|
|
4525
|
+
}
|
|
4526
|
+
async function which(cmd, pathVar) {
|
|
4527
|
+
const executable = async (p) => await within2(fsp.stat(p).then(async (st) => st.isFile() && await fsp.access(p, fs15.constants.X_OK).then(() => true, () => false), () => false)) === true;
|
|
4528
|
+
if (cmd.includes("/")) return await executable(cmd) ? cmd : null;
|
|
4529
|
+
for (const dir of (pathVar ?? "").split(path18.delimiter)) {
|
|
4530
|
+
if (!dir) continue;
|
|
4531
|
+
const p = path18.join(dir, cmd);
|
|
4532
|
+
if (await executable(p)) return p;
|
|
4533
|
+
}
|
|
4534
|
+
return null;
|
|
4535
|
+
}
|
|
4536
|
+
var agentEnv = (env, a) => ({ ...env, ...a.env });
|
|
4537
|
+
function familyOf(a) {
|
|
4538
|
+
const words = [a.command, ...a.args].map((s) => path18.basename(s));
|
|
4539
|
+
if (words.some((s) => s.includes("claude-agent-acp") || s.includes("claude-code-acp"))) return "claude";
|
|
4540
|
+
if (words.some((s) => s.includes("codex-acp"))) return "codex";
|
|
4541
|
+
if (path18.basename(a.command) === "agent" || path18.basename(a.command) === "cursor-agent") return "cursor";
|
|
4542
|
+
return "other";
|
|
4543
|
+
}
|
|
4544
|
+
var LOGIN_FIX = {
|
|
4545
|
+
claude: "claude auth login",
|
|
4546
|
+
codex: "codex login",
|
|
4547
|
+
cursor: "agent login"
|
|
4548
|
+
};
|
|
4549
|
+
function installFix(cmd, family) {
|
|
4550
|
+
const base = path18.basename(cmd);
|
|
4551
|
+
if (base === "npx" || base === "node") return "install Node.js 22 or newer (it brings npx): https://nodejs.org";
|
|
4552
|
+
if (family === "cursor") return "install the Cursor CLI: curl https://cursor.com/install -fsS | bash";
|
|
4553
|
+
return `install ${base}, or set this agent's command in agents.toml to its full path`;
|
|
4554
|
+
}
|
|
4555
|
+
var set = (env, name) => typeof env[name] === "string" && env[name] !== "";
|
|
4556
|
+
async function claudeSignedIn(env, d) {
|
|
4557
|
+
const keys = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY"];
|
|
4558
|
+
if (keys.some((k) => set(env, k))) return true;
|
|
4559
|
+
const dir = env.CLAUDE_CONFIG_DIR?.trim() || path18.join(d.home, ".claude");
|
|
4560
|
+
const settings = await readJson(path18.join(dir, "settings.json"));
|
|
4561
|
+
if (settings && (typeof settings.apiKeyHelper === "string" || isObject(settings.env) && keys.some((k) => typeof settings.env[k] === "string"))) return true;
|
|
4562
|
+
const claude = await which("claude", env.PATH);
|
|
4563
|
+
if (claude) {
|
|
4564
|
+
const r = await d.probe(claude, ["auth", "status", "--json"], env);
|
|
4565
|
+
try {
|
|
4566
|
+
const v = JSON.parse(r.out.slice(r.out.indexOf("{")));
|
|
4567
|
+
if (isObject(v) && typeof v.loggedIn === "boolean") return v.loggedIn;
|
|
4568
|
+
} catch {
|
|
4569
|
+
}
|
|
4570
|
+
}
|
|
4571
|
+
if (await fileExists(path18.join(dir, ".credentials.json"))) return true;
|
|
4572
|
+
if (d.platform === "darwin") return null;
|
|
4573
|
+
return false;
|
|
4574
|
+
}
|
|
4575
|
+
async function codexAuth(env, d) {
|
|
4576
|
+
const home = env.CODEX_HOME?.trim() || path18.join(d.home, ".codex");
|
|
4577
|
+
let cfg = {};
|
|
4578
|
+
try {
|
|
4579
|
+
const t = await readText(path18.join(home, "config.toml"));
|
|
4580
|
+
if (t !== null) cfg = parseToml2(t);
|
|
4581
|
+
} catch {
|
|
4582
|
+
}
|
|
4583
|
+
const name = typeof cfg.model_provider === "string" ? cfg.model_provider : "openai";
|
|
4584
|
+
const providers = isObject(cfg.model_providers) ? cfg.model_providers : {};
|
|
4585
|
+
const provider = isObject(providers[name]) ? providers[name] : null;
|
|
4586
|
+
if (name !== "openai" && provider && provider.requires_openai_auth !== true) {
|
|
4587
|
+
const key = typeof provider.env_key === "string" ? provider.env_key : null;
|
|
4588
|
+
if (!key || set(env, key)) return { ok: true };
|
|
4589
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(key)) return { ok: false, detail: `Codex's model provider "${name.slice(0, 64)}" has an env_key that is not a variable name`, fix: `set env_key in ${path18.join(home, "config.toml")} to the NAME of the variable that holds the key` };
|
|
4590
|
+
return { ok: false, detail: `Codex uses the model provider "${name}", which reads ${key}, and ${key} is not set where the connector runs`, fix: `set ${key} for this agent: add it to the agent's env in agents.toml, or export it before starting the connector` };
|
|
4591
|
+
}
|
|
4592
|
+
if (set(env, "OPENAI_API_KEY")) return { ok: true };
|
|
4593
|
+
const codex = await which("codex", env.PATH);
|
|
4594
|
+
if (codex) {
|
|
4595
|
+
const r = await d.probe(codex, ["login", "status"], env);
|
|
4596
|
+
if (r.code === 0 && !/not logged in/i.test(r.out)) return { ok: true };
|
|
4597
|
+
if (/not logged in/i.test(r.out)) return { ok: false, detail: "Codex is not signed in on this machine", fix: LOGIN_FIX.codex };
|
|
4598
|
+
}
|
|
4599
|
+
if (await fileExists(path18.join(home, "auth.json"))) return { ok: true };
|
|
4600
|
+
if (codex || typeof cfg.cli_auth_credentials_store === "string" && cfg.cli_auth_credentials_store !== "file") return null;
|
|
4601
|
+
return { ok: false, detail: "Codex is not signed in on this machine", fix: "npm install -g @openai/codex, then: codex login" };
|
|
4602
|
+
}
|
|
4603
|
+
async function checkAll(input, d) {
|
|
4604
|
+
const ok = [];
|
|
4605
|
+
const problems = [...input.before ?? []];
|
|
4606
|
+
const add = (p) => problems.push(p);
|
|
4607
|
+
const major = Number(d.nodeVersion.split(".")[0]);
|
|
4608
|
+
if (!Number.isFinite(major) || major < NODE_MIN_MAJOR) {
|
|
4609
|
+
add({ code: "node_too_old", severity: "fail", detail: `Node.js ${d.nodeVersion} is older than ${NODE_MIN_MAJOR}`, fix: `install Node.js ${NODE_MIN_MAJOR} or newer (https://nodejs.org), then: npm install -g @primusclaw/connector` });
|
|
4610
|
+
} else ok.push(`Node.js ${d.nodeVersion}`);
|
|
4611
|
+
const servicePath = input.by === "doctor" ? await installedServicePath(input.dirs) : null;
|
|
4612
|
+
const same = (x, y) => x.length === y.length && x.every((v, i) => v === y[i]);
|
|
4613
|
+
const checked = input.agents.filter((a) => !(a.attach && input.agents.some((b) => !b.attach && b.command === a.command && same(b.args, a.args) && same(b.roots, a.roots))));
|
|
4614
|
+
const perAgent = await Promise.all(checked.map(async (a) => {
|
|
4615
|
+
const env = agentEnv(d.env, a);
|
|
4616
|
+
const family = familyOf(a);
|
|
4617
|
+
const out = { ok: [], problems: [] };
|
|
4618
|
+
const found = await which(a.command, env.PATH);
|
|
4619
|
+
if (!found) {
|
|
4620
|
+
out.problems.push({ code: "agent_cli_missing", severity: "fail", agent: a.id, detail: `${a.command} (the program agent ${a.id} runs) is not on PATH`, fix: installFix(a.command, family) });
|
|
4621
|
+
} else {
|
|
4622
|
+
out.ok.push(`${a.id}: ${a.command} is ${found}`);
|
|
4623
|
+
if (servicePath !== null && a.env.PATH === void 0 && !await which(a.command, servicePath)) {
|
|
4624
|
+
out.problems.push({ code: "service_path_missing", severity: "fail", agent: a.id, detail: `the installed service's PATH does not find ${a.command}, so the service cannot start agent ${a.id}`, fix: "claw-connector service install (run it again from this shell: it carries this shell's PATH into the service)" });
|
|
4625
|
+
}
|
|
4626
|
+
}
|
|
4627
|
+
if (family === "claude") {
|
|
4628
|
+
const signed = await claudeSignedIn(env, d);
|
|
4629
|
+
if (signed === false) out.problems.push({ code: "agent_not_signed_in", severity: "fail", agent: a.id, detail: "Claude Code is not signed in on this machine", fix: LOGIN_FIX.claude });
|
|
4630
|
+
else if (signed) out.ok.push(`${a.id}: Claude Code is signed in`);
|
|
4631
|
+
} else if (family === "codex") {
|
|
4632
|
+
const auth = await codexAuth(env, d);
|
|
4633
|
+
if (auth && !auth.ok) out.problems.push({ code: "agent_not_signed_in", severity: "fail", agent: a.id, detail: auth.detail, fix: auth.fix });
|
|
4634
|
+
else if (auth?.ok) out.ok.push(`${a.id}: Codex can sign in`);
|
|
4635
|
+
}
|
|
4636
|
+
for (const root of a.roots) {
|
|
4637
|
+
const why = await within2(Promise.resolve(d.writable(root)), d.fsTimeoutMs) ?? "ETIMEDOUT";
|
|
4638
|
+
if (why === "ETIMEDOUT") continue;
|
|
4639
|
+
if (why === null) out.ok.push(`${a.id}: ${root} is writable`);
|
|
4640
|
+
else {
|
|
4641
|
+
out.problems.push(why === "ENOENT" ? { code: "root_missing", severity: "fail", agent: a.id, detail: `${root} (a root of agent ${a.id}) does not exist`, fix: `mkdir -p ${shq(root)} (or change roots in agents.toml)` } : { code: "root_not_writable", severity: "fail", agent: a.id, detail: `${root} (a root of agent ${a.id}) is not writable by ${d.user || "this user"}${why === "EROFS" ? " (a read-only file system)" : ""}`, fix: await rootFix(root, why, d) });
|
|
4642
|
+
}
|
|
4643
|
+
}
|
|
4644
|
+
return out;
|
|
4645
|
+
}));
|
|
4646
|
+
for (const r of perAgent) {
|
|
4647
|
+
ok.push(...r.ok);
|
|
4648
|
+
problems.push(...r.problems);
|
|
4649
|
+
}
|
|
4650
|
+
for (const [upper, lower] of [["HTTPS_PROXY", "https_proxy"], ["HTTP_PROXY", "http_proxy"], ["NO_PROXY", "no_proxy"]]) {
|
|
4651
|
+
const u = d.env[upper];
|
|
4652
|
+
const l = d.env[lower];
|
|
4653
|
+
if (u !== void 0 && l !== void 0 && u !== l) {
|
|
4654
|
+
add({ code: "proxy_mismatch", severity: "warn", detail: `${upper} and ${lower} are set to different values${l === "" || u === "" ? " (one of them is empty)" : ""}; agents that read ${lower} (git, curl) take ${l === "" ? "no proxy" : "the other one"}`, fix: `export ${lower}="$${upper}" (or unset the one you do not mean)` });
|
|
4655
|
+
}
|
|
4656
|
+
}
|
|
4657
|
+
for (const name of ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"]) {
|
|
4658
|
+
const v = d.env[name];
|
|
4659
|
+
if (!v) continue;
|
|
4660
|
+
try {
|
|
4661
|
+
new URL(v);
|
|
4662
|
+
} catch {
|
|
4663
|
+
add({ code: "proxy_invalid", severity: "warn", detail: `${name} is not a URL`, fix: `export ${name}=http://HOST:PORT (or unset it)` });
|
|
4664
|
+
}
|
|
4665
|
+
}
|
|
4666
|
+
await checkSupervisor(input, d, ok, add);
|
|
4667
|
+
if (input.by === "doctor" && input.server) {
|
|
4668
|
+
const r = await d.get(`${input.server.replace(/\/+$/, "")}/health`);
|
|
4669
|
+
const shown = withoutUserinfo(input.server);
|
|
4670
|
+
if ("status" in r && r.status < 500) ok.push(`Claw answers at ${shown}`);
|
|
4671
|
+
else {
|
|
4672
|
+
const why = "status" in r ? `HTTP ${r.status}` : r.error;
|
|
4673
|
+
const proxied = ["HTTPS_PROXY", "https_proxy"].some((k) => set(d.env, k));
|
|
4674
|
+
const untrusted = /certificate|self[- ]signed|UNABLE_TO_GET_ISSUER|CERT_/i.test(why);
|
|
4675
|
+
if (untrusted) {
|
|
4676
|
+
add({
|
|
4677
|
+
code: "claw_tls_untrusted",
|
|
4678
|
+
severity: "fail",
|
|
4679
|
+
detail: `Node.js does not trust the certificate of ${shown} (${why.split(input.server).join(shown)})`,
|
|
4680
|
+
fix: "export NODE_USE_SYSTEM_CA=1 (Node.js then trusts the CAs this machine trusts; or NODE_EXTRA_CA_CERTS=/path/to/ca.pem), then run the connector again; an installed service: claw-connector service install again so it carries it"
|
|
4681
|
+
});
|
|
4682
|
+
} else add({
|
|
4683
|
+
code: "claw_unreachable",
|
|
4684
|
+
severity: "fail",
|
|
4685
|
+
detail: `${shown} does not answer (${why.split(input.server).join(shown)})`,
|
|
4686
|
+
fix: proxied && !set(d.env, "NODE_USE_ENV_PROXY") ? "export NODE_USE_ENV_PROXY=1 (Node.js only uses HTTPS_PROXY when it is set; needs Node 22.21+ or 24+), then run the connector again" : `check that this machine reaches it: curl -sS ${shown.replace(/\/+$/, "")}/health`
|
|
4687
|
+
});
|
|
4688
|
+
}
|
|
4689
|
+
}
|
|
4690
|
+
return { checked_at: new Date(d.now()).toISOString(), by: input.by, ok, problems: dedupe(problems) };
|
|
4691
|
+
}
|
|
4692
|
+
function withoutUserinfo(v) {
|
|
4693
|
+
try {
|
|
4694
|
+
const u = new URL(v);
|
|
4695
|
+
return `${u.protocol}//${u.host}${u.pathname === "/" && !v.endsWith("/") ? "" : u.pathname}`;
|
|
4696
|
+
} catch {
|
|
4697
|
+
return v.replace(/\/\/[^/@]*@/, "//").replace(/[?#].*$/, "");
|
|
3264
4698
|
}
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
return
|
|
4699
|
+
}
|
|
4700
|
+
function dedupe(ps) {
|
|
4701
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4702
|
+
return ps.filter((p) => {
|
|
4703
|
+
const k = `${p.code}\0${p.detail}`;
|
|
4704
|
+
if (seen.has(k)) return false;
|
|
4705
|
+
seen.add(k);
|
|
4706
|
+
return true;
|
|
4707
|
+
});
|
|
4708
|
+
}
|
|
4709
|
+
async function rootFix(root, why, d) {
|
|
4710
|
+
if (why === "EROFS") return "pick a root on a writable disk in agents.toml";
|
|
4711
|
+
const owner = await within2(fsp.stat(root).then((st) => st.uid, () => null));
|
|
4712
|
+
if (owner !== null && d.uid !== null && owner !== d.uid) return `pick a root you own in agents.toml (${root} belongs to uid ${owner})`;
|
|
4713
|
+
return `chmod u+w ${shq(root)} (or pick a root you own in agents.toml)`;
|
|
4714
|
+
}
|
|
4715
|
+
var shq = (s) => /^[A-Za-z0-9_./~:@%+-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
|
|
4716
|
+
async function installedSupervisorOf(dirs) {
|
|
4717
|
+
const v = await readJson(serviceFile(dirs));
|
|
4718
|
+
return v && typeof v.supervisor === "string" ? v.supervisor : null;
|
|
4719
|
+
}
|
|
4720
|
+
async function checkSupervisor(input, d, ok, add) {
|
|
4721
|
+
const sup = await installedSupervisorOf(input.dirs);
|
|
4722
|
+
if (sup === null) {
|
|
4723
|
+
add({ code: "no_service", severity: "warn", detail: "the connector is not installed as a service: nothing starts it again after it dies or the machine restarts", fix: "claw-connector service install" });
|
|
4724
|
+
return;
|
|
3271
4725
|
}
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
const
|
|
3275
|
-
|
|
3276
|
-
|
|
4726
|
+
if (sup === "systemd") {
|
|
4727
|
+
if (d.platform !== "linux") return;
|
|
4728
|
+
const linger = await d.exec("loginctl", ["show-user", d.user, "-p", "Linger", "--value"]);
|
|
4729
|
+
if (linger.code === 0 && linger.out.trim() !== "yes") {
|
|
4730
|
+
add({ code: "linger_off", severity: "warn", detail: "the systemd user service stops when you log out and does not start at boot", fix: `loginctl enable-linger ${d.user}` });
|
|
4731
|
+
} else ok.push("service: systemd --user");
|
|
4732
|
+
return;
|
|
3277
4733
|
}
|
|
3278
|
-
|
|
3279
|
-
|
|
4734
|
+
if (sup === "script") {
|
|
4735
|
+
const last = await within2(fsp.stat(keepaliveStampFile(input.dirs)).then((st) => st.mtimeMs, () => 0)) ?? 0;
|
|
4736
|
+
if (d.now() - last <= KEEPALIVE_STALE_MS) {
|
|
4737
|
+
ok.push("service: keep-alive script, called recently");
|
|
4738
|
+
return;
|
|
4739
|
+
}
|
|
4740
|
+
const script = keepaliveFile(input.dirs);
|
|
4741
|
+
const tab = await d.crontab();
|
|
4742
|
+
if (tab !== null && tab.split("\n").some((l) => !l.trim().startsWith("#") && l.includes(script))) {
|
|
4743
|
+
ok.push("service: keep-alive script in crontab");
|
|
4744
|
+
return;
|
|
4745
|
+
}
|
|
4746
|
+
add({
|
|
4747
|
+
code: "keepalive_not_scheduled",
|
|
4748
|
+
severity: "warn",
|
|
4749
|
+
detail: `nothing has run ${script} in the last ${KEEPALIVE_STALE_MS / 6e4} minutes: after a restart the connector stays offline`,
|
|
4750
|
+
fix: `add to crontab -e (or the container's startup hook): */5 * * * * sh ${script}`
|
|
4751
|
+
});
|
|
4752
|
+
return;
|
|
3280
4753
|
}
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
4754
|
+
ok.push(`service: ${sup}`);
|
|
4755
|
+
}
|
|
4756
|
+
async function installedServicePath(dirs, home = os6.homedir(), xdgConfig) {
|
|
4757
|
+
const v = await readJson(serviceFile(dirs));
|
|
4758
|
+
if (!v) return null;
|
|
4759
|
+
if (typeof v.path === "string") return v.path;
|
|
4760
|
+
if (v.supervisor === "script") {
|
|
4761
|
+
const m = (await readText(keepaliveFile(dirs)))?.match(/^export PATH='((?:[^']|'\\'')*)'$/m);
|
|
4762
|
+
return m ? m[1].replace(/'\\''/g, "'") : null;
|
|
4763
|
+
}
|
|
4764
|
+
if (v.supervisor === "systemd") {
|
|
4765
|
+
const unit = await readText(path18.join(xdgConfig?.trim() || path18.join(home, ".config"), "systemd", "user", "claw-connector.service"));
|
|
4766
|
+
const m = unit?.match(/^Environment="PATH=((?:[^"\\]|\\.)*)"$/m);
|
|
4767
|
+
return m ? m[1].replace(/\\(.)/g, "$1").replace(/%%/g, "%") : null;
|
|
4768
|
+
}
|
|
4769
|
+
if (v.supervisor === "launchd") {
|
|
4770
|
+
const plist = await readText(path18.join(home, "Library", "LaunchAgents", "com.primusclaw.connector.plist"));
|
|
4771
|
+
const m = plist?.match(/<key>PATH<\/key><string>([^<]*)<\/string>/);
|
|
4772
|
+
return m ? m[1].replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/&/g, "&") : null;
|
|
3286
4773
|
}
|
|
3287
|
-
|
|
4774
|
+
return null;
|
|
4775
|
+
}
|
|
4776
|
+
function agentHealth(report, agentId) {
|
|
4777
|
+
const cut = (s, n) => s.length > n ? `${s.slice(0, n - 1)}\u2026` : s;
|
|
4778
|
+
const mine = report.problems.filter((p) => p.agent === void 0 || p.agent === agentId);
|
|
4779
|
+
mine.sort((a, b) => a.severity === b.severity ? 0 : a.severity === "fail" ? -1 : 1);
|
|
4780
|
+
return {
|
|
4781
|
+
checked_at: report.checked_at,
|
|
4782
|
+
problems: mine.slice(0, MAX_REPORTED2).map((p) => ({ code: p.code, severity: p.severity, scope: p.agent === void 0 ? "machine" : "agent", detail: cut(p.detail, 200), fix: cut(p.fix, 200) }))
|
|
4783
|
+
};
|
|
4784
|
+
}
|
|
4785
|
+
function writeReport(dirs, report) {
|
|
4786
|
+
try {
|
|
4787
|
+
fs15.mkdirSync(dirs.state, { recursive: true, mode: 448 });
|
|
4788
|
+
const file = doctorFile(dirs);
|
|
4789
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
4790
|
+
fs15.writeFileSync(tmp, JSON.stringify(report, null, 2) + "\n", { mode: 384 });
|
|
4791
|
+
fs15.renameSync(tmp, file);
|
|
4792
|
+
} catch {
|
|
4793
|
+
}
|
|
4794
|
+
}
|
|
4795
|
+
function formatReport(r) {
|
|
4796
|
+
const lines = r.ok.map((l) => `ok ${l}`);
|
|
4797
|
+
for (const p of r.problems) {
|
|
4798
|
+
lines.push(`${p.severity === "fail" ? "FAIL" : "warn"} ${p.agent ? `${p.agent}: ` : ""}${p.detail}`);
|
|
4799
|
+
lines.push(` fix: ${p.fix}`);
|
|
4800
|
+
}
|
|
4801
|
+
const fails = r.problems.filter((p) => p.severity === "fail").length;
|
|
4802
|
+
const warns = r.problems.length - fails;
|
|
4803
|
+
lines.push("");
|
|
4804
|
+
lines.push(r.problems.length === 0 ? "No problems found." : `${fails} problem${fails === 1 ? " that stops" : "s that stop"} sessions, ${warns} warning${warns === 1 ? "" : "s"}.`);
|
|
4805
|
+
return lines;
|
|
4806
|
+
}
|
|
3288
4807
|
|
|
3289
4808
|
// src/connector.ts
|
|
4809
|
+
function withHealth(base, health, limit = REMOTE_AGENT_LIMITS.maxCapabilitiesBytes / 2) {
|
|
4810
|
+
if (!health) return base;
|
|
4811
|
+
const size = (c) => new TextEncoder().encode(JSON.stringify(c)).length;
|
|
4812
|
+
const problems = [...health.problems];
|
|
4813
|
+
for (; ; ) {
|
|
4814
|
+
const caps = { ...base, health: { ...health, problems } };
|
|
4815
|
+
if (size(caps) <= limit) return caps;
|
|
4816
|
+
if (problems.length === 0) return base;
|
|
4817
|
+
problems.pop();
|
|
4818
|
+
}
|
|
4819
|
+
}
|
|
4820
|
+
var RESTART_RECONNECT_GAP_MS = 5e3;
|
|
4821
|
+
var READ_ONLY_HINT = `sessions of this agent start read-only: it answers and plans but changes no file. To let phone sessions write code, set approval = "never" for this agent in agents.toml on that machine and restart the connector`;
|
|
3290
4822
|
var DEFAULT_TIMING = {
|
|
3291
4823
|
...DEFAULT_SESSION_TIMING,
|
|
3292
4824
|
heartbeatMs: REMOTE_AGENT_TIMING.heartbeatMs,
|
|
@@ -3297,11 +4829,17 @@ var DEFAULT_TIMING = {
|
|
|
3297
4829
|
backoffMaxMs: 6e4,
|
|
3298
4830
|
pausePollMs: 1e3,
|
|
3299
4831
|
catalogMs: 12e4,
|
|
4832
|
+
sessionsWatchMs: 1e4,
|
|
3300
4833
|
attachCliMs: 8e3,
|
|
3301
4834
|
recentWriteMs: 1e4,
|
|
3302
4835
|
authFailLimit: 4,
|
|
3303
|
-
authFailWindowMs: 12e4
|
|
4836
|
+
authFailWindowMs: 12e4,
|
|
4837
|
+
healthMs: RECHECK_MS,
|
|
4838
|
+
healthMinGapMs: 5e3,
|
|
4839
|
+
limitRetryMs: 10 * 6e4
|
|
3304
4840
|
};
|
|
4841
|
+
var CONNECTOR_LIMIT_HINT = "Claw already holds the most connectors one person may have, so it will not register this machine: remove machines you no longer use (in the chat: /agents, or claw-connector logout on that machine). This connector tries again every 10 minutes by itself.";
|
|
4842
|
+
var isConnectorLimit = (e) => e instanceof HttpError && e.status === 409 && e.code === "connector_limit_reached";
|
|
3305
4843
|
var CommandLedger = class {
|
|
3306
4844
|
inflight = /* @__PURE__ */ new Set();
|
|
3307
4845
|
inflightIds = /* @__PURE__ */ new Set();
|
|
@@ -3402,6 +4940,11 @@ var Connector = class {
|
|
|
3402
4940
|
lastFrame = "";
|
|
3403
4941
|
wakeSender = null;
|
|
3404
4942
|
wakeHeartbeat = null;
|
|
4943
|
+
/** The last health report (null until the first check finishes, or when none is configured). */
|
|
4944
|
+
healthReport = null;
|
|
4945
|
+
wakeHealth = null;
|
|
4946
|
+
healthAsked = false;
|
|
4947
|
+
lastHealthAsk = 0;
|
|
3405
4948
|
streamCtl = null;
|
|
3406
4949
|
ctl = new AbortController();
|
|
3407
4950
|
/** Every event leaves through here: scrubbed of credential shapes, numbered, written to disk, then posted. */
|
|
@@ -3423,8 +4966,19 @@ var Connector = class {
|
|
|
3423
4966
|
}
|
|
3424
4967
|
async descriptors() {
|
|
3425
4968
|
return Promise.all(this.d.config.agents.map(async (a) => {
|
|
3426
|
-
const
|
|
4969
|
+
const readOnly = a.readOnlyModes.includes(a.askMode);
|
|
3427
4970
|
const support = this.attach.get(a.id);
|
|
4971
|
+
const base = withHealth({
|
|
4972
|
+
ask_mode: a.askMode,
|
|
4973
|
+
modes: a.safeModes,
|
|
4974
|
+
roots: a.roots.slice(0, 8),
|
|
4975
|
+
protocol: 1,
|
|
4976
|
+
unattended: a.unattended,
|
|
4977
|
+
unattended_allowed: a.allowUnsafeModes,
|
|
4978
|
+
...support ? { watch_live: true } : {},
|
|
4979
|
+
...a.readOnlyModes.length ? { read_only_modes: a.readOnlyModes } : {},
|
|
4980
|
+
...readOnly ? { edits: false, edits_hint: READ_ONLY_HINT } : {}
|
|
4981
|
+
}, this.healthReport ? agentHealth(this.healthReport, a.id) : null);
|
|
3428
4982
|
const capabilities = support ? fitResumable(base, await support.resumable()) : base;
|
|
3429
4983
|
return { agent_id: a.id, kind: a.kind, name: a.name, capabilities };
|
|
3430
4984
|
}));
|
|
@@ -3432,7 +4986,7 @@ var Connector = class {
|
|
|
3432
4986
|
// --- identity -------------------------------------------------------------------
|
|
3433
4987
|
readIdentity() {
|
|
3434
4988
|
try {
|
|
3435
|
-
const raw = JSON.parse(
|
|
4989
|
+
const raw = JSON.parse(fs16.readFileSync(identityFile(this.d.dirs), "utf8"));
|
|
3436
4990
|
if (isObject(raw) && raw.server === this.d.server && typeof raw.connector_id === "string") {
|
|
3437
4991
|
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
4992
|
}
|
|
@@ -3443,9 +4997,9 @@ var Connector = class {
|
|
|
3443
4997
|
writeIdentity() {
|
|
3444
4998
|
const st = { server: this.d.server, connector_id: this.cid, cursor: this.ledger.cursor, handled: this.ledger.recentIds, ...this.rowRevoked ? { revoked: true } : {} };
|
|
3445
4999
|
const file = identityFile(this.d.dirs);
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
5000
|
+
fs16.mkdirSync(this.d.dirs.state, { recursive: true, mode: 448 });
|
|
5001
|
+
fs16.writeFileSync(`${file}.tmp`, JSON.stringify(st), { mode: 384 });
|
|
5002
|
+
fs16.renameSync(`${file}.tmp`, file);
|
|
3449
5003
|
}
|
|
3450
5004
|
/** Use the connector id this machine already has, or register a new one. */
|
|
3451
5005
|
async identify(forceNew = false) {
|
|
@@ -3487,6 +5041,7 @@ var Connector = class {
|
|
|
3487
5041
|
* fix and ends the run with its message.
|
|
3488
5042
|
*/
|
|
3489
5043
|
async startup() {
|
|
5044
|
+
let atLimit = false;
|
|
3490
5045
|
for (let attempt = 0; !this.ctl.signal.aborted; attempt++) {
|
|
3491
5046
|
try {
|
|
3492
5047
|
await this.identify();
|
|
@@ -3497,6 +5052,17 @@ var Connector = class {
|
|
|
3497
5052
|
await this.onRevoked(e);
|
|
3498
5053
|
return "revoked";
|
|
3499
5054
|
}
|
|
5055
|
+
if (isConnectorLimit(e)) {
|
|
5056
|
+
if (!atLimit) {
|
|
5057
|
+
atLimit = true;
|
|
5058
|
+
this.d.log.error(CONNECTOR_LIMIT_HINT, { error: errMessage(e) });
|
|
5059
|
+
this.d.audit.record("connector_limit_reached", {});
|
|
5060
|
+
recordDisconnect(this.d.dirs, { reason: "connector_limit_reached", detail: CONNECTOR_LIMIT_HINT });
|
|
5061
|
+
}
|
|
5062
|
+
await sleep(this.timing.limitRetryMs, this.ctl.signal);
|
|
5063
|
+
while (!this.ctl.signal.aborted && fs16.existsSync(pauseFile(this.d.dirs))) await sleep(this.timing.pausePollMs, this.ctl.signal);
|
|
5064
|
+
continue;
|
|
5065
|
+
}
|
|
3500
5066
|
if (!isTransient(e)) throw e;
|
|
3501
5067
|
this.d.log.warn("cannot reach Claw yet; will retry", { error: errMessage(e), attempt });
|
|
3502
5068
|
await sleep(backoffMs(attempt, this.timing.backoffMinMs, this.timing.backoffMaxMs), this.ctl.signal);
|
|
@@ -3552,7 +5118,7 @@ var Connector = class {
|
|
|
3552
5118
|
}
|
|
3553
5119
|
async pauseWatcher() {
|
|
3554
5120
|
while (!this.ctl.signal.aborted) {
|
|
3555
|
-
const want =
|
|
5121
|
+
const want = fs16.existsSync(pauseFile(this.d.dirs));
|
|
3556
5122
|
if (want && !this.paused) {
|
|
3557
5123
|
this.paused = true;
|
|
3558
5124
|
this.d.audit.record("paused");
|
|
@@ -3567,6 +5133,63 @@ var Connector = class {
|
|
|
3567
5133
|
await sleep(this.timing.pausePollMs, this.ctl.signal);
|
|
3568
5134
|
}
|
|
3569
5135
|
}
|
|
5136
|
+
/**
|
|
5137
|
+
* Check the machine at start, every `healthMs`, and when asked (`recheckHealth`: `claw-connector doctor` signals a running
|
|
5138
|
+
* connector after it ran). A changed result is sent with an early heartbeat. A check that throws keeps the last report, and
|
|
5139
|
+
* a stop never waits for one: it must never take the connection down. Nothing here touches the file system itself (the
|
|
5140
|
+
* checks run in a child process: see commands.ts `runHealth`), so a stalled disk cannot reach this process.
|
|
5141
|
+
*/
|
|
5142
|
+
async healthWatcher() {
|
|
5143
|
+
const check = this.d.health;
|
|
5144
|
+
if (!check) return;
|
|
5145
|
+
while (!this.ctl.signal.aborted) {
|
|
5146
|
+
this.healthAsked = false;
|
|
5147
|
+
try {
|
|
5148
|
+
const before = this.healthReport ? JSON.stringify(this.healthReport.problems) : null;
|
|
5149
|
+
let onAbort = () => {
|
|
5150
|
+
};
|
|
5151
|
+
const stopped = new Promise((resolve) => {
|
|
5152
|
+
onAbort = () => resolve(null);
|
|
5153
|
+
if (this.ctl.signal.aborted) resolve(null);
|
|
5154
|
+
else this.ctl.signal.addEventListener("abort", onAbort, { once: true });
|
|
5155
|
+
});
|
|
5156
|
+
let report;
|
|
5157
|
+
try {
|
|
5158
|
+
report = await Promise.race([check(this.ctl.signal), stopped]);
|
|
5159
|
+
} finally {
|
|
5160
|
+
this.ctl.signal.removeEventListener("abort", onAbort);
|
|
5161
|
+
}
|
|
5162
|
+
if (report === null) break;
|
|
5163
|
+
this.healthReport = report;
|
|
5164
|
+
if (JSON.stringify(this.healthReport.problems) !== before) {
|
|
5165
|
+
this.d.log.info("health", { problems: this.healthReport.problems.map((p) => `${p.agent ?? "machine"}:${p.code}`) });
|
|
5166
|
+
this.wakeHeartbeat?.();
|
|
5167
|
+
}
|
|
5168
|
+
} catch (e) {
|
|
5169
|
+
this.d.log.warn("health check failed", { error: errMessage(e) });
|
|
5170
|
+
}
|
|
5171
|
+
if (this.healthAsked) continue;
|
|
5172
|
+
await new Promise((resolve) => {
|
|
5173
|
+
const done = () => {
|
|
5174
|
+
clearTimeout(t);
|
|
5175
|
+
this.wakeHealth = null;
|
|
5176
|
+
this.ctl.signal.removeEventListener("abort", done);
|
|
5177
|
+
resolve();
|
|
5178
|
+
};
|
|
5179
|
+
const t = setTimeout(done, this.timing.healthMs);
|
|
5180
|
+
this.wakeHealth = done;
|
|
5181
|
+
this.ctl.signal.addEventListener("abort", done, { once: true });
|
|
5182
|
+
});
|
|
5183
|
+
}
|
|
5184
|
+
}
|
|
5185
|
+
/** Check the machine again now (a `doctor` run asks; at most once per `healthMinGapMs`, so a burst of asks is one check). */
|
|
5186
|
+
recheckHealth() {
|
|
5187
|
+
const now = Date.now();
|
|
5188
|
+
if (now - this.lastHealthAsk < this.timing.healthMinGapMs) return;
|
|
5189
|
+
this.lastHealthAsk = now;
|
|
5190
|
+
this.healthAsked = true;
|
|
5191
|
+
this.wakeHealth?.();
|
|
5192
|
+
}
|
|
3570
5193
|
// --- the loops -----------------------------------------------------------------------
|
|
3571
5194
|
/** Post events in batches, one request at a time and in order, until everything is acknowledged. */
|
|
3572
5195
|
async sender() {
|
|
@@ -3606,6 +5229,22 @@ var Connector = class {
|
|
|
3606
5229
|
}
|
|
3607
5230
|
}
|
|
3608
5231
|
}
|
|
5232
|
+
/** Report a change in the conversations on this machine (see {@link ConnectorTiming.sessionsWatchMs}) without waiting for the heartbeat. */
|
|
5233
|
+
async sessionsWatcher() {
|
|
5234
|
+
if (this.attach.size === 0) return;
|
|
5235
|
+
while (!this.ctl.signal.aborted) {
|
|
5236
|
+
let changed = false;
|
|
5237
|
+
for (const support of this.attach.values()) {
|
|
5238
|
+
try {
|
|
5239
|
+
if (await support.poll()) changed = true;
|
|
5240
|
+
} catch (e) {
|
|
5241
|
+
this.d.log.warn("looking for changed sessions failed", { agent: support.agent.id, error: errMessage(e) });
|
|
5242
|
+
}
|
|
5243
|
+
}
|
|
5244
|
+
if (changed && !this.paused) this.wakeHeartbeat?.();
|
|
5245
|
+
await sleep(this.timing.sessionsWatchMs, this.ctl.signal);
|
|
5246
|
+
}
|
|
5247
|
+
}
|
|
3609
5248
|
async heartbeater() {
|
|
3610
5249
|
while (!this.ctl.signal.aborted) {
|
|
3611
5250
|
await new Promise((resolve) => {
|
|
@@ -3673,6 +5312,7 @@ var Connector = class {
|
|
|
3673
5312
|
async streamLoop() {
|
|
3674
5313
|
let attempt = 0;
|
|
3675
5314
|
let reconnect = false;
|
|
5315
|
+
let lastRestartAt = -Infinity;
|
|
3676
5316
|
while (!this.ctl.signal.aborted) {
|
|
3677
5317
|
if (this.paused) {
|
|
3678
5318
|
await sleep(this.timing.pausePollMs, this.ctl.signal);
|
|
@@ -3683,6 +5323,7 @@ var Connector = class {
|
|
|
3683
5323
|
const sig = AbortSignal.any([this.ctl.signal, this.streamCtl.signal]);
|
|
3684
5324
|
let got = false;
|
|
3685
5325
|
this.lastFrame = "";
|
|
5326
|
+
let restart = false;
|
|
3686
5327
|
for await (const f of this.d.client.commands(this.cid, { lastEventId: this.ledger.highestSeen || void 0, signal: sig, watchdogMs: this.timing.watchdogMs })) {
|
|
3687
5328
|
if (!got) {
|
|
3688
5329
|
got = true;
|
|
@@ -3691,11 +5332,20 @@ var Connector = class {
|
|
|
3691
5332
|
}
|
|
3692
5333
|
this.onFrame(f, reconnect);
|
|
3693
5334
|
if (f.event === "ready") reconnect = true;
|
|
5335
|
+
if (f.event === "reconnect") {
|
|
5336
|
+
restart = true;
|
|
5337
|
+
break;
|
|
5338
|
+
}
|
|
3694
5339
|
}
|
|
3695
5340
|
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)";
|
|
5341
|
+
const why = restart ? "restart" : this.lastFrame === "superseded" || this.lastFrame === "token_expired" ? this.lastFrame : "the server closed the stream (a Claw release or restart)";
|
|
3697
5342
|
this.d.log.info("command stream ended by the server; reconnecting", { why });
|
|
3698
5343
|
recordDisconnect(this.d.dirs, { reason: "server_closed", detail: why });
|
|
5344
|
+
if (restart && Date.now() - lastRestartAt >= RESTART_RECONNECT_GAP_MS) {
|
|
5345
|
+
lastRestartAt = Date.now();
|
|
5346
|
+
attempt = 0;
|
|
5347
|
+
continue;
|
|
5348
|
+
}
|
|
3699
5349
|
}
|
|
3700
5350
|
} catch (e) {
|
|
3701
5351
|
if (this.isRevocation(e)) return void await this.onRevoked(e);
|
|
@@ -3720,50 +5370,57 @@ var Connector = class {
|
|
|
3720
5370
|
}
|
|
3721
5371
|
/** Run until stopped or revoked. Always leaves no adapter process behind. */
|
|
3722
5372
|
async run() {
|
|
3723
|
-
|
|
5373
|
+
fs16.mkdirSync(this.d.dirs.state, { recursive: true, mode: 448 });
|
|
3724
5374
|
await this.registry.killAll();
|
|
3725
5375
|
for (const p of this.store.takePending()) {
|
|
3726
5376
|
this.d.audit.record("permission_orphaned", { session_id: p.session_id, action_id: p.action_id });
|
|
3727
5377
|
this.emit(p.session_id, "permission_resolved", { actionId: p.action_id, optionId: null, outcome: "cancelled", by: "connector_restart" });
|
|
3728
5378
|
}
|
|
5379
|
+
let downForMs = null;
|
|
5380
|
+
try {
|
|
5381
|
+
downForMs = Math.max(0, Date.now() - fs16.statSync(identityFile(this.d.dirs)).mtimeMs);
|
|
5382
|
+
} catch {
|
|
5383
|
+
}
|
|
3729
5384
|
const pidf = pidFile(this.d.dirs);
|
|
3730
5385
|
let leftover = false;
|
|
3731
5386
|
try {
|
|
3732
|
-
|
|
5387
|
+
fs16.readFileSync(pidf);
|
|
3733
5388
|
leftover = true;
|
|
3734
5389
|
} catch {
|
|
3735
5390
|
}
|
|
3736
5391
|
if (leftover) {
|
|
3737
5392
|
let last;
|
|
3738
5393
|
try {
|
|
3739
|
-
last =
|
|
5394
|
+
last = fs16.statSync(identityFile(this.d.dirs)).mtime.toISOString();
|
|
3740
5395
|
} catch {
|
|
3741
5396
|
}
|
|
3742
5397
|
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
5398
|
}
|
|
3744
|
-
|
|
3745
|
-
this.paused =
|
|
5399
|
+
fs16.writeFileSync(pidf, JSON.stringify({ pid: process.pid, start: processStart(process.pid) ?? "" }), { mode: 384 });
|
|
5400
|
+
this.paused = fs16.existsSync(pauseFile(this.d.dirs));
|
|
3746
5401
|
let result = "stopped";
|
|
3747
5402
|
let failure = "";
|
|
3748
5403
|
try {
|
|
3749
5404
|
const up = await this.startup();
|
|
3750
5405
|
if (up !== "ok") return result = up;
|
|
5406
|
+
await this.manager.restoreReadOnly();
|
|
5407
|
+
this.manager.recover({ downForMs, mayContinue: !this.paused && !fs16.existsSync(pauseFile(this.d.dirs)) });
|
|
3751
5408
|
this.d.audit.record("connector_online", {
|
|
3752
5409
|
connector_id: this.cid,
|
|
3753
5410
|
agents: this.d.config.agents.map((a) => a.id),
|
|
3754
5411
|
paused: this.paused,
|
|
3755
5412
|
unattended_agents: this.d.config.agents.filter((a) => a.unattended).map((a) => a.id)
|
|
3756
5413
|
});
|
|
3757
|
-
await Promise.all([this.sender(), this.heartbeater(), this.streamLoop(), this.pauseWatcher()]);
|
|
5414
|
+
await Promise.all([this.sender(), this.heartbeater(), this.streamLoop(), this.pauseWatcher(), this.sessionsWatcher(), this.healthWatcher()]);
|
|
3758
5415
|
result = this.revoked ? "revoked" : this.authFailed ? "auth_failed" : "stopped";
|
|
3759
5416
|
} catch (e) {
|
|
3760
5417
|
failure = errMessage(e);
|
|
3761
5418
|
throw e;
|
|
3762
5419
|
} finally {
|
|
3763
|
-
await this.manager.killAll(this.revoked ? "revoked" : "stopped").catch(() => void 0);
|
|
5420
|
+
await this.manager.killAll(this.revoked ? "revoked" : "stopped", !this.revoked && !this.authFailed).catch(() => void 0);
|
|
3764
5421
|
const reason = this.revoked ? "revoked" : this.authFailed ? "auth_failed" : failure ? "failed" : this.paused ? "stopped_while_paused" : result;
|
|
3765
5422
|
recordDisconnect(this.d.dirs, { reason, detail: this.authWhy || failure || void 0 });
|
|
3766
|
-
|
|
5423
|
+
fs16.rmSync(pidf, { force: true });
|
|
3767
5424
|
}
|
|
3768
5425
|
return result;
|
|
3769
5426
|
}
|
|
@@ -3779,12 +5436,13 @@ var Connector = class {
|
|
|
3779
5436
|
};
|
|
3780
5437
|
|
|
3781
5438
|
// src/service.ts
|
|
3782
|
-
import { spawn as
|
|
3783
|
-
import
|
|
3784
|
-
import
|
|
3785
|
-
import
|
|
5439
|
+
import { spawn as spawn4, spawnSync } from "node:child_process";
|
|
5440
|
+
import fs17 from "node:fs";
|
|
5441
|
+
import os7 from "node:os";
|
|
5442
|
+
import path19 from "node:path";
|
|
3786
5443
|
import { fileURLToPath } from "node:url";
|
|
3787
5444
|
import { parseArgs } from "node:util";
|
|
5445
|
+
import { parse as parseToml3 } from "smol-toml";
|
|
3788
5446
|
var SUPERVISORS = ["systemd", "launchd", "script"];
|
|
3789
5447
|
var UNIT = "claw-connector.service";
|
|
3790
5448
|
var LABEL = "com.primusclaw.connector";
|
|
@@ -3793,11 +5451,14 @@ var NEEDS_PERSON = 78;
|
|
|
3793
5451
|
var CARRIED_ENV = [
|
|
3794
5452
|
"PATH",
|
|
3795
5453
|
"CLAW_CONNECTOR_HOME",
|
|
5454
|
+
"CLAW_CONNECTOR_CONFIG_DIR",
|
|
3796
5455
|
"XDG_CONFIG_HOME",
|
|
3797
5456
|
"XDG_STATE_HOME",
|
|
3798
5457
|
"CLAUDE_CONFIG_DIR",
|
|
3799
5458
|
"CODEX_HOME",
|
|
3800
5459
|
"NODE_EXTRA_CA_CERTS",
|
|
5460
|
+
"NODE_USE_SYSTEM_CA",
|
|
5461
|
+
"NODE_USE_ENV_PROXY",
|
|
3801
5462
|
"SSL_CERT_FILE",
|
|
3802
5463
|
"HTTPS_PROXY",
|
|
3803
5464
|
"https_proxy",
|
|
@@ -3805,27 +5466,97 @@ var CARRIED_ENV = [
|
|
|
3805
5466
|
"http_proxy",
|
|
3806
5467
|
"NO_PROXY",
|
|
3807
5468
|
"no_proxy",
|
|
3808
|
-
"LANG"
|
|
5469
|
+
"LANG",
|
|
5470
|
+
"IS_SANDBOX",
|
|
5471
|
+
"npm_config_registry",
|
|
5472
|
+
"NPM_CONFIG_REGISTRY",
|
|
5473
|
+
"npm_config_proxy",
|
|
5474
|
+
"npm_config_https_proxy",
|
|
5475
|
+
"AWS_REGION",
|
|
5476
|
+
"AWS_PROFILE",
|
|
5477
|
+
"CLOUD_ML_REGION",
|
|
5478
|
+
"GOOGLE_APPLICATION_CREDENTIALS"
|
|
3809
5479
|
];
|
|
3810
|
-
var
|
|
5480
|
+
var AGENT_SETTING = /^(?:ANTHROPIC|CLAUDE_CODE|OPENAI|CODEX|CURSOR)_/;
|
|
5481
|
+
var TERMINAL_SESSION = /^(?:CLAUDE_CODE_(?:ENTRYPOINT|SSE_PORT|SESSION|CHILD_SESSION|MESSAGING|EXECPATH|IDE|GIT_BASH)|CODEX_(?:SANDBOX|THREAD|SESSION|MANAGED|INTERNAL|CI))/;
|
|
5482
|
+
var SECRET_NAME = /KEY|TOKEN(?!S)|SECRET|PASSWORD|CREDENTIAL/i;
|
|
5483
|
+
var AGENT_SECRETS = [
|
|
5484
|
+
"ANTHROPIC_API_KEY",
|
|
5485
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
5486
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
5487
|
+
"ANTHROPIC_CUSTOM_HEADERS",
|
|
5488
|
+
"OPENAI_API_KEY",
|
|
5489
|
+
"CODEX_API_KEY",
|
|
5490
|
+
"CURSOR_API_KEY",
|
|
5491
|
+
"AWS_ACCESS_KEY_ID",
|
|
5492
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
5493
|
+
"AWS_SESSION_TOKEN"
|
|
5494
|
+
];
|
|
5495
|
+
var NEVER_CARRIED = /* @__PURE__ */ new Set(["CLAW_CONNECTOR_KEY"]);
|
|
5496
|
+
function accountName(env, uid, userInfo = os7.userInfo) {
|
|
5497
|
+
if (env.USER) return env.USER;
|
|
5498
|
+
if (env.LOGNAME) return env.LOGNAME;
|
|
5499
|
+
try {
|
|
5500
|
+
return userInfo().username;
|
|
5501
|
+
} catch {
|
|
5502
|
+
return String(uid);
|
|
5503
|
+
}
|
|
5504
|
+
}
|
|
5505
|
+
function cronDaemonRunning(proc = "/proc") {
|
|
5506
|
+
let self;
|
|
5507
|
+
let pids;
|
|
5508
|
+
try {
|
|
5509
|
+
self = fs17.readlinkSync(`${proc}/self/ns/mnt`);
|
|
5510
|
+
pids = fs17.readdirSync(proc).filter((n) => /^\d+$/.test(n));
|
|
5511
|
+
} catch {
|
|
5512
|
+
return false;
|
|
5513
|
+
}
|
|
5514
|
+
for (const pid of pids) {
|
|
5515
|
+
try {
|
|
5516
|
+
if (!/^(?:cron|crond|busybox)$/.test(fs17.readFileSync(`${proc}/${pid}/comm`, "utf8").trim())) continue;
|
|
5517
|
+
if (!/cron/.test(fs17.readFileSync(`${proc}/${pid}/cmdline`, "utf8"))) continue;
|
|
5518
|
+
if (fs17.readlinkSync(`${proc}/${pid}/ns/mnt`) === self) return true;
|
|
5519
|
+
} catch {
|
|
5520
|
+
}
|
|
5521
|
+
}
|
|
5522
|
+
return false;
|
|
5523
|
+
}
|
|
5524
|
+
function systemCrontab() {
|
|
5525
|
+
return {
|
|
5526
|
+
readCrontab: () => {
|
|
5527
|
+
if (!cronDaemonRunning()) return null;
|
|
5528
|
+
const r = spawnSync("crontab", ["-l"], { encoding: "utf8", timeout: 1e4, stdio: ["ignore", "pipe", "pipe"] });
|
|
5529
|
+
if (r.error) return null;
|
|
5530
|
+
return r.status === 0 ? r.stdout : /no crontab/i.test(r.stderr ?? "") ? "" : null;
|
|
5531
|
+
},
|
|
5532
|
+
writeCrontab: (text) => {
|
|
5533
|
+
const r = spawnSync("crontab", ["-"], { input: text, encoding: "utf8", timeout: 1e4, stdio: ["pipe", "pipe", "pipe"] });
|
|
5534
|
+
return !r.error && r.status === 0;
|
|
5535
|
+
}
|
|
5536
|
+
};
|
|
5537
|
+
}
|
|
3811
5538
|
function deps(ctx) {
|
|
3812
5539
|
const d = ctx.service ?? {};
|
|
5540
|
+
const uid = d.uid ?? (process.getuid?.() ?? 0);
|
|
5541
|
+
const cron = d.readCrontab && d.writeCrontab ? null : systemCrontab();
|
|
3813
5542
|
return {
|
|
3814
5543
|
platform: d.platform ?? process.platform,
|
|
3815
|
-
home: d.home ??
|
|
3816
|
-
uid
|
|
3817
|
-
user: d.user ?? (ctx.env
|
|
5544
|
+
home: d.home ?? os7.homedir(),
|
|
5545
|
+
uid,
|
|
5546
|
+
user: d.user ?? accountName(ctx.env, uid),
|
|
5547
|
+
readCrontab: d.readCrontab ?? cron.readCrontab,
|
|
5548
|
+
writeCrontab: d.writeCrontab ?? cron.writeCrontab,
|
|
3818
5549
|
exec: d.exec ?? ((cmd, args) => {
|
|
3819
5550
|
const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 3e4, stdio: ["ignore", "pipe", "pipe"] });
|
|
3820
5551
|
return { code: r.error ? null : r.status, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
|
|
3821
5552
|
}),
|
|
3822
5553
|
node: d.node ?? ctx.node ?? process.execPath,
|
|
3823
5554
|
cli: d.cli ?? fileURLToPath(import.meta.url),
|
|
3824
|
-
systemdBooted: d.systemdBooted ?? (() =>
|
|
5555
|
+
systemdBooted: d.systemdBooted ?? (() => fs17.existsSync("/run/systemd/system"))
|
|
3825
5556
|
};
|
|
3826
5557
|
}
|
|
3827
|
-
var systemdUnitPath = (ctx, d) =>
|
|
3828
|
-
var launchdPlistPath = (d) =>
|
|
5558
|
+
var systemdUnitPath = (ctx, d) => path19.join(ctx.env.XDG_CONFIG_HOME?.trim() || path19.join(d.home, ".config"), "systemd", "user", UNIT);
|
|
5559
|
+
var launchdPlistPath = (d) => path19.join(d.home, "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
3829
5560
|
function detectSupervisor(d) {
|
|
3830
5561
|
if (d.platform === "darwin") return "launchd";
|
|
3831
5562
|
if (!d.systemdBooted()) return "script";
|
|
@@ -3835,29 +5566,71 @@ function detectSupervisor(d) {
|
|
|
3835
5566
|
var hasUserinfo = (v) => {
|
|
3836
5567
|
try {
|
|
3837
5568
|
const u = new URL(v);
|
|
3838
|
-
|
|
5569
|
+
if (u.username !== "" || u.password !== "") return true;
|
|
3839
5570
|
} catch {
|
|
3840
|
-
return /\/\/[^/]*@/.test(v);
|
|
3841
5571
|
}
|
|
5572
|
+
return /\/\/[^/\s]*@/.test(v);
|
|
3842
5573
|
};
|
|
3843
|
-
function
|
|
5574
|
+
function codexEnvKeys(env, home) {
|
|
5575
|
+
const dir = env.CODEX_HOME?.trim() || path19.join(home, ".codex");
|
|
5576
|
+
try {
|
|
5577
|
+
const doc = parseToml3(fs17.readFileSync(path19.join(dir, "config.toml"), "utf8"));
|
|
5578
|
+
const providers = isObject(doc.model_providers) ? doc.model_providers : {};
|
|
5579
|
+
return Object.values(providers).flatMap((p) => isObject(p) && typeof p.env_key === "string" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(p.env_key) ? [p.env_key] : []);
|
|
5580
|
+
} catch {
|
|
5581
|
+
return [];
|
|
5582
|
+
}
|
|
5583
|
+
}
|
|
5584
|
+
function carriedEnv(env, extra = [], secretNames = AGENT_SECRETS) {
|
|
3844
5585
|
const vars = [];
|
|
5586
|
+
const secrets = [];
|
|
3845
5587
|
const refused = [];
|
|
3846
5588
|
const withheld = [];
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
5589
|
+
const settings = Object.keys(env).filter((k) => AGENT_SETTING.test(k) && !TERMINAL_SESSION.test(k)).sort();
|
|
5590
|
+
for (const name of /* @__PURE__ */ new Set([...CARRIED_ENV, ...settings, ...secretNames, ...extra])) {
|
|
5591
|
+
if (NEVER_CARRIED.has(name)) {
|
|
5592
|
+
if (extra.includes(name)) refused.push(name);
|
|
3850
5593
|
continue;
|
|
3851
5594
|
}
|
|
3852
5595
|
const v = env[name];
|
|
3853
5596
|
if (v === void 0 || v === "") continue;
|
|
3854
|
-
|
|
5597
|
+
const proxy = /proxy$/i.test(name);
|
|
5598
|
+
const secret = !CARRIED_ENV.includes(name) && (SECRET_NAME.test(name) || secretNames.includes(name)) || !proxy && hasUserinfo(v);
|
|
5599
|
+
if (secret) {
|
|
5600
|
+
secrets.push([name, v]);
|
|
5601
|
+
continue;
|
|
5602
|
+
}
|
|
5603
|
+
if (proxy && hasUserinfo(v) && !extra.includes(name)) {
|
|
5604
|
+
withheld.push(name);
|
|
5605
|
+
continue;
|
|
5606
|
+
}
|
|
5607
|
+
if (/[\r\n]/.test(v)) {
|
|
3855
5608
|
withheld.push(name);
|
|
3856
5609
|
continue;
|
|
3857
5610
|
}
|
|
3858
5611
|
vars.push([name, v]);
|
|
3859
5612
|
}
|
|
3860
|
-
return { vars, refused, withheld };
|
|
5613
|
+
return { vars, secrets, refused, withheld };
|
|
5614
|
+
}
|
|
5615
|
+
function applyServiceEnv(agents, kept, env) {
|
|
5616
|
+
const used = /* @__PURE__ */ new Set();
|
|
5617
|
+
for (const a of agents) {
|
|
5618
|
+
for (const [k, v] of Object.entries(kept)) {
|
|
5619
|
+
if (env[k] !== void 0 || Object.hasOwn(a.env, k)) continue;
|
|
5620
|
+
a.env = { ...a.env, [k]: v };
|
|
5621
|
+
used.add(k);
|
|
5622
|
+
}
|
|
5623
|
+
}
|
|
5624
|
+
return [...used].sort();
|
|
5625
|
+
}
|
|
5626
|
+
function loadServiceEnv(file) {
|
|
5627
|
+
try {
|
|
5628
|
+
const raw = JSON.parse(fs17.readFileSync(file, "utf8"));
|
|
5629
|
+
if (!isObject(raw)) return {};
|
|
5630
|
+
return Object.fromEntries(Object.entries(raw).filter((e) => typeof e[1] === "string" && !NEVER_CARRIED.has(e[0])));
|
|
5631
|
+
} catch {
|
|
5632
|
+
return {};
|
|
5633
|
+
}
|
|
3861
5634
|
}
|
|
3862
5635
|
var sdEnv = (s) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%")}"`;
|
|
3863
5636
|
var sdQuote = (s) => sdEnv(s).replace(/\$/g, "$$$$");
|
|
@@ -3875,12 +5648,14 @@ StartLimitIntervalSec=0
|
|
|
3875
5648
|
[Service]
|
|
3876
5649
|
Type=simple
|
|
3877
5650
|
ExecStart=${sdQuote(d.node)} ${sdQuote(d.cli)} run --supervised
|
|
5651
|
+
ExecStop=${sdQuote(d.node)} ${sdQuote(d.cli)} stop
|
|
3878
5652
|
${vars.map(([k, v]) => `Environment=${sdEnv(`${k}=${v}`)}`).join("\n")}
|
|
3879
5653
|
Restart=on-failure
|
|
3880
5654
|
RestartSec=${RESTART_SEC}
|
|
3881
5655
|
# 78: revoked, sign-in refused, or no usable config. Restarting cannot help; "claw-connector login" can.
|
|
3882
5656
|
RestartPreventExitStatus=${NEEDS_PERSON}
|
|
3883
|
-
# "claw-connector stop" and "systemctl --user stop" both end it cleanly (exit 0), which is not
|
|
5657
|
+
# "claw-connector stop" and "systemctl --user stop" (through ExecStop) both end it cleanly (exit 0), which is not
|
|
5658
|
+
# restarted. Any other SIGTERM ends a supervised run with exit 75, which is.
|
|
3884
5659
|
KillMode=mixed
|
|
3885
5660
|
TimeoutStopSec=20
|
|
3886
5661
|
|
|
@@ -3924,7 +5699,7 @@ exec ${sh(d.node)} ${sh(d.cli)} service ensure
|
|
|
3924
5699
|
}
|
|
3925
5700
|
function readPidRec(file) {
|
|
3926
5701
|
try {
|
|
3927
|
-
const raw = JSON.parse(
|
|
5702
|
+
const raw = JSON.parse(fs17.readFileSync(file, "utf8"));
|
|
3928
5703
|
if (isObject(raw) && typeof raw.pid === "number" && typeof raw.start === "string" && raw.start !== "") return { pid: raw.pid, start: raw.start };
|
|
3929
5704
|
} catch {
|
|
3930
5705
|
}
|
|
@@ -3944,8 +5719,8 @@ async function supervise(ctx, o = {}) {
|
|
|
3944
5719
|
ctx.err(`a supervisor is already running (pid ${other})`);
|
|
3945
5720
|
return 1;
|
|
3946
5721
|
}
|
|
3947
|
-
|
|
3948
|
-
|
|
5722
|
+
fs17.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
|
|
5723
|
+
fs17.writeFileSync(pidf, JSON.stringify({ pid: process.pid, start: processStart(process.pid) ?? "" }), { mode: 384 });
|
|
3949
5724
|
let stopping2 = false;
|
|
3950
5725
|
let child = null;
|
|
3951
5726
|
const wake = new AbortController();
|
|
@@ -3960,7 +5735,7 @@ async function supervise(ctx, o = {}) {
|
|
|
3960
5735
|
o.signal?.addEventListener("abort", () => onSig("SIGTERM"), { once: true });
|
|
3961
5736
|
try {
|
|
3962
5737
|
for (let round = 0; !stopping2; round++) {
|
|
3963
|
-
child =
|
|
5738
|
+
child = spawn4(cmd, args, { stdio: ["ignore", "inherit", "inherit"], env: ctx.env });
|
|
3964
5739
|
const c = child;
|
|
3965
5740
|
const { code, signal } = await new Promise((resolve) => {
|
|
3966
5741
|
c.once("exit", (code2, signal2) => resolve({ code: code2, signal: signal2 }));
|
|
@@ -3989,13 +5764,14 @@ async function supervise(ctx, o = {}) {
|
|
|
3989
5764
|
} finally {
|
|
3990
5765
|
for (const [s, h] of handlers) process.off(s, h);
|
|
3991
5766
|
const rec = readPidRec(pidf);
|
|
3992
|
-
if (rec?.pid === process.pid)
|
|
5767
|
+
if (rec?.pid === process.pid) fs17.rmSync(pidf, { force: true });
|
|
3993
5768
|
}
|
|
3994
5769
|
}
|
|
3995
5770
|
async function cmdSupervise(_args, ctx) {
|
|
3996
5771
|
return supervise(ctx);
|
|
3997
5772
|
}
|
|
3998
|
-
async function ensure(ctx, d, h) {
|
|
5773
|
+
async function ensure(ctx, d, h, o = {}) {
|
|
5774
|
+
if (!o.byInstall) stampKeepalive(ctx);
|
|
3999
5775
|
const sup = supervisorPid(ctx);
|
|
4000
5776
|
if (sup !== null) {
|
|
4001
5777
|
ctx.out(`supervisor running (pid ${sup})`);
|
|
@@ -4011,17 +5787,24 @@ async function ensure(ctx, d, h) {
|
|
|
4011
5787
|
ctx.out(`the connector is running (pid ${run2}) without the supervisor; leaving it`);
|
|
4012
5788
|
return 0;
|
|
4013
5789
|
}
|
|
4014
|
-
|
|
4015
|
-
const log =
|
|
5790
|
+
fs17.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
|
|
5791
|
+
const log = fs17.openSync(runLogFile(ctx.dirs), "a", 384);
|
|
4016
5792
|
try {
|
|
4017
|
-
const p =
|
|
5793
|
+
const p = spawn4(d.node, [d.cli, "supervise"], { detached: true, cwd: d.home, stdio: ["ignore", log, log], env: ctx.env });
|
|
4018
5794
|
p.unref();
|
|
4019
5795
|
ctx.out(`started the supervisor (pid ${p.pid}); log: ${runLogFile(ctx.dirs)}`);
|
|
4020
5796
|
} finally {
|
|
4021
|
-
|
|
5797
|
+
fs17.closeSync(log);
|
|
4022
5798
|
}
|
|
4023
5799
|
return 0;
|
|
4024
5800
|
}
|
|
5801
|
+
function stampKeepalive(ctx) {
|
|
5802
|
+
try {
|
|
5803
|
+
fs17.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
|
|
5804
|
+
fs17.writeFileSync(keepaliveStampFile(ctx.dirs), (/* @__PURE__ */ new Date()).toISOString() + "\n", { mode: 384 });
|
|
5805
|
+
} catch {
|
|
5806
|
+
}
|
|
5807
|
+
}
|
|
4025
5808
|
async function cmdService(args, ctx, h) {
|
|
4026
5809
|
const [sub, ...rest] = args;
|
|
4027
5810
|
const usage = "usage: claw-connector service install|uninstall|status [--supervisor systemd|launchd|script] [--env NAME ...]";
|
|
@@ -4048,7 +5831,7 @@ async function cmdService(args, ctx, h) {
|
|
|
4048
5831
|
}
|
|
4049
5832
|
function installedSupervisor(ctx) {
|
|
4050
5833
|
try {
|
|
4051
|
-
const raw = JSON.parse(
|
|
5834
|
+
const raw = JSON.parse(fs17.readFileSync(serviceFile(ctx.dirs), "utf8"));
|
|
4052
5835
|
if (isObject(raw) && typeof raw.supervisor === "string" && SUPERVISORS.includes(raw.supervisor)) return raw.supervisor;
|
|
4053
5836
|
} catch {
|
|
4054
5837
|
}
|
|
@@ -4068,11 +5851,67 @@ function startAfterLogin(ctx, h) {
|
|
|
4068
5851
|
}
|
|
4069
5852
|
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
5853
|
}
|
|
5854
|
+
var UnsafeDirError = class extends Error {
|
|
5855
|
+
};
|
|
5856
|
+
function writeSecretFile(file, body) {
|
|
5857
|
+
const dir = path19.dirname(file);
|
|
5858
|
+
fs17.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
5859
|
+
let dfd;
|
|
5860
|
+
try {
|
|
5861
|
+
dfd = fs17.openSync(dir, fs17.constants.O_RDONLY | (fs17.constants.O_DIRECTORY ?? 0) | (fs17.constants.O_NOFOLLOW ?? 0));
|
|
5862
|
+
} catch (e) {
|
|
5863
|
+
throw new UnsafeDirError(`${dir} cannot be opened as a directory of its own (${e.code ?? errMessage(e)}; a link is not followed): the agents' sign-in variables are not written there (set CLAW_CONNECTOR_CONFIG_DIR to a directory of your own)`);
|
|
5864
|
+
}
|
|
5865
|
+
try {
|
|
5866
|
+
const st = fs17.fstatSync(dfd);
|
|
5867
|
+
const uid = process.getuid?.();
|
|
5868
|
+
if (!st.isDirectory() || uid !== void 0 && st.uid !== uid) {
|
|
5869
|
+
throw new UnsafeDirError(`${dir} is ${!st.isDirectory() ? "not a directory" : "owned by another account"}: the agents' sign-in variables are not written there (set CLAW_CONNECTOR_CONFIG_DIR to a directory of your own)`);
|
|
5870
|
+
}
|
|
5871
|
+
if ((st.mode & 63) !== 0) fs17.fchmodSync(dfd, 448);
|
|
5872
|
+
let anchored;
|
|
5873
|
+
if (fs17.existsSync(`/proc/self/fd/${dfd}`)) anchored = `/proc/self/fd/${dfd}`;
|
|
5874
|
+
else {
|
|
5875
|
+
const real = fs17.realpathSync(dir);
|
|
5876
|
+
const rs = fs17.statSync(real);
|
|
5877
|
+
if (rs.dev !== st.dev || rs.ino !== st.ino) throw new UnsafeDirError(`${dir} changed while it was being checked; the agents' sign-in variables are not written there`);
|
|
5878
|
+
const weak = renamableAncestor(real);
|
|
5879
|
+
if (weak) throw new UnsafeDirError(`${weak} can be changed by other accounts, so ${dir} could be swapped while the agents' sign-in variables are written; they are not written there (set CLAW_CONNECTOR_CONFIG_DIR to a directory of your own)`);
|
|
5880
|
+
anchored = real;
|
|
5881
|
+
}
|
|
5882
|
+
const tmp = path19.join(anchored, `${path19.basename(file)}.${process.pid}.${Date.now()}.tmp`);
|
|
5883
|
+
const fd = fs17.openSync(tmp, fs17.constants.O_WRONLY | fs17.constants.O_CREAT | fs17.constants.O_EXCL | (fs17.constants.O_NOFOLLOW ?? 0), 384);
|
|
5884
|
+
try {
|
|
5885
|
+
fs17.writeSync(fd, body);
|
|
5886
|
+
fs17.fchmodSync(fd, 384);
|
|
5887
|
+
} finally {
|
|
5888
|
+
fs17.closeSync(fd);
|
|
5889
|
+
}
|
|
5890
|
+
fs17.renameSync(tmp, path19.join(anchored, path19.basename(file)));
|
|
5891
|
+
} finally {
|
|
5892
|
+
fs17.closeSync(dfd);
|
|
5893
|
+
}
|
|
5894
|
+
}
|
|
5895
|
+
function renamableAncestor(dir, uid = process.getuid?.()) {
|
|
5896
|
+
for (let p = path19.dirname(path19.resolve(dir)); ; p = path19.dirname(p)) {
|
|
5897
|
+
let st;
|
|
5898
|
+
try {
|
|
5899
|
+
st = fs17.lstatSync(p);
|
|
5900
|
+
} catch {
|
|
5901
|
+
return p;
|
|
5902
|
+
}
|
|
5903
|
+
if (!st.isDirectory()) return p;
|
|
5904
|
+
const owner = st.uid === 0 || uid === void 0 || st.uid === uid;
|
|
5905
|
+
const sticky = (st.mode & 512) !== 0;
|
|
5906
|
+
if (!owner || (st.mode & 18) !== 0 && !sticky) return p;
|
|
5907
|
+
if (p === path19.dirname(p)) return null;
|
|
5908
|
+
}
|
|
5909
|
+
}
|
|
4071
5910
|
function writeFile600(file, body, mode = 384) {
|
|
4072
|
-
|
|
5911
|
+
fs17.mkdirSync(path19.dirname(file), { recursive: true, mode: 448 });
|
|
4073
5912
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
4074
|
-
|
|
4075
|
-
|
|
5913
|
+
fs17.writeFileSync(tmp, body, { mode });
|
|
5914
|
+
fs17.renameSync(tmp, file);
|
|
4076
5915
|
}
|
|
4077
5916
|
async function install(ctx, d, h, sup, extra) {
|
|
4078
5917
|
const problem = h.preflight(ctx);
|
|
@@ -4080,13 +5919,22 @@ async function install(ctx, d, h, sup, extra) {
|
|
|
4080
5919
|
ctx.err(`cannot install the service yet: ${problem}`);
|
|
4081
5920
|
return NEEDS_PERSON;
|
|
4082
5921
|
}
|
|
4083
|
-
if (!
|
|
5922
|
+
if (!fs17.existsSync(d.cli)) {
|
|
4084
5923
|
ctx.err(`${d.cli} does not exist (a source checkout needs "npm run build" first)`);
|
|
4085
5924
|
return 1;
|
|
4086
5925
|
}
|
|
4087
|
-
const { vars, refused, withheld } = carriedEnv(ctx.env, extra);
|
|
4088
|
-
for (const r of refused) ctx.err(`--env ${r}: not carried into the service (it
|
|
4089
|
-
for (const w of withheld)
|
|
5926
|
+
const { vars, secrets, refused, withheld } = carriedEnv(ctx.env, extra, [...AGENT_SECRETS, ...codexEnvKeys(ctx.env, d.home)]);
|
|
5927
|
+
for (const r of refused) ctx.err(`--env ${r}: not carried into the service (it is the connector's own key, kept in credentials.json)`);
|
|
5928
|
+
for (const w of withheld) {
|
|
5929
|
+
ctx.err(/[\r\n]/.test(ctx.env[w] ?? "") ? `${w}: not carried into the service because its value spans several lines` : `${w}: not carried into the service because it holds a password; if the connector needs it, install again with --env ${w} (it is then written into the 0600 unit file)`);
|
|
5930
|
+
}
|
|
5931
|
+
try {
|
|
5932
|
+
saveServiceEnv(ctx, secrets);
|
|
5933
|
+
} catch (e) {
|
|
5934
|
+
if (!(e instanceof UnsafeDirError)) throw e;
|
|
5935
|
+
ctx.err(`cannot install the service: ${e.message}`);
|
|
5936
|
+
return 1;
|
|
5937
|
+
}
|
|
4090
5938
|
const manual = h.runningPid(ctx);
|
|
4091
5939
|
if (manual !== null && supervisorPid(ctx) === null) {
|
|
4092
5940
|
await h.stop(ctx);
|
|
@@ -4096,7 +5944,7 @@ async function install(ctx, d, h, sup, extra) {
|
|
|
4096
5944
|
if (sup === "systemd") {
|
|
4097
5945
|
const unit = systemdUnitPath(ctx, d);
|
|
4098
5946
|
writeFile600(unit, systemdUnit(d, vars));
|
|
4099
|
-
recordInstalled(ctx, "systemd");
|
|
5947
|
+
recordInstalled(ctx, "systemd", vars);
|
|
4100
5948
|
if (!d.systemdBooted()) {
|
|
4101
5949
|
ctx.err("systemd is not this machine's init (no /run/systemd/system): use --supervisor script");
|
|
4102
5950
|
return 1;
|
|
@@ -4119,7 +5967,7 @@ async function install(ctx, d, h, sup, extra) {
|
|
|
4119
5967
|
} else if (sup === "launchd") {
|
|
4120
5968
|
const plist = launchdPlistPath(d);
|
|
4121
5969
|
writeFile600(plist, launchdPlist(d, vars, runLogFile(ctx.dirs)));
|
|
4122
|
-
recordInstalled(ctx, "launchd");
|
|
5970
|
+
recordInstalled(ctx, "launchd", vars);
|
|
4123
5971
|
d.exec("launchctl", ["bootout", `gui/${d.uid}/${LABEL}`]);
|
|
4124
5972
|
const r = d.exec("launchctl", ["bootstrap", `gui/${d.uid}`, plist]);
|
|
4125
5973
|
if (r.code !== 0) {
|
|
@@ -4130,33 +5978,126 @@ async function install(ctx, d, h, sup, extra) {
|
|
|
4130
5978
|
} else {
|
|
4131
5979
|
const script = keepaliveFile(ctx.dirs);
|
|
4132
5980
|
writeFile600(script, keepaliveScript(d, vars), 448);
|
|
4133
|
-
recordInstalled(ctx, "script");
|
|
4134
|
-
const code = await ensure(ctx, d, h);
|
|
5981
|
+
recordInstalled(ctx, "script", vars);
|
|
5982
|
+
const code = await ensure(ctx, d, h, { byInstall: true });
|
|
4135
5983
|
if (code !== 0) return code;
|
|
4136
|
-
ctx.out(`wrote ${script}. This machine has no systemd: the supervisor restarts the connector 5 s after it dies
|
|
4137
|
-
ctx
|
|
4138
|
-
ctx.out(` @reboot sh ${script}`);
|
|
4139
|
-
ctx.out(` */5 * * * * sh ${script}`);
|
|
5984
|
+
ctx.out(`wrote ${script}. This machine has no systemd: the supervisor restarts the connector 5 s after it dies.`);
|
|
5985
|
+
scheduleKeepalive(ctx, d, script);
|
|
4140
5986
|
}
|
|
4141
5987
|
return 0;
|
|
4142
5988
|
}
|
|
4143
|
-
|
|
4144
|
-
|
|
5989
|
+
var cronLines = (script) => [`@reboot sh ${sh(script)} >/dev/null 2>&1`, `*/5 * * * * sh ${sh(script)} >/dev/null 2>&1`];
|
|
5990
|
+
function scheduleKeepalive(ctx, d, script) {
|
|
5991
|
+
const tab = d.readCrontab();
|
|
5992
|
+
const want = cronLines(script);
|
|
5993
|
+
if (tab !== null) {
|
|
5994
|
+
const lines = tab.split("\n");
|
|
5995
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
5996
|
+
const kept = lines.filter((l) => !want.includes(l.trim()));
|
|
5997
|
+
if (d.writeCrontab([...kept, ...want].join("\n") + "\n")) {
|
|
5998
|
+
ctx.out(`added to your crontab, so it comes back after a reboot and within 5 minutes of the supervisor dying:`);
|
|
5999
|
+
for (const l of want) ctx.out(` ${l}`);
|
|
6000
|
+
return;
|
|
6001
|
+
}
|
|
6002
|
+
ctx.err("could not write your crontab (crontab -); add the lines below by hand");
|
|
6003
|
+
}
|
|
6004
|
+
ctx.out("No cron daemon runs here: after a reboot or a container restart NOTHING starts the connector again.");
|
|
6005
|
+
ctx.out("Run this from the container's startup command (it does nothing while the connector is up; ~/.bashrc works too, then it");
|
|
6006
|
+
ctx.out("comes back when you next open a shell):");
|
|
6007
|
+
ctx.out(` sh ${sh(script)} >/dev/null 2>&1`);
|
|
6008
|
+
const where = ephemeralWhere(ctx.dirs.config);
|
|
6009
|
+
if (where) ctx.out(`Note: ${where}`);
|
|
6010
|
+
}
|
|
6011
|
+
function withoutOurCron(d, script) {
|
|
6012
|
+
const tab = d.readCrontab();
|
|
6013
|
+
if (tab === null) return null;
|
|
6014
|
+
const ours = cronLines(script);
|
|
6015
|
+
const lines = tab.split("\n");
|
|
6016
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
6017
|
+
const kept = lines.filter((l) => !ours.includes(l.trim()));
|
|
6018
|
+
return { tab: kept.length ? kept.join("\n") + "\n" : "", removed: lines.length - kept.length };
|
|
6019
|
+
}
|
|
6020
|
+
var EPHEMERAL_FS = /* @__PURE__ */ new Set(["overlay", "overlayfs", "aufs", "tmpfs"]);
|
|
6021
|
+
var PERSISTENT_FS = /^(?:ceph|nfs|nfs4|lustre|wekafs|gpfs|beegfs|cifs|smb3|fuse\.(?:ceph|juicefs|s3fs|gcsfuse|sshfs|rclone))$/;
|
|
6022
|
+
function readMounts(text) {
|
|
6023
|
+
let raw = text;
|
|
6024
|
+
if (raw === void 0) {
|
|
6025
|
+
try {
|
|
6026
|
+
raw = fs17.readFileSync("/proc/self/mountinfo", "utf8");
|
|
6027
|
+
} catch {
|
|
6028
|
+
return [];
|
|
6029
|
+
}
|
|
6030
|
+
}
|
|
6031
|
+
const out = [];
|
|
6032
|
+
for (const line of raw.split("\n")) {
|
|
6033
|
+
const dash = line.indexOf(" - ");
|
|
6034
|
+
if (dash < 0) continue;
|
|
6035
|
+
const fields = line.slice(0, dash).split(" ");
|
|
6036
|
+
const point = fields[4]?.replace(/\\([0-7]{3})/g, (_m, o) => String.fromCharCode(parseInt(o, 8)));
|
|
6037
|
+
const fstype = line.slice(dash + 3).split(" ")[0];
|
|
6038
|
+
if (point && fstype) out.push({ point, fstype });
|
|
6039
|
+
}
|
|
6040
|
+
return out;
|
|
6041
|
+
}
|
|
6042
|
+
function mountOf(p, mounts) {
|
|
6043
|
+
let best = null;
|
|
6044
|
+
for (const m of mounts) {
|
|
6045
|
+
const inside2 = p === m.point || p.startsWith(m.point.endsWith("/") ? m.point : `${m.point}/`);
|
|
6046
|
+
if (inside2 && (!best || m.point.length >= best.point.length)) best = m;
|
|
6047
|
+
}
|
|
6048
|
+
return best;
|
|
6049
|
+
}
|
|
6050
|
+
function ephemeralWhere(configDir, mounts = readMounts()) {
|
|
6051
|
+
let real = configDir;
|
|
6052
|
+
for (let p = configDir; ; p = path19.dirname(p)) {
|
|
6053
|
+
try {
|
|
6054
|
+
real = path19.join(fs17.realpathSync(p), path19.relative(p, configDir));
|
|
6055
|
+
break;
|
|
6056
|
+
} catch {
|
|
6057
|
+
if (p === path19.dirname(p)) break;
|
|
6058
|
+
}
|
|
6059
|
+
}
|
|
6060
|
+
const m = mountOf(real, mounts);
|
|
6061
|
+
if (!m || !EPHEMERAL_FS.has(m.fstype)) return null;
|
|
6062
|
+
const persistent = [...new Set(mounts.filter((x) => PERSISTENT_FS.test(x.fstype) && !x.point.startsWith("/proc") && !x.point.startsWith("/sys")).map((x) => x.point))].filter((pt) => {
|
|
6063
|
+
try {
|
|
6064
|
+
fs17.accessSync(pt, fs17.constants.W_OK);
|
|
6065
|
+
return true;
|
|
6066
|
+
} catch {
|
|
6067
|
+
return false;
|
|
6068
|
+
}
|
|
6069
|
+
}).slice(0, 3);
|
|
6070
|
+
return `agents.toml and the sign-in are in ${configDir}, on the container's own file system (${m.fstype}), which is lost when the container is recreated. Keep them on storage that outlives it: export CLAW_CONNECTOR_CONFIG_DIR=<a directory of yours on ${persistent.length ? persistent.join(" or ") : "a persistent volume"}>/claw-connector in your shell profile and the container's startup command, then run init and login again (the state directory, with its pid and lock files, stays where it is).`;
|
|
6071
|
+
}
|
|
6072
|
+
function saveServiceEnv(ctx, secrets) {
|
|
6073
|
+
const file = serviceEnvFile(ctx.dirs);
|
|
6074
|
+
const before = loadServiceEnv(file);
|
|
6075
|
+
const merged = { ...before, ...Object.fromEntries(secrets) };
|
|
6076
|
+
const names = Object.keys(merged).sort();
|
|
6077
|
+
if (names.length === 0) return;
|
|
6078
|
+
writeSecretFile(file, JSON.stringify(merged, null, 2) + "\n");
|
|
6079
|
+
const fresh = secrets.map(([k]) => k);
|
|
6080
|
+
const kept = names.filter((n) => !fresh.includes(n));
|
|
6081
|
+
ctx.out(`the agents' sign-in for the service (in ${file}, mode 0600, never in a unit): ${[...fresh, ...kept.map((k) => `${k} (kept from before)`)].join(", ")}`);
|
|
6082
|
+
}
|
|
6083
|
+
function recordInstalled(ctx, sup, vars) {
|
|
6084
|
+
const p = vars.find(([k]) => k === "PATH")?.[1];
|
|
6085
|
+
writeFile600(serviceFile(ctx.dirs), JSON.stringify({ supervisor: sup, installed_at: (/* @__PURE__ */ new Date()).toISOString(), ...p !== void 0 ? { path: p } : {} }));
|
|
4145
6086
|
}
|
|
4146
6087
|
async function uninstall(ctx, d, sup) {
|
|
4147
|
-
|
|
6088
|
+
fs17.rmSync(serviceFile(ctx.dirs), { force: true });
|
|
4148
6089
|
if (sup === "systemd") {
|
|
4149
6090
|
const unit = systemdUnitPath(ctx, d);
|
|
4150
6091
|
d.exec("systemctl", ["--user", "disable", "--now", UNIT]);
|
|
4151
|
-
const had =
|
|
4152
|
-
|
|
6092
|
+
const had = fs17.existsSync(unit);
|
|
6093
|
+
fs17.rmSync(unit, { force: true });
|
|
4153
6094
|
d.exec("systemctl", ["--user", "daemon-reload"]);
|
|
4154
6095
|
ctx.out(had ? `stopped and removed ${unit}` : `no unit at ${unit}`);
|
|
4155
6096
|
} else if (sup === "launchd") {
|
|
4156
6097
|
const plist = launchdPlistPath(d);
|
|
4157
6098
|
d.exec("launchctl", ["bootout", `gui/${d.uid}/${LABEL}`]);
|
|
4158
|
-
const had =
|
|
4159
|
-
|
|
6099
|
+
const had = fs17.existsSync(plist);
|
|
6100
|
+
fs17.rmSync(plist, { force: true });
|
|
4160
6101
|
ctx.out(had ? `stopped and removed ${plist}` : `no LaunchAgent at ${plist}`);
|
|
4161
6102
|
} else {
|
|
4162
6103
|
const pid = supervisorPid(ctx);
|
|
@@ -4166,9 +6107,15 @@ async function uninstall(ctx, d, sup) {
|
|
|
4166
6107
|
while (Date.now() < end && isAlive(pid) && processStart(pid) !== null) await sleep(100);
|
|
4167
6108
|
}
|
|
4168
6109
|
const script = keepaliveFile(ctx.dirs);
|
|
4169
|
-
const had =
|
|
4170
|
-
|
|
4171
|
-
|
|
6110
|
+
const had = fs17.existsSync(script);
|
|
6111
|
+
fs17.rmSync(script, { force: true });
|
|
6112
|
+
const cron = withoutOurCron(d, script);
|
|
6113
|
+
const cronGone = cron !== null && cron.removed > 0 && d.writeCrontab(cron.tab);
|
|
6114
|
+
ctx.out(`${pid !== null ? `stopped the supervisor (pid ${pid}) and the connector; ` : ""}${had ? `removed ${script}` : "no keep-alive script"}${cronGone ? " and its crontab lines" : ". Remove any startup-hook or cron line that calls it."}`);
|
|
6115
|
+
}
|
|
6116
|
+
if (fs17.existsSync(serviceEnvFile(ctx.dirs))) {
|
|
6117
|
+
fs17.rmSync(serviceEnvFile(ctx.dirs), { force: true });
|
|
6118
|
+
ctx.out(`removed ${serviceEnvFile(ctx.dirs)} (the agents' sign-in variables kept for the service)`);
|
|
4172
6119
|
}
|
|
4173
6120
|
return 0;
|
|
4174
6121
|
}
|
|
@@ -4184,6 +6131,8 @@ function status(ctx, d, h, json) {
|
|
|
4184
6131
|
const st = {
|
|
4185
6132
|
installed: sup,
|
|
4186
6133
|
active,
|
|
6134
|
+
/** The names (never the values) of the agents' sign-in variables the service keeps. */
|
|
6135
|
+
agent_env: Object.keys(loadServiceEnv(serviceEnvFile(ctx.dirs))).sort(),
|
|
4187
6136
|
running: h.runningPid(ctx),
|
|
4188
6137
|
needs_login: needsLogin(ctx.dirs),
|
|
4189
6138
|
recent_disconnects: recentDisconnects(ctx.dirs, 5)
|
|
@@ -4194,21 +6143,108 @@ function status(ctx, d, h, json) {
|
|
|
4194
6143
|
}
|
|
4195
6144
|
ctx.out(`service: ${sup ? `${sup} (${active})` : "not installed; run: claw-connector service install"}`);
|
|
4196
6145
|
ctx.out(`connector: ${st.running !== null ? `running (pid ${st.running})` : "not running"}`);
|
|
6146
|
+
if (st.agent_env.length) ctx.out(`agent env: ${st.agent_env.join(", ")} (from ${serviceEnvFile(ctx.dirs)})`);
|
|
4197
6147
|
if (st.needs_login) ctx.out(`problem: needs a new sign-in: ${st.needs_login.reason}; run: claw-connector login`);
|
|
4198
6148
|
for (const x of st.recent_disconnects) ctx.out(`down: ${x.at} ${x.reason}${x.detail ? ` (${x.detail})` : ""}`);
|
|
4199
6149
|
return 0;
|
|
4200
6150
|
}
|
|
4201
6151
|
|
|
4202
6152
|
// src/version.ts
|
|
4203
|
-
var VERSION = "0.1.
|
|
6153
|
+
var VERSION = "0.1.6";
|
|
6154
|
+
|
|
6155
|
+
// src/probe.ts
|
|
6156
|
+
import fs18 from "node:fs";
|
|
6157
|
+
import os8 from "node:os";
|
|
6158
|
+
import path20 from "node:path";
|
|
6159
|
+
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`;
|
|
6160
|
+
function modesOf(res) {
|
|
6161
|
+
const m = isObject(res.modes) ? res.modes : null;
|
|
6162
|
+
if (m && Array.isArray(m.availableModes)) return m.availableModes.filter((x) => isObject(x) && typeof x.id === "string").map((x) => x.id);
|
|
6163
|
+
const opt = Array.isArray(res.configOptions) ? res.configOptions.find((o) => isObject(o) && o.category === "mode") : null;
|
|
6164
|
+
if (isObject(opt) && Array.isArray(opt.options)) return opt.options.filter((x) => isObject(x) && typeof x.value === "string").map((x) => x.value);
|
|
6165
|
+
return null;
|
|
6166
|
+
}
|
|
6167
|
+
var probeDir = (a) => a.roots.find((r) => {
|
|
6168
|
+
try {
|
|
6169
|
+
return fs18.statSync(r).isDirectory();
|
|
6170
|
+
} catch {
|
|
6171
|
+
return false;
|
|
6172
|
+
}
|
|
6173
|
+
}) ?? os8.homedir();
|
|
6174
|
+
async function probeAgent(a, o) {
|
|
6175
|
+
const cwd = probeDir(a);
|
|
6176
|
+
const out = { agent: a.id, cwd, ok: false, modes: null, problems: [], notes: [] };
|
|
6177
|
+
if (!a.roots.includes(cwd)) out.notes.push(`no root of this agent exists yet (${a.roots.join(", ")}); probed in ${cwd}`);
|
|
6178
|
+
const tmp = fs18.mkdtempSync(path20.join(os8.tmpdir(), "claw-probe-"));
|
|
6179
|
+
const registry = new ChildRegistry(path20.join(tmp, "children.json"));
|
|
6180
|
+
const timeoutMs = o.timeoutMs ?? 12e4;
|
|
6181
|
+
let adapter = null;
|
|
6182
|
+
try {
|
|
6183
|
+
adapter = await AdapterProcess.start({
|
|
6184
|
+
cfg: a,
|
|
6185
|
+
registry,
|
|
6186
|
+
sessionId: `probe-${a.id}`,
|
|
6187
|
+
cwd,
|
|
6188
|
+
logFile: path20.join(o.logDir, `adapter-${a.id}.log`),
|
|
6189
|
+
version: o.version,
|
|
6190
|
+
initTimeoutMs: timeoutMs,
|
|
6191
|
+
hooks: {
|
|
6192
|
+
onUpdate: () => {
|
|
6193
|
+
},
|
|
6194
|
+
onNotification: () => {
|
|
6195
|
+
},
|
|
6196
|
+
onStderr: () => {
|
|
6197
|
+
},
|
|
6198
|
+
onExit: () => {
|
|
6199
|
+
},
|
|
6200
|
+
// No prompt is sent, so nothing should ask; anything that does is refused.
|
|
6201
|
+
onPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
|
6202
|
+
onExtension: async () => ({})
|
|
6203
|
+
}
|
|
6204
|
+
});
|
|
6205
|
+
const res = await adapter.request("session/new", { cwd, mcpServers: [], ...claudeSessionMeta(a) }, timeoutMs);
|
|
6206
|
+
out.modes = modesOf(res);
|
|
6207
|
+
if (out.modes === null || out.modes.length === 0) {
|
|
6208
|
+
out.notes.push("the adapter lists no modes; the connector sets the ask mode and reads it back when a session opens");
|
|
6209
|
+
out.ok = true;
|
|
6210
|
+
return out;
|
|
6211
|
+
}
|
|
6212
|
+
if (!out.modes.includes(a.askMode)) {
|
|
6213
|
+
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}` : ""}`);
|
|
6214
|
+
}
|
|
6215
|
+
const missing = a.safeModes.filter((m) => m !== a.askMode && !out.modes.includes(m));
|
|
6216
|
+
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`);
|
|
6217
|
+
out.ok = out.problems.length === 0;
|
|
6218
|
+
} catch (e) {
|
|
6219
|
+
out.problems.push(`the adapter did not open a session: ${errMessage(e)} (its log: ${path20.join(o.logDir, `adapter-${a.id}.log`)})`);
|
|
6220
|
+
} finally {
|
|
6221
|
+
await adapter?.kill().catch(() => void 0);
|
|
6222
|
+
fs18.rmSync(tmp, { recursive: true, force: true });
|
|
6223
|
+
}
|
|
6224
|
+
return out;
|
|
6225
|
+
}
|
|
6226
|
+
var probeAgents = async (agents, o) => {
|
|
6227
|
+
const out = [];
|
|
6228
|
+
for (const a of agents) out.push(await probeAgent(a, o));
|
|
6229
|
+
return out;
|
|
6230
|
+
};
|
|
6231
|
+
function probeLines(results) {
|
|
6232
|
+
const lines = [];
|
|
6233
|
+
for (const r of results) {
|
|
6234
|
+
lines.push(`${r.agent}: ${r.ok ? "ok" : "PROBLEM"}${r.modes ? ` (modes: ${r.modes.join(", ")})` : ""}`);
|
|
6235
|
+
for (const p of r.problems) lines.push(` problem: ${p}`);
|
|
6236
|
+
for (const n of r.notes) lines.push(` note: ${n}`);
|
|
6237
|
+
}
|
|
6238
|
+
return lines;
|
|
6239
|
+
}
|
|
4204
6240
|
|
|
4205
6241
|
// src/commands.ts
|
|
4206
|
-
var EXIT = { ok: 0, failure: 1, usage: 2, needsPerson: 78, revoked: 78 };
|
|
6242
|
+
var EXIT = { ok: 0, failure: 1, usage: 2, terminated: 75, needsPerson: 78, revoked: 78 };
|
|
4207
6243
|
var HELP = `claw-connector ${VERSION}: connect the coding agents on this machine to Claw (outbound HTTPS only).
|
|
4208
6244
|
|
|
4209
6245
|
Usage: claw-connector <command> [options]
|
|
4210
6246
|
|
|
4211
|
-
init [--approval ask|never | --approval AGENT=ask|never ...] [--no-hooks]
|
|
6247
|
+
init [--approval ask|never | --approval AGENT=ask|never ...] [--no-hooks] [--no-probe]
|
|
4212
6248
|
write a sample agents.toml, and install the Claude Code session hooks (see "hooks"; --no-hooks
|
|
4213
6249
|
skips that). Agents ask before every write and command by default;
|
|
4214
6250
|
"--approval never" (or "--approval claude=never") lets phone sessions run like your
|
|
@@ -4224,8 +6260,18 @@ Usage: claw-connector <command> [options]
|
|
|
4224
6260
|
service install|uninstall|status [--supervisor systemd|launchd|script] [--env NAME ...]
|
|
4225
6261
|
keep "run" up: started at login/boot, restarted 5 s after it dies, never restarted
|
|
4226
6262
|
after exit 78. systemd --user on Linux, launchd on macOS; "script" (containers without
|
|
4227
|
-
systemd) writes a keep-alive script
|
|
6263
|
+
systemd) writes a keep-alive script and adds it to crontab, or says what must call it.
|
|
6264
|
+
The agents' settings in this shell (PATH, proxies, ANTHROPIC_BASE_URL...) go into the
|
|
6265
|
+
service; their sign-in variables (ANTHROPIC_API_KEY, OPENAI_API_KEY...) and any --env
|
|
6266
|
+
named like a credential go into service-env.json (0600), never into the unit
|
|
4228
6267
|
status [--json] what is running, signed in, paused, the last disconnect and why
|
|
6268
|
+
doctor [--agent ID] [--json]
|
|
6269
|
+
check this machine before a phone session fails on it, one fix per problem: Node, each
|
|
6270
|
+
agent's program on PATH (and on the installed service's PATH), Claude Code / Codex signed
|
|
6271
|
+
in, the roots writable, the proxy variables, Claw's certificate, the service and Claw
|
|
6272
|
+
itself; then start each agent's adapter, open a session (no prompt) and check that it
|
|
6273
|
+
offers the modes agents.toml needs. Exit 1 when something stops sessions. A running
|
|
6274
|
+
connector repeats the machine checks and shows them in the chat; init runs the adapter check
|
|
4229
6275
|
pause stop every agent now and stay offline until "resume"
|
|
4230
6276
|
resume undo "pause"
|
|
4231
6277
|
stop stop the running connector and every agent it started
|
|
@@ -4233,18 +6279,23 @@ Usage: claw-connector <command> [options]
|
|
|
4233
6279
|
--version, --help
|
|
4234
6280
|
|
|
4235
6281
|
Files: ${"$"}XDG_CONFIG_HOME/claw-connector (agents.toml, credentials.json) and ${"$"}XDG_STATE_HOME/claw-connector
|
|
4236
|
-
(audit.jsonl, logs, outbox); set CLAW_CONNECTOR_HOME to keep everything under one directory
|
|
6282
|
+
(audit.jsonl, logs, outbox); set CLAW_CONNECTOR_HOME to keep everything under one directory, and
|
|
6283
|
+
CLAW_CONNECTOR_CONFIG_DIR to keep only the configuration and sign-in elsewhere (storage that outlives a container).
|
|
4237
6284
|
`;
|
|
4238
|
-
function loadCfg(ctx) {
|
|
4239
|
-
|
|
6285
|
+
function loadCfg(ctx, notes = []) {
|
|
6286
|
+
const cfg = loadConfig(configFile(ctx.dirs), { hostname: ctx.hostname ?? os9.hostname() });
|
|
6287
|
+
applyServiceEnv(cfg.agents, loadServiceEnv(serviceEnvFile(ctx.dirs)), ctx.env);
|
|
6288
|
+
notes.push(...applyRootSandbox(cfg.agents, ctx.env, { euid: ctx.euid }));
|
|
6289
|
+
return cfg;
|
|
4240
6290
|
}
|
|
4241
|
-
var noAsk = (ctx, cfg) =>
|
|
6291
|
+
var noAsk = (ctx, cfg) => bypassUnavailableWarnings(cfg.agents, ctx.env, { managedFile: ctx.claudeManagedSettings, euid: ctx.euid });
|
|
4242
6292
|
function serverUrl(ctx, flag, cfg, creds) {
|
|
4243
6293
|
const v = flag ?? ctx.env.CLAW_URL ?? cfg?.server ?? creds?.server ?? null;
|
|
4244
6294
|
return v ? secureUrl(v.trim(), "the Claw server URL") : null;
|
|
4245
6295
|
}
|
|
4246
6296
|
async function main(argv, ctx) {
|
|
4247
6297
|
const [cmd, ...rest] = argv;
|
|
6298
|
+
useAdapterStore(new AdapterStore(adaptersDir(ctx.dirs.state), { autoInstall: ctx.installAdapters === true }));
|
|
4248
6299
|
try {
|
|
4249
6300
|
switch (cmd) {
|
|
4250
6301
|
case void 0:
|
|
@@ -4269,11 +6320,16 @@ async function main(argv, ctx) {
|
|
|
4269
6320
|
return code === EXIT.needsPerson && ctx.env.CLAW_CONNECTOR_SUPERVISOR === "launchd" ? EXIT.ok : code;
|
|
4270
6321
|
}
|
|
4271
6322
|
case "service":
|
|
6323
|
+
if (rest[0] === "install") await installAdapters(ctx, false);
|
|
4272
6324
|
return await cmdService(rest, ctx, serviceHelpers);
|
|
4273
6325
|
case "supervise":
|
|
4274
6326
|
return await cmdSupervise(rest, ctx);
|
|
4275
6327
|
case "status":
|
|
4276
6328
|
return cmdStatus(rest, ctx);
|
|
6329
|
+
case "doctor":
|
|
6330
|
+
return await cmdDoctor(rest, ctx);
|
|
6331
|
+
case "health-check":
|
|
6332
|
+
return await cmdHealthCheck(ctx, ctx.stdin ?? readStdin);
|
|
4277
6333
|
case "pause":
|
|
4278
6334
|
return await cmdPause(ctx);
|
|
4279
6335
|
case "resume":
|
|
@@ -4313,16 +6369,16 @@ function parseApproval(values) {
|
|
|
4313
6369
|
return out;
|
|
4314
6370
|
}
|
|
4315
6371
|
async function cmdInit(args, ctx) {
|
|
4316
|
-
const { values } = parseArgs2({ args, strict: true, options: { approval: { type: "string", multiple: true }, "no-hooks": { type: "boolean" } } });
|
|
6372
|
+
const { values } = parseArgs2({ args, strict: true, options: { approval: { type: "string", multiple: true }, "no-hooks": { type: "boolean" }, "no-probe": { type: "boolean" } } });
|
|
4317
6373
|
const approval = parseApproval(values.approval ?? []);
|
|
4318
6374
|
if (typeof approval === "string") {
|
|
4319
6375
|
ctx.err(approval);
|
|
4320
6376
|
return EXIT.usage;
|
|
4321
6377
|
}
|
|
4322
6378
|
const file = configFile(ctx.dirs);
|
|
4323
|
-
|
|
6379
|
+
fs19.mkdirSync(path21.dirname(file), { recursive: true, mode: 448 });
|
|
4324
6380
|
try {
|
|
4325
|
-
|
|
6381
|
+
fs19.writeFileSync(file, sampleConfig(approval), { mode: 384, flag: "wx" });
|
|
4326
6382
|
ctx.out(`wrote ${file}: edit the roots (the directories an agent may work in), then run: claw-connector login`);
|
|
4327
6383
|
const { unattended, parked } = sampleApprovals(approval);
|
|
4328
6384
|
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.`);
|
|
@@ -4331,13 +6387,37 @@ async function cmdInit(args, ctx) {
|
|
|
4331
6387
|
if (e.code !== "EEXIST") throw e;
|
|
4332
6388
|
ctx.out(`${file} already exists; left alone${values.approval?.length ? ` (to change an agent's approval, set approval = "ask" or "never" in its [[agents]] table)` : ""}`);
|
|
4333
6389
|
}
|
|
6390
|
+
const where = ephemeralWhere(ctx.dirs.config);
|
|
6391
|
+
if (where) ctx.out(`note: ${where}`);
|
|
4334
6392
|
if (!values["no-hooks"]) {
|
|
4335
6393
|
const dir = defaultClaudeDir(ctx);
|
|
4336
|
-
if (!
|
|
6394
|
+
if (!fs19.existsSync(dir)) ctx.out(`no Claude Code directory at ${dir}; once you use Claude Code, run: claw-connector hooks install`);
|
|
4337
6395
|
else installForInit(ctx, dir);
|
|
4338
6396
|
}
|
|
6397
|
+
await installAdapters(ctx, true);
|
|
6398
|
+
if (ctx.probe && !values["no-probe"]) {
|
|
6399
|
+
try {
|
|
6400
|
+
const cfg = loadCfg(ctx);
|
|
6401
|
+
ctx.out("checking each agent's adapter (claw-connector doctor; skip with --no-probe):");
|
|
6402
|
+
for (const l of probeLines(await ctx.probe(cfg.agents, { logDir: logDir(ctx.dirs), version: VERSION }))) ctx.out(l);
|
|
6403
|
+
} catch (e) {
|
|
6404
|
+
ctx.out(`could not check the agents yet: ${errMessage(e)}; after editing agents.toml run: claw-connector doctor`);
|
|
6405
|
+
}
|
|
6406
|
+
}
|
|
4339
6407
|
return EXIT.ok;
|
|
4340
6408
|
}
|
|
6409
|
+
async function installAdapters(ctx, initFallback) {
|
|
6410
|
+
const store2 = adapterStore();
|
|
6411
|
+
if (!store2 || !ctx.installAdapters) return;
|
|
6412
|
+
let agents;
|
|
6413
|
+
try {
|
|
6414
|
+
agents = loadCfg(ctx).agents;
|
|
6415
|
+
} catch {
|
|
6416
|
+
if (!initFallback) return;
|
|
6417
|
+
agents = SAMPLE_ACTIVE.map((id) => ({ command: PRESETS[id].command, args: PRESETS[id].args, env: {} }));
|
|
6418
|
+
}
|
|
6419
|
+
await installAll(store2, agents, ctx.env, (l) => ctx.out(`adapters: ${l}`));
|
|
6420
|
+
}
|
|
4341
6421
|
function installForInit(ctx, dir) {
|
|
4342
6422
|
try {
|
|
4343
6423
|
ctx.out(describeInstall(dir, installOne(ctx, dir).changed));
|
|
@@ -4345,19 +6425,19 @@ function installForInit(ctx, dir) {
|
|
|
4345
6425
|
ctx.err(`hooks: ${errMessage(e)} (everything else is set up; fix it and run: claw-connector hooks install)`);
|
|
4346
6426
|
}
|
|
4347
6427
|
}
|
|
4348
|
-
var defaultClaudeDir = (ctx) =>
|
|
6428
|
+
var defaultClaudeDir = (ctx) => path21.resolve(ctx.env.CLAUDE_CONFIG_DIR?.trim() || path21.join(os9.homedir(), ".claude"));
|
|
4349
6429
|
function claudeDirs(ctx, flag) {
|
|
4350
|
-
if (flag !== void 0) return [
|
|
6430
|
+
if (flag !== void 0) return [path21.resolve(flag)];
|
|
4351
6431
|
let dirs = [];
|
|
4352
6432
|
try {
|
|
4353
|
-
dirs = loadCfg(ctx).agents.filter((a) => a.attach).map((a) =>
|
|
6433
|
+
dirs = loadCfg(ctx).agents.filter((a) => a.attach).map((a) => path21.resolve(a.env.CLAUDE_CONFIG_DIR ?? ctx.env.CLAUDE_CONFIG_DIR?.trim() ?? path21.join(os9.homedir(), ".claude")));
|
|
4354
6434
|
} catch {
|
|
4355
6435
|
}
|
|
4356
6436
|
return dirs.length ? [...new Set(dirs)] : [defaultClaudeDir(ctx)];
|
|
4357
6437
|
}
|
|
4358
6438
|
function hookScript(ctx) {
|
|
4359
6439
|
const script = ctx.hookScript ?? fileURLToPath2(new URL("./terminal-hook.js", import.meta.url));
|
|
4360
|
-
if (!
|
|
6440
|
+
if (!fs19.existsSync(script)) throw new HooksError(`${script} does not exist (a source checkout needs "npm run build" first)`);
|
|
4361
6441
|
return script;
|
|
4362
6442
|
}
|
|
4363
6443
|
function installOne(ctx, dir) {
|
|
@@ -4377,7 +6457,7 @@ function cmdHooks(args, ctx) {
|
|
|
4377
6457
|
for (const dir of dirs) {
|
|
4378
6458
|
try {
|
|
4379
6459
|
if (sub === "install") {
|
|
4380
|
-
if (values["config-dir"] === void 0 && !
|
|
6460
|
+
if (values["config-dir"] === void 0 && !fs19.existsSync(dir)) {
|
|
4381
6461
|
ctx.err(`no Claude Code directory at ${dir}; pass --config-dir to name one`);
|
|
4382
6462
|
code = EXIT.failure;
|
|
4383
6463
|
continue;
|
|
@@ -4402,11 +6482,11 @@ function forgetRevokedIdentity(ctx) {
|
|
|
4402
6482
|
const file = identityFile(ctx.dirs);
|
|
4403
6483
|
let revoked = false;
|
|
4404
6484
|
try {
|
|
4405
|
-
const raw = JSON.parse(
|
|
6485
|
+
const raw = JSON.parse(fs19.readFileSync(file, "utf8"));
|
|
4406
6486
|
revoked = isObject(raw) && raw.revoked === true;
|
|
4407
6487
|
} catch {
|
|
4408
6488
|
}
|
|
4409
|
-
if (revoked)
|
|
6489
|
+
if (revoked) fs19.rmSync(file, { force: true });
|
|
4410
6490
|
}
|
|
4411
6491
|
async function cmdLogin(args, ctx) {
|
|
4412
6492
|
const { values } = parseArgs2({
|
|
@@ -4441,7 +6521,7 @@ async function cmdLogin(args, ctx) {
|
|
|
4441
6521
|
ctx.err("login needs --identity-url <identity service URL> (or CLAW_IDENTITY_URL, or identity_url = in agents.toml)");
|
|
4442
6522
|
return EXIT.usage;
|
|
4443
6523
|
}
|
|
4444
|
-
const deviceName = values.name ?? cfg?.name ?? ctx.hostname ??
|
|
6524
|
+
const deviceName = values.name ?? cfg?.name ?? ctx.hostname ?? os9.hostname();
|
|
4445
6525
|
const tok = await deviceLogin({ identityUrl, deviceName, fetch: ctx.fetch, print: ctx.out });
|
|
4446
6526
|
saveCredentials(file, {
|
|
4447
6527
|
v: 1,
|
|
@@ -4461,14 +6541,14 @@ async function cmdLogin(args, ctx) {
|
|
|
4461
6541
|
}
|
|
4462
6542
|
function runningPid(ctx) {
|
|
4463
6543
|
try {
|
|
4464
|
-
const raw = JSON.parse(
|
|
6544
|
+
const raw = JSON.parse(fs19.readFileSync(pidFile(ctx.dirs), "utf8"));
|
|
4465
6545
|
if (isObject(raw) && typeof raw.pid === "number" && typeof raw.start === "string" && raw.start !== "" && processStart(raw.pid) === raw.start) return raw.pid;
|
|
4466
6546
|
} catch {
|
|
4467
6547
|
}
|
|
4468
6548
|
return null;
|
|
4469
6549
|
}
|
|
4470
6550
|
async function cmdRun(args, ctx) {
|
|
4471
|
-
parseArgs2({ args, strict: true, options: { supervised: { type: "boolean" } } });
|
|
6551
|
+
const { values: runFlags } = parseArgs2({ args, strict: true, options: { supervised: { type: "boolean" } } });
|
|
4472
6552
|
{
|
|
4473
6553
|
const nl = needsLogin(ctx.dirs);
|
|
4474
6554
|
if (nl) {
|
|
@@ -4478,8 +6558,9 @@ async function cmdRun(args, ctx) {
|
|
|
4478
6558
|
}
|
|
4479
6559
|
let cfg;
|
|
4480
6560
|
let creds;
|
|
6561
|
+
const notes = [];
|
|
4481
6562
|
try {
|
|
4482
|
-
cfg = loadCfg(ctx);
|
|
6563
|
+
cfg = loadCfg(ctx, notes);
|
|
4483
6564
|
creds = loadCredentials(credentialsFile(ctx.dirs));
|
|
4484
6565
|
} catch (e) {
|
|
4485
6566
|
if (e instanceof ConfigError || e instanceof CredentialsError) {
|
|
@@ -4506,10 +6587,16 @@ async function cmdRun(args, ctx) {
|
|
|
4506
6587
|
const client2 = new ClawClient({ server, tokens, fetch: ctx.fetch });
|
|
4507
6588
|
const log = stderrLogger();
|
|
4508
6589
|
const audit = fileAudit(auditFile(ctx.dirs));
|
|
4509
|
-
const connector2 = new Connector({ config: cfg, dirs: ctx.dirs, client: client2, server, audit, log, version: VERSION, timing: ctx.timing });
|
|
6590
|
+
const connector2 = new Connector({ config: cfg, dirs: ctx.dirs, client: client2, server, audit, log, version: VERSION, timing: ctx.timing, health: runHealth(ctx, cfg) });
|
|
6591
|
+
if (ctx.installAdapters) for (const { pkg, env } of pinnedOf(cfg.agents, ctx.env)) void adapterStore()?.install(pkg, env, (l) => ctx.out(`adapters: ${l}`));
|
|
4510
6592
|
ctx.onConnector?.(connector2);
|
|
4511
6593
|
ctx.out(`connecting ${cfg.agents.map((a) => a.unattended ? `${a.id} (no per-step approval)` : a.id).join(", ")} to ${server} as "${cfg.name}"`);
|
|
6594
|
+
for (const n of notes) ctx.out(`note: ${n}`);
|
|
4512
6595
|
for (const w of noAsk(ctx, cfg)) ctx.out(`warning: ${w}`);
|
|
6596
|
+
for (const a of cfg.agents) if (a.readOnlyModes.includes(a.askMode)) ctx.out(`note: ${a.id}: its sessions start read-only (${a.askMode} mode changes no file); to let phone sessions write code, set approval = "never" on it in agents.toml`);
|
|
6597
|
+
void rootProblems(cfg.agents).then((ps) => {
|
|
6598
|
+
for (const p of ps) ctx.out(`warning: ${p}`);
|
|
6599
|
+
}, () => void 0);
|
|
4513
6600
|
try {
|
|
4514
6601
|
const result = await connector2.run();
|
|
4515
6602
|
if (result === "revoked") {
|
|
@@ -4522,6 +6609,10 @@ async function cmdRun(args, ctx) {
|
|
|
4522
6609
|
ctx.err(`Claw keeps refusing this sign-in (${connector2.authFailure}): every agent was stopped. Run login again.`);
|
|
4523
6610
|
return EXIT.needsPerson;
|
|
4524
6611
|
}
|
|
6612
|
+
if (runFlags.supervised && !takeStopRequest(ctx)) {
|
|
6613
|
+
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`);
|
|
6614
|
+
return EXIT.terminated;
|
|
6615
|
+
}
|
|
4525
6616
|
return EXIT.ok;
|
|
4526
6617
|
} catch (e) {
|
|
4527
6618
|
if (isAuthRefusal(e) || e instanceof RevokedError || e instanceof GrantRevokedError || e instanceof CredentialsError) {
|
|
@@ -4536,6 +6627,94 @@ async function cmdRun(args, ctx) {
|
|
|
4536
6627
|
throw e;
|
|
4537
6628
|
}
|
|
4538
6629
|
}
|
|
6630
|
+
async function cmdDoctor(args, ctx) {
|
|
6631
|
+
const { values } = parseArgs2({ args, strict: true, options: { agent: { type: "string" }, json: { type: "boolean" } } });
|
|
6632
|
+
const before = [];
|
|
6633
|
+
const notes = [];
|
|
6634
|
+
let cfg = null;
|
|
6635
|
+
let creds = null;
|
|
6636
|
+
try {
|
|
6637
|
+
cfg = loadCfg(ctx, notes);
|
|
6638
|
+
} catch (e) {
|
|
6639
|
+
const missing = !fs19.existsSync(configFile(ctx.dirs));
|
|
6640
|
+
const roots = e instanceof ConfigError ? e.problems.map((p) => /^agent (\S+): root (.+) does not exist$/.exec(p)) : [];
|
|
6641
|
+
if (roots.length && roots.every((m) => m !== null)) {
|
|
6642
|
+
for (const m of roots) before.push({ code: "root_missing", severity: "fail", agent: m[1], detail: `${m[2]} (a root of agent ${m[1]}) does not exist`, fix: `mkdir -p ${shq(m[2])} (or change roots in ${configFile(ctx.dirs)})` });
|
|
6643
|
+
} else before.push({ code: "config_invalid", severity: "fail", detail: errMessage(e), fix: missing ? "claw-connector init" : `edit ${configFile(ctx.dirs)}` });
|
|
6644
|
+
}
|
|
6645
|
+
const agents = cfg ? values.agent ? cfg.agents.filter((a) => a.id === values.agent) : cfg.agents : [];
|
|
6646
|
+
if (cfg && agents.length === 0) {
|
|
6647
|
+
if (values.agent) {
|
|
6648
|
+
ctx.err(`no agent ${JSON.stringify(values.agent)} in agents.toml`);
|
|
6649
|
+
return EXIT.failure;
|
|
6650
|
+
}
|
|
6651
|
+
before.push({ code: "config_invalid", severity: "fail", detail: "agents.toml defines no agents", fix: `add an [[agents]] table to ${configFile(ctx.dirs)} (claw-connector init writes a sample)` });
|
|
6652
|
+
}
|
|
6653
|
+
try {
|
|
6654
|
+
creds = loadCredentials(credentialsFile(ctx.dirs));
|
|
6655
|
+
} catch (e) {
|
|
6656
|
+
before.push({ code: "not_signed_in", severity: "fail", detail: errMessage(e), fix: "claw-connector login" });
|
|
6657
|
+
}
|
|
6658
|
+
if (!creds && !before.some((p) => p.code === "not_signed_in")) before.push({ code: "not_signed_in", severity: "fail", detail: "this connector is not signed in to Claw", fix: "claw-connector login" });
|
|
6659
|
+
const nl = needsLogin(ctx.dirs);
|
|
6660
|
+
if (nl) before.push({ code: "needs_login", severity: "fail", detail: `needs a new sign-in: ${nl.reason}`, fix: "claw-connector login" });
|
|
6661
|
+
let server = null;
|
|
6662
|
+
try {
|
|
6663
|
+
server = serverUrl(ctx, void 0, cfg, creds);
|
|
6664
|
+
} catch {
|
|
6665
|
+
}
|
|
6666
|
+
const report = await checkAll({ dirs: ctx.dirs, agents, by: "doctor", server, before }, ctx.doctor ?? defaultDeps(ctx.env));
|
|
6667
|
+
writeReport(ctx.dirs, report);
|
|
6668
|
+
const warnings = cfg ? noAsk(ctx, { ...cfg, agents }) : [];
|
|
6669
|
+
const results = agents.length ? redactDeep(await (ctx.probe ?? probeAgents)(agents, { logDir: logDir(ctx.dirs), version: VERSION })) : [];
|
|
6670
|
+
const adaptersOk = results.every((r) => r.ok);
|
|
6671
|
+
const machineOk = !report.problems.some((p) => p.severity === "fail");
|
|
6672
|
+
if (values.json) {
|
|
6673
|
+
ctx.out(JSON.stringify({ version: VERSION, ok: adaptersOk, machine_ok: machineOk, notes, warnings, agents: results, environment: report }, null, 2));
|
|
6674
|
+
} else {
|
|
6675
|
+
ctx.out(`claw-connector ${VERSION} doctor${cfg ? ` (${cfg.name})` : ""}`);
|
|
6676
|
+
for (const l of formatReport(report)) ctx.out(l);
|
|
6677
|
+
if (results.length) {
|
|
6678
|
+
ctx.out("");
|
|
6679
|
+
ctx.out("each agent's adapter (a session opened without a prompt):");
|
|
6680
|
+
for (const n of notes) ctx.out(`note: ${n}`);
|
|
6681
|
+
for (const w of warnings) ctx.out(`warning: ${w}`);
|
|
6682
|
+
for (const l of probeLines(results)) ctx.out(l);
|
|
6683
|
+
}
|
|
6684
|
+
}
|
|
6685
|
+
const running = runningPid(ctx);
|
|
6686
|
+
if (running !== null) {
|
|
6687
|
+
try {
|
|
6688
|
+
(ctx.askRecheck ?? ((pid) => process.kill(pid, "SIGWINCH")))(running);
|
|
6689
|
+
if (!values.json) ctx.out("The running connector checks the machine again now and shows the result in the chat.");
|
|
6690
|
+
} catch {
|
|
6691
|
+
}
|
|
6692
|
+
}
|
|
6693
|
+
return adaptersOk && machineOk ? EXIT.ok : EXIT.failure;
|
|
6694
|
+
}
|
|
6695
|
+
var HEALTH_CHILD_MS = 6e4;
|
|
6696
|
+
function runHealth(ctx, cfg) {
|
|
6697
|
+
if (ctx.doctor) return () => checkAll({ dirs: ctx.dirs, agents: cfg.agents, by: "run" }, ctx.doctor);
|
|
6698
|
+
const cli = ctx.cli ?? ctx.service?.cli ?? fileURLToPath2(import.meta.url);
|
|
6699
|
+
const node = ctx.node ?? process.execPath;
|
|
6700
|
+
return async (signal) => {
|
|
6701
|
+
const r = await runProbe(node, [cli, "health-check"], ctx.env, signal, HEALTH_CHILD_MS, JSON.stringify({ agents: cfg.agents }));
|
|
6702
|
+
const v = JSON.parse(r.out.slice(r.out.indexOf("{")));
|
|
6703
|
+
if (!isObject(v) || typeof v.checked_at !== "string" || !Array.isArray(v.problems)) throw new Error(`health-check answered ${r.code === null ? "nothing in time" : `exit ${r.code}`}`);
|
|
6704
|
+
return v;
|
|
6705
|
+
};
|
|
6706
|
+
}
|
|
6707
|
+
async function cmdHealthCheck(ctx, stdin) {
|
|
6708
|
+
const v = JSON.parse(await stdin());
|
|
6709
|
+
const agents = isObject(v) && Array.isArray(v.agents) ? v.agents : [];
|
|
6710
|
+
ctx.out(JSON.stringify(await checkAll({ dirs: ctx.dirs, agents, by: "run" }, defaultDeps(ctx.env, { ownGroup: false }))));
|
|
6711
|
+
return EXIT.ok;
|
|
6712
|
+
}
|
|
6713
|
+
async function readStdin() {
|
|
6714
|
+
const chunks = [];
|
|
6715
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
6716
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
6717
|
+
}
|
|
4539
6718
|
function cmdStatus(args, ctx) {
|
|
4540
6719
|
const { values } = parseArgs2({ args, strict: true, options: { json: { type: "boolean" } } });
|
|
4541
6720
|
let creds = null;
|
|
@@ -4559,7 +6738,7 @@ function cmdStatus(args, ctx) {
|
|
|
4559
6738
|
}
|
|
4560
6739
|
let connectorId = null;
|
|
4561
6740
|
try {
|
|
4562
|
-
const raw = JSON.parse(
|
|
6741
|
+
const raw = JSON.parse(fs19.readFileSync(identityFile(ctx.dirs), "utf8"));
|
|
4563
6742
|
if (isObject(raw) && typeof raw.connector_id === "string") connectorId = raw.connector_id;
|
|
4564
6743
|
} catch {
|
|
4565
6744
|
}
|
|
@@ -4567,7 +6746,7 @@ function cmdStatus(args, ctx) {
|
|
|
4567
6746
|
const status2 = {
|
|
4568
6747
|
version: VERSION,
|
|
4569
6748
|
running: runningPid(ctx),
|
|
4570
|
-
paused:
|
|
6749
|
+
paused: fs19.existsSync(pauseFile(ctx.dirs)),
|
|
4571
6750
|
signed_in: creds ? { kind: creds.kind, server: creds.server, device_name: creds.device_name ?? null } : null,
|
|
4572
6751
|
connector_id: connectorId,
|
|
4573
6752
|
agents,
|
|
@@ -4579,7 +6758,9 @@ function cmdStatus(args, ctx) {
|
|
|
4579
6758
|
warnings,
|
|
4580
6759
|
/** Why the connection last went down, and whether only a new sign-in can bring it back. */
|
|
4581
6760
|
last_disconnect: recentDisconnects(ctx.dirs, 1)[0] ?? null,
|
|
4582
|
-
needs_login: needsLogin(ctx.dirs)
|
|
6761
|
+
needs_login: needsLogin(ctx.dirs),
|
|
6762
|
+
/** The last `doctor` report's problems (null: doctor never ran). */
|
|
6763
|
+
doctor: lastDoctor(ctx)
|
|
4583
6764
|
};
|
|
4584
6765
|
if (values.json) {
|
|
4585
6766
|
ctx.out(JSON.stringify(status2, null, 2));
|
|
@@ -4593,11 +6774,22 @@ function cmdStatus(args, ctx) {
|
|
|
4593
6774
|
if (status2.needs_login) ctx.out(`problem: needs a new sign-in: ${status2.needs_login.reason}; run: claw-connector login`);
|
|
4594
6775
|
for (const p of status2.problems) ctx.out(`problem: ${p}`);
|
|
4595
6776
|
for (const w of status2.warnings) ctx.out(`warning: ${w}`);
|
|
6777
|
+
if (status2.doctor) ctx.out(`doctor: ${status2.doctor.problems.length ? `${status2.doctor.problems.length} problem(s) at ${status2.doctor.checked_at}; run: claw-connector doctor` : `no problems at ${status2.doctor.checked_at}`}`);
|
|
6778
|
+
else ctx.out("doctor: never run; run: claw-connector doctor");
|
|
4596
6779
|
return EXIT.ok;
|
|
4597
6780
|
}
|
|
6781
|
+
function lastDoctor(ctx) {
|
|
6782
|
+
try {
|
|
6783
|
+
const raw = JSON.parse(fs19.readFileSync(doctorFile(ctx.dirs), "utf8"));
|
|
6784
|
+
if (!isObject(raw) || typeof raw.checked_at !== "string" || !Array.isArray(raw.problems)) return null;
|
|
6785
|
+
return { checked_at: raw.checked_at, problems: raw.problems.filter(isObject).map((p) => ({ code: String(p.code), ...typeof p.agent === "string" ? { agent: p.agent } : {}, fix: String(p.fix) })) };
|
|
6786
|
+
} catch {
|
|
6787
|
+
return null;
|
|
6788
|
+
}
|
|
6789
|
+
}
|
|
4598
6790
|
async function cmdPause(ctx) {
|
|
4599
|
-
|
|
4600
|
-
|
|
6791
|
+
fs19.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
|
|
6792
|
+
fs19.writeFileSync(pauseFile(ctx.dirs), (/* @__PURE__ */ new Date()).toISOString() + "\n", { mode: 384 });
|
|
4601
6793
|
if (runningPid(ctx) === null) {
|
|
4602
6794
|
const left = await new ChildRegistry(childrenFile(ctx.dirs)).killAll();
|
|
4603
6795
|
ctx.out(`paused (the connector is not running${left.killed.length ? `; stopped ${left.killed.length} leftover agent process group(s)` : ""})`);
|
|
@@ -4607,13 +6799,15 @@ async function cmdPause(ctx) {
|
|
|
4607
6799
|
return EXIT.ok;
|
|
4608
6800
|
}
|
|
4609
6801
|
function cmdResume(ctx) {
|
|
4610
|
-
|
|
6802
|
+
fs19.rmSync(pauseFile(ctx.dirs), { force: true });
|
|
4611
6803
|
ctx.out(runningPid(ctx) === null ? "resumed; start it with: claw-connector run" : "resumed: the running connector reconnects now");
|
|
4612
6804
|
return EXIT.ok;
|
|
4613
6805
|
}
|
|
4614
6806
|
async function cmdStop(ctx, quiet = false) {
|
|
4615
6807
|
const pid = runningPid(ctx);
|
|
4616
6808
|
if (pid !== null) {
|
|
6809
|
+
fs19.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
|
|
6810
|
+
fs19.writeFileSync(stopRequestFile(ctx.dirs), JSON.stringify({ pid, start: processStart(pid) ?? "", at: (/* @__PURE__ */ new Date()).toISOString() }), { mode: 384 });
|
|
4617
6811
|
process.kill(pid, "SIGTERM");
|
|
4618
6812
|
const deadline = Date.now() + 15e3;
|
|
4619
6813
|
while (Date.now() < deadline && isAlive(pid) && processStart(pid) !== null) await sleep(100);
|
|
@@ -4621,14 +6815,27 @@ async function cmdStop(ctx, quiet = false) {
|
|
|
4621
6815
|
process.kill(pid, "SIGKILL");
|
|
4622
6816
|
if (!quiet) ctx.out(`the connector (pid ${pid}) did not stop in 15 s and was killed`);
|
|
4623
6817
|
}
|
|
6818
|
+
fs19.rmSync(stopRequestFile(ctx.dirs), { force: true });
|
|
4624
6819
|
}
|
|
4625
6820
|
const left = await new ChildRegistry(childrenFile(ctx.dirs)).killAll();
|
|
4626
6821
|
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
6822
|
return EXIT.ok;
|
|
4628
6823
|
}
|
|
6824
|
+
function takeStopRequest(ctx) {
|
|
6825
|
+
const file = stopRequestFile(ctx.dirs);
|
|
6826
|
+
let mine = false;
|
|
6827
|
+
try {
|
|
6828
|
+
const raw = JSON.parse(fs19.readFileSync(file, "utf8"));
|
|
6829
|
+
mine = isObject(raw) && raw.pid === process.pid && raw.start === (processStart(process.pid) ?? "");
|
|
6830
|
+
} catch {
|
|
6831
|
+
return false;
|
|
6832
|
+
}
|
|
6833
|
+
if (mine) fs19.rmSync(file, { force: true });
|
|
6834
|
+
return mine;
|
|
6835
|
+
}
|
|
4629
6836
|
function storedConnector(ctx) {
|
|
4630
6837
|
try {
|
|
4631
|
-
const raw = JSON.parse(
|
|
6838
|
+
const raw = JSON.parse(fs19.readFileSync(identityFile(ctx.dirs), "utf8"));
|
|
4632
6839
|
if (isObject(raw) && typeof raw.server === "string" && typeof raw.connector_id === "string") {
|
|
4633
6840
|
return { server: raw.server, connector_id: raw.connector_id, revoked: raw.revoked === true };
|
|
4634
6841
|
}
|
|
@@ -4700,8 +6907,8 @@ async function cmdLogout(ctx) {
|
|
|
4700
6907
|
ctx.err(`could not revoke the sign-in at the identity service (${errMessage(e)}); it was removed from this machine`);
|
|
4701
6908
|
}
|
|
4702
6909
|
}
|
|
4703
|
-
|
|
4704
|
-
if (rowGone)
|
|
6910
|
+
fs19.rmSync(file, { force: true });
|
|
6911
|
+
if (rowGone) fs19.rmSync(identityFile(ctx.dirs), { force: true });
|
|
4705
6912
|
else forgetRevokedIdentity(ctx);
|
|
4706
6913
|
if (row && notRevoked !== null) {
|
|
4707
6914
|
ctx.err(`signed out on this machine, but connector ${row.connector_id} was NOT revoked at Claw (${notRevoked}).`);
|
|
@@ -4739,12 +6946,16 @@ var onSignal = () => {
|
|
|
4739
6946
|
};
|
|
4740
6947
|
main(process.argv.slice(2), {
|
|
4741
6948
|
dirs: resolveDirs(),
|
|
6949
|
+
cli: fileURLToPath3(import.meta.url),
|
|
4742
6950
|
env: process.env,
|
|
4743
6951
|
out: (l) => process.stdout.write(l + "\n"),
|
|
4744
6952
|
err: (l) => process.stderr.write(l + "\n"),
|
|
6953
|
+
probe: probeAgents,
|
|
6954
|
+
installAdapters: true,
|
|
4745
6955
|
onConnector: (c) => {
|
|
4746
6956
|
connector = c;
|
|
4747
6957
|
for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) process.on(sig, onSignal);
|
|
6958
|
+
process.on("SIGWINCH", () => c.recheckHealth());
|
|
4748
6959
|
}
|
|
4749
6960
|
}).then((code) => process.exit(code), (e) => {
|
|
4750
6961
|
process.stderr.write(`fatal: ${e instanceof Error ? e.stack : String(e)}
|