@primusclaw/connector 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -17
- package/dist/cli.js +1344 -381
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,16 +1,297 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/commands.ts
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import
|
|
4
|
+
import fs18 from "node:fs";
|
|
5
|
+
import os7 from "node:os";
|
|
6
|
+
import path19 from "node:path";
|
|
7
7
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
8
8
|
import { parseArgs as parseArgs2 } from "node:util";
|
|
9
9
|
|
|
10
|
-
// src/
|
|
10
|
+
// src/adapter-install.ts
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
11
12
|
import fs from "node:fs";
|
|
12
13
|
import path from "node:path";
|
|
13
14
|
|
|
15
|
+
// src/util.ts
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
var sleep = (ms, signal) => new Promise((resolve) => {
|
|
18
|
+
if (signal?.aborted) return resolve();
|
|
19
|
+
const t = setTimeout(done, ms);
|
|
20
|
+
function done() {
|
|
21
|
+
signal?.removeEventListener("abort", done);
|
|
22
|
+
clearTimeout(t);
|
|
23
|
+
resolve();
|
|
24
|
+
}
|
|
25
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
26
|
+
});
|
|
27
|
+
var sha256 = (s) => createHash("sha256").update(s).digest("hex");
|
|
28
|
+
var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
29
|
+
var errMessage = (e) => e instanceof Error ? e.message : String(e);
|
|
30
|
+
function truncateBytes(text, max) {
|
|
31
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
32
|
+
if (bytes <= max) return { text, truncated: false, bytes };
|
|
33
|
+
let cut = Buffer.from(text, "utf8").subarray(0, max).toString("utf8");
|
|
34
|
+
if (cut.endsWith("\uFFFD")) cut = cut.slice(0, -1);
|
|
35
|
+
return { text: `${cut}
|
|
36
|
+
[truncated, ${bytes} bytes in all]`, truncated: true, bytes };
|
|
37
|
+
}
|
|
38
|
+
function backoffMs(attempt, minMs, maxMs, random = Math.random) {
|
|
39
|
+
const raw = Math.min(maxMs, minMs * 2 ** Math.min(attempt, 30));
|
|
40
|
+
return Math.round(raw * (0.5 + random() / 2));
|
|
41
|
+
}
|
|
42
|
+
var stderrLogger = (quiet = false) => {
|
|
43
|
+
const w = (level) => (msg, extra) => {
|
|
44
|
+
if (quiet && level === "info") return;
|
|
45
|
+
process.stderr.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${level} ${msg}${extra ? " " + JSON.stringify(extra) : ""}
|
|
46
|
+
`);
|
|
47
|
+
};
|
|
48
|
+
return { info: w("info"), warn: w("warn"), error: w("error") };
|
|
49
|
+
};
|
|
50
|
+
var UrlError = class extends Error {
|
|
51
|
+
};
|
|
52
|
+
function secureUrl(raw, what) {
|
|
53
|
+
let u;
|
|
54
|
+
try {
|
|
55
|
+
u = new URL(raw);
|
|
56
|
+
} catch {
|
|
57
|
+
throw new UrlError(`${what} is not a valid URL: ${JSON.stringify(raw.slice(0, 80))}`);
|
|
58
|
+
}
|
|
59
|
+
const loopback = u.hostname === "localhost" || u.hostname === "[::1]" || /^127(\.\d{1,3}){3}$/.test(u.hostname);
|
|
60
|
+
if (u.protocol !== "https:" && !(u.protocol === "http:" && loopback)) {
|
|
61
|
+
throw new UrlError(`${what} must be an https:// URL (http:// is accepted only for localhost), got ${u.protocol}//${u.host}`);
|
|
62
|
+
}
|
|
63
|
+
return raw.replace(/\/+$/, "");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// src/adapter-install.ts
|
|
67
|
+
var PINNED_RE = /^((?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*)@(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)$/;
|
|
68
|
+
var INSTALL_WAIT_MS = 5 * 6e4;
|
|
69
|
+
var INSTALL_TIMEOUT_MS = 15 * 6e4;
|
|
70
|
+
var RETRY_AFTER_MS = 10 * 6e4;
|
|
71
|
+
function pinnedPackage(cfg) {
|
|
72
|
+
const base = path.basename(cfg.command).replace(/\.(cmd|exe)$/i, "");
|
|
73
|
+
if (base !== "npx") return null;
|
|
74
|
+
const [flag, spec, ...rest] = cfg.args;
|
|
75
|
+
if (flag !== "-y" && flag !== "--yes" || typeof spec !== "string") return null;
|
|
76
|
+
const m = PINNED_RE.exec(spec);
|
|
77
|
+
if (!m) return null;
|
|
78
|
+
return { spec, name: m[1], version: m[2], rest };
|
|
79
|
+
}
|
|
80
|
+
function npmCommand(env) {
|
|
81
|
+
const exe = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
82
|
+
for (const d of (env.PATH ?? "").split(path.delimiter)) if (d && isExecutable(path.join(d, exe))) return exe;
|
|
83
|
+
const sibling = path.join(path.dirname(process.execPath), exe);
|
|
84
|
+
return isExecutable(sibling) ? sibling : exe;
|
|
85
|
+
}
|
|
86
|
+
function isExecutable(f) {
|
|
87
|
+
try {
|
|
88
|
+
fs.accessSync(f, fs.constants.X_OK);
|
|
89
|
+
return fs.statSync(f).isFile();
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
var npmInstaller = ({ dir, spec, env, timeoutMs }) => new Promise((resolve, reject) => {
|
|
95
|
+
const args = ["install", "--prefix", dir, "--no-save", "--no-package-lock", "--no-audit", "--no-fund", "--omit=dev", "--loglevel=error", spec];
|
|
96
|
+
let out = "";
|
|
97
|
+
const p = spawn(npmCommand(env), args, { cwd: dir, env, stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32", timeout: timeoutMs, killSignal: "SIGKILL" });
|
|
98
|
+
const keep = (d) => {
|
|
99
|
+
out = (out + d.toString("utf8")).slice(-2e3);
|
|
100
|
+
};
|
|
101
|
+
p.stdout.on("data", keep);
|
|
102
|
+
p.stderr.on("data", keep);
|
|
103
|
+
p.once("error", (e) => reject(new Error(`could not run npm: ${errMessage(e)}`)));
|
|
104
|
+
p.once("exit", (code, signal) => {
|
|
105
|
+
if (code === 0) resolve();
|
|
106
|
+
else reject(new Error(`npm install ${spec} ${signal ? `was killed (${signal})` : `exited ${code}`}${out.trim() ? `: ${out.trim().split("\n").slice(-3).join(" | ")}` : ""}`));
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
function binOf(pkgDir, pkg) {
|
|
110
|
+
let raw;
|
|
111
|
+
try {
|
|
112
|
+
raw = JSON.parse(fs.readFileSync(path.join(pkgDir, "package.json"), "utf8"));
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
if (!isObject(raw) || raw.version !== pkg.version) return null;
|
|
117
|
+
const short = pkg.name.split("/").pop();
|
|
118
|
+
let rel = null;
|
|
119
|
+
if (typeof raw.bin === "string") rel = raw.bin;
|
|
120
|
+
else if (isObject(raw.bin)) {
|
|
121
|
+
const entries = Object.entries(raw.bin);
|
|
122
|
+
rel = entries.length === 1 ? entries[0][1] : raw.bin[short];
|
|
123
|
+
}
|
|
124
|
+
if (typeof rel !== "string" || !rel) return null;
|
|
125
|
+
const file = path.resolve(pkgDir, rel);
|
|
126
|
+
if (!file.startsWith(pkgDir + path.sep)) return null;
|
|
127
|
+
try {
|
|
128
|
+
return fs.statSync(file).isFile() ? file : null;
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function runByNode(file) {
|
|
134
|
+
let head = "";
|
|
135
|
+
try {
|
|
136
|
+
const fd = fs.openSync(file, "r");
|
|
137
|
+
try {
|
|
138
|
+
const b = Buffer.alloc(128);
|
|
139
|
+
head = b.subarray(0, fs.readSync(fd, b, 0, 128, 0)).toString("utf8");
|
|
140
|
+
} finally {
|
|
141
|
+
fs.closeSync(fd);
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
if (head.startsWith("#!")) return /\bnode\b/.test(head.split("\n")[0]);
|
|
147
|
+
return /\.(c|m)?js$/.test(file);
|
|
148
|
+
}
|
|
149
|
+
var AdapterStore = class {
|
|
150
|
+
constructor(dir, o) {
|
|
151
|
+
this.dir = dir;
|
|
152
|
+
this.o = o;
|
|
153
|
+
this.installer = o.installer ?? npmInstaller;
|
|
154
|
+
}
|
|
155
|
+
dir;
|
|
156
|
+
o;
|
|
157
|
+
pending = /* @__PURE__ */ new Map();
|
|
158
|
+
failedAt = /* @__PURE__ */ new Map();
|
|
159
|
+
installer;
|
|
160
|
+
/** Where a package lives once installed. */
|
|
161
|
+
finalDir(pkg) {
|
|
162
|
+
return path.join(this.dir, `${pkg.name.replace("/", "+")}@${pkg.version}`);
|
|
163
|
+
}
|
|
164
|
+
/** The installed bin script, or null when there is no complete install. */
|
|
165
|
+
installed(pkg) {
|
|
166
|
+
return binOf(path.join(this.finalDir(pkg), "node_modules", ...pkg.name.split("/")), pkg);
|
|
167
|
+
}
|
|
168
|
+
/** Whether an install of this package is running in this process. */
|
|
169
|
+
installing(pkg) {
|
|
170
|
+
return this.pending.has(pkg.spec);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Install `pkg` unless it is there. Never throws: null when it could not be installed (the reason goes to `say`).
|
|
174
|
+
* One install per package at a time in this process; another process installing the same one is harmless.
|
|
175
|
+
*/
|
|
176
|
+
install(pkg, env, say = () => {
|
|
177
|
+
}) {
|
|
178
|
+
const have = this.installed(pkg);
|
|
179
|
+
if (have) return Promise.resolve(have);
|
|
180
|
+
const running = this.pending.get(pkg.spec);
|
|
181
|
+
if (running) return running;
|
|
182
|
+
const p = this.doInstall(pkg, env, say).finally(() => this.pending.delete(pkg.spec));
|
|
183
|
+
this.pending.set(pkg.spec, p);
|
|
184
|
+
return p;
|
|
185
|
+
}
|
|
186
|
+
async doInstall(pkg, env, say) {
|
|
187
|
+
const final = this.finalDir(pkg);
|
|
188
|
+
const started = Date.now();
|
|
189
|
+
let tmp = null;
|
|
190
|
+
try {
|
|
191
|
+
fs.mkdirSync(this.dir, { recursive: true, mode: 448 });
|
|
192
|
+
this.sweepAbandoned(pkg);
|
|
193
|
+
tmp = fs.mkdtempSync(path.join(this.dir, `.install-${pkg.name.replace("/", "+")}@${pkg.version}-`));
|
|
194
|
+
say(`installing ${pkg.spec} into ${final} (once; an adapter with its dependencies is a few hundred MB, so this can take a few minutes)`);
|
|
195
|
+
await this.installer({ dir: tmp, spec: pkg.spec, env, timeoutMs: INSTALL_TIMEOUT_MS });
|
|
196
|
+
if (!binOf(path.join(tmp, "node_modules", ...pkg.name.split("/")), pkg)) throw new Error(`npm reported success but ${pkg.spec} has no runnable bin in ${tmp}`);
|
|
197
|
+
if (!this.installed(pkg)) {
|
|
198
|
+
if (fs.existsSync(final)) fs.renameSync(final, `${final}.stale-${process.pid}-${Date.now()}`);
|
|
199
|
+
try {
|
|
200
|
+
fs.renameSync(tmp, final);
|
|
201
|
+
tmp = null;
|
|
202
|
+
} catch (e) {
|
|
203
|
+
if (!this.installed(pkg)) throw e;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
for (const f of fs.readdirSync(this.dir)) if (f.startsWith(`${path.basename(final)}.stale-`)) fs.rmSync(path.join(this.dir, f), { recursive: true, force: true });
|
|
207
|
+
const bin = this.installed(pkg);
|
|
208
|
+
if (!bin) throw new Error(`${pkg.spec} is not runnable after the install`);
|
|
209
|
+
this.failedAt.delete(pkg.spec);
|
|
210
|
+
say(`installed ${pkg.spec} in ${Math.round((Date.now() - started) / 1e3)} s`);
|
|
211
|
+
return bin;
|
|
212
|
+
} catch (e) {
|
|
213
|
+
this.failedAt.set(pkg.spec, this.now());
|
|
214
|
+
say(`could not install ${pkg.spec}: ${errMessage(e)}; sessions start it through npx instead (slower, and needs the registry each time)`);
|
|
215
|
+
return null;
|
|
216
|
+
} finally {
|
|
217
|
+
if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** Install directories a killed connector left behind (older than any install can take). */
|
|
221
|
+
sweepAbandoned(pkg) {
|
|
222
|
+
const prefix = `.install-${pkg.name.replace("/", "+")}@${pkg.version}-`;
|
|
223
|
+
for (const f of fs.readdirSync(this.dir)) {
|
|
224
|
+
if (!f.startsWith(prefix)) continue;
|
|
225
|
+
const p = path.join(this.dir, f);
|
|
226
|
+
try {
|
|
227
|
+
if (Date.now() - fs.statSync(p).mtimeMs > 2 * INSTALL_TIMEOUT_MS) fs.rmSync(p, { recursive: true, force: true });
|
|
228
|
+
} catch {
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
now() {
|
|
233
|
+
return (this.o.now ?? Date.now)();
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* How to start an agent's adapter for a session in `cwd`. A pinned package that is installed runs from here; one
|
|
237
|
+
* whose install is running is waited for (at most `waitMs`); otherwise npx runs as configured, in this directory.
|
|
238
|
+
*/
|
|
239
|
+
async launch(cfg, cwd, env) {
|
|
240
|
+
const pkg = pinnedPackage(cfg);
|
|
241
|
+
if (!pkg) return { command: cfg.command, args: cfg.args, cwd, installed: false };
|
|
242
|
+
let bin = this.installed(pkg);
|
|
243
|
+
if (!bin) {
|
|
244
|
+
const failed = this.failedAt.get(pkg.spec);
|
|
245
|
+
if (failed === void 0) {
|
|
246
|
+
const job = this.o.autoInstall ? this.install(pkg, env) : this.pending.get(pkg.spec);
|
|
247
|
+
if (job) bin = await raceTimeout(job, this.o.waitMs ?? INSTALL_WAIT_MS);
|
|
248
|
+
} else if (this.o.autoInstall && !this.installing(pkg) && this.now() - failed >= RETRY_AFTER_MS) {
|
|
249
|
+
void this.install(pkg, env);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (bin) return runByNode(bin) ? { command: process.execPath, args: [bin, ...pkg.rest], cwd, installed: true } : { command: bin, args: pkg.rest, cwd, installed: true };
|
|
253
|
+
fs.mkdirSync(this.dir, { recursive: true, mode: 448 });
|
|
254
|
+
return { command: cfg.command, args: cfg.args, cwd: this.dir, installed: false };
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
function raceTimeout(p, ms) {
|
|
258
|
+
let timer;
|
|
259
|
+
const t = new Promise((resolve) => {
|
|
260
|
+
timer = setTimeout(() => resolve(null), ms);
|
|
261
|
+
timer.unref?.();
|
|
262
|
+
});
|
|
263
|
+
return Promise.race([p, t]).finally(() => clearTimeout(timer));
|
|
264
|
+
}
|
|
265
|
+
var store = null;
|
|
266
|
+
function useAdapterStore(s) {
|
|
267
|
+
store = s;
|
|
268
|
+
}
|
|
269
|
+
function adapterStore() {
|
|
270
|
+
return store;
|
|
271
|
+
}
|
|
272
|
+
function pinnedOf(agents, base = process.env) {
|
|
273
|
+
const seen = /* @__PURE__ */ new Map();
|
|
274
|
+
for (const a of agents) {
|
|
275
|
+
const pkg = pinnedPackage(a);
|
|
276
|
+
if (pkg && !seen.has(pkg.spec)) seen.set(pkg.spec, { pkg, env: { ...base, ...a.env } });
|
|
277
|
+
}
|
|
278
|
+
return [...seen.values()];
|
|
279
|
+
}
|
|
280
|
+
async function installAll(s, agents, base, say) {
|
|
281
|
+
for (const { pkg, env } of pinnedOf(agents, base)) {
|
|
282
|
+
if (s.installed(pkg)) {
|
|
283
|
+
say(`${pkg.spec} is installed`);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
await s.install(pkg, env, say);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
var adaptersDir = (stateDir) => path.join(stateDir, "adapters");
|
|
290
|
+
|
|
291
|
+
// src/audit.ts
|
|
292
|
+
import fs2 from "node:fs";
|
|
293
|
+
import path2 from "node:path";
|
|
294
|
+
|
|
14
295
|
// src/redact.ts
|
|
15
296
|
var MASK = "[hidden]";
|
|
16
297
|
var B64 = "[A-Za-z0-9+/=_-]";
|
|
@@ -58,12 +339,12 @@ function redactDeep(value) {
|
|
|
58
339
|
|
|
59
340
|
// src/audit.ts
|
|
60
341
|
function fileAudit(file, now = Date.now) {
|
|
61
|
-
|
|
342
|
+
fs2.mkdirSync(path2.dirname(file), { recursive: true, mode: 448 });
|
|
62
343
|
return {
|
|
63
344
|
record(kind, detail = {}) {
|
|
64
345
|
const line = JSON.stringify({ ts: new Date(now()).toISOString(), ...redactDeep(detail), kind });
|
|
65
346
|
try {
|
|
66
|
-
|
|
347
|
+
fs2.appendFileSync(file, line + "\n", { mode: 384 });
|
|
67
348
|
} catch {
|
|
68
349
|
process.stderr.write(`audit write failed: ${kind}
|
|
69
350
|
`);
|
|
@@ -74,65 +355,12 @@ function fileAudit(file, now = Date.now) {
|
|
|
74
355
|
|
|
75
356
|
// src/children.ts
|
|
76
357
|
import { execFileSync } from "node:child_process";
|
|
77
|
-
import
|
|
78
|
-
import
|
|
79
|
-
|
|
80
|
-
// src/util.ts
|
|
81
|
-
import { createHash } from "node:crypto";
|
|
82
|
-
var sleep = (ms, signal) => new Promise((resolve) => {
|
|
83
|
-
if (signal?.aborted) return resolve();
|
|
84
|
-
const t = setTimeout(done, ms);
|
|
85
|
-
function done() {
|
|
86
|
-
signal?.removeEventListener("abort", done);
|
|
87
|
-
clearTimeout(t);
|
|
88
|
-
resolve();
|
|
89
|
-
}
|
|
90
|
-
signal?.addEventListener("abort", done, { once: true });
|
|
91
|
-
});
|
|
92
|
-
var sha256 = (s) => createHash("sha256").update(s).digest("hex");
|
|
93
|
-
var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
94
|
-
var errMessage = (e) => e instanceof Error ? e.message : String(e);
|
|
95
|
-
function truncateBytes(text, max) {
|
|
96
|
-
const bytes = Buffer.byteLength(text, "utf8");
|
|
97
|
-
if (bytes <= max) return { text, truncated: false, bytes };
|
|
98
|
-
let cut = Buffer.from(text, "utf8").subarray(0, max).toString("utf8");
|
|
99
|
-
if (cut.endsWith("\uFFFD")) cut = cut.slice(0, -1);
|
|
100
|
-
return { text: `${cut}
|
|
101
|
-
[truncated, ${bytes} bytes in all]`, truncated: true, bytes };
|
|
102
|
-
}
|
|
103
|
-
function backoffMs(attempt, minMs, maxMs, random = Math.random) {
|
|
104
|
-
const raw = Math.min(maxMs, minMs * 2 ** Math.min(attempt, 30));
|
|
105
|
-
return Math.round(raw * (0.5 + random() / 2));
|
|
106
|
-
}
|
|
107
|
-
var stderrLogger = (quiet = false) => {
|
|
108
|
-
const w = (level) => (msg, extra) => {
|
|
109
|
-
if (quiet && level === "info") return;
|
|
110
|
-
process.stderr.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${level} ${msg}${extra ? " " + JSON.stringify(extra) : ""}
|
|
111
|
-
`);
|
|
112
|
-
};
|
|
113
|
-
return { info: w("info"), warn: w("warn"), error: w("error") };
|
|
114
|
-
};
|
|
115
|
-
var UrlError = class extends Error {
|
|
116
|
-
};
|
|
117
|
-
function secureUrl(raw, what) {
|
|
118
|
-
let u;
|
|
119
|
-
try {
|
|
120
|
-
u = new URL(raw);
|
|
121
|
-
} catch {
|
|
122
|
-
throw new UrlError(`${what} is not a valid URL: ${JSON.stringify(raw.slice(0, 80))}`);
|
|
123
|
-
}
|
|
124
|
-
const loopback = u.hostname === "localhost" || u.hostname === "[::1]" || /^127(\.\d{1,3}){3}$/.test(u.hostname);
|
|
125
|
-
if (u.protocol !== "https:" && !(u.protocol === "http:" && loopback)) {
|
|
126
|
-
throw new UrlError(`${what} must be an https:// URL (http:// is accepted only for localhost), got ${u.protocol}//${u.host}`);
|
|
127
|
-
}
|
|
128
|
-
return raw.replace(/\/+$/, "");
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// src/children.ts
|
|
358
|
+
import fs3 from "node:fs";
|
|
359
|
+
import path3 from "node:path";
|
|
132
360
|
function processStart(pid) {
|
|
133
361
|
try {
|
|
134
362
|
if (process.platform === "linux") {
|
|
135
|
-
const stat =
|
|
363
|
+
const stat = fs3.readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
136
364
|
const rest = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
137
365
|
return rest[19] ?? null;
|
|
138
366
|
}
|
|
@@ -157,10 +385,10 @@ var ChildRegistry = class {
|
|
|
157
385
|
file;
|
|
158
386
|
recs = /* @__PURE__ */ new Map();
|
|
159
387
|
save() {
|
|
160
|
-
|
|
388
|
+
fs3.mkdirSync(path3.dirname(this.file), { recursive: true, mode: 448 });
|
|
161
389
|
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
162
|
-
|
|
163
|
-
|
|
390
|
+
fs3.writeFileSync(tmp, JSON.stringify([...this.recs.values()]), { mode: 384 });
|
|
391
|
+
fs3.renameSync(tmp, this.file);
|
|
164
392
|
}
|
|
165
393
|
add(rec) {
|
|
166
394
|
const full = { ...rec, start: rec.start ?? processStart(rec.pid) ?? "", started_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
@@ -251,7 +479,7 @@ var ChildRegistry = class {
|
|
|
251
479
|
};
|
|
252
480
|
function readRecords(file) {
|
|
253
481
|
try {
|
|
254
|
-
const raw = JSON.parse(
|
|
482
|
+
const raw = JSON.parse(fs3.readFileSync(file, "utf8"));
|
|
255
483
|
return Array.isArray(raw) ? raw.filter((r) => Number.isInteger(r?.pid) && Number.isInteger(r?.pgid) && r.pgid > 1) : [];
|
|
256
484
|
} catch {
|
|
257
485
|
return [];
|
|
@@ -335,11 +563,11 @@ var RevokedError = class extends Error {
|
|
|
335
563
|
}
|
|
336
564
|
};
|
|
337
565
|
var HttpError = class extends Error {
|
|
338
|
-
constructor(status2, code,
|
|
339
|
-
super(`${
|
|
566
|
+
constructor(status2, code, path20) {
|
|
567
|
+
super(`${path20} answered ${status2} ${code}`);
|
|
340
568
|
this.status = status2;
|
|
341
569
|
this.code = code;
|
|
342
|
-
this.path =
|
|
570
|
+
this.path = path20;
|
|
343
571
|
}
|
|
344
572
|
status;
|
|
345
573
|
code;
|
|
@@ -363,8 +591,8 @@ var ClawClient = class {
|
|
|
363
591
|
...extra
|
|
364
592
|
};
|
|
365
593
|
}
|
|
366
|
-
async send(method,
|
|
367
|
-
const url = this.o.server +
|
|
594
|
+
async send(method, path20, body, signal) {
|
|
595
|
+
const url = this.o.server + path20;
|
|
368
596
|
for (let attempt = 0; ; attempt++) {
|
|
369
597
|
const headers = await this.headers(body === void 0 ? {} : { "content-type": "application/json" });
|
|
370
598
|
let res;
|
|
@@ -376,7 +604,7 @@ var ClawClient = class {
|
|
|
376
604
|
signal: signal ?? AbortSignal.timeout(this.o.requestTimeoutMs ?? 2e4)
|
|
377
605
|
});
|
|
378
606
|
} catch (e) {
|
|
379
|
-
throw new TransportError(`${method} ${
|
|
607
|
+
throw new TransportError(`${method} ${path20}: ${errMessage(e)}`);
|
|
380
608
|
}
|
|
381
609
|
if (res.status !== 401) return res;
|
|
382
610
|
const err = await errorCode(res);
|
|
@@ -385,12 +613,12 @@ var ClawClient = class {
|
|
|
385
613
|
this.o.tokens.invalidate();
|
|
386
614
|
continue;
|
|
387
615
|
}
|
|
388
|
-
throw new HttpError(401, err,
|
|
616
|
+
throw new HttpError(401, err, path20);
|
|
389
617
|
}
|
|
390
618
|
}
|
|
391
|
-
async json(method,
|
|
392
|
-
const res = await this.send(method,
|
|
393
|
-
if (!res.ok) throw new HttpError(res.status, await errorCode(res),
|
|
619
|
+
async json(method, path20, body) {
|
|
620
|
+
const res = await this.send(method, path20, body);
|
|
621
|
+
if (!res.ok) throw new HttpError(res.status, await errorCode(res), path20);
|
|
394
622
|
const parsed = await res.json().catch(() => null);
|
|
395
623
|
return isObject(parsed) && isObject(parsed.data) ? parsed.data : {};
|
|
396
624
|
}
|
|
@@ -406,6 +634,17 @@ var ClawClient = class {
|
|
|
406
634
|
const d = await this.json("POST", `/v1/remote-agents/connectors/${cid}/events`, { batch_id: batchId, events });
|
|
407
635
|
return { acked: isObject(d.acked) ? d.acked : {}, rejected: Array.isArray(d.rejected) ? d.rejected : [] };
|
|
408
636
|
}
|
|
637
|
+
/**
|
|
638
|
+
* Revoke connector `cid` at Claw, for `logout`: Claw sends it nothing more and closes its sessions. A key is a user's
|
|
639
|
+
* credential and uses the user-side DELETE; a device sign-in's access token is refused there, and uses the
|
|
640
|
+
* connector-side route made for this. Throws {@link HttpError} 404 `not_found` when Claw has no live row by that id
|
|
641
|
+
* for this sign-in (already revoked, or another account's).
|
|
642
|
+
*/
|
|
643
|
+
async revoke(cid, kind) {
|
|
644
|
+
const id = encodeURIComponent(cid);
|
|
645
|
+
if (kind === "key") await this.json("DELETE", `/v1/remote-agents/connectors/${id}`);
|
|
646
|
+
else await this.json("POST", `/v1/remote-agents/connectors/${id}/revoke`);
|
|
647
|
+
}
|
|
409
648
|
/**
|
|
410
649
|
* Hold the command stream. Yields every frame (a `ping` too, since it is
|
|
411
650
|
* proof of life). Ends normally when the server closes it; throws
|
|
@@ -428,21 +667,21 @@ var ClawClient = class {
|
|
|
428
667
|
};
|
|
429
668
|
try {
|
|
430
669
|
arm();
|
|
431
|
-
const
|
|
670
|
+
const path20 = `/v1/remote-agents/connectors/${cid}/commands`;
|
|
432
671
|
const headers = await this.headers({ accept: "text/event-stream", ...opts.lastEventId ? { "last-event-id": String(opts.lastEventId) } : {} });
|
|
433
672
|
let res;
|
|
434
673
|
try {
|
|
435
|
-
res = await this.f(this.o.server +
|
|
674
|
+
res = await this.f(this.o.server + path20, { headers, signal: stop.signal });
|
|
436
675
|
} catch (e) {
|
|
437
|
-
throw new TransportError(timedOut ? `no response for ${opts.watchdogMs} ms` : `GET ${
|
|
676
|
+
throw new TransportError(timedOut ? `no response for ${opts.watchdogMs} ms` : `GET ${path20}: ${errMessage(e)}`);
|
|
438
677
|
}
|
|
439
678
|
if (res.status === 401) {
|
|
440
679
|
const err = await errorCode(res);
|
|
441
680
|
if (err === "revoked") throw new RevokedError();
|
|
442
681
|
this.o.tokens.invalidate();
|
|
443
|
-
throw new HttpError(401, err,
|
|
682
|
+
throw new HttpError(401, err, path20);
|
|
444
683
|
}
|
|
445
|
-
if (!res.ok || !res.body) throw new HttpError(res.status, await errorCode(res),
|
|
684
|
+
if (!res.ok || !res.body) throw new HttpError(res.status, await errorCode(res), path20);
|
|
446
685
|
try {
|
|
447
686
|
for await (const frame of readSse(res.body, arm)) yield frame;
|
|
448
687
|
} catch (e) {
|
|
@@ -462,33 +701,33 @@ async function errorCode(res) {
|
|
|
462
701
|
}
|
|
463
702
|
|
|
464
703
|
// src/config.ts
|
|
465
|
-
import
|
|
704
|
+
import fs5 from "node:fs";
|
|
466
705
|
import os from "node:os";
|
|
467
|
-
import
|
|
706
|
+
import path5 from "node:path";
|
|
468
707
|
import { parse as parseToml } from "smol-toml";
|
|
469
708
|
|
|
470
709
|
// src/roots.ts
|
|
471
|
-
import
|
|
472
|
-
import
|
|
710
|
+
import fs4 from "node:fs";
|
|
711
|
+
import path4 from "node:path";
|
|
473
712
|
function realRoot(root) {
|
|
474
713
|
try {
|
|
475
|
-
return
|
|
714
|
+
return fs4.realpathSync.native(root);
|
|
476
715
|
} catch {
|
|
477
716
|
return null;
|
|
478
717
|
}
|
|
479
718
|
}
|
|
480
|
-
var inside = (child, root) => child === root || child.startsWith(root.endsWith(
|
|
719
|
+
var inside = (child, root) => child === root || child.startsWith(root.endsWith(path4.sep) ? root : root + path4.sep);
|
|
481
720
|
function checkCwd(cwd, roots) {
|
|
482
721
|
if (typeof cwd !== "string" || cwd === "" || cwd.includes("\0")) return { ok: false, reason: "invalid_cwd" };
|
|
483
|
-
if (!
|
|
722
|
+
if (!path4.isAbsolute(cwd)) return { ok: false, reason: "cwd_not_absolute" };
|
|
484
723
|
let real;
|
|
485
724
|
try {
|
|
486
|
-
real =
|
|
725
|
+
real = fs4.realpathSync.native(cwd);
|
|
487
726
|
} catch {
|
|
488
727
|
return { ok: false, reason: "cwd_not_found" };
|
|
489
728
|
}
|
|
490
729
|
try {
|
|
491
|
-
if (!
|
|
730
|
+
if (!fs4.statSync(real).isDirectory()) return { ok: false, reason: "cwd_not_directory" };
|
|
492
731
|
} catch {
|
|
493
732
|
return { ok: false, reason: "cwd_not_found" };
|
|
494
733
|
}
|
|
@@ -499,19 +738,19 @@ function checkCwd(cwd, roots) {
|
|
|
499
738
|
return { ok: false, reason: "cwd_outside_roots" };
|
|
500
739
|
}
|
|
501
740
|
function relativeInside(p, cwd) {
|
|
502
|
-
const abs =
|
|
741
|
+
const abs = path4.resolve(cwd, p);
|
|
503
742
|
let real;
|
|
504
743
|
try {
|
|
505
|
-
real =
|
|
744
|
+
real = fs4.realpathSync.native(abs);
|
|
506
745
|
} catch {
|
|
507
746
|
try {
|
|
508
|
-
real =
|
|
747
|
+
real = path4.join(fs4.realpathSync.native(path4.dirname(abs)), path4.basename(abs));
|
|
509
748
|
} catch {
|
|
510
749
|
return null;
|
|
511
750
|
}
|
|
512
751
|
}
|
|
513
752
|
if (!inside(real, cwd)) return null;
|
|
514
|
-
return
|
|
753
|
+
return path4.relative(cwd, real).split(path4.sep).join("/");
|
|
515
754
|
}
|
|
516
755
|
|
|
517
756
|
// src/config.ts
|
|
@@ -574,7 +813,7 @@ var strList = (v, what, problems) => {
|
|
|
574
813
|
}
|
|
575
814
|
return v;
|
|
576
815
|
};
|
|
577
|
-
var expandHome = (p) => p === "~" ? os.homedir() : p.startsWith("~/") ?
|
|
816
|
+
var expandHome = (p) => p === "~" ? os.homedir() : p.startsWith("~/") ? path5.join(os.homedir(), p.slice(2)) : p;
|
|
578
817
|
function parseConfig(text, defaults = {}) {
|
|
579
818
|
const problems = [];
|
|
580
819
|
let doc;
|
|
@@ -641,10 +880,10 @@ function parseConfig(text, defaults = {}) {
|
|
|
641
880
|
} else asking.add(askMode);
|
|
642
881
|
}
|
|
643
882
|
if (asking.has(askMode) && !safeModes.includes(askMode)) safeModes.push(askMode);
|
|
644
|
-
const roots = strList(raw.roots, `${label}.roots`, problems).map((r) =>
|
|
883
|
+
const roots = strList(raw.roots, `${label}.roots`, problems).map((r) => path5.resolve(expandHome(r)));
|
|
645
884
|
if (roots.length === 0) problems.push(`${label}: roots is required (an agent with no allowed directory is never offered)`);
|
|
646
885
|
for (const r of roots) {
|
|
647
|
-
if (r ===
|
|
886
|
+
if (r === path5.parse(r).root) problems.push(`${label}: a root may not be the filesystem root`);
|
|
648
887
|
else if (!realRoot(r)) problems.push(`${label}: root ${r} does not exist`);
|
|
649
888
|
}
|
|
650
889
|
const env = {};
|
|
@@ -685,9 +924,50 @@ function parseConfig(text, defaults = {}) {
|
|
|
685
924
|
attach
|
|
686
925
|
});
|
|
687
926
|
});
|
|
927
|
+
if (Array.isArray(rawAgents)) agents.push(...existingSessionAgents(rawAgents, agents, problems));
|
|
688
928
|
if (problems.length) throw new ConfigError(problems);
|
|
689
929
|
return { server, identityUrl, name, allowUnsafeModes, dropMessagePatterns, agents };
|
|
690
930
|
}
|
|
931
|
+
var EXISTING_SESSIONS_SUFFIX = "-sessions";
|
|
932
|
+
function existingSessionAgents(rawAgents, agents, problems) {
|
|
933
|
+
const out = [];
|
|
934
|
+
const configDir = (a) => path5.resolve(expandHome(a.env.CLAUDE_CONFIG_DIR ?? process.env.CLAUDE_CONFIG_DIR ?? path5.join(os.homedir(), ".claude")));
|
|
935
|
+
const ids = new Set(agents.map((a) => a.id));
|
|
936
|
+
for (const raw of rawAgents) {
|
|
937
|
+
if (!isObject(raw) || typeof raw.id !== "string") continue;
|
|
938
|
+
const flag = raw.existing_sessions;
|
|
939
|
+
if (flag !== void 0 && typeof flag !== "boolean") {
|
|
940
|
+
problems.push(`agent ${raw.id}: existing_sessions must be true or false`);
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
943
|
+
if (flag !== void 0 && raw.preset !== "claude") {
|
|
944
|
+
problems.push(`agent ${raw.id}: existing_sessions only applies to an agent made from the claude preset`);
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
if (raw.preset !== "claude" || flag === false) continue;
|
|
948
|
+
const src = agents.find((a) => a.id === raw.id);
|
|
949
|
+
if (!src) continue;
|
|
950
|
+
if ([...agents, ...out].some((a) => a.attach && configDir(a) === configDir(src))) continue;
|
|
951
|
+
const id = `${src.id.slice(0, 64 - EXISTING_SESSIONS_SUFFIX.length)}${EXISTING_SESSIONS_SUFFIX}`;
|
|
952
|
+
if (ids.has(id)) continue;
|
|
953
|
+
ids.add(id);
|
|
954
|
+
const name = src.name === PRESETS.claude.name ? PRESETS["claude-attach"].name : `${src.name} (existing sessions)`.slice(0, 128);
|
|
955
|
+
out.push({
|
|
956
|
+
...src,
|
|
957
|
+
id,
|
|
958
|
+
kind: PRESETS["claude-attach"].kind,
|
|
959
|
+
name,
|
|
960
|
+
args: [...src.args],
|
|
961
|
+
roots: [...src.roots],
|
|
962
|
+
env: { ...src.env },
|
|
963
|
+
safeModes: [...src.safeModes],
|
|
964
|
+
askingModes: [...src.askingModes],
|
|
965
|
+
autoAllow: [...src.autoAllow],
|
|
966
|
+
attach: { cli: "claude" }
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
return out;
|
|
970
|
+
}
|
|
691
971
|
function parseAutoAllow(raw, at, problems) {
|
|
692
972
|
if (raw === void 0) return [];
|
|
693
973
|
if (!Array.isArray(raw)) {
|
|
@@ -699,7 +979,7 @@ function parseAutoAllow(raw, at, problems) {
|
|
|
699
979
|
if (!isObject(r) || typeof r.kind !== "string" || typeof r.path_glob !== "string" || !r.kind || !r.path_glob) {
|
|
700
980
|
return void problems.push(`${at}[${i}] needs a kind and a path_glob`);
|
|
701
981
|
}
|
|
702
|
-
if (
|
|
982
|
+
if (path5.isAbsolute(r.path_glob) || r.path_glob.split("/").includes("..")) {
|
|
703
983
|
return void problems.push(`${at}[${i}].path_glob must be relative to the working directory and may not contain ..`);
|
|
704
984
|
}
|
|
705
985
|
rules.push({ kind: r.kind, pathGlob: r.path_glob });
|
|
@@ -709,7 +989,7 @@ function parseAutoAllow(raw, at, problems) {
|
|
|
709
989
|
function loadConfig(file, defaults = {}) {
|
|
710
990
|
let text;
|
|
711
991
|
try {
|
|
712
|
-
text =
|
|
992
|
+
text = fs5.readFileSync(file, "utf8");
|
|
713
993
|
} catch (e) {
|
|
714
994
|
if (e.code === "ENOENT") throw new ConfigError([`${file} does not exist; run "claw-connector init" to create one`]);
|
|
715
995
|
throw e;
|
|
@@ -756,8 +1036,8 @@ ${SAMPLE_AGENTS.map(agent).join("\n\n")}
|
|
|
756
1036
|
var SAMPLE_CONFIG = sampleConfig();
|
|
757
1037
|
|
|
758
1038
|
// src/credentials.ts
|
|
759
|
-
import
|
|
760
|
-
import
|
|
1039
|
+
import fs6 from "node:fs";
|
|
1040
|
+
import path6 from "node:path";
|
|
761
1041
|
var DEFAULT_CLIENT_ID = "claw-connector";
|
|
762
1042
|
var CONNECTOR_SCOPE = "claw.connector";
|
|
763
1043
|
var CredentialsError = class extends Error {
|
|
@@ -772,20 +1052,20 @@ var TransientAuthError = class extends Error {
|
|
|
772
1052
|
function loadCredentials(file) {
|
|
773
1053
|
let fd;
|
|
774
1054
|
try {
|
|
775
|
-
fd =
|
|
1055
|
+
fd = fs6.openSync(file, "r");
|
|
776
1056
|
} catch (e) {
|
|
777
1057
|
if (e.code === "ENOENT") return null;
|
|
778
1058
|
throw e;
|
|
779
1059
|
}
|
|
780
1060
|
let text;
|
|
781
1061
|
try {
|
|
782
|
-
const st =
|
|
1062
|
+
const st = fs6.fstatSync(fd);
|
|
783
1063
|
if (process.platform !== "win32" && (st.mode & 63) !== 0) {
|
|
784
1064
|
throw new CredentialsError(`${file} is readable by other users (mode ${(st.mode & 511).toString(8)}); run: chmod 600 ${file}`);
|
|
785
1065
|
}
|
|
786
|
-
text =
|
|
1066
|
+
text = fs6.readFileSync(fd, "utf8");
|
|
787
1067
|
} finally {
|
|
788
|
-
|
|
1068
|
+
fs6.closeSync(fd);
|
|
789
1069
|
}
|
|
790
1070
|
let raw;
|
|
791
1071
|
try {
|
|
@@ -799,11 +1079,11 @@ function loadCredentials(file) {
|
|
|
799
1079
|
return raw;
|
|
800
1080
|
}
|
|
801
1081
|
function saveCredentials(file, creds) {
|
|
802
|
-
|
|
1082
|
+
fs6.mkdirSync(path6.dirname(file), { recursive: true, mode: 448 });
|
|
803
1083
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
1084
|
+
fs6.writeFileSync(tmp, JSON.stringify(creds, null, 2) + "\n", { mode: 384 });
|
|
1085
|
+
fs6.chmodSync(tmp, 384);
|
|
1086
|
+
fs6.renameSync(tmp, file);
|
|
807
1087
|
}
|
|
808
1088
|
var KeyTokenProvider = class {
|
|
809
1089
|
constructor(key) {
|
|
@@ -993,10 +1273,9 @@ async function deviceLogin(o) {
|
|
|
993
1273
|
}
|
|
994
1274
|
|
|
995
1275
|
// src/claude-mode.ts
|
|
996
|
-
import
|
|
1276
|
+
import fs7 from "node:fs";
|
|
997
1277
|
import os2 from "node:os";
|
|
998
|
-
import
|
|
999
|
-
var isClaude = (a) => !a.attach && [a.command, ...a.args].some((s) => s.includes("claude-agent-acp"));
|
|
1278
|
+
import path7 from "node:path";
|
|
1000
1279
|
function managedSettingsFile(platform = process.platform) {
|
|
1001
1280
|
if (platform === "darwin") return "/Library/Application Support/ClaudeCode/managed-settings.json";
|
|
1002
1281
|
if (platform === "win32") return "C:\\Program Files\\ClaudeCode\\managed-settings.json";
|
|
@@ -1005,7 +1284,7 @@ function managedSettingsFile(platform = process.platform) {
|
|
|
1005
1284
|
function readSettings(file, unreadable) {
|
|
1006
1285
|
let text;
|
|
1007
1286
|
try {
|
|
1008
|
-
text =
|
|
1287
|
+
text = fs7.readFileSync(file, "utf8");
|
|
1009
1288
|
} catch (e) {
|
|
1010
1289
|
if (e.code !== "ENOENT") unreadable.push(file);
|
|
1011
1290
|
return null;
|
|
@@ -1018,67 +1297,63 @@ function readSettings(file, unreadable) {
|
|
|
1018
1297
|
return null;
|
|
1019
1298
|
}
|
|
1020
1299
|
}
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
let from = null;
|
|
1035
|
-
let disable;
|
|
1036
|
-
for (const [source, file] of files) {
|
|
1037
|
-
const s = readSettings(file, unreadable);
|
|
1038
|
-
const perms = s && isObject(s.permissions) ? s.permissions : null;
|
|
1039
|
-
if (!perms) continue;
|
|
1040
|
-
if (perms.defaultMode !== void 0) {
|
|
1041
|
-
raw = perms.defaultMode;
|
|
1042
|
-
from = [source, file];
|
|
1043
|
-
}
|
|
1044
|
-
if (perms.disableBypassPermissionsMode !== void 0) disable = perms.disableBypassPermissionsMode;
|
|
1045
|
-
}
|
|
1046
|
-
let bypass = raw === "bypassPermissions" && from !== null && from[0] !== "project" && from[0] !== "local";
|
|
1047
|
-
if (bypass) {
|
|
1048
|
-
const euid = o.euid === void 0 ? process.geteuid?.() ?? null : o.euid;
|
|
1049
|
-
if (euid === 0 && !o.env.IS_SANDBOX || disable === "disable") bypass = false;
|
|
1050
|
-
}
|
|
1051
|
-
return { bypass, file: bypass ? from[1] : null, unreadable };
|
|
1052
|
-
}
|
|
1053
|
-
function noAskWarnings(agents, env, opts = {}) {
|
|
1300
|
+
var runsClaudeAdapter = (a) => [a.command, ...a.args].some((s) => s.includes("claude-agent-acp"));
|
|
1301
|
+
var currentEuid = (euid) => euid === void 0 ? process.geteuid?.() ?? null : euid;
|
|
1302
|
+
function applyRootSandbox(agents, env, opts = {}) {
|
|
1303
|
+
if (currentEuid(opts.euid) !== 0 || env.IS_SANDBOX) return [];
|
|
1304
|
+
const out = [];
|
|
1305
|
+
for (const a of agents) {
|
|
1306
|
+
if (!runsClaudeAdapter(a) || !a.allowUnsafeModes || Object.hasOwn(a.env, "IS_SANDBOX")) continue;
|
|
1307
|
+
a.env = { ...a.env, IS_SANDBOX: "1" };
|
|
1308
|
+
out.push(`${a.id}: running as root, where Claude Code offers bypassPermissions only with IS_SANDBOX set; set IS_SANDBOX=1 for this agent's adapter because it runs without per-step approval`);
|
|
1309
|
+
}
|
|
1310
|
+
return out;
|
|
1311
|
+
}
|
|
1312
|
+
function bypassUnavailableWarnings(agents, env, opts = {}) {
|
|
1054
1313
|
const out = [];
|
|
1314
|
+
const euid = currentEuid(opts.euid);
|
|
1055
1315
|
for (const a of agents) {
|
|
1056
|
-
if (!
|
|
1057
|
-
const
|
|
1058
|
-
if (
|
|
1059
|
-
out.push(`${a.id}: sessions will be refused (
|
|
1316
|
+
if (!runsClaudeAdapter(a) || !a.allowUnsafeModes || a.askMode !== "bypassPermissions") continue;
|
|
1317
|
+
const merged = { ...env, ...a.env };
|
|
1318
|
+
if (euid === 0 && !merged.IS_SANDBOX) {
|
|
1319
|
+
out.push(`${a.id}: sessions will be refused (mode_unavailable): Claude Code offers bypassPermissions to root only with IS_SANDBOX set, and this agent's env sets IS_SANDBOX to an empty value; remove IS_SANDBOX from its env, or set approval = "ask"`);
|
|
1320
|
+
continue;
|
|
1321
|
+
}
|
|
1322
|
+
const home = merged.HOME?.trim() || os2.homedir();
|
|
1323
|
+
const configDir = merged.CLAUDE_CONFIG_DIR?.trim() || path7.join(home, ".claude");
|
|
1324
|
+
const unreadable = [];
|
|
1325
|
+
for (const file of [path7.join(configDir, "settings.json"), opts.managedFile ?? managedSettingsFile()]) {
|
|
1326
|
+
const s = readSettings(file, unreadable);
|
|
1327
|
+
const perms = s && isObject(s.permissions) ? s.permissions : null;
|
|
1328
|
+
if (perms?.disableBypassPermissionsMode === "disable") {
|
|
1329
|
+
out.push(`${a.id}: sessions will be refused (mode_unavailable): permissions.disableBypassPermissionsMode is "disable" in ${file}, so Claude Code does not offer bypassPermissions; remove that setting, or set approval = "ask" on this agent`);
|
|
1330
|
+
break;
|
|
1331
|
+
}
|
|
1060
1332
|
}
|
|
1061
|
-
for (const f of r.unreadable) out.push(`${a.id}: cannot tell whether Claude defaults to bypassPermissions: ${f} is not readable JSON`);
|
|
1062
1333
|
}
|
|
1063
1334
|
return out;
|
|
1064
1335
|
}
|
|
1336
|
+
function claudeSessionMeta(a) {
|
|
1337
|
+
if (!runsClaudeAdapter(a) || a.attach || a.allowUnsafeModes) return {};
|
|
1338
|
+
return { _meta: { claudeCode: { options: { allowDangerouslySkipPermissions: false } } } };
|
|
1339
|
+
}
|
|
1065
1340
|
|
|
1066
1341
|
// src/hooks.ts
|
|
1067
|
-
import
|
|
1068
|
-
import
|
|
1342
|
+
import fs9 from "node:fs";
|
|
1343
|
+
import path9 from "node:path";
|
|
1069
1344
|
|
|
1070
1345
|
// src/terminals.ts
|
|
1071
|
-
import
|
|
1072
|
-
import
|
|
1346
|
+
import fs8 from "node:fs";
|
|
1347
|
+
import path8 from "node:path";
|
|
1073
1348
|
var SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1074
1349
|
var TERMINALS_DIR = "claw-connector-terminals";
|
|
1075
|
-
var terminalsDir = (configDir) =>
|
|
1350
|
+
var terminalsDir = (configDir) => path8.join(configDir, TERMINALS_DIR);
|
|
1076
1351
|
var UNREADABLE_GRACE_MS = 6e4;
|
|
1077
1352
|
var MAX_STDIN = 1 << 20;
|
|
1078
1353
|
function localPidDomain() {
|
|
1079
1354
|
if (process.platform !== "linux") return null;
|
|
1080
1355
|
try {
|
|
1081
|
-
return `linux:${
|
|
1356
|
+
return `linux:${fs8.readFileSync("/etc/machine-id", "utf8").trim()}:${fs8.readlinkSync("/proc/self/ns/pid")}`;
|
|
1082
1357
|
} catch {
|
|
1083
1358
|
return null;
|
|
1084
1359
|
}
|
|
@@ -1109,7 +1384,7 @@ function readTerminals(configDir, localDomain, now = Date.now()) {
|
|
|
1109
1384
|
const dir = terminalsDir(configDir);
|
|
1110
1385
|
let names;
|
|
1111
1386
|
try {
|
|
1112
|
-
names =
|
|
1387
|
+
names = fs8.readdirSync(dir);
|
|
1113
1388
|
} catch (e) {
|
|
1114
1389
|
if (e.code === "ENOENT") return [];
|
|
1115
1390
|
throw e;
|
|
@@ -1117,15 +1392,15 @@ function readTerminals(configDir, localDomain, now = Date.now()) {
|
|
|
1117
1392
|
const out = [];
|
|
1118
1393
|
for (const name of names) {
|
|
1119
1394
|
if (!name.endsWith(".json")) continue;
|
|
1120
|
-
const file =
|
|
1395
|
+
const file = path8.join(dir, name);
|
|
1121
1396
|
let doc;
|
|
1122
1397
|
try {
|
|
1123
|
-
doc = JSON.parse(
|
|
1398
|
+
doc = JSON.parse(fs8.readFileSync(file, "utf8"));
|
|
1124
1399
|
} catch (e) {
|
|
1125
1400
|
if (e.code === "ENOENT") continue;
|
|
1126
1401
|
let age = Infinity;
|
|
1127
1402
|
try {
|
|
1128
|
-
age = now -
|
|
1403
|
+
age = now - fs8.statSync(file).mtimeMs;
|
|
1129
1404
|
} catch {
|
|
1130
1405
|
}
|
|
1131
1406
|
if (age < UNREADABLE_GRACE_MS) throw new Error(`${file} is unreadable (${e.message.split("\n")[0]})`);
|
|
@@ -1152,19 +1427,19 @@ var shellQuote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
|
1152
1427
|
function hookCommand(node, script, arg, configDir) {
|
|
1153
1428
|
return `${shellQuote(node)} ${shellQuote(script)} ${arg} --dir ${shellQuote(configDir)} ${MANAGED_MARK}`;
|
|
1154
1429
|
}
|
|
1155
|
-
var settingsFile = (configDir) =>
|
|
1430
|
+
var settingsFile = (configDir) => path9.join(configDir, "settings.json");
|
|
1156
1431
|
var isObject3 = (x) => typeof x === "object" && x !== null && !Array.isArray(x);
|
|
1157
1432
|
var isOurs = (h) => isObject3(h) && h.type === "command" && typeof h.command === "string" && h.command.endsWith(` ${MANAGED_MARK}`) && OURS_RE.test(h.command);
|
|
1158
1433
|
function load(configDir) {
|
|
1159
1434
|
const given = settingsFile(configDir);
|
|
1160
1435
|
let file = given;
|
|
1161
1436
|
try {
|
|
1162
|
-
file =
|
|
1437
|
+
file = fs9.realpathSync(given);
|
|
1163
1438
|
} catch {
|
|
1164
1439
|
}
|
|
1165
1440
|
let text;
|
|
1166
1441
|
try {
|
|
1167
|
-
text =
|
|
1442
|
+
text = fs9.readFileSync(file, "utf8");
|
|
1168
1443
|
} catch (e) {
|
|
1169
1444
|
if (e.code === "ENOENT") return { doc: {}, text: null, file };
|
|
1170
1445
|
throw new HooksError(`cannot read ${given}: ${e.message}`);
|
|
@@ -1182,18 +1457,18 @@ function load(configDir) {
|
|
|
1182
1457
|
function save(l, doc) {
|
|
1183
1458
|
const indent = l.text?.match(/^([ \t]+)"/m)?.[1] ?? " ";
|
|
1184
1459
|
const body = JSON.stringify(doc, null, indent) + "\n";
|
|
1185
|
-
|
|
1460
|
+
fs9.mkdirSync(path9.dirname(l.file), { recursive: true, mode: 448 });
|
|
1186
1461
|
let mode = 384;
|
|
1187
1462
|
try {
|
|
1188
|
-
mode =
|
|
1463
|
+
mode = fs9.statSync(l.file).mode & 511;
|
|
1189
1464
|
} catch {
|
|
1190
1465
|
}
|
|
1191
1466
|
const tmp = `${l.file}.claw-connector.${process.pid}.tmp`;
|
|
1192
|
-
|
|
1467
|
+
fs9.writeFileSync(tmp, body, { mode });
|
|
1193
1468
|
try {
|
|
1194
|
-
|
|
1469
|
+
fs9.renameSync(tmp, l.file);
|
|
1195
1470
|
} catch (e) {
|
|
1196
|
-
|
|
1471
|
+
fs9.rmSync(tmp, { force: true });
|
|
1197
1472
|
throw e;
|
|
1198
1473
|
}
|
|
1199
1474
|
}
|
|
@@ -1273,7 +1548,7 @@ function uninstallHooks(configDir) {
|
|
|
1273
1548
|
if (touchedAny && Object.keys(hooks).length === 0) delete doc.hooks;
|
|
1274
1549
|
}
|
|
1275
1550
|
if (touchedAny) save(l, doc);
|
|
1276
|
-
|
|
1551
|
+
fs9.rmSync(terminalsDir(configDir), { recursive: true, force: true });
|
|
1277
1552
|
return { changed: touchedAny, file: l.file };
|
|
1278
1553
|
}
|
|
1279
1554
|
function hooksStatus(configDir) {
|
|
@@ -1296,7 +1571,7 @@ function hooksStatus(configDir) {
|
|
|
1296
1571
|
const m = /^'((?:[^']|'\\'')*)' '((?:[^']|'\\'')*)' /.exec(String(x.command));
|
|
1297
1572
|
if (!m) continue;
|
|
1298
1573
|
for (const f of [m[1], m[2]].map((s) => s.replace(/'\\''/g, "'"))) {
|
|
1299
|
-
if (!
|
|
1574
|
+
if (!fs9.existsSync(f)) {
|
|
1300
1575
|
healthy = false;
|
|
1301
1576
|
problem ??= `${f} (named by the ${h.event} hook) does not exist; run: claw-connector hooks install`;
|
|
1302
1577
|
}
|
|
@@ -1307,19 +1582,20 @@ function hooksStatus(configDir) {
|
|
|
1307
1582
|
}
|
|
1308
1583
|
|
|
1309
1584
|
// src/connector.ts
|
|
1310
|
-
import
|
|
1585
|
+
import fs15 from "node:fs";
|
|
1311
1586
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1312
1587
|
|
|
1313
1588
|
// src/attach.ts
|
|
1314
1589
|
import { execFile, execFileSync as execFileSync2 } from "node:child_process";
|
|
1315
|
-
import
|
|
1590
|
+
import fs11 from "node:fs";
|
|
1316
1591
|
import os3 from "node:os";
|
|
1317
|
-
import
|
|
1592
|
+
import path11 from "node:path";
|
|
1593
|
+
import { StringDecoder } from "node:string_decoder";
|
|
1318
1594
|
|
|
1319
1595
|
// src/adapter.ts
|
|
1320
|
-
import { spawn } from "node:child_process";
|
|
1321
|
-
import
|
|
1322
|
-
import
|
|
1596
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
1597
|
+
import fs10 from "node:fs";
|
|
1598
|
+
import path10 from "node:path";
|
|
1323
1599
|
import { Readable, Writable } from "node:stream";
|
|
1324
1600
|
import * as acp from "@agentclientprotocol/sdk";
|
|
1325
1601
|
var AdapterProcess = class _AdapterProcess {
|
|
@@ -1335,20 +1611,22 @@ var AdapterProcess = class _AdapterProcess {
|
|
|
1335
1611
|
exited = false;
|
|
1336
1612
|
conn;
|
|
1337
1613
|
static async start(o) {
|
|
1338
|
-
|
|
1339
|
-
const
|
|
1340
|
-
const
|
|
1341
|
-
|
|
1342
|
-
|
|
1614
|
+
fs10.mkdirSync(path10.dirname(o.logFile), { recursive: true, mode: 448 });
|
|
1615
|
+
const env = { ...process.env, ...o.cfg.env };
|
|
1616
|
+
const run2 = await adapterStore()?.launch(o.cfg, o.cwd, env) ?? { command: o.cfg.command, args: o.cfg.args, cwd: o.cwd };
|
|
1617
|
+
const log = fs10.openSync(o.logFile, "a", 384);
|
|
1618
|
+
const child = spawn2(run2.command, run2.args, {
|
|
1619
|
+
cwd: run2.cwd,
|
|
1620
|
+
env,
|
|
1343
1621
|
stdio: ["pipe", "pipe", "pipe"],
|
|
1344
1622
|
// Its own process group, so the whole tree (an npx wrapper and the agent below it) can be stopped as one.
|
|
1345
1623
|
detached: true
|
|
1346
1624
|
});
|
|
1347
1625
|
await new Promise((resolve, reject) => {
|
|
1348
1626
|
child.once("spawn", () => resolve());
|
|
1349
|
-
child.once("error", (e) => reject(new Error(`could not start ${
|
|
1627
|
+
child.once("error", (e) => reject(new Error(`could not start ${run2.command}: ${errMessage(e)}`)));
|
|
1350
1628
|
}).catch((e) => {
|
|
1351
|
-
|
|
1629
|
+
fs10.closeSync(log);
|
|
1352
1630
|
throw e;
|
|
1353
1631
|
});
|
|
1354
1632
|
const pid = child.pid;
|
|
@@ -1358,7 +1636,7 @@ var AdapterProcess = class _AdapterProcess {
|
|
|
1358
1636
|
const flushLine = (line) => {
|
|
1359
1637
|
const text = redactText(line).text;
|
|
1360
1638
|
try {
|
|
1361
|
-
|
|
1639
|
+
fs10.writeSync(log, text + "\n");
|
|
1362
1640
|
} catch {
|
|
1363
1641
|
}
|
|
1364
1642
|
try {
|
|
@@ -1384,7 +1662,7 @@ var AdapterProcess = class _AdapterProcess {
|
|
|
1384
1662
|
tail = "";
|
|
1385
1663
|
}
|
|
1386
1664
|
try {
|
|
1387
|
-
|
|
1665
|
+
fs10.closeSync(log);
|
|
1388
1666
|
} catch {
|
|
1389
1667
|
}
|
|
1390
1668
|
});
|
|
@@ -1500,6 +1778,9 @@ var SESSION_REF_RE = SESSION_ID_RE;
|
|
|
1500
1778
|
var MAX_REPORTED = 12;
|
|
1501
1779
|
var MAX_LIST_PAGES = 8;
|
|
1502
1780
|
var MAX_TITLE = 80;
|
|
1781
|
+
var MAX_WAITING_FOR = 60;
|
|
1782
|
+
var UNKNOWN_RELIST_MS = 2e4;
|
|
1783
|
+
var MAX_WATCHED_PROJECT_DIRS = 200;
|
|
1503
1784
|
var MAX_SCANNED_PROJECT_DIRS = 2e3;
|
|
1504
1785
|
var UNREADABLE_GRACE_MS2 = 6e4;
|
|
1505
1786
|
var NO_HOOKS = {
|
|
@@ -1520,7 +1801,7 @@ var AttachSupport = class {
|
|
|
1520
1801
|
constructor(agent, d) {
|
|
1521
1802
|
this.agent = agent;
|
|
1522
1803
|
this.d = d;
|
|
1523
|
-
this.configDir =
|
|
1804
|
+
this.configDir = path11.resolve(agent.env.CLAUDE_CONFIG_DIR ?? d.env.CLAUDE_CONFIG_DIR ?? path11.join(os3.homedir(), ".claude"));
|
|
1524
1805
|
}
|
|
1525
1806
|
agent;
|
|
1526
1807
|
d;
|
|
@@ -1579,7 +1860,7 @@ var AttachSupport = class {
|
|
|
1579
1860
|
sessionId: `list-${this.agent.id}`,
|
|
1580
1861
|
cwd: this.agent.roots[0] ?? os3.homedir(),
|
|
1581
1862
|
hooks: NO_HOOKS,
|
|
1582
|
-
logFile:
|
|
1863
|
+
logFile: path11.join(this.d.logDir, `adapter-${this.agent.id}.log`),
|
|
1583
1864
|
version: this.d.version,
|
|
1584
1865
|
initTimeoutMs: 3e4
|
|
1585
1866
|
});
|
|
@@ -1638,13 +1919,108 @@ var AttachSupport = class {
|
|
|
1638
1919
|
} else if (this.now() - this.listedAt > this.d.catalogMs) void this.refreshCatalog();
|
|
1639
1920
|
const live = await within(this.liveEntries().catch(() => null), capMs, null);
|
|
1640
1921
|
const attached = this.d.attachedRefs();
|
|
1641
|
-
return this.listed.slice(0, MAX_REPORTED).map((l) =>
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
cwd: l.cwd,
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
})
|
|
1922
|
+
return this.listed.slice(0, MAX_REPORTED).map((l) => {
|
|
1923
|
+
const holders = live?.filter((e) => e.sessionId.toLowerCase() === l.ref) ?? [];
|
|
1924
|
+
const state = attached.has(l.ref) ? "attached" : live === null ? "unknown" : holders.length ? "live" : "idle";
|
|
1925
|
+
const out = { ref: l.ref, title: l.title, cwd: l.cwd, updated_at: Math.floor(l.updatedAt / 1e3), state };
|
|
1926
|
+
if (state === "live") Object.assign(out, liveActivity(holders));
|
|
1927
|
+
return out;
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1930
|
+
// --- noticing a change without starting anything ----------------------------------------
|
|
1931
|
+
lastPrint = null;
|
|
1932
|
+
lastUnknownList = 0;
|
|
1933
|
+
/**
|
|
1934
|
+
* A cheap look, with no process started: the names and modification times of Claude Code's session registry files (one
|
|
1935
|
+
* is written when a terminal opens a conversation, rewritten when it starts or finishes a turn or starts waiting, and
|
|
1936
|
+
* removed when it exits) and of its projects directory (a new working directory). True when something changed since
|
|
1937
|
+
* the previous look, so that the caller reports at once rather than at the next heartbeat. A live conversation inside
|
|
1938
|
+
* the roots whose transcript exists but which the list does not have yet (a terminal just opened it), or a new transcript
|
|
1939
|
+
* in a project folder inside the roots (one a terminal ran and already ended), is listed again first, at most every
|
|
1940
|
+
* {@link UNKNOWN_RELIST_MS} (a change inside that time is listed when it is up). The first look only learns what is there.
|
|
1941
|
+
*/
|
|
1942
|
+
async poll() {
|
|
1943
|
+
const print = this.fingerprint();
|
|
1944
|
+
const projects = this.projectsPrint();
|
|
1945
|
+
if (print === this.lastPrint && projects === this.lastProjects && !this.relistDue) return false;
|
|
1946
|
+
const first = this.lastPrint === null;
|
|
1947
|
+
const looked = print !== this.lastPrint || projects !== this.lastProjects;
|
|
1948
|
+
const newTranscript = !first && projects !== this.lastProjects;
|
|
1949
|
+
this.lastPrint = print;
|
|
1950
|
+
this.lastProjects = projects;
|
|
1951
|
+
let rows;
|
|
1952
|
+
try {
|
|
1953
|
+
rows = readRegistry(this.configDir, this.localDomain, this.now());
|
|
1954
|
+
} catch {
|
|
1955
|
+
rows = null;
|
|
1956
|
+
}
|
|
1957
|
+
if (looked && (newTranscript || rows !== null && this.unlistedLive(rows))) this.relistDue = true;
|
|
1958
|
+
let relisted = false;
|
|
1959
|
+
if (this.relistDue && this.now() - this.lastUnknownList >= UNKNOWN_RELIST_MS) {
|
|
1960
|
+
this.relistDue = false;
|
|
1961
|
+
this.lastUnknownList = this.now();
|
|
1962
|
+
relisted = await within(this.refreshCatalog().then(() => true), 6e4, false);
|
|
1963
|
+
}
|
|
1964
|
+
const summary = (rows === null ? "unreadable;" : "") + this.listed.slice(0, MAX_REPORTED).map((l) => {
|
|
1965
|
+
const holders = (rows ?? []).filter((e) => e.sessionId.toLowerCase() === l.ref);
|
|
1966
|
+
return `${l.ref}:${holders.length ? liveActivity(holders).activity ?? "live" : "-"}:${liveActivity(holders).waiting_for ?? ""}`;
|
|
1967
|
+
}).join(",");
|
|
1968
|
+
const changed = summary !== this.lastSummary;
|
|
1969
|
+
this.lastSummary = summary;
|
|
1970
|
+
return changed && (!first || relisted);
|
|
1971
|
+
}
|
|
1972
|
+
lastSummary = "";
|
|
1973
|
+
lastProjects = null;
|
|
1974
|
+
/** A change asked for a new list that the rate limit has held back; the next look makes it. */
|
|
1975
|
+
relistDue = false;
|
|
1976
|
+
/**
|
|
1977
|
+
* The project folders inside the roots (Claude Code names one after the working directory, every non-alphanumeric
|
|
1978
|
+
* turned into "-") with their modification times: one changes when a conversation is started in it. At most
|
|
1979
|
+
* {@link MAX_WATCHED_PROJECT_DIRS} of them, so a huge config directory is not walked every few seconds.
|
|
1980
|
+
*/
|
|
1981
|
+
projectsPrint() {
|
|
1982
|
+
const projects = path11.join(this.configDir, "projects");
|
|
1983
|
+
const prefixes = this.agent.roots.map((r) => r.replace(/[^A-Za-z0-9]/g, "-"));
|
|
1984
|
+
const parts = [];
|
|
1985
|
+
try {
|
|
1986
|
+
parts.push(`projects:${fs11.statSync(projects).mtimeMs}`);
|
|
1987
|
+
for (const name of fs11.readdirSync(projects).sort()) {
|
|
1988
|
+
if (!prefixes.some((p) => name.startsWith(p))) continue;
|
|
1989
|
+
if (parts.length > MAX_WATCHED_PROJECT_DIRS) break;
|
|
1990
|
+
try {
|
|
1991
|
+
parts.push(`${name}:${fs11.statSync(path11.join(projects, name)).mtimeMs}`);
|
|
1992
|
+
} catch {
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
} catch {
|
|
1996
|
+
parts.push("no-projects");
|
|
1997
|
+
}
|
|
1998
|
+
return parts.join("|");
|
|
1999
|
+
}
|
|
2000
|
+
fingerprint() {
|
|
2001
|
+
const parts = [];
|
|
2002
|
+
const dir = path11.join(this.configDir, "sessions");
|
|
2003
|
+
try {
|
|
2004
|
+
for (const name of fs11.readdirSync(dir).sort()) {
|
|
2005
|
+
if (!name.endsWith(".json")) continue;
|
|
2006
|
+
try {
|
|
2007
|
+
parts.push(`${name}:${fs11.statSync(path11.join(dir, name)).mtimeMs}`);
|
|
2008
|
+
} catch {
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
} catch {
|
|
2012
|
+
parts.push("no-sessions");
|
|
2013
|
+
}
|
|
2014
|
+
return parts.join("|");
|
|
2015
|
+
}
|
|
2016
|
+
/** Whether a process holds open a conversation in the roots, with a transcript on disk, that the last list did not have. */
|
|
2017
|
+
unlistedLive(rows) {
|
|
2018
|
+
return rows.some((e) => {
|
|
2019
|
+
const ref = e.sessionId.toLowerCase();
|
|
2020
|
+
if (e.foreign || !SESSION_REF_RE.test(ref) || !e.cwd || this.listed.some((l) => l.ref === ref)) return false;
|
|
2021
|
+
if (!checkCwd(e.cwd, this.agent.roots).ok) return false;
|
|
2022
|
+
return fs11.existsSync(path11.join(this.configDir, "projects", e.cwd.replace(/[^A-Za-z0-9]/g, "-"), `${ref}.jsonl`));
|
|
2023
|
+
});
|
|
1648
2024
|
}
|
|
1649
2025
|
// --- the twin check ----------------------------------------------------------------
|
|
1650
2026
|
/**
|
|
@@ -1673,7 +2049,7 @@ var AttachSupport = class {
|
|
|
1673
2049
|
const file = this.transcriptFile(want);
|
|
1674
2050
|
let age = null;
|
|
1675
2051
|
try {
|
|
1676
|
-
if (file) age = this.now() -
|
|
2052
|
+
if (file) age = this.now() - fs11.statSync(file).mtimeMs;
|
|
1677
2053
|
} catch {
|
|
1678
2054
|
}
|
|
1679
2055
|
if (age !== null && age < this.d.recentWriteMs) return { ok: false, reason: "recently_written", detail: `its transcript was written ${Math.max(0, Math.round(age / 1e3))} s ago` };
|
|
@@ -1728,7 +2104,19 @@ var AttachSupport = class {
|
|
|
1728
2104
|
transcriptSize(ref) {
|
|
1729
2105
|
const file = SESSION_REF_RE.test(ref) ? this.transcriptFile(ref.toLowerCase()) : null;
|
|
1730
2106
|
try {
|
|
1731
|
-
return file ?
|
|
2107
|
+
return file ? fs11.statSync(file).size : null;
|
|
2108
|
+
} catch {
|
|
2109
|
+
return null;
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
/** Where the conversation's transcript is, or null when it cannot be found (yet). */
|
|
2113
|
+
transcriptPath(ref) {
|
|
2114
|
+
const file = SESSION_REF_RE.test(ref) ? this.transcriptFile(ref.toLowerCase()) : null;
|
|
2115
|
+
if (!file) return null;
|
|
2116
|
+
try {
|
|
2117
|
+
const root = fs11.realpathSync(path11.join(this.configDir, "projects"));
|
|
2118
|
+
const real = fs11.realpathSync(file);
|
|
2119
|
+
return real.startsWith(root + path11.sep) && real.endsWith(`${ref.toLowerCase()}.jsonl`) ? real : null;
|
|
1732
2120
|
} catch {
|
|
1733
2121
|
return null;
|
|
1734
2122
|
}
|
|
@@ -1736,24 +2124,24 @@ var AttachSupport = class {
|
|
|
1736
2124
|
/** `<config dir>/projects/<working directory with every non-alphanumeric turned into "-">/<id>.jsonl`, found by name when that guess misses. */
|
|
1737
2125
|
transcriptFile(ref) {
|
|
1738
2126
|
const cached = this.transcripts.get(ref);
|
|
1739
|
-
if (cached &&
|
|
2127
|
+
if (cached && fs11.existsSync(cached)) return cached;
|
|
1740
2128
|
this.transcripts.delete(ref);
|
|
1741
|
-
const projects =
|
|
2129
|
+
const projects = path11.join(this.configDir, "projects");
|
|
1742
2130
|
const remember = (f) => {
|
|
1743
2131
|
this.transcripts.set(ref, f);
|
|
1744
2132
|
return f;
|
|
1745
2133
|
};
|
|
1746
2134
|
const known = this.listed.find((l) => l.ref === ref);
|
|
1747
2135
|
if (known) {
|
|
1748
|
-
const guess =
|
|
1749
|
-
if (
|
|
2136
|
+
const guess = path11.join(projects, known.cwd.replace(/[^A-Za-z0-9]/g, "-"), `${ref}.jsonl`);
|
|
2137
|
+
if (fs11.existsSync(guess)) return remember(guess);
|
|
1750
2138
|
}
|
|
1751
2139
|
try {
|
|
1752
2140
|
let n = 0;
|
|
1753
|
-
for (const dir of
|
|
2141
|
+
for (const dir of fs11.readdirSync(projects)) {
|
|
1754
2142
|
if (++n > MAX_SCANNED_PROJECT_DIRS) break;
|
|
1755
|
-
const f =
|
|
1756
|
-
if (
|
|
2143
|
+
const f = path11.join(projects, dir, `${ref}.jsonl`);
|
|
2144
|
+
if (fs11.existsSync(f)) return remember(f);
|
|
1757
2145
|
}
|
|
1758
2146
|
} catch {
|
|
1759
2147
|
}
|
|
@@ -1793,30 +2181,37 @@ function parseAgentsJson(text) {
|
|
|
1793
2181
|
const out = [];
|
|
1794
2182
|
for (const x of raw) {
|
|
1795
2183
|
if (isObject(x) && typeof x.pid === "number" && Number.isInteger(x.pid) && typeof x.sessionId === "string") {
|
|
1796
|
-
out.push({
|
|
2184
|
+
out.push({
|
|
2185
|
+
pid: x.pid,
|
|
2186
|
+
sessionId: x.sessionId,
|
|
2187
|
+
status: typeof x.status === "string" ? x.status : null,
|
|
2188
|
+
foreign: false,
|
|
2189
|
+
waitingFor: typeof x.waitingFor === "string" ? x.waitingFor : null,
|
|
2190
|
+
cwd: typeof x.cwd === "string" ? x.cwd : null
|
|
2191
|
+
});
|
|
1797
2192
|
}
|
|
1798
2193
|
}
|
|
1799
2194
|
return out;
|
|
1800
2195
|
}
|
|
1801
2196
|
function readRegistry(configDir, localDomain, now = Date.now()) {
|
|
1802
|
-
const dir =
|
|
2197
|
+
const dir = path11.join(configDir, "sessions");
|
|
1803
2198
|
let names;
|
|
1804
2199
|
try {
|
|
1805
|
-
names =
|
|
2200
|
+
names = fs11.readdirSync(dir);
|
|
1806
2201
|
} catch (e) {
|
|
1807
2202
|
throw new TwinCheckError(`${dir}: ${errMessage(e)}`);
|
|
1808
2203
|
}
|
|
1809
2204
|
const out = [];
|
|
1810
2205
|
for (const name of names) {
|
|
1811
2206
|
if (!name.endsWith(".json")) continue;
|
|
1812
|
-
const file =
|
|
2207
|
+
const file = path11.join(dir, name);
|
|
1813
2208
|
let doc;
|
|
1814
2209
|
try {
|
|
1815
|
-
doc = JSON.parse(
|
|
2210
|
+
doc = JSON.parse(fs11.readFileSync(file, "utf8"));
|
|
1816
2211
|
} catch (e) {
|
|
1817
2212
|
let age = Infinity;
|
|
1818
2213
|
try {
|
|
1819
|
-
age = now -
|
|
2214
|
+
age = now - fs11.statSync(file).mtimeMs;
|
|
1820
2215
|
} catch {
|
|
1821
2216
|
}
|
|
1822
2217
|
if (age < UNREADABLE_GRACE_MS2) throw new TwinCheckError(`${file} is unreadable (${errMessage(e).split("\n")[0]})`);
|
|
@@ -1829,14 +2224,21 @@ function readRegistry(configDir, localDomain, now = Date.now()) {
|
|
|
1829
2224
|
if (!isAlive(doc.pid)) continue;
|
|
1830
2225
|
if (process.platform === "linux" && typeof doc.procStart === "string" && processStart(doc.pid) !== doc.procStart) continue;
|
|
1831
2226
|
}
|
|
1832
|
-
out.push({
|
|
2227
|
+
out.push({
|
|
2228
|
+
pid: doc.pid,
|
|
2229
|
+
sessionId: doc.sessionId,
|
|
2230
|
+
status: typeof doc.status === "string" ? doc.status : null,
|
|
2231
|
+
foreign,
|
|
2232
|
+
waitingFor: typeof doc.waitingFor === "string" ? doc.waitingFor : null,
|
|
2233
|
+
cwd: typeof doc.cwd === "string" ? doc.cwd : null
|
|
2234
|
+
});
|
|
1833
2235
|
}
|
|
1834
2236
|
return out;
|
|
1835
2237
|
}
|
|
1836
2238
|
function processGroup(pid) {
|
|
1837
2239
|
try {
|
|
1838
2240
|
if (process.platform === "linux") {
|
|
1839
|
-
const stat =
|
|
2241
|
+
const stat = fs11.readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
1840
2242
|
return Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[2]) || null;
|
|
1841
2243
|
}
|
|
1842
2244
|
return Number(execFileSync2("ps", ["-o", "pgid=", "-p", String(pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim()) || null;
|
|
@@ -1844,6 +2246,16 @@ function processGroup(pid) {
|
|
|
1844
2246
|
return null;
|
|
1845
2247
|
}
|
|
1846
2248
|
}
|
|
2249
|
+
function liveActivity(holders) {
|
|
2250
|
+
const waiting = holders.find((e) => e.status === "waiting");
|
|
2251
|
+
if (waiting) {
|
|
2252
|
+
const why = waiting.waitingFor ? cleanTitle(waiting.waitingFor).slice(0, MAX_WAITING_FOR) : "";
|
|
2253
|
+
return why ? { activity: "waiting", waiting_for: why } : { activity: "waiting" };
|
|
2254
|
+
}
|
|
2255
|
+
if (holders.some((e) => e.status === "busy")) return { activity: "working" };
|
|
2256
|
+
if (holders.some((e) => e.status === "idle")) return { activity: "idle" };
|
|
2257
|
+
return {};
|
|
2258
|
+
}
|
|
1847
2259
|
function cleanTitle(raw) {
|
|
1848
2260
|
const one = redactText(raw).text.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
|
|
1849
2261
|
return one.length > MAX_TITLE ? `${one.slice(0, MAX_TITLE - 1)}\u2026` : one;
|
|
@@ -1854,70 +2266,223 @@ function fitResumable(base, items, limit = REMOTE_AGENT_LIMITS.maxCapabilitiesBy
|
|
|
1854
2266
|
for (; ; ) {
|
|
1855
2267
|
const caps = { ...base, resumable: items.slice(0, n) };
|
|
1856
2268
|
if (size(caps) <= limit) return caps;
|
|
1857
|
-
if (n === 0)
|
|
2269
|
+
if (n === 0) {
|
|
2270
|
+
const { watch_live: _watch, ...rest } = base;
|
|
2271
|
+
return size(base) > limit && size(rest) < size(base) ? rest : base;
|
|
2272
|
+
}
|
|
1858
2273
|
n--;
|
|
1859
2274
|
}
|
|
1860
2275
|
}
|
|
2276
|
+
var MAX_TOOL_TITLE = 200;
|
|
2277
|
+
function transcriptUpdates(line) {
|
|
2278
|
+
let doc;
|
|
2279
|
+
try {
|
|
2280
|
+
doc = JSON.parse(line);
|
|
2281
|
+
} catch {
|
|
2282
|
+
return [];
|
|
2283
|
+
}
|
|
2284
|
+
if (!isObject(doc) || doc.isSidechain === true || doc.isMeta === true) return [];
|
|
2285
|
+
if (doc.type !== "user" && doc.type !== "assistant") return [];
|
|
2286
|
+
const msg = isObject(doc.message) ? doc.message : null;
|
|
2287
|
+
if (!msg) return [];
|
|
2288
|
+
const text = (type, t) => t.trim() ? [{ type, update: { sessionUpdate: type, content: { type: "text", text: t } } }] : [];
|
|
2289
|
+
if (typeof msg.content === "string") return text(doc.type === "user" ? "user_message_chunk" : "agent_message_chunk", msg.content);
|
|
2290
|
+
if (!Array.isArray(msg.content)) return [];
|
|
2291
|
+
const out = [];
|
|
2292
|
+
for (const block of msg.content) {
|
|
2293
|
+
if (!isObject(block)) continue;
|
|
2294
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
2295
|
+
out.push(...text(doc.type === "user" ? "user_message_chunk" : "agent_message_chunk", block.text));
|
|
2296
|
+
} else if (doc.type === "assistant" && block.type === "tool_use" && typeof block.id === "string") {
|
|
2297
|
+
out.push({ type: "tool_call", update: { sessionUpdate: "tool_call", toolCallId: block.id, title: toolTitle(block.name, block.input), kind: "other", status: "in_progress", rawInput: isObject(block.input) ? block.input : {} } });
|
|
2298
|
+
} else if (doc.type === "user" && block.type === "tool_result" && typeof block.tool_use_id === "string") {
|
|
2299
|
+
const result = resultText(block.content);
|
|
2300
|
+
out.push({ type: "tool_call_update", update: {
|
|
2301
|
+
sessionUpdate: "tool_call_update",
|
|
2302
|
+
toolCallId: block.tool_use_id,
|
|
2303
|
+
status: block.is_error === true ? "failed" : "completed",
|
|
2304
|
+
...result ? { content: [{ type: "content", content: { type: "text", text: result } }] } : {}
|
|
2305
|
+
} });
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
return out;
|
|
2309
|
+
}
|
|
2310
|
+
function toolTitle(name, input) {
|
|
2311
|
+
const tool = typeof name === "string" && name ? name : "Tool";
|
|
2312
|
+
const i = isObject(input) ? input : {};
|
|
2313
|
+
const what = [i.description, i.command, i.file_path, i.path, i.pattern, i.url].find((v) => typeof v === "string" && v.trim() !== "");
|
|
2314
|
+
return cleanTitleTo(what ? `${tool}: ${what}` : tool, MAX_TOOL_TITLE);
|
|
2315
|
+
}
|
|
2316
|
+
function resultText(content) {
|
|
2317
|
+
if (typeof content === "string") return content;
|
|
2318
|
+
if (!Array.isArray(content)) return "";
|
|
2319
|
+
return content.filter((c) => isObject(c) && c.type === "text" && typeof c.text === "string").map((c) => c.text).join("\n");
|
|
2320
|
+
}
|
|
2321
|
+
function cleanTitleTo(raw, max) {
|
|
2322
|
+
const one = raw.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
|
|
2323
|
+
return one.length > max ? `${one.slice(0, max - 1)}\u2026` : one;
|
|
2324
|
+
}
|
|
2325
|
+
var TAIL_BACKLOG_BYTES = 64 * 1024;
|
|
2326
|
+
var TAIL_READ_BYTES = 256 * 1024;
|
|
2327
|
+
var TAIL_MAX_LINE = 1024 * 1024;
|
|
2328
|
+
var TAIL_BACKLOG_UPDATES = 20;
|
|
2329
|
+
var TranscriptTail = class {
|
|
2330
|
+
constructor(locate) {
|
|
2331
|
+
this.locate = locate;
|
|
2332
|
+
}
|
|
2333
|
+
locate;
|
|
2334
|
+
offset = null;
|
|
2335
|
+
partial = "";
|
|
2336
|
+
skipping = false;
|
|
2337
|
+
/** A read can end inside a character; the decoder keeps its first bytes for the next one. */
|
|
2338
|
+
decoder = new StringDecoder("utf8");
|
|
2339
|
+
/** The last updates already in the transcript (at most {@link TAIL_BACKLOG_UPDATES}); from here on `poll()` reads what is new. */
|
|
2340
|
+
backlog() {
|
|
2341
|
+
const file = this.locate();
|
|
2342
|
+
if (!file) return [];
|
|
2343
|
+
let fd = null;
|
|
2344
|
+
try {
|
|
2345
|
+
fd = fs11.openSync(file, "r");
|
|
2346
|
+
const size = fs11.fstatSync(fd).size;
|
|
2347
|
+
const start = Math.max(0, size - TAIL_BACKLOG_BYTES);
|
|
2348
|
+
const buf = Buffer.alloc(size - start);
|
|
2349
|
+
const n = buf.length ? fs11.readSync(fd, buf, 0, buf.length, start) : 0;
|
|
2350
|
+
this.offset = start + n;
|
|
2351
|
+
this.decoder = new StringDecoder("utf8");
|
|
2352
|
+
let text = this.decoder.write(buf.subarray(0, n));
|
|
2353
|
+
if (start > 0) text = text.slice(text.indexOf("\n") + 1);
|
|
2354
|
+
const end = text.lastIndexOf("\n");
|
|
2355
|
+
this.partial = end === text.length - 1 ? "" : text.slice(end + 1);
|
|
2356
|
+
const updates = text.slice(0, end + 1).split("\n").flatMap((l) => l ? transcriptUpdates(l) : []);
|
|
2357
|
+
return updates.slice(-TAIL_BACKLOG_UPDATES);
|
|
2358
|
+
} catch {
|
|
2359
|
+
return [];
|
|
2360
|
+
} finally {
|
|
2361
|
+
if (fd !== null) fs11.closeSync(fd);
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
/** Start from the end without reading what is there (a watcher that was already following, after a restart). */
|
|
2365
|
+
fromEnd() {
|
|
2366
|
+
const file = this.locate();
|
|
2367
|
+
try {
|
|
2368
|
+
this.offset = file ? fs11.statSync(file).size : null;
|
|
2369
|
+
} catch {
|
|
2370
|
+
this.offset = null;
|
|
2371
|
+
}
|
|
2372
|
+
this.partial = "";
|
|
2373
|
+
this.decoder = new StringDecoder("utf8");
|
|
2374
|
+
}
|
|
2375
|
+
/** What was appended since the last look. */
|
|
2376
|
+
poll() {
|
|
2377
|
+
const file = this.locate();
|
|
2378
|
+
if (!file) return [];
|
|
2379
|
+
let fd = null;
|
|
2380
|
+
try {
|
|
2381
|
+
fd = fs11.openSync(file, "r");
|
|
2382
|
+
const size = fs11.fstatSync(fd).size;
|
|
2383
|
+
if (this.offset === null) this.offset = 0;
|
|
2384
|
+
if (size < this.offset) {
|
|
2385
|
+
this.offset = size;
|
|
2386
|
+
this.partial = "";
|
|
2387
|
+
this.decoder = new StringDecoder("utf8");
|
|
2388
|
+
return [];
|
|
2389
|
+
}
|
|
2390
|
+
if (size === this.offset) return [];
|
|
2391
|
+
const buf = Buffer.alloc(Math.min(TAIL_READ_BYTES, size - this.offset));
|
|
2392
|
+
const n = fs11.readSync(fd, buf, 0, buf.length, this.offset);
|
|
2393
|
+
this.offset += n;
|
|
2394
|
+
return this.take(this.decoder.write(buf.subarray(0, n)));
|
|
2395
|
+
} catch {
|
|
2396
|
+
return [];
|
|
2397
|
+
} finally {
|
|
2398
|
+
if (fd !== null) fs11.closeSync(fd);
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
take(chunk) {
|
|
2402
|
+
const text = this.partial + chunk;
|
|
2403
|
+
const end = text.lastIndexOf("\n");
|
|
2404
|
+
if (end < 0) {
|
|
2405
|
+
this.partial = text;
|
|
2406
|
+
if (this.partial.length > TAIL_MAX_LINE) {
|
|
2407
|
+
this.partial = "";
|
|
2408
|
+
this.skipping = true;
|
|
2409
|
+
}
|
|
2410
|
+
return [];
|
|
2411
|
+
}
|
|
2412
|
+
this.partial = text.slice(end + 1);
|
|
2413
|
+
const lines = text.slice(0, end).split("\n");
|
|
2414
|
+
if (this.skipping) {
|
|
2415
|
+
lines.shift();
|
|
2416
|
+
this.skipping = false;
|
|
2417
|
+
}
|
|
2418
|
+
if (this.partial.length > TAIL_MAX_LINE) {
|
|
2419
|
+
this.partial = "";
|
|
2420
|
+
this.skipping = true;
|
|
2421
|
+
}
|
|
2422
|
+
return lines.flatMap((l) => l ? transcriptUpdates(l) : []);
|
|
2423
|
+
}
|
|
2424
|
+
};
|
|
1861
2425
|
|
|
1862
2426
|
// src/health.ts
|
|
1863
|
-
import
|
|
1864
|
-
import
|
|
2427
|
+
import fs12 from "node:fs";
|
|
2428
|
+
import path13 from "node:path";
|
|
1865
2429
|
|
|
1866
2430
|
// src/paths.ts
|
|
1867
2431
|
import os4 from "node:os";
|
|
1868
|
-
import
|
|
2432
|
+
import path12 from "node:path";
|
|
1869
2433
|
function resolveDirs(env = process.env, home = os4.homedir()) {
|
|
1870
2434
|
const one = env.CLAW_CONNECTOR_HOME?.trim();
|
|
1871
|
-
if (one) return { config:
|
|
1872
|
-
const cfg = env.XDG_CONFIG_HOME?.trim() ||
|
|
1873
|
-
const st = env.XDG_STATE_HOME?.trim() ||
|
|
1874
|
-
return { config:
|
|
1875
|
-
}
|
|
1876
|
-
var configFile = (d) =>
|
|
1877
|
-
var credentialsFile = (d) =>
|
|
1878
|
-
var identityFile = (d) =>
|
|
1879
|
-
var sessionsFile = (d) =>
|
|
1880
|
-
var childrenFile = (d) =>
|
|
1881
|
-
var pidFile = (d) =>
|
|
1882
|
-
var pauseFile = (d) =>
|
|
1883
|
-
var
|
|
1884
|
-
var
|
|
1885
|
-
var
|
|
1886
|
-
var
|
|
1887
|
-
var
|
|
1888
|
-
var
|
|
1889
|
-
var
|
|
1890
|
-
var
|
|
1891
|
-
var
|
|
2435
|
+
if (one) return { config: path12.join(one, "config"), state: path12.join(one, "state") };
|
|
2436
|
+
const cfg = env.XDG_CONFIG_HOME?.trim() || path12.join(home, ".config");
|
|
2437
|
+
const st = env.XDG_STATE_HOME?.trim() || path12.join(home, ".local", "state");
|
|
2438
|
+
return { config: path12.join(cfg, "claw-connector"), state: path12.join(st, "claw-connector") };
|
|
2439
|
+
}
|
|
2440
|
+
var configFile = (d) => path12.join(d.config, "agents.toml");
|
|
2441
|
+
var credentialsFile = (d) => path12.join(d.config, "credentials.json");
|
|
2442
|
+
var identityFile = (d) => path12.join(d.state, "connector.json");
|
|
2443
|
+
var sessionsFile = (d) => path12.join(d.state, "sessions.json");
|
|
2444
|
+
var childrenFile = (d) => path12.join(d.state, "children.json");
|
|
2445
|
+
var pidFile = (d) => path12.join(d.state, "connector.pid");
|
|
2446
|
+
var pauseFile = (d) => path12.join(d.state, "paused");
|
|
2447
|
+
var stopRequestFile = (d) => path12.join(d.state, "stop-requested.json");
|
|
2448
|
+
var auditFile = (d) => path12.join(d.state, "audit.jsonl");
|
|
2449
|
+
var outboxDir = (d) => path12.join(d.state, "outbox");
|
|
2450
|
+
var logDir = (d) => path12.join(d.state, "logs");
|
|
2451
|
+
var disconnectsFile = (d) => path12.join(d.state, "disconnects.jsonl");
|
|
2452
|
+
var needsLoginFile = (d) => path12.join(d.state, "needs-login.json");
|
|
2453
|
+
var supervisorPidFile = (d) => path12.join(d.state, "supervisor.pid");
|
|
2454
|
+
var runLogFile = (d) => path12.join(d.state, "run.log");
|
|
2455
|
+
var keepaliveFile = (d) => path12.join(d.state, "keepalive.sh");
|
|
2456
|
+
var serviceFile = (d) => path12.join(d.state, "service.json");
|
|
1892
2457
|
|
|
1893
2458
|
// src/health.ts
|
|
1894
2459
|
var MAX_BYTES = 128 * 1024;
|
|
1895
2460
|
function recordDisconnect(dirs, d) {
|
|
1896
2461
|
const file = disconnectsFile(dirs);
|
|
1897
2462
|
try {
|
|
1898
|
-
|
|
1899
|
-
const fd =
|
|
2463
|
+
fs12.mkdirSync(path13.dirname(file), { recursive: true, mode: 448 });
|
|
2464
|
+
const fd = fs12.openSync(file, "a+", 384);
|
|
1900
2465
|
let lines = null;
|
|
1901
2466
|
try {
|
|
1902
|
-
|
|
1903
|
-
const size =
|
|
2467
|
+
fs12.writeSync(fd, JSON.stringify({ at: d.at ?? (/* @__PURE__ */ new Date()).toISOString(), reason: d.reason, ...d.detail ? { detail: d.detail.slice(0, 300) } : {} }) + "\n");
|
|
2468
|
+
const size = fs12.fstatSync(fd).size;
|
|
1904
2469
|
if (size > MAX_BYTES) {
|
|
1905
2470
|
const buf = Buffer.alloc(size);
|
|
1906
2471
|
let got = 0;
|
|
1907
2472
|
while (got < size) {
|
|
1908
|
-
const n =
|
|
2473
|
+
const n = fs12.readSync(fd, buf, got, size - got, got);
|
|
1909
2474
|
if (n === 0) break;
|
|
1910
2475
|
got += n;
|
|
1911
2476
|
}
|
|
1912
2477
|
lines = buf.subarray(0, got).toString("utf8").split("\n").filter(Boolean);
|
|
1913
2478
|
}
|
|
1914
2479
|
} finally {
|
|
1915
|
-
|
|
2480
|
+
fs12.closeSync(fd);
|
|
1916
2481
|
}
|
|
1917
2482
|
if (lines) {
|
|
1918
2483
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
1919
|
-
|
|
1920
|
-
|
|
2484
|
+
fs12.writeFileSync(tmp, lines.slice(Math.floor(lines.length / 2)).join("\n") + "\n", { mode: 384 });
|
|
2485
|
+
fs12.renameSync(tmp, file);
|
|
1921
2486
|
}
|
|
1922
2487
|
} catch {
|
|
1923
2488
|
}
|
|
@@ -1925,7 +2490,7 @@ function recordDisconnect(dirs, d) {
|
|
|
1925
2490
|
function recentDisconnects(dirs, n = 1) {
|
|
1926
2491
|
try {
|
|
1927
2492
|
const out = [];
|
|
1928
|
-
for (const line of
|
|
2493
|
+
for (const line of fs12.readFileSync(disconnectsFile(dirs), "utf8").split("\n").filter(Boolean).slice(-n)) {
|
|
1929
2494
|
try {
|
|
1930
2495
|
const v = JSON.parse(line);
|
|
1931
2496
|
if (isObject(v) && typeof v.at === "string" && typeof v.reason === "string") out.push({ at: v.at, reason: v.reason, ...typeof v.detail === "string" ? { detail: v.detail } : {} });
|
|
@@ -1939,14 +2504,14 @@ function recentDisconnects(dirs, n = 1) {
|
|
|
1939
2504
|
}
|
|
1940
2505
|
function markNeedsLogin(dirs, reason) {
|
|
1941
2506
|
try {
|
|
1942
|
-
|
|
1943
|
-
|
|
2507
|
+
fs12.mkdirSync(dirs.state, { recursive: true, mode: 448 });
|
|
2508
|
+
fs12.writeFileSync(needsLoginFile(dirs), JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString(), reason: reason.slice(0, 300) }), { mode: 384 });
|
|
1944
2509
|
} catch {
|
|
1945
2510
|
}
|
|
1946
2511
|
}
|
|
1947
2512
|
function needsLogin(dirs) {
|
|
1948
2513
|
try {
|
|
1949
|
-
const v = JSON.parse(
|
|
2514
|
+
const v = JSON.parse(fs12.readFileSync(needsLoginFile(dirs), "utf8"));
|
|
1950
2515
|
if (isObject(v) && typeof v.reason === "string") return { at: typeof v.at === "string" ? v.at : "", reason: v.reason };
|
|
1951
2516
|
return { at: "", reason: "unreadable needs-login.json" };
|
|
1952
2517
|
} catch (e) {
|
|
@@ -1954,11 +2519,11 @@ function needsLogin(dirs) {
|
|
|
1954
2519
|
}
|
|
1955
2520
|
}
|
|
1956
2521
|
function clearNeedsLogin(dirs) {
|
|
1957
|
-
|
|
2522
|
+
fs12.rmSync(needsLoginFile(dirs), { force: true });
|
|
1958
2523
|
}
|
|
1959
2524
|
|
|
1960
2525
|
// src/session.ts
|
|
1961
|
-
import
|
|
2526
|
+
import path14 from "node:path";
|
|
1962
2527
|
|
|
1963
2528
|
// src/permissions.ts
|
|
1964
2529
|
import { randomUUID } from "node:crypto";
|
|
@@ -2086,7 +2651,15 @@ var PermissionBook = class {
|
|
|
2086
2651
|
};
|
|
2087
2652
|
|
|
2088
2653
|
// src/session.ts
|
|
2089
|
-
var DEFAULT_SESSION_TIMING = {
|
|
2654
|
+
var DEFAULT_SESSION_TIMING = {
|
|
2655
|
+
idleMs: 30 * 6e4,
|
|
2656
|
+
permissionTtlMs: 10 * 6e4,
|
|
2657
|
+
modeConfirmMs: 3e3,
|
|
2658
|
+
twinWatchMs: 1e3,
|
|
2659
|
+
spectateMs: 1e3,
|
|
2660
|
+
spectateCheckMs: 5e3
|
|
2661
|
+
};
|
|
2662
|
+
var READ_ONLY_REASON = "read_only_live_session";
|
|
2090
2663
|
var SessionRefusal = class extends Error {
|
|
2091
2664
|
constructor(reason, message) {
|
|
2092
2665
|
super(message ?? reason);
|
|
@@ -2094,6 +2667,7 @@ var SessionRefusal = class extends Error {
|
|
|
2094
2667
|
}
|
|
2095
2668
|
reason;
|
|
2096
2669
|
};
|
|
2670
|
+
var BYPASS_HINT = ` (Claude Code offers it to root only with IS_SANDBOX set, and never when permissions.disableBypassPermissionsMode is "disable"; "claw-connector doctor" names the fix)`;
|
|
2097
2671
|
var MAX_TOOL_OUTPUT_BYTES = 8 * 1024;
|
|
2098
2672
|
var MAX_QUEUED_PROMPTS = 5;
|
|
2099
2673
|
var PERMISSION_WARNING_RE = /canUseTool will not be invoked/i;
|
|
@@ -2151,6 +2725,15 @@ var Session = class {
|
|
|
2151
2725
|
queue = [];
|
|
2152
2726
|
launching = null;
|
|
2153
2727
|
idle;
|
|
2728
|
+
/**
|
|
2729
|
+
* The conversation is open in a terminal: this session only reads its transcript and shows it (see {@link spectate}).
|
|
2730
|
+
* No adapter is ever started for it, and every command that would write is refused.
|
|
2731
|
+
*/
|
|
2732
|
+
readOnly = false;
|
|
2733
|
+
tail = null;
|
|
2734
|
+
tailTimer;
|
|
2735
|
+
lastTerminalCheck = 0;
|
|
2736
|
+
checkingTerminal = false;
|
|
2154
2737
|
book;
|
|
2155
2738
|
// --- helpers -----------------------------------------------------------------
|
|
2156
2739
|
emit(type, data) {
|
|
@@ -2209,6 +2792,11 @@ var Session = class {
|
|
|
2209
2792
|
if (requestedMode && !this.agent.safeModes.includes(requestedMode) && !this.agent.allowUnsafeModes) {
|
|
2210
2793
|
return this.refuse(new SessionRefusal("mode_not_allowed", `mode ${requestedMode} needs approval = "never" for this agent (or allow_unsafe_modes) on this machine`));
|
|
2211
2794
|
}
|
|
2795
|
+
const support = this.env.attach.get(this.agent.id);
|
|
2796
|
+
if (support && this.acpId) {
|
|
2797
|
+
const verdict = await support.check(this.acpId, { transcript: false });
|
|
2798
|
+
if (!verdict.ok && verdict.reason === "session_live") return this.spectate(support, title, verdict.detail);
|
|
2799
|
+
}
|
|
2212
2800
|
if (requestedMode) this.targetMode = requestedMode;
|
|
2213
2801
|
try {
|
|
2214
2802
|
await this.launch("resume");
|
|
@@ -2221,6 +2809,85 @@ var Session = class {
|
|
|
2221
2809
|
this.emit("session_started", { acp_session_id: this.acpId, agent_id: this.agent.id, kind: this.agent.kind, cwd: this.cwd, title, attached: true, modes: this.shownModes(), currentMode: this.currentMode, unattended: this.unattended });
|
|
2222
2810
|
this.touch();
|
|
2223
2811
|
}
|
|
2812
|
+
// --- read-only: a conversation open in a terminal ------------------------------------
|
|
2813
|
+
/**
|
|
2814
|
+
* Show a conversation that a terminal has open without taking it over. Nothing is started: the transcript the terminal
|
|
2815
|
+
* writes is read from its end (`TranscriptTail`, read-only) and each new line goes out as the update an adapter would
|
|
2816
|
+
* have sent, scrubbed like every other event, marked `read_only`. What was there just before is sent first, marked
|
|
2817
|
+
* `replay`. Prompts, mode changes and decisions are refused (`read_only_live_session`) and never reach anything that
|
|
2818
|
+
* could write to the transcript. When the terminal no longer has the conversation open the session ends
|
|
2819
|
+
* (`terminal_closed`): taking it over then is a new session, through the full check.
|
|
2820
|
+
*/
|
|
2821
|
+
spectate(support, title, detail) {
|
|
2822
|
+
this.readOnly = true;
|
|
2823
|
+
this.tail = new TranscriptTail(() => this.acpId ? support.transcriptPath(this.acpId) : null);
|
|
2824
|
+
const backlog = this.tail.backlog();
|
|
2825
|
+
this.env.store.put(this.id, { agent_id: this.agent.id, acp_session_id: this.acpId, cwd: this.cwd, status: "active", mode: null, read_only: true, created_at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
2826
|
+
this.audit("session_spectating", { cwd: this.cwd, acp_session_id: this.acpId, detail: detail.slice(0, 300) });
|
|
2827
|
+
this.emit("session_started", {
|
|
2828
|
+
acp_session_id: this.acpId,
|
|
2829
|
+
agent_id: this.agent.id,
|
|
2830
|
+
kind: this.agent.kind,
|
|
2831
|
+
cwd: this.cwd,
|
|
2832
|
+
title,
|
|
2833
|
+
attached: true,
|
|
2834
|
+
read_only: true,
|
|
2835
|
+
read_only_reason: "session_live",
|
|
2836
|
+
modes: [],
|
|
2837
|
+
currentMode: null,
|
|
2838
|
+
unattended: false
|
|
2839
|
+
});
|
|
2840
|
+
this.announced = true;
|
|
2841
|
+
for (const u of backlog) this.emitTranscript(u, true);
|
|
2842
|
+
this.followTail(support);
|
|
2843
|
+
}
|
|
2844
|
+
/** After a restart: a session that was read-only is again, from the transcript's end (what was sent before is not sent twice). */
|
|
2845
|
+
restoreSpectate() {
|
|
2846
|
+
this.readOnly = true;
|
|
2847
|
+
const support = this.env.attach.get(this.agent.id);
|
|
2848
|
+
if (!support) return;
|
|
2849
|
+
this.tail = new TranscriptTail(() => this.acpId ? support.transcriptPath(this.acpId) : null);
|
|
2850
|
+
this.tail.fromEnd();
|
|
2851
|
+
this.followTail(support);
|
|
2852
|
+
}
|
|
2853
|
+
followTail(support) {
|
|
2854
|
+
this.lastTerminalCheck = Date.now();
|
|
2855
|
+
const tick = () => {
|
|
2856
|
+
if (this.closed || !this.tail) return;
|
|
2857
|
+
for (const u of this.tail.poll()) this.emitTranscript(u, false);
|
|
2858
|
+
if (!this.checkingTerminal && Date.now() - this.lastTerminalCheck >= this.env.timing.spectateCheckMs) void this.checkTerminal(support);
|
|
2859
|
+
};
|
|
2860
|
+
this.tailTimer = setInterval(tick, this.env.timing.spectateMs);
|
|
2861
|
+
this.tailTimer.unref?.();
|
|
2862
|
+
}
|
|
2863
|
+
/** Is the conversation still open somewhere else? The cheap look first; only a look that finds nothing asks every source. */
|
|
2864
|
+
async checkTerminal(support) {
|
|
2865
|
+
if (!this.acpId) return;
|
|
2866
|
+
this.checkingTerminal = true;
|
|
2867
|
+
try {
|
|
2868
|
+
this.lastTerminalCheck = Date.now();
|
|
2869
|
+
if (support.peek(this.acpId, null)?.twin) return;
|
|
2870
|
+
const verdict = await support.check(this.acpId, { transcript: false });
|
|
2871
|
+
if (verdict.ok && !this.closed) {
|
|
2872
|
+
for (const u of this.tail?.poll() ?? []) this.emitTranscript(u, false);
|
|
2873
|
+
await this.close("terminal_closed");
|
|
2874
|
+
}
|
|
2875
|
+
} finally {
|
|
2876
|
+
this.checkingTerminal = false;
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
emitTranscript(u, replay) {
|
|
2880
|
+
const text = u.type === "agent_message_chunk" ? String(u.update.content?.text ?? "") : "";
|
|
2881
|
+
if (text && this.env.config.dropMessagePatterns.some((re) => re.test(text))) return;
|
|
2882
|
+
const data = limitStrings(redactDeep({ sessionId: this.acpId, update: u.update }), MAX_TOOL_OUTPUT_BYTES);
|
|
2883
|
+
this.emit(u.type, { ...data, read_only: true, ...replay ? { replay: true } : {} });
|
|
2884
|
+
}
|
|
2885
|
+
/** The process is stopping: the transcript is no longer followed (the session itself stays recorded, read-only). */
|
|
2886
|
+
stopTail() {
|
|
2887
|
+
clearInterval(this.tailTimer);
|
|
2888
|
+
this.tailTimer = void 0;
|
|
2889
|
+
this.tail = null;
|
|
2890
|
+
}
|
|
2224
2891
|
/** Start (or restart) the adapter and bring the ACP session up in a verified mode. One at a time. */
|
|
2225
2892
|
launch(how) {
|
|
2226
2893
|
this.launching ??= this.guardedLaunch(how).finally(() => {
|
|
@@ -2304,7 +2971,7 @@ var Session = class {
|
|
|
2304
2971
|
sessionId: this.id,
|
|
2305
2972
|
cwd: this.cwd,
|
|
2306
2973
|
hooks: this.hooks(),
|
|
2307
|
-
logFile:
|
|
2974
|
+
logFile: path14.join(this.env.logDir, `adapter-${this.agent.id}.log`),
|
|
2308
2975
|
version: this.env.version
|
|
2309
2976
|
}).catch((e) => {
|
|
2310
2977
|
throw new SessionRefusal("adapter_start_failed", errMessage(e));
|
|
@@ -2317,7 +2984,7 @@ var Session = class {
|
|
|
2317
2984
|
this.modeSeen = [];
|
|
2318
2985
|
try {
|
|
2319
2986
|
if (how === "new") {
|
|
2320
|
-
const res = await adapter.request("session/new", { cwd: this.cwd, mcpServers: [] });
|
|
2987
|
+
const res = await adapter.request("session/new", { cwd: this.cwd, mcpServers: [], ...claudeSessionMeta(this.agent) });
|
|
2321
2988
|
if (typeof res.sessionId !== "string") throw new SessionRefusal("adapter_protocol", "session/new returned no session id");
|
|
2322
2989
|
this.acpId = res.sessionId;
|
|
2323
2990
|
this.absorb(res);
|
|
@@ -2325,11 +2992,11 @@ var Session = class {
|
|
|
2325
2992
|
if (!this.acpId) throw new SessionRefusal("adapter_cannot_resume", "no adapter session to resume");
|
|
2326
2993
|
const caps = adapter.caps.sessionCapabilities;
|
|
2327
2994
|
if (caps?.resume) {
|
|
2328
|
-
this.absorb(await adapter.request("session/resume", { sessionId: this.acpId, cwd: this.cwd, mcpServers: [] }));
|
|
2995
|
+
this.absorb(await adapter.request("session/resume", { sessionId: this.acpId, cwd: this.cwd, mcpServers: [], ...claudeSessionMeta(this.agent) }));
|
|
2329
2996
|
} else if (adapter.caps.loadSession) {
|
|
2330
2997
|
this.replaying = true;
|
|
2331
2998
|
try {
|
|
2332
|
-
this.absorb(await adapter.requestUnbounded("session/load", { sessionId: this.acpId, cwd: this.cwd, mcpServers: [] }));
|
|
2999
|
+
this.absorb(await adapter.requestUnbounded("session/load", { sessionId: this.acpId, cwd: this.cwd, mcpServers: [], ...claudeSessionMeta(this.agent) }));
|
|
2333
3000
|
} finally {
|
|
2334
3001
|
this.replaying = false;
|
|
2335
3002
|
}
|
|
@@ -2370,7 +3037,7 @@ var Session = class {
|
|
|
2370
3037
|
async enforceMode(mode) {
|
|
2371
3038
|
const a = this.adapter;
|
|
2372
3039
|
if (!a || !this.acpId) throw new SessionRefusal("adapter_gone");
|
|
2373
|
-
if (this.modes.length && !this.modes.some((m) => m.id === mode)) throw new SessionRefusal("mode_unavailable", `the adapter offers no mode ${mode}`);
|
|
3040
|
+
if (this.modes.length && !this.modes.some((m) => m.id === mode)) throw new SessionRefusal("mode_unavailable", `the adapter offers no mode ${mode}${mode === "bypassPermissions" ? BYPASS_HINT : ""}`);
|
|
2374
3041
|
this.enforcing = true;
|
|
2375
3042
|
this.modeSeen = [];
|
|
2376
3043
|
try {
|
|
@@ -2426,6 +3093,7 @@ var Session = class {
|
|
|
2426
3093
|
}
|
|
2427
3094
|
/** `set_mode`: a user-chosen mode, only from the allowed list, always read back. */
|
|
2428
3095
|
async setMode(modeId, cmdId) {
|
|
3096
|
+
if (this.readOnly) return this.rejectCommand("set_mode", cmdId, READ_ONLY_REASON);
|
|
2429
3097
|
if (!this.adapter) await this.ensureAdapter();
|
|
2430
3098
|
if (!this.modeAllowed(modeId)) return this.rejectCommand("set_mode", cmdId, "mode_not_allowed");
|
|
2431
3099
|
if (this.modes.length && !this.modes.some((m) => m.id === modeId)) return this.rejectCommand("set_mode", cmdId, "mode_unavailable");
|
|
@@ -2600,16 +3268,22 @@ var Session = class {
|
|
|
2600
3268
|
* delivers it again instead of losing it.
|
|
2601
3269
|
*/
|
|
2602
3270
|
enqueuePrompt(text, cmdId, started = () => {
|
|
2603
|
-
}) {
|
|
3271
|
+
}, cmdSeq = null) {
|
|
2604
3272
|
if (this.closed) {
|
|
2605
|
-
this.rejectCommand("prompt", cmdId, "session_closed");
|
|
3273
|
+
this.rejectCommand("prompt", cmdId, "session_closed", cmdSeq);
|
|
3274
|
+
return started();
|
|
3275
|
+
}
|
|
3276
|
+
if (this.readOnly) {
|
|
3277
|
+
this.audit("prompt_refused", { reason: READ_ONLY_REASON });
|
|
3278
|
+
this.rejectCommand("prompt", cmdId, READ_ONLY_REASON, cmdSeq);
|
|
3279
|
+
this.emit("turn_end", { stopReason: "error", error: READ_ONLY_REASON, read_only: true, cmd_id: cmdId, cmd_seq: cmdSeq });
|
|
2606
3280
|
return started();
|
|
2607
3281
|
}
|
|
2608
3282
|
if (this.queue.length >= MAX_QUEUED_PROMPTS) {
|
|
2609
|
-
this.rejectCommand("prompt", cmdId, "too_many_queued_prompts");
|
|
3283
|
+
this.rejectCommand("prompt", cmdId, "too_many_queued_prompts", cmdSeq);
|
|
2610
3284
|
return started();
|
|
2611
3285
|
}
|
|
2612
|
-
this.queue.push({ text, cmdId, started });
|
|
3286
|
+
this.queue.push({ text, cmdId, cmdSeq, started });
|
|
2613
3287
|
if (!this.turnRunning) void this.pump();
|
|
2614
3288
|
}
|
|
2615
3289
|
/** Prompts that will now never run (the process is being stopped): they count as handled. */
|
|
@@ -2624,21 +3298,22 @@ var Session = class {
|
|
|
2624
3298
|
try {
|
|
2625
3299
|
while (this.queue.length && !this.closed) {
|
|
2626
3300
|
const next = this.queue.shift();
|
|
2627
|
-
await this.runTurn(next.text, next.started, next.cmdId);
|
|
3301
|
+
await this.runTurn(next.text, next.started, next.cmdId, next.cmdSeq);
|
|
2628
3302
|
}
|
|
2629
3303
|
} finally {
|
|
2630
3304
|
this.turnRunning = false;
|
|
2631
3305
|
this.touch();
|
|
2632
3306
|
}
|
|
2633
3307
|
}
|
|
2634
|
-
|
|
3308
|
+
/** Every `turn_end` of the turn names its prompt: `cmd_id`, and `cmd_seq` (a number, which no credential scrub ever masks). */
|
|
3309
|
+
async runTurn(text, started, cmdId, cmdSeq) {
|
|
2635
3310
|
if (this.agent.attach) {
|
|
2636
3311
|
const refused = await this.preflight();
|
|
2637
3312
|
if (refused) {
|
|
2638
3313
|
started();
|
|
2639
3314
|
this.audit("prompt_refused", { reason: refused.reason, detail: refused.detail.slice(0, 300) });
|
|
2640
|
-
this.rejectCommand("prompt", cmdId, refused.reason);
|
|
2641
|
-
this.emit("turn_end", { stopReason: "error", error: refused.reason });
|
|
3315
|
+
this.rejectCommand("prompt", cmdId, refused.reason, cmdSeq);
|
|
3316
|
+
this.emit("turn_end", { stopReason: "error", error: refused.reason, cmd_id: cmdId, cmd_seq: cmdSeq });
|
|
2642
3317
|
return;
|
|
2643
3318
|
}
|
|
2644
3319
|
}
|
|
@@ -2646,7 +3321,7 @@ var Session = class {
|
|
|
2646
3321
|
await this.ensureAdapter();
|
|
2647
3322
|
} catch (e) {
|
|
2648
3323
|
started();
|
|
2649
|
-
this.emit("turn_end", { stopReason: "error", error: e instanceof SessionRefusal ? e.reason : "adapter_unavailable" });
|
|
3324
|
+
this.emit("turn_end", { stopReason: "error", error: e instanceof SessionRefusal ? e.reason : "adapter_unavailable", cmd_id: cmdId, cmd_seq: cmdSeq });
|
|
2650
3325
|
return this.refuse(e);
|
|
2651
3326
|
}
|
|
2652
3327
|
const a = this.adapter;
|
|
@@ -2658,10 +3333,10 @@ var Session = class {
|
|
|
2658
3333
|
started();
|
|
2659
3334
|
const res = await reply;
|
|
2660
3335
|
const usage = isObject(res.usage) ? res.usage : void 0;
|
|
2661
|
-
this.emit("turn_end", { stopReason: typeof res.stopReason === "string" ? res.stopReason : "end_turn", ...usage ? { usage } : {} });
|
|
3336
|
+
this.emit("turn_end", { stopReason: typeof res.stopReason === "string" ? res.stopReason : "end_turn", ...usage ? { usage } : {}, cmd_id: cmdId, cmd_seq: cmdSeq });
|
|
2662
3337
|
} catch (e) {
|
|
2663
3338
|
this.audit("turn_failed", { error: errMessage(e) });
|
|
2664
|
-
this.emit("turn_end", { stopReason: "error", error: errMessage(e).slice(0, 300) });
|
|
3339
|
+
this.emit("turn_end", { stopReason: "error", error: errMessage(e).slice(0, 300), cmd_id: cmdId, cmd_seq: cmdSeq });
|
|
2665
3340
|
} finally {
|
|
2666
3341
|
this.markTranscript();
|
|
2667
3342
|
if (watch) {
|
|
@@ -2720,6 +3395,10 @@ var Session = class {
|
|
|
2720
3395
|
/** `resume`: bring the session back. A live adapter is NOT asked to resume (that would reset its mode); the mode is re-asserted instead. */
|
|
2721
3396
|
async resume(cmdId) {
|
|
2722
3397
|
if (this.closed) return this.rejectCommand("resume", cmdId, "session_closed");
|
|
3398
|
+
if (this.readOnly) {
|
|
3399
|
+
this.emit("session_resumed", { acp_session_id: this.acpId, read_only: true, modes: [], currentMode: null, unattended: false });
|
|
3400
|
+
return;
|
|
3401
|
+
}
|
|
2723
3402
|
try {
|
|
2724
3403
|
if (this.adapter && !this.adapter.exited) await this.enforceMode(this.targetMode);
|
|
2725
3404
|
else await this.launch("resume");
|
|
@@ -2730,15 +3409,20 @@ var Session = class {
|
|
|
2730
3409
|
this.emit("session_resumed", { acp_session_id: this.acpId, modes: this.shownModes(), currentMode: this.currentMode, unattended: this.unattended });
|
|
2731
3410
|
this.touch();
|
|
2732
3411
|
}
|
|
2733
|
-
/**
|
|
3412
|
+
/**
|
|
3413
|
+
* `cancel`: stop the turn, drop what was queued, answer every waiting permission request `cancelled`. Each prompt dropped
|
|
3414
|
+
* unrun is refused by name (`command_rejected`, reason `cancelled`, with its `cmd_id` and `cmd_seq`): it never gets a `turn_end`.
|
|
3415
|
+
*/
|
|
2734
3416
|
async cancel() {
|
|
2735
|
-
const
|
|
3417
|
+
const dropped = this.queue.map((q) => [q.cmdId, q.cmdSeq]);
|
|
3418
|
+
const queued = dropped.length;
|
|
2736
3419
|
this.dropQueued();
|
|
3420
|
+
for (const [cmdId, cmdSeq] of dropped) this.rejectCommand("prompt", cmdId, "cancelled", cmdSeq);
|
|
2737
3421
|
const wasRunning = this.turnRunning;
|
|
2738
3422
|
if (wasRunning && this.adapter && this.acpId) await this.adapter.notify("session/cancel", { sessionId: this.acpId }).catch(() => void 0);
|
|
2739
3423
|
for (const actionId of this.book.cancelAll()) this.emit("permission_resolved", { actionId, optionId: null, outcome: "cancelled", by: "cancel" });
|
|
2740
3424
|
this.audit("cancelled", { turn_running: wasRunning, queued_dropped: queued });
|
|
2741
|
-
if (!wasRunning) this.emit("turn_end", { stopReason: "cancelled" });
|
|
3425
|
+
if (!wasRunning) this.emit("turn_end", { stopReason: "cancelled", cmd_id: null, cmd_seq: null });
|
|
2742
3426
|
}
|
|
2743
3427
|
// --- ending ----------------------------------------------------------------------------
|
|
2744
3428
|
/** The adapter's process goes away; the session stays and resumes on demand. */
|
|
@@ -2757,6 +3441,7 @@ var Session = class {
|
|
|
2757
3441
|
if (this.closed) return;
|
|
2758
3442
|
this.closed = true;
|
|
2759
3443
|
clearTimeout(this.idle);
|
|
3444
|
+
this.stopTail();
|
|
2760
3445
|
this.dropQueued();
|
|
2761
3446
|
for (const actionId of this.book.cancelAll()) this.emit("permission_resolved", { actionId, optionId: null, outcome: "cancelled", by: reason });
|
|
2762
3447
|
const a = this.adapter;
|
|
@@ -2773,9 +3458,10 @@ var Session = class {
|
|
|
2773
3458
|
this.audit("session_refused", { reason: r.reason, message: r.message.slice(0, 300) });
|
|
2774
3459
|
await this.close(r.reason);
|
|
2775
3460
|
}
|
|
2776
|
-
|
|
3461
|
+
/** A command refused; a prompt's refusal names it by `cmd_seq` too, as its turn_end would. */
|
|
3462
|
+
rejectCommand(type, cmdId, reason, cmdSeq = null) {
|
|
2777
3463
|
this.audit("command_rejected", { type, cmd_id: cmdId, reason });
|
|
2778
|
-
this.emit("command_rejected", { type, cmd_id: cmdId, reason });
|
|
3464
|
+
this.emit("command_rejected", { type, cmd_id: cmdId, reason, ...cmdSeq !== null ? { cmd_seq: cmdSeq } : {} });
|
|
2779
3465
|
}
|
|
2780
3466
|
};
|
|
2781
3467
|
function mapAdapterError(e) {
|
|
@@ -2801,6 +3487,7 @@ function limitStrings(value, max) {
|
|
|
2801
3487
|
|
|
2802
3488
|
// src/manager.ts
|
|
2803
3489
|
var MAX_ACTIVE_SESSIONS = 10;
|
|
3490
|
+
var cmdSeqOf = (cmd) => Number.isSafeInteger(cmd.cmd_seq) ? cmd.cmd_seq : null;
|
|
2804
3491
|
var SessionManager = class {
|
|
2805
3492
|
constructor(env) {
|
|
2806
3493
|
this.env = env;
|
|
@@ -2869,10 +3556,10 @@ var SessionManager = class {
|
|
|
2869
3556
|
switch (type) {
|
|
2870
3557
|
case "prompt":
|
|
2871
3558
|
if (typeof cmd.text !== "string" || cmd.text === "") {
|
|
2872
|
-
s.rejectCommand("prompt", cmdId, "text_required");
|
|
3559
|
+
s.rejectCommand("prompt", cmdId, "text_required", cmdSeqOf(cmd));
|
|
2873
3560
|
return false;
|
|
2874
3561
|
}
|
|
2875
|
-
s.enqueuePrompt(cmd.text, cmdId, handled);
|
|
3562
|
+
s.enqueuePrompt(cmd.text, cmdId, handled, cmdSeqOf(cmd));
|
|
2876
3563
|
return true;
|
|
2877
3564
|
case "cancel":
|
|
2878
3565
|
await s.cancel();
|
|
@@ -2897,6 +3584,10 @@ var SessionManager = class {
|
|
|
2897
3584
|
async sessionNew(cmd) {
|
|
2898
3585
|
const sid = cmd.session_id;
|
|
2899
3586
|
if (this.sessions.has(sid)) return;
|
|
3587
|
+
if (this.env.store.get(sid)?.read_only) {
|
|
3588
|
+
await this.lookup(sid);
|
|
3589
|
+
return;
|
|
3590
|
+
}
|
|
2900
3591
|
const agent = this.agent(cmd.agent_id);
|
|
2901
3592
|
if (!agent) {
|
|
2902
3593
|
this.env.audit.record("session_refused", { session_id: sid, reason: "unknown_agent", agent_id: String(cmd.agent_id).slice(0, 64) });
|
|
@@ -2944,7 +3635,7 @@ var SessionManager = class {
|
|
|
2944
3635
|
if (!support) return refuse("attach_unavailable");
|
|
2945
3636
|
const want = ref.toLowerCase();
|
|
2946
3637
|
const claim = `${agent.id}:${want}`;
|
|
2947
|
-
if (this.attaching.has(claim) || [...this.sessions.values()].some((s) => !s.closed && s.agent.id === agent.id && s.acpId?.toLowerCase() === want)) return refuse("already_attached");
|
|
3638
|
+
if (this.attaching.has(claim) || [...this.sessions.values()].some((s) => !s.closed && !s.readOnly && s.agent.id === agent.id && s.acpId?.toLowerCase() === want)) return refuse("already_attached");
|
|
2948
3639
|
if ([...this.sessions.values()].filter((s) => !s.closed).length + this.attaching.size >= MAX_ACTIVE_SESSIONS) return refuse("too_many_sessions");
|
|
2949
3640
|
this.attaching.add(claim);
|
|
2950
3641
|
try {
|
|
@@ -2964,7 +3655,7 @@ var SessionManager = class {
|
|
|
2964
3655
|
/** The ids of the existing sessions this connector holds open, by agent. */
|
|
2965
3656
|
attachedRefs(agentId) {
|
|
2966
3657
|
const out = /* @__PURE__ */ new Set();
|
|
2967
|
-
for (const s of this.sessions.values()) if (!s.closed && s.agent.id === agentId && s.acpId) out.add(s.acpId.toLowerCase());
|
|
3658
|
+
for (const s of this.sessions.values()) if (!s.closed && !s.readOnly && s.agent.id === agentId && s.acpId) out.add(s.acpId.toLowerCase());
|
|
2968
3659
|
return out;
|
|
2969
3660
|
}
|
|
2970
3661
|
/** A session this process knows, or one it made earlier (before a restart) and can bring back. */
|
|
@@ -2983,8 +3674,16 @@ var SessionManager = class {
|
|
|
2983
3674
|
}
|
|
2984
3675
|
const s = new Session(sid, agent, verdict.real, this.env, rec.acp_session_id, rec.mode);
|
|
2985
3676
|
this.sessions.set(sid, s);
|
|
3677
|
+
if (rec.read_only) s.restoreSpectate();
|
|
2986
3678
|
return s;
|
|
2987
3679
|
}
|
|
3680
|
+
/** At start: every session that was watching a terminal's conversation goes on watching it (and notices when it ends). */
|
|
3681
|
+
async restoreReadOnly() {
|
|
3682
|
+
for (const sid of this.env.store.ids()) {
|
|
3683
|
+
const rec = this.env.store.get(sid);
|
|
3684
|
+
if (rec?.read_only && rec.status === "active" && !this.sessions.has(sid)) await this.lookup(sid);
|
|
3685
|
+
}
|
|
3686
|
+
}
|
|
2988
3687
|
/** After the command stream reconnects: cards still waiting are shown again. */
|
|
2989
3688
|
resendPending() {
|
|
2990
3689
|
for (const s of this.sessions.values()) s.resendPending();
|
|
@@ -2997,6 +3696,7 @@ var SessionManager = class {
|
|
|
2997
3696
|
for (const s of this.sessions.values()) {
|
|
2998
3697
|
this.env.audit.record("session_killed", { session_id: s.id, reason });
|
|
2999
3698
|
s.dropQueued();
|
|
3699
|
+
s.stopTail();
|
|
3000
3700
|
}
|
|
3001
3701
|
await this.env.registry.killAll();
|
|
3002
3702
|
this.sessions.clear();
|
|
@@ -3004,8 +3704,8 @@ var SessionManager = class {
|
|
|
3004
3704
|
};
|
|
3005
3705
|
|
|
3006
3706
|
// src/outbox.ts
|
|
3007
|
-
import
|
|
3008
|
-
import
|
|
3707
|
+
import fs13 from "node:fs";
|
|
3708
|
+
import path15 from "node:path";
|
|
3009
3709
|
var DROPPABLE_TYPES = /* @__PURE__ */ new Set([
|
|
3010
3710
|
"agent_message_chunk",
|
|
3011
3711
|
"agent_thought_chunk",
|
|
@@ -3032,35 +3732,35 @@ var Outbox = class {
|
|
|
3032
3732
|
this.dir = opts.dir;
|
|
3033
3733
|
this.maxBytes = opts.maxBytesPerSession ?? 5 * 1024 * 1024;
|
|
3034
3734
|
this.now = opts.now ?? Date.now;
|
|
3035
|
-
|
|
3735
|
+
fs13.mkdirSync(this.dir, { recursive: true, mode: 448 });
|
|
3036
3736
|
this.load();
|
|
3037
3737
|
}
|
|
3038
|
-
eventsFile = (sid) =>
|
|
3039
|
-
metaFile = (sid) =>
|
|
3738
|
+
eventsFile = (sid) => path15.join(this.dir, `${safeName(sid)}.jsonl`);
|
|
3739
|
+
metaFile = (sid) => path15.join(this.dir, `${safeName(sid)}.meta.json`);
|
|
3040
3740
|
writeMeta(sid) {
|
|
3041
3741
|
const tmp = this.metaFile(sid) + ".tmp";
|
|
3042
|
-
|
|
3043
|
-
|
|
3742
|
+
fs13.writeFileSync(tmp, JSON.stringify(this.meta.get(sid)), { mode: 384 });
|
|
3743
|
+
fs13.renameSync(tmp, this.metaFile(sid));
|
|
3044
3744
|
}
|
|
3045
3745
|
/** Rebuild the queue from disk. The highest sequence number ever used is the larger of the meta file and the log itself. */
|
|
3046
3746
|
load() {
|
|
3047
3747
|
const loaded = [];
|
|
3048
3748
|
const bases = /* @__PURE__ */ new Set();
|
|
3049
|
-
for (const name of
|
|
3749
|
+
for (const name of fs13.readdirSync(this.dir)) {
|
|
3050
3750
|
if (name.endsWith(".meta.json")) bases.add(name.slice(0, -".meta.json".length));
|
|
3051
3751
|
else if (name.endsWith(".jsonl")) bases.add(name.slice(0, -".jsonl".length));
|
|
3052
3752
|
}
|
|
3053
3753
|
for (const base of bases) {
|
|
3054
3754
|
let meta = { lastSeq: 0, acked: 0 };
|
|
3055
3755
|
try {
|
|
3056
|
-
meta = JSON.parse(
|
|
3756
|
+
meta = JSON.parse(fs13.readFileSync(path15.join(this.dir, `${base}.meta.json`), "utf8"));
|
|
3057
3757
|
} catch {
|
|
3058
3758
|
}
|
|
3059
3759
|
let sid = null;
|
|
3060
|
-
const file =
|
|
3760
|
+
const file = path15.join(this.dir, `${base}.jsonl`);
|
|
3061
3761
|
let text = "";
|
|
3062
3762
|
try {
|
|
3063
|
-
text =
|
|
3763
|
+
text = fs13.readFileSync(file, "utf8");
|
|
3064
3764
|
} catch {
|
|
3065
3765
|
}
|
|
3066
3766
|
for (const line of text.split("\n")) {
|
|
@@ -3078,8 +3778,8 @@ var Outbox = class {
|
|
|
3078
3778
|
if (sid === null) {
|
|
3079
3779
|
sid = base;
|
|
3080
3780
|
try {
|
|
3081
|
-
if (this.now() -
|
|
3082
|
-
for (const f of [`${base}.meta.json`, `${base}.jsonl`])
|
|
3781
|
+
if (this.now() - fs13.statSync(path15.join(this.dir, `${base}.meta.json`)).mtimeMs > TOMBSTONE_MS) {
|
|
3782
|
+
for (const f of [`${base}.meta.json`, `${base}.jsonl`]) fs13.rmSync(path15.join(this.dir, f), { force: true });
|
|
3083
3783
|
continue;
|
|
3084
3784
|
}
|
|
3085
3785
|
} catch {
|
|
@@ -3101,7 +3801,7 @@ var Outbox = class {
|
|
|
3101
3801
|
meta.lastSeq = ev.seq;
|
|
3102
3802
|
this.meta.set(sessionId, meta);
|
|
3103
3803
|
const line = JSON.stringify(ev);
|
|
3104
|
-
|
|
3804
|
+
fs13.appendFileSync(this.eventsFile(sessionId), line + "\n", { mode: 384 });
|
|
3105
3805
|
if (ev.seq === 1) this.writeMeta(sessionId);
|
|
3106
3806
|
this.queue.push(ev);
|
|
3107
3807
|
this.bytes.set(sessionId, (this.bytes.get(sessionId) ?? 0) + line.length);
|
|
@@ -3130,8 +3830,8 @@ var Outbox = class {
|
|
|
3130
3830
|
this.writeMeta(sessionId);
|
|
3131
3831
|
const mine = this.queue.filter((e) => e.session_id === sessionId);
|
|
3132
3832
|
const tmp = this.eventsFile(sessionId) + ".tmp";
|
|
3133
|
-
|
|
3134
|
-
|
|
3833
|
+
fs13.writeFileSync(tmp, mine.map((e) => JSON.stringify(e) + "\n").join(""), { mode: 384 });
|
|
3834
|
+
fs13.renameSync(tmp, this.eventsFile(sessionId));
|
|
3135
3835
|
}
|
|
3136
3836
|
/** The oldest events not yet acknowledged, in the order they were appended. */
|
|
3137
3837
|
pending(max) {
|
|
@@ -3169,7 +3869,7 @@ var Outbox = class {
|
|
|
3169
3869
|
this.writeMeta(sid);
|
|
3170
3870
|
const left = this.queue.filter((e) => e.session_id === sid);
|
|
3171
3871
|
if (left.length === 0) {
|
|
3172
|
-
|
|
3872
|
+
fs13.writeFileSync(this.eventsFile(sid), "", { mode: 384 });
|
|
3173
3873
|
this.bytes.set(sid, 0);
|
|
3174
3874
|
if (this.closed.has(sid)) this.forget(sid);
|
|
3175
3875
|
} else if (left.length < this.queue.length / 2 || meta.acked > 1e3) {
|
|
@@ -3188,7 +3888,7 @@ var Outbox = class {
|
|
|
3188
3888
|
* repeat. What stays is the small meta file, a tombstone, removed after {@link TOMBSTONE_MS}.
|
|
3189
3889
|
*/
|
|
3190
3890
|
forget(sid) {
|
|
3191
|
-
|
|
3891
|
+
fs13.rmSync(this.eventsFile(sid), { force: true });
|
|
3192
3892
|
if (this.meta.has(sid)) this.writeMeta(sid);
|
|
3193
3893
|
this.bytes.delete(sid);
|
|
3194
3894
|
this.closed.delete(sid);
|
|
@@ -3196,19 +3896,19 @@ var Outbox = class {
|
|
|
3196
3896
|
};
|
|
3197
3897
|
|
|
3198
3898
|
// src/session-store.ts
|
|
3199
|
-
import
|
|
3200
|
-
import
|
|
3899
|
+
import fs14 from "node:fs";
|
|
3900
|
+
import path16 from "node:path";
|
|
3201
3901
|
var SessionStore = class {
|
|
3202
3902
|
constructor(file) {
|
|
3203
3903
|
this.file = file;
|
|
3204
3904
|
this.pendingFile = `${file}.pending`;
|
|
3205
3905
|
try {
|
|
3206
|
-
const raw = JSON.parse(
|
|
3906
|
+
const raw = JSON.parse(fs14.readFileSync(this.pendingFile, "utf8"));
|
|
3207
3907
|
if (Array.isArray(raw)) this.pending = raw.filter((p) => p && typeof p.session_id === "string" && typeof p.action_id === "string");
|
|
3208
3908
|
} catch {
|
|
3209
3909
|
}
|
|
3210
3910
|
try {
|
|
3211
|
-
const raw = JSON.parse(
|
|
3911
|
+
const raw = JSON.parse(fs14.readFileSync(file, "utf8"));
|
|
3212
3912
|
if (raw && typeof raw === "object" && !Array.isArray(raw)) this.recs = raw;
|
|
3213
3913
|
} catch {
|
|
3214
3914
|
}
|
|
@@ -3251,23 +3951,24 @@ var SessionStore = class {
|
|
|
3251
3951
|
return out;
|
|
3252
3952
|
}
|
|
3253
3953
|
savePending() {
|
|
3254
|
-
|
|
3954
|
+
fs14.mkdirSync(path16.dirname(this.pendingFile), { recursive: true, mode: 448 });
|
|
3255
3955
|
const tmp = `${this.pendingFile}.${process.pid}.tmp`;
|
|
3256
|
-
|
|
3257
|
-
|
|
3956
|
+
fs14.writeFileSync(tmp, JSON.stringify(this.pending), { mode: 384 });
|
|
3957
|
+
fs14.renameSync(tmp, this.pendingFile);
|
|
3258
3958
|
}
|
|
3259
3959
|
ids() {
|
|
3260
3960
|
return Object.keys(this.recs);
|
|
3261
3961
|
}
|
|
3262
3962
|
save() {
|
|
3263
|
-
|
|
3963
|
+
fs14.mkdirSync(path16.dirname(this.file), { recursive: true, mode: 448 });
|
|
3264
3964
|
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
3265
|
-
|
|
3266
|
-
|
|
3965
|
+
fs14.writeFileSync(tmp, JSON.stringify(this.recs), { mode: 384 });
|
|
3966
|
+
fs14.renameSync(tmp, this.file);
|
|
3267
3967
|
}
|
|
3268
3968
|
};
|
|
3269
3969
|
|
|
3270
3970
|
// src/connector.ts
|
|
3971
|
+
var RESTART_RECONNECT_GAP_MS = 5e3;
|
|
3271
3972
|
var DEFAULT_TIMING = {
|
|
3272
3973
|
...DEFAULT_SESSION_TIMING,
|
|
3273
3974
|
heartbeatMs: REMOTE_AGENT_TIMING.heartbeatMs,
|
|
@@ -3278,6 +3979,7 @@ var DEFAULT_TIMING = {
|
|
|
3278
3979
|
backoffMaxMs: 6e4,
|
|
3279
3980
|
pausePollMs: 1e3,
|
|
3280
3981
|
catalogMs: 12e4,
|
|
3982
|
+
sessionsWatchMs: 1e4,
|
|
3281
3983
|
attachCliMs: 8e3,
|
|
3282
3984
|
recentWriteMs: 1e4,
|
|
3283
3985
|
authFailLimit: 4,
|
|
@@ -3404,8 +4106,16 @@ var Connector = class {
|
|
|
3404
4106
|
}
|
|
3405
4107
|
async descriptors() {
|
|
3406
4108
|
return Promise.all(this.d.config.agents.map(async (a) => {
|
|
3407
|
-
const base = { ask_mode: a.askMode, modes: a.safeModes, roots: a.roots.slice(0, 8), protocol: 1, unattended: a.unattended, unattended_allowed: a.allowUnsafeModes };
|
|
3408
4109
|
const support = this.attach.get(a.id);
|
|
4110
|
+
const base = {
|
|
4111
|
+
ask_mode: a.askMode,
|
|
4112
|
+
modes: a.safeModes,
|
|
4113
|
+
roots: a.roots.slice(0, 8),
|
|
4114
|
+
protocol: 1,
|
|
4115
|
+
unattended: a.unattended,
|
|
4116
|
+
unattended_allowed: a.allowUnsafeModes,
|
|
4117
|
+
...support ? { watch_live: true } : {}
|
|
4118
|
+
};
|
|
3409
4119
|
const capabilities = support ? fitResumable(base, await support.resumable()) : base;
|
|
3410
4120
|
return { agent_id: a.id, kind: a.kind, name: a.name, capabilities };
|
|
3411
4121
|
}));
|
|
@@ -3413,7 +4123,7 @@ var Connector = class {
|
|
|
3413
4123
|
// --- identity -------------------------------------------------------------------
|
|
3414
4124
|
readIdentity() {
|
|
3415
4125
|
try {
|
|
3416
|
-
const raw = JSON.parse(
|
|
4126
|
+
const raw = JSON.parse(fs15.readFileSync(identityFile(this.d.dirs), "utf8"));
|
|
3417
4127
|
if (isObject(raw) && raw.server === this.d.server && typeof raw.connector_id === "string") {
|
|
3418
4128
|
return { server: raw.server, connector_id: raw.connector_id, cursor: typeof raw.cursor === "number" ? raw.cursor : 0, handled: Array.isArray(raw.handled) ? raw.handled.filter((x) => typeof x === "string") : [] };
|
|
3419
4129
|
}
|
|
@@ -3424,9 +4134,9 @@ var Connector = class {
|
|
|
3424
4134
|
writeIdentity() {
|
|
3425
4135
|
const st = { server: this.d.server, connector_id: this.cid, cursor: this.ledger.cursor, handled: this.ledger.recentIds, ...this.rowRevoked ? { revoked: true } : {} };
|
|
3426
4136
|
const file = identityFile(this.d.dirs);
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
4137
|
+
fs15.mkdirSync(this.d.dirs.state, { recursive: true, mode: 448 });
|
|
4138
|
+
fs15.writeFileSync(`${file}.tmp`, JSON.stringify(st), { mode: 384 });
|
|
4139
|
+
fs15.renameSync(`${file}.tmp`, file);
|
|
3430
4140
|
}
|
|
3431
4141
|
/** Use the connector id this machine already has, or register a new one. */
|
|
3432
4142
|
async identify(forceNew = false) {
|
|
@@ -3533,7 +4243,7 @@ var Connector = class {
|
|
|
3533
4243
|
}
|
|
3534
4244
|
async pauseWatcher() {
|
|
3535
4245
|
while (!this.ctl.signal.aborted) {
|
|
3536
|
-
const want =
|
|
4246
|
+
const want = fs15.existsSync(pauseFile(this.d.dirs));
|
|
3537
4247
|
if (want && !this.paused) {
|
|
3538
4248
|
this.paused = true;
|
|
3539
4249
|
this.d.audit.record("paused");
|
|
@@ -3587,6 +4297,22 @@ var Connector = class {
|
|
|
3587
4297
|
}
|
|
3588
4298
|
}
|
|
3589
4299
|
}
|
|
4300
|
+
/** Report a change in the conversations on this machine (see {@link ConnectorTiming.sessionsWatchMs}) without waiting for the heartbeat. */
|
|
4301
|
+
async sessionsWatcher() {
|
|
4302
|
+
if (this.attach.size === 0) return;
|
|
4303
|
+
while (!this.ctl.signal.aborted) {
|
|
4304
|
+
let changed = false;
|
|
4305
|
+
for (const support of this.attach.values()) {
|
|
4306
|
+
try {
|
|
4307
|
+
if (await support.poll()) changed = true;
|
|
4308
|
+
} catch (e) {
|
|
4309
|
+
this.d.log.warn("looking for changed sessions failed", { agent: support.agent.id, error: errMessage(e) });
|
|
4310
|
+
}
|
|
4311
|
+
}
|
|
4312
|
+
if (changed && !this.paused) this.wakeHeartbeat?.();
|
|
4313
|
+
await sleep(this.timing.sessionsWatchMs, this.ctl.signal);
|
|
4314
|
+
}
|
|
4315
|
+
}
|
|
3590
4316
|
async heartbeater() {
|
|
3591
4317
|
while (!this.ctl.signal.aborted) {
|
|
3592
4318
|
await new Promise((resolve) => {
|
|
@@ -3640,7 +4366,7 @@ var Connector = class {
|
|
|
3640
4366
|
this.d.audit.record("command_dropped", { reason: "malformed_frame", id: f.id });
|
|
3641
4367
|
return;
|
|
3642
4368
|
}
|
|
3643
|
-
const cmd = data;
|
|
4369
|
+
const cmd = { ...data, cmd_seq: seq };
|
|
3644
4370
|
if (this.ledger.seen(seq, cmd.cmd_id)) return;
|
|
3645
4371
|
this.ledger.begin(seq, cmd.cmd_id);
|
|
3646
4372
|
void this.manager.handle(cmd, () => {
|
|
@@ -3654,6 +4380,7 @@ var Connector = class {
|
|
|
3654
4380
|
async streamLoop() {
|
|
3655
4381
|
let attempt = 0;
|
|
3656
4382
|
let reconnect = false;
|
|
4383
|
+
let lastRestartAt = -Infinity;
|
|
3657
4384
|
while (!this.ctl.signal.aborted) {
|
|
3658
4385
|
if (this.paused) {
|
|
3659
4386
|
await sleep(this.timing.pausePollMs, this.ctl.signal);
|
|
@@ -3664,6 +4391,7 @@ var Connector = class {
|
|
|
3664
4391
|
const sig = AbortSignal.any([this.ctl.signal, this.streamCtl.signal]);
|
|
3665
4392
|
let got = false;
|
|
3666
4393
|
this.lastFrame = "";
|
|
4394
|
+
let restart = false;
|
|
3667
4395
|
for await (const f of this.d.client.commands(this.cid, { lastEventId: this.ledger.highestSeen || void 0, signal: sig, watchdogMs: this.timing.watchdogMs })) {
|
|
3668
4396
|
if (!got) {
|
|
3669
4397
|
got = true;
|
|
@@ -3672,11 +4400,20 @@ var Connector = class {
|
|
|
3672
4400
|
}
|
|
3673
4401
|
this.onFrame(f, reconnect);
|
|
3674
4402
|
if (f.event === "ready") reconnect = true;
|
|
4403
|
+
if (f.event === "reconnect") {
|
|
4404
|
+
restart = true;
|
|
4405
|
+
break;
|
|
4406
|
+
}
|
|
3675
4407
|
}
|
|
3676
4408
|
if (!this.ctl.signal.aborted && !this.paused) {
|
|
3677
|
-
const why = this.lastFrame === "superseded" || this.lastFrame === "token_expired" ? this.lastFrame : "the server closed the stream (a Claw release or restart)";
|
|
4409
|
+
const why = restart ? "restart" : this.lastFrame === "superseded" || this.lastFrame === "token_expired" ? this.lastFrame : "the server closed the stream (a Claw release or restart)";
|
|
3678
4410
|
this.d.log.info("command stream ended by the server; reconnecting", { why });
|
|
3679
4411
|
recordDisconnect(this.d.dirs, { reason: "server_closed", detail: why });
|
|
4412
|
+
if (restart && Date.now() - lastRestartAt >= RESTART_RECONNECT_GAP_MS) {
|
|
4413
|
+
lastRestartAt = Date.now();
|
|
4414
|
+
attempt = 0;
|
|
4415
|
+
continue;
|
|
4416
|
+
}
|
|
3680
4417
|
}
|
|
3681
4418
|
} catch (e) {
|
|
3682
4419
|
if (this.isRevocation(e)) return void await this.onRevoked(e);
|
|
@@ -3701,7 +4438,7 @@ var Connector = class {
|
|
|
3701
4438
|
}
|
|
3702
4439
|
/** Run until stopped or revoked. Always leaves no adapter process behind. */
|
|
3703
4440
|
async run() {
|
|
3704
|
-
|
|
4441
|
+
fs15.mkdirSync(this.d.dirs.state, { recursive: true, mode: 448 });
|
|
3705
4442
|
await this.registry.killAll();
|
|
3706
4443
|
for (const p of this.store.takePending()) {
|
|
3707
4444
|
this.d.audit.record("permission_orphaned", { session_id: p.session_id, action_id: p.action_id });
|
|
@@ -3710,32 +4447,33 @@ var Connector = class {
|
|
|
3710
4447
|
const pidf = pidFile(this.d.dirs);
|
|
3711
4448
|
let leftover = false;
|
|
3712
4449
|
try {
|
|
3713
|
-
|
|
4450
|
+
fs15.readFileSync(pidf);
|
|
3714
4451
|
leftover = true;
|
|
3715
4452
|
} catch {
|
|
3716
4453
|
}
|
|
3717
4454
|
if (leftover) {
|
|
3718
4455
|
let last;
|
|
3719
4456
|
try {
|
|
3720
|
-
last =
|
|
4457
|
+
last = fs15.statSync(identityFile(this.d.dirs)).mtime.toISOString();
|
|
3721
4458
|
} catch {
|
|
3722
4459
|
}
|
|
3723
4460
|
recordDisconnect(this.d.dirs, { reason: "process_died", detail: `the previous run ended without shutting down (killed, crashed, or the machine restarted)${last ? `; last heartbeat ${last}` : ""}`, ...last ? { at: last } : {} });
|
|
3724
4461
|
}
|
|
3725
|
-
|
|
3726
|
-
this.paused =
|
|
4462
|
+
fs15.writeFileSync(pidf, JSON.stringify({ pid: process.pid, start: processStart(process.pid) ?? "" }), { mode: 384 });
|
|
4463
|
+
this.paused = fs15.existsSync(pauseFile(this.d.dirs));
|
|
3727
4464
|
let result = "stopped";
|
|
3728
4465
|
let failure = "";
|
|
3729
4466
|
try {
|
|
3730
4467
|
const up = await this.startup();
|
|
3731
4468
|
if (up !== "ok") return result = up;
|
|
4469
|
+
await this.manager.restoreReadOnly();
|
|
3732
4470
|
this.d.audit.record("connector_online", {
|
|
3733
4471
|
connector_id: this.cid,
|
|
3734
4472
|
agents: this.d.config.agents.map((a) => a.id),
|
|
3735
4473
|
paused: this.paused,
|
|
3736
4474
|
unattended_agents: this.d.config.agents.filter((a) => a.unattended).map((a) => a.id)
|
|
3737
4475
|
});
|
|
3738
|
-
await Promise.all([this.sender(), this.heartbeater(), this.streamLoop(), this.pauseWatcher()]);
|
|
4476
|
+
await Promise.all([this.sender(), this.heartbeater(), this.streamLoop(), this.pauseWatcher(), this.sessionsWatcher()]);
|
|
3739
4477
|
result = this.revoked ? "revoked" : this.authFailed ? "auth_failed" : "stopped";
|
|
3740
4478
|
} catch (e) {
|
|
3741
4479
|
failure = errMessage(e);
|
|
@@ -3744,7 +4482,7 @@ var Connector = class {
|
|
|
3744
4482
|
await this.manager.killAll(this.revoked ? "revoked" : "stopped").catch(() => void 0);
|
|
3745
4483
|
const reason = this.revoked ? "revoked" : this.authFailed ? "auth_failed" : failure ? "failed" : this.paused ? "stopped_while_paused" : result;
|
|
3746
4484
|
recordDisconnect(this.d.dirs, { reason, detail: this.authWhy || failure || void 0 });
|
|
3747
|
-
|
|
4485
|
+
fs15.rmSync(pidf, { force: true });
|
|
3748
4486
|
}
|
|
3749
4487
|
return result;
|
|
3750
4488
|
}
|
|
@@ -3760,10 +4498,10 @@ var Connector = class {
|
|
|
3760
4498
|
};
|
|
3761
4499
|
|
|
3762
4500
|
// src/service.ts
|
|
3763
|
-
import { spawn as
|
|
3764
|
-
import
|
|
4501
|
+
import { spawn as spawn3, spawnSync } from "node:child_process";
|
|
4502
|
+
import fs16 from "node:fs";
|
|
3765
4503
|
import os5 from "node:os";
|
|
3766
|
-
import
|
|
4504
|
+
import path17 from "node:path";
|
|
3767
4505
|
import { fileURLToPath } from "node:url";
|
|
3768
4506
|
import { parseArgs } from "node:util";
|
|
3769
4507
|
var SUPERVISORS = ["systemd", "launchd", "script"];
|
|
@@ -3802,11 +4540,11 @@ function deps(ctx) {
|
|
|
3802
4540
|
}),
|
|
3803
4541
|
node: d.node ?? ctx.node ?? process.execPath,
|
|
3804
4542
|
cli: d.cli ?? fileURLToPath(import.meta.url),
|
|
3805
|
-
systemdBooted: d.systemdBooted ?? (() =>
|
|
4543
|
+
systemdBooted: d.systemdBooted ?? (() => fs16.existsSync("/run/systemd/system"))
|
|
3806
4544
|
};
|
|
3807
4545
|
}
|
|
3808
|
-
var systemdUnitPath = (ctx, d) =>
|
|
3809
|
-
var launchdPlistPath = (d) =>
|
|
4546
|
+
var systemdUnitPath = (ctx, d) => path17.join(ctx.env.XDG_CONFIG_HOME?.trim() || path17.join(d.home, ".config"), "systemd", "user", UNIT);
|
|
4547
|
+
var launchdPlistPath = (d) => path17.join(d.home, "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
3810
4548
|
function detectSupervisor(d) {
|
|
3811
4549
|
if (d.platform === "darwin") return "launchd";
|
|
3812
4550
|
if (!d.systemdBooted()) return "script";
|
|
@@ -3856,12 +4594,14 @@ StartLimitIntervalSec=0
|
|
|
3856
4594
|
[Service]
|
|
3857
4595
|
Type=simple
|
|
3858
4596
|
ExecStart=${sdQuote(d.node)} ${sdQuote(d.cli)} run --supervised
|
|
4597
|
+
ExecStop=${sdQuote(d.node)} ${sdQuote(d.cli)} stop
|
|
3859
4598
|
${vars.map(([k, v]) => `Environment=${sdEnv(`${k}=${v}`)}`).join("\n")}
|
|
3860
4599
|
Restart=on-failure
|
|
3861
4600
|
RestartSec=${RESTART_SEC}
|
|
3862
4601
|
# 78: revoked, sign-in refused, or no usable config. Restarting cannot help; "claw-connector login" can.
|
|
3863
4602
|
RestartPreventExitStatus=${NEEDS_PERSON}
|
|
3864
|
-
# "claw-connector stop" and "systemctl --user stop" both end it cleanly (exit 0), which is not
|
|
4603
|
+
# "claw-connector stop" and "systemctl --user stop" (through ExecStop) both end it cleanly (exit 0), which is not
|
|
4604
|
+
# restarted. Any other SIGTERM ends a supervised run with exit 75, which is.
|
|
3865
4605
|
KillMode=mixed
|
|
3866
4606
|
TimeoutStopSec=20
|
|
3867
4607
|
|
|
@@ -3905,7 +4645,7 @@ exec ${sh(d.node)} ${sh(d.cli)} service ensure
|
|
|
3905
4645
|
}
|
|
3906
4646
|
function readPidRec(file) {
|
|
3907
4647
|
try {
|
|
3908
|
-
const raw = JSON.parse(
|
|
4648
|
+
const raw = JSON.parse(fs16.readFileSync(file, "utf8"));
|
|
3909
4649
|
if (isObject(raw) && typeof raw.pid === "number" && typeof raw.start === "string" && raw.start !== "") return { pid: raw.pid, start: raw.start };
|
|
3910
4650
|
} catch {
|
|
3911
4651
|
}
|
|
@@ -3925,8 +4665,8 @@ async function supervise(ctx, o = {}) {
|
|
|
3925
4665
|
ctx.err(`a supervisor is already running (pid ${other})`);
|
|
3926
4666
|
return 1;
|
|
3927
4667
|
}
|
|
3928
|
-
|
|
3929
|
-
|
|
4668
|
+
fs16.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
|
|
4669
|
+
fs16.writeFileSync(pidf, JSON.stringify({ pid: process.pid, start: processStart(process.pid) ?? "" }), { mode: 384 });
|
|
3930
4670
|
let stopping2 = false;
|
|
3931
4671
|
let child = null;
|
|
3932
4672
|
const wake = new AbortController();
|
|
@@ -3941,7 +4681,7 @@ async function supervise(ctx, o = {}) {
|
|
|
3941
4681
|
o.signal?.addEventListener("abort", () => onSig("SIGTERM"), { once: true });
|
|
3942
4682
|
try {
|
|
3943
4683
|
for (let round = 0; !stopping2; round++) {
|
|
3944
|
-
child =
|
|
4684
|
+
child = spawn3(cmd, args, { stdio: ["ignore", "inherit", "inherit"], env: ctx.env });
|
|
3945
4685
|
const c = child;
|
|
3946
4686
|
const { code, signal } = await new Promise((resolve) => {
|
|
3947
4687
|
c.once("exit", (code2, signal2) => resolve({ code: code2, signal: signal2 }));
|
|
@@ -3970,7 +4710,7 @@ async function supervise(ctx, o = {}) {
|
|
|
3970
4710
|
} finally {
|
|
3971
4711
|
for (const [s, h] of handlers) process.off(s, h);
|
|
3972
4712
|
const rec = readPidRec(pidf);
|
|
3973
|
-
if (rec?.pid === process.pid)
|
|
4713
|
+
if (rec?.pid === process.pid) fs16.rmSync(pidf, { force: true });
|
|
3974
4714
|
}
|
|
3975
4715
|
}
|
|
3976
4716
|
async function cmdSupervise(_args, ctx) {
|
|
@@ -3992,14 +4732,14 @@ async function ensure(ctx, d, h) {
|
|
|
3992
4732
|
ctx.out(`the connector is running (pid ${run2}) without the supervisor; leaving it`);
|
|
3993
4733
|
return 0;
|
|
3994
4734
|
}
|
|
3995
|
-
|
|
3996
|
-
const log =
|
|
4735
|
+
fs16.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
|
|
4736
|
+
const log = fs16.openSync(runLogFile(ctx.dirs), "a", 384);
|
|
3997
4737
|
try {
|
|
3998
|
-
const p =
|
|
4738
|
+
const p = spawn3(d.node, [d.cli, "supervise"], { detached: true, cwd: d.home, stdio: ["ignore", log, log], env: ctx.env });
|
|
3999
4739
|
p.unref();
|
|
4000
4740
|
ctx.out(`started the supervisor (pid ${p.pid}); log: ${runLogFile(ctx.dirs)}`);
|
|
4001
4741
|
} finally {
|
|
4002
|
-
|
|
4742
|
+
fs16.closeSync(log);
|
|
4003
4743
|
}
|
|
4004
4744
|
return 0;
|
|
4005
4745
|
}
|
|
@@ -4029,7 +4769,7 @@ async function cmdService(args, ctx, h) {
|
|
|
4029
4769
|
}
|
|
4030
4770
|
function installedSupervisor(ctx) {
|
|
4031
4771
|
try {
|
|
4032
|
-
const raw = JSON.parse(
|
|
4772
|
+
const raw = JSON.parse(fs16.readFileSync(serviceFile(ctx.dirs), "utf8"));
|
|
4033
4773
|
if (isObject(raw) && typeof raw.supervisor === "string" && SUPERVISORS.includes(raw.supervisor)) return raw.supervisor;
|
|
4034
4774
|
} catch {
|
|
4035
4775
|
}
|
|
@@ -4050,10 +4790,10 @@ function startAfterLogin(ctx, h) {
|
|
|
4050
4790
|
ctx.out(r.code === 0 ? `started the ${sup} service again` : `could not start the ${sup} service (${r.out || `exit ${r.code}`}); run: claw-connector service install`);
|
|
4051
4791
|
}
|
|
4052
4792
|
function writeFile600(file, body, mode = 384) {
|
|
4053
|
-
|
|
4793
|
+
fs16.mkdirSync(path17.dirname(file), { recursive: true, mode: 448 });
|
|
4054
4794
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
4055
|
-
|
|
4056
|
-
|
|
4795
|
+
fs16.writeFileSync(tmp, body, { mode });
|
|
4796
|
+
fs16.renameSync(tmp, file);
|
|
4057
4797
|
}
|
|
4058
4798
|
async function install(ctx, d, h, sup, extra) {
|
|
4059
4799
|
const problem = h.preflight(ctx);
|
|
@@ -4061,7 +4801,7 @@ async function install(ctx, d, h, sup, extra) {
|
|
|
4061
4801
|
ctx.err(`cannot install the service yet: ${problem}`);
|
|
4062
4802
|
return NEEDS_PERSON;
|
|
4063
4803
|
}
|
|
4064
|
-
if (!
|
|
4804
|
+
if (!fs16.existsSync(d.cli)) {
|
|
4065
4805
|
ctx.err(`${d.cli} does not exist (a source checkout needs "npm run build" first)`);
|
|
4066
4806
|
return 1;
|
|
4067
4807
|
}
|
|
@@ -4125,19 +4865,19 @@ function recordInstalled(ctx, sup) {
|
|
|
4125
4865
|
writeFile600(serviceFile(ctx.dirs), JSON.stringify({ supervisor: sup, installed_at: (/* @__PURE__ */ new Date()).toISOString() }));
|
|
4126
4866
|
}
|
|
4127
4867
|
async function uninstall(ctx, d, sup) {
|
|
4128
|
-
|
|
4868
|
+
fs16.rmSync(serviceFile(ctx.dirs), { force: true });
|
|
4129
4869
|
if (sup === "systemd") {
|
|
4130
4870
|
const unit = systemdUnitPath(ctx, d);
|
|
4131
4871
|
d.exec("systemctl", ["--user", "disable", "--now", UNIT]);
|
|
4132
|
-
const had =
|
|
4133
|
-
|
|
4872
|
+
const had = fs16.existsSync(unit);
|
|
4873
|
+
fs16.rmSync(unit, { force: true });
|
|
4134
4874
|
d.exec("systemctl", ["--user", "daemon-reload"]);
|
|
4135
4875
|
ctx.out(had ? `stopped and removed ${unit}` : `no unit at ${unit}`);
|
|
4136
4876
|
} else if (sup === "launchd") {
|
|
4137
4877
|
const plist = launchdPlistPath(d);
|
|
4138
4878
|
d.exec("launchctl", ["bootout", `gui/${d.uid}/${LABEL}`]);
|
|
4139
|
-
const had =
|
|
4140
|
-
|
|
4879
|
+
const had = fs16.existsSync(plist);
|
|
4880
|
+
fs16.rmSync(plist, { force: true });
|
|
4141
4881
|
ctx.out(had ? `stopped and removed ${plist}` : `no LaunchAgent at ${plist}`);
|
|
4142
4882
|
} else {
|
|
4143
4883
|
const pid = supervisorPid(ctx);
|
|
@@ -4147,8 +4887,8 @@ async function uninstall(ctx, d, sup) {
|
|
|
4147
4887
|
while (Date.now() < end && isAlive(pid) && processStart(pid) !== null) await sleep(100);
|
|
4148
4888
|
}
|
|
4149
4889
|
const script = keepaliveFile(ctx.dirs);
|
|
4150
|
-
const had =
|
|
4151
|
-
|
|
4890
|
+
const had = fs16.existsSync(script);
|
|
4891
|
+
fs16.rmSync(script, { force: true });
|
|
4152
4892
|
ctx.out(`${pid !== null ? `stopped the supervisor (pid ${pid}) and the connector; ` : ""}${had ? `removed ${script}` : "no keep-alive script"}. Remove the @reboot and */5 lines that call it.`);
|
|
4153
4893
|
}
|
|
4154
4894
|
return 0;
|
|
@@ -4181,15 +4921,101 @@ function status(ctx, d, h, json) {
|
|
|
4181
4921
|
}
|
|
4182
4922
|
|
|
4183
4923
|
// src/version.ts
|
|
4184
|
-
var VERSION = "0.1.
|
|
4924
|
+
var VERSION = "0.1.5";
|
|
4925
|
+
|
|
4926
|
+
// src/probe.ts
|
|
4927
|
+
import fs17 from "node:fs";
|
|
4928
|
+
import os6 from "node:os";
|
|
4929
|
+
import path18 from "node:path";
|
|
4930
|
+
var BYPASS_FIX = `Claude Code offers bypassPermissions to root only with IS_SANDBOX set (the connector sets it for an agent with approval = "never" unless its env names IS_SANDBOX), and never when permissions.disableBypassPermissionsMode is "disable" in its settings; fix that, or set approval = "ask" on this agent`;
|
|
4931
|
+
function modesOf(res) {
|
|
4932
|
+
const m = isObject(res.modes) ? res.modes : null;
|
|
4933
|
+
if (m && Array.isArray(m.availableModes)) return m.availableModes.filter((x) => isObject(x) && typeof x.id === "string").map((x) => x.id);
|
|
4934
|
+
const opt = Array.isArray(res.configOptions) ? res.configOptions.find((o) => isObject(o) && o.category === "mode") : null;
|
|
4935
|
+
if (isObject(opt) && Array.isArray(opt.options)) return opt.options.filter((x) => isObject(x) && typeof x.value === "string").map((x) => x.value);
|
|
4936
|
+
return null;
|
|
4937
|
+
}
|
|
4938
|
+
var probeDir = (a) => a.roots.find((r) => {
|
|
4939
|
+
try {
|
|
4940
|
+
return fs17.statSync(r).isDirectory();
|
|
4941
|
+
} catch {
|
|
4942
|
+
return false;
|
|
4943
|
+
}
|
|
4944
|
+
}) ?? os6.homedir();
|
|
4945
|
+
async function probeAgent(a, o) {
|
|
4946
|
+
const cwd = probeDir(a);
|
|
4947
|
+
const out = { agent: a.id, cwd, ok: false, modes: null, problems: [], notes: [] };
|
|
4948
|
+
if (!a.roots.includes(cwd)) out.notes.push(`no root of this agent exists yet (${a.roots.join(", ")}); probed in ${cwd}`);
|
|
4949
|
+
const tmp = fs17.mkdtempSync(path18.join(os6.tmpdir(), "claw-probe-"));
|
|
4950
|
+
const registry = new ChildRegistry(path18.join(tmp, "children.json"));
|
|
4951
|
+
const timeoutMs = o.timeoutMs ?? 12e4;
|
|
4952
|
+
let adapter = null;
|
|
4953
|
+
try {
|
|
4954
|
+
adapter = await AdapterProcess.start({
|
|
4955
|
+
cfg: a,
|
|
4956
|
+
registry,
|
|
4957
|
+
sessionId: `probe-${a.id}`,
|
|
4958
|
+
cwd,
|
|
4959
|
+
logFile: path18.join(o.logDir, `adapter-${a.id}.log`),
|
|
4960
|
+
version: o.version,
|
|
4961
|
+
initTimeoutMs: timeoutMs,
|
|
4962
|
+
hooks: {
|
|
4963
|
+
onUpdate: () => {
|
|
4964
|
+
},
|
|
4965
|
+
onNotification: () => {
|
|
4966
|
+
},
|
|
4967
|
+
onStderr: () => {
|
|
4968
|
+
},
|
|
4969
|
+
onExit: () => {
|
|
4970
|
+
},
|
|
4971
|
+
// No prompt is sent, so nothing should ask; anything that does is refused.
|
|
4972
|
+
onPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
|
4973
|
+
onExtension: async () => ({})
|
|
4974
|
+
}
|
|
4975
|
+
});
|
|
4976
|
+
const res = await adapter.request("session/new", { cwd, mcpServers: [], ...claudeSessionMeta(a) }, timeoutMs);
|
|
4977
|
+
out.modes = modesOf(res);
|
|
4978
|
+
if (out.modes === null || out.modes.length === 0) {
|
|
4979
|
+
out.notes.push("the adapter lists no modes; the connector sets the ask mode and reads it back when a session opens");
|
|
4980
|
+
out.ok = true;
|
|
4981
|
+
return out;
|
|
4982
|
+
}
|
|
4983
|
+
if (!out.modes.includes(a.askMode)) {
|
|
4984
|
+
out.problems.push(`sessions will be refused (mode_unavailable): this agent's sessions run in ${a.askMode}${a.unattended ? ` (approval = "never")` : ""}, but the adapter offers only ${out.modes.join(", ")}${a.askMode === "bypassPermissions" ? `. ${BYPASS_FIX}` : ""}`);
|
|
4985
|
+
}
|
|
4986
|
+
const missing = a.safeModes.filter((m) => m !== a.askMode && !out.modes.includes(m));
|
|
4987
|
+
if (missing.length) out.notes.push(`safe_modes lists ${missing.join(", ")}, which the adapter does not offer here; a phone that picks one is refused`);
|
|
4988
|
+
out.ok = out.problems.length === 0;
|
|
4989
|
+
} catch (e) {
|
|
4990
|
+
out.problems.push(`the adapter did not open a session: ${errMessage(e)} (its log: ${path18.join(o.logDir, `adapter-${a.id}.log`)})`);
|
|
4991
|
+
} finally {
|
|
4992
|
+
await adapter?.kill().catch(() => void 0);
|
|
4993
|
+
fs17.rmSync(tmp, { recursive: true, force: true });
|
|
4994
|
+
}
|
|
4995
|
+
return out;
|
|
4996
|
+
}
|
|
4997
|
+
var probeAgents = async (agents, o) => {
|
|
4998
|
+
const out = [];
|
|
4999
|
+
for (const a of agents) out.push(await probeAgent(a, o));
|
|
5000
|
+
return out;
|
|
5001
|
+
};
|
|
5002
|
+
function probeLines(results) {
|
|
5003
|
+
const lines = [];
|
|
5004
|
+
for (const r of results) {
|
|
5005
|
+
lines.push(`${r.agent}: ${r.ok ? "ok" : "PROBLEM"}${r.modes ? ` (modes: ${r.modes.join(", ")})` : ""}`);
|
|
5006
|
+
for (const p of r.problems) lines.push(` problem: ${p}`);
|
|
5007
|
+
for (const n of r.notes) lines.push(` note: ${n}`);
|
|
5008
|
+
}
|
|
5009
|
+
return lines;
|
|
5010
|
+
}
|
|
4185
5011
|
|
|
4186
5012
|
// src/commands.ts
|
|
4187
|
-
var EXIT = { ok: 0, failure: 1, usage: 2, needsPerson: 78, revoked: 78 };
|
|
5013
|
+
var EXIT = { ok: 0, failure: 1, usage: 2, terminated: 75, needsPerson: 78, revoked: 78 };
|
|
4188
5014
|
var HELP = `claw-connector ${VERSION}: connect the coding agents on this machine to Claw (outbound HTTPS only).
|
|
4189
5015
|
|
|
4190
5016
|
Usage: claw-connector <command> [options]
|
|
4191
5017
|
|
|
4192
|
-
init [--approval ask|never | --approval AGENT=ask|never ...] [--no-hooks]
|
|
5018
|
+
init [--approval ask|never | --approval AGENT=ask|never ...] [--no-hooks] [--no-probe]
|
|
4193
5019
|
write a sample agents.toml, and install the Claude Code session hooks (see "hooks"; --no-hooks
|
|
4194
5020
|
skips that). Agents ask before every write and command by default;
|
|
4195
5021
|
"--approval never" (or "--approval claude=never") lets phone sessions run like your
|
|
@@ -4207,25 +5033,31 @@ Usage: claw-connector <command> [options]
|
|
|
4207
5033
|
after exit 78. systemd --user on Linux, launchd on macOS; "script" (containers without
|
|
4208
5034
|
systemd) writes a keep-alive script for a startup hook and a 5-minute cron line
|
|
4209
5035
|
status [--json] what is running, signed in, paused, the last disconnect and why
|
|
5036
|
+
doctor [--agent ID] [--json]
|
|
5037
|
+
start each agent's adapter, open a session (no prompt) and check that it offers the
|
|
5038
|
+
modes agents.toml needs, so a phone session is not refused; init runs it too
|
|
4210
5039
|
pause stop every agent now and stay offline until "resume"
|
|
4211
5040
|
resume undo "pause"
|
|
4212
5041
|
stop stop the running connector and every agent it started
|
|
4213
|
-
logout stop, revoke the sign-in and delete
|
|
5042
|
+
logout stop, revoke this connector at Claw and the sign-in, and delete the sign-in
|
|
4214
5043
|
--version, --help
|
|
4215
5044
|
|
|
4216
5045
|
Files: ${"$"}XDG_CONFIG_HOME/claw-connector (agents.toml, credentials.json) and ${"$"}XDG_STATE_HOME/claw-connector
|
|
4217
5046
|
(audit.jsonl, logs, outbox); set CLAW_CONNECTOR_HOME to keep everything under one directory.
|
|
4218
5047
|
`;
|
|
4219
|
-
function loadCfg(ctx) {
|
|
4220
|
-
|
|
5048
|
+
function loadCfg(ctx, notes = []) {
|
|
5049
|
+
const cfg = loadConfig(configFile(ctx.dirs), { hostname: ctx.hostname ?? os7.hostname() });
|
|
5050
|
+
notes.push(...applyRootSandbox(cfg.agents, ctx.env, { euid: ctx.euid }));
|
|
5051
|
+
return cfg;
|
|
4221
5052
|
}
|
|
4222
|
-
var noAsk = (ctx, cfg) =>
|
|
5053
|
+
var noAsk = (ctx, cfg) => bypassUnavailableWarnings(cfg.agents, ctx.env, { managedFile: ctx.claudeManagedSettings, euid: ctx.euid });
|
|
4223
5054
|
function serverUrl(ctx, flag, cfg, creds) {
|
|
4224
5055
|
const v = flag ?? ctx.env.CLAW_URL ?? cfg?.server ?? creds?.server ?? null;
|
|
4225
5056
|
return v ? secureUrl(v.trim(), "the Claw server URL") : null;
|
|
4226
5057
|
}
|
|
4227
5058
|
async function main(argv, ctx) {
|
|
4228
5059
|
const [cmd, ...rest] = argv;
|
|
5060
|
+
useAdapterStore(new AdapterStore(adaptersDir(ctx.dirs.state), { autoInstall: ctx.installAdapters === true }));
|
|
4229
5061
|
try {
|
|
4230
5062
|
switch (cmd) {
|
|
4231
5063
|
case void 0:
|
|
@@ -4250,11 +5082,14 @@ async function main(argv, ctx) {
|
|
|
4250
5082
|
return code === EXIT.needsPerson && ctx.env.CLAW_CONNECTOR_SUPERVISOR === "launchd" ? EXIT.ok : code;
|
|
4251
5083
|
}
|
|
4252
5084
|
case "service":
|
|
5085
|
+
if (rest[0] === "install") await installAdapters(ctx, false);
|
|
4253
5086
|
return await cmdService(rest, ctx, serviceHelpers);
|
|
4254
5087
|
case "supervise":
|
|
4255
5088
|
return await cmdSupervise(rest, ctx);
|
|
4256
5089
|
case "status":
|
|
4257
5090
|
return cmdStatus(rest, ctx);
|
|
5091
|
+
case "doctor":
|
|
5092
|
+
return await cmdDoctor(rest, ctx);
|
|
4258
5093
|
case "pause":
|
|
4259
5094
|
return await cmdPause(ctx);
|
|
4260
5095
|
case "resume":
|
|
@@ -4294,16 +5129,16 @@ function parseApproval(values) {
|
|
|
4294
5129
|
return out;
|
|
4295
5130
|
}
|
|
4296
5131
|
async function cmdInit(args, ctx) {
|
|
4297
|
-
const { values } = parseArgs2({ args, strict: true, options: { approval: { type: "string", multiple: true }, "no-hooks": { type: "boolean" } } });
|
|
5132
|
+
const { values } = parseArgs2({ args, strict: true, options: { approval: { type: "string", multiple: true }, "no-hooks": { type: "boolean" }, "no-probe": { type: "boolean" } } });
|
|
4298
5133
|
const approval = parseApproval(values.approval ?? []);
|
|
4299
5134
|
if (typeof approval === "string") {
|
|
4300
5135
|
ctx.err(approval);
|
|
4301
5136
|
return EXIT.usage;
|
|
4302
5137
|
}
|
|
4303
5138
|
const file = configFile(ctx.dirs);
|
|
4304
|
-
|
|
5139
|
+
fs18.mkdirSync(path19.dirname(file), { recursive: true, mode: 448 });
|
|
4305
5140
|
try {
|
|
4306
|
-
|
|
5141
|
+
fs18.writeFileSync(file, sampleConfig(approval), { mode: 384, flag: "wx" });
|
|
4307
5142
|
ctx.out(`wrote ${file}: edit the roots (the directories an agent may work in), then run: claw-connector login`);
|
|
4308
5143
|
const { unattended, parked } = sampleApprovals(approval);
|
|
4309
5144
|
ctx.out(unattended.length ? `no per-step approval for: ${unattended.join(", ")}. Those agents write files and run commands without asking; the phone shows such a session as unattended. To go back, set approval = "ask" on the agent.` : `every agent in this file asks before it writes a file or runs a command. To let phone sessions run like your terminal instead: claw-connector init --approval never (on a new file), or set approval = "never" on an agent.`);
|
|
@@ -4314,11 +5149,54 @@ async function cmdInit(args, ctx) {
|
|
|
4314
5149
|
}
|
|
4315
5150
|
if (!values["no-hooks"]) {
|
|
4316
5151
|
const dir = defaultClaudeDir(ctx);
|
|
4317
|
-
if (!
|
|
5152
|
+
if (!fs18.existsSync(dir)) ctx.out(`no Claude Code directory at ${dir}; once you use Claude Code, run: claw-connector hooks install`);
|
|
4318
5153
|
else installForInit(ctx, dir);
|
|
4319
5154
|
}
|
|
5155
|
+
await installAdapters(ctx, true);
|
|
5156
|
+
if (ctx.probe && !values["no-probe"]) {
|
|
5157
|
+
try {
|
|
5158
|
+
const cfg = loadCfg(ctx);
|
|
5159
|
+
ctx.out("checking each agent's adapter (claw-connector doctor; skip with --no-probe):");
|
|
5160
|
+
for (const l of probeLines(await ctx.probe(cfg.agents, { logDir: logDir(ctx.dirs), version: VERSION }))) ctx.out(l);
|
|
5161
|
+
} catch (e) {
|
|
5162
|
+
ctx.out(`could not check the agents yet: ${errMessage(e)}; after editing agents.toml run: claw-connector doctor`);
|
|
5163
|
+
}
|
|
5164
|
+
}
|
|
4320
5165
|
return EXIT.ok;
|
|
4321
5166
|
}
|
|
5167
|
+
async function cmdDoctor(args, ctx) {
|
|
5168
|
+
const { values } = parseArgs2({ args, strict: true, options: { agent: { type: "string" }, json: { type: "boolean" } } });
|
|
5169
|
+
const notes = [];
|
|
5170
|
+
const cfg = loadCfg(ctx, notes);
|
|
5171
|
+
const agents = values.agent ? cfg.agents.filter((a) => a.id === values.agent) : cfg.agents;
|
|
5172
|
+
if (agents.length === 0) {
|
|
5173
|
+
ctx.err(values.agent ? `no agent ${JSON.stringify(values.agent)} in agents.toml` : "agents.toml defines no agents; add an [[agents]] table");
|
|
5174
|
+
return EXIT.failure;
|
|
5175
|
+
}
|
|
5176
|
+
const warnings = noAsk(ctx, { ...cfg, agents });
|
|
5177
|
+
const results = await (ctx.probe ?? probeAgents)(agents, { logDir: logDir(ctx.dirs), version: VERSION });
|
|
5178
|
+
const ok = results.every((r) => r.ok);
|
|
5179
|
+
if (values.json) {
|
|
5180
|
+
ctx.out(JSON.stringify({ version: VERSION, ok, notes, warnings, agents: results }, null, 2));
|
|
5181
|
+
} else {
|
|
5182
|
+
for (const n of notes) ctx.out(`note: ${n}`);
|
|
5183
|
+
for (const w of warnings) ctx.out(`warning: ${w}`);
|
|
5184
|
+
for (const l of probeLines(results)) ctx.out(l);
|
|
5185
|
+
}
|
|
5186
|
+
return ok ? EXIT.ok : EXIT.failure;
|
|
5187
|
+
}
|
|
5188
|
+
async function installAdapters(ctx, initFallback) {
|
|
5189
|
+
const store2 = adapterStore();
|
|
5190
|
+
if (!store2 || !ctx.installAdapters) return;
|
|
5191
|
+
let agents;
|
|
5192
|
+
try {
|
|
5193
|
+
agents = loadCfg(ctx).agents;
|
|
5194
|
+
} catch {
|
|
5195
|
+
if (!initFallback) return;
|
|
5196
|
+
agents = SAMPLE_ACTIVE.map((id) => ({ command: PRESETS[id].command, args: PRESETS[id].args, env: {} }));
|
|
5197
|
+
}
|
|
5198
|
+
await installAll(store2, agents, ctx.env, (l) => ctx.out(`adapters: ${l}`));
|
|
5199
|
+
}
|
|
4322
5200
|
function installForInit(ctx, dir) {
|
|
4323
5201
|
try {
|
|
4324
5202
|
ctx.out(describeInstall(dir, installOne(ctx, dir).changed));
|
|
@@ -4326,19 +5204,19 @@ function installForInit(ctx, dir) {
|
|
|
4326
5204
|
ctx.err(`hooks: ${errMessage(e)} (everything else is set up; fix it and run: claw-connector hooks install)`);
|
|
4327
5205
|
}
|
|
4328
5206
|
}
|
|
4329
|
-
var defaultClaudeDir = (ctx) =>
|
|
5207
|
+
var defaultClaudeDir = (ctx) => path19.resolve(ctx.env.CLAUDE_CONFIG_DIR?.trim() || path19.join(os7.homedir(), ".claude"));
|
|
4330
5208
|
function claudeDirs(ctx, flag) {
|
|
4331
|
-
if (flag !== void 0) return [
|
|
5209
|
+
if (flag !== void 0) return [path19.resolve(flag)];
|
|
4332
5210
|
let dirs = [];
|
|
4333
5211
|
try {
|
|
4334
|
-
dirs = loadCfg(ctx).agents.filter((a) => a.attach).map((a) =>
|
|
5212
|
+
dirs = loadCfg(ctx).agents.filter((a) => a.attach).map((a) => path19.resolve(a.env.CLAUDE_CONFIG_DIR ?? ctx.env.CLAUDE_CONFIG_DIR?.trim() ?? path19.join(os7.homedir(), ".claude")));
|
|
4335
5213
|
} catch {
|
|
4336
5214
|
}
|
|
4337
5215
|
return dirs.length ? [...new Set(dirs)] : [defaultClaudeDir(ctx)];
|
|
4338
5216
|
}
|
|
4339
5217
|
function hookScript(ctx) {
|
|
4340
5218
|
const script = ctx.hookScript ?? fileURLToPath2(new URL("./terminal-hook.js", import.meta.url));
|
|
4341
|
-
if (!
|
|
5219
|
+
if (!fs18.existsSync(script)) throw new HooksError(`${script} does not exist (a source checkout needs "npm run build" first)`);
|
|
4342
5220
|
return script;
|
|
4343
5221
|
}
|
|
4344
5222
|
function installOne(ctx, dir) {
|
|
@@ -4358,7 +5236,7 @@ function cmdHooks(args, ctx) {
|
|
|
4358
5236
|
for (const dir of dirs) {
|
|
4359
5237
|
try {
|
|
4360
5238
|
if (sub === "install") {
|
|
4361
|
-
if (values["config-dir"] === void 0 && !
|
|
5239
|
+
if (values["config-dir"] === void 0 && !fs18.existsSync(dir)) {
|
|
4362
5240
|
ctx.err(`no Claude Code directory at ${dir}; pass --config-dir to name one`);
|
|
4363
5241
|
code = EXIT.failure;
|
|
4364
5242
|
continue;
|
|
@@ -4383,11 +5261,11 @@ function forgetRevokedIdentity(ctx) {
|
|
|
4383
5261
|
const file = identityFile(ctx.dirs);
|
|
4384
5262
|
let revoked = false;
|
|
4385
5263
|
try {
|
|
4386
|
-
const raw = JSON.parse(
|
|
5264
|
+
const raw = JSON.parse(fs18.readFileSync(file, "utf8"));
|
|
4387
5265
|
revoked = isObject(raw) && raw.revoked === true;
|
|
4388
5266
|
} catch {
|
|
4389
5267
|
}
|
|
4390
|
-
if (revoked)
|
|
5268
|
+
if (revoked) fs18.rmSync(file, { force: true });
|
|
4391
5269
|
}
|
|
4392
5270
|
async function cmdLogin(args, ctx) {
|
|
4393
5271
|
const { values } = parseArgs2({
|
|
@@ -4422,7 +5300,7 @@ async function cmdLogin(args, ctx) {
|
|
|
4422
5300
|
ctx.err("login needs --identity-url <identity service URL> (or CLAW_IDENTITY_URL, or identity_url = in agents.toml)");
|
|
4423
5301
|
return EXIT.usage;
|
|
4424
5302
|
}
|
|
4425
|
-
const deviceName = values.name ?? cfg?.name ?? ctx.hostname ??
|
|
5303
|
+
const deviceName = values.name ?? cfg?.name ?? ctx.hostname ?? os7.hostname();
|
|
4426
5304
|
const tok = await deviceLogin({ identityUrl, deviceName, fetch: ctx.fetch, print: ctx.out });
|
|
4427
5305
|
saveCredentials(file, {
|
|
4428
5306
|
v: 1,
|
|
@@ -4442,14 +5320,14 @@ async function cmdLogin(args, ctx) {
|
|
|
4442
5320
|
}
|
|
4443
5321
|
function runningPid(ctx) {
|
|
4444
5322
|
try {
|
|
4445
|
-
const raw = JSON.parse(
|
|
5323
|
+
const raw = JSON.parse(fs18.readFileSync(pidFile(ctx.dirs), "utf8"));
|
|
4446
5324
|
if (isObject(raw) && typeof raw.pid === "number" && typeof raw.start === "string" && raw.start !== "" && processStart(raw.pid) === raw.start) return raw.pid;
|
|
4447
5325
|
} catch {
|
|
4448
5326
|
}
|
|
4449
5327
|
return null;
|
|
4450
5328
|
}
|
|
4451
5329
|
async function cmdRun(args, ctx) {
|
|
4452
|
-
parseArgs2({ args, strict: true, options: { supervised: { type: "boolean" } } });
|
|
5330
|
+
const { values: runFlags } = parseArgs2({ args, strict: true, options: { supervised: { type: "boolean" } } });
|
|
4453
5331
|
{
|
|
4454
5332
|
const nl = needsLogin(ctx.dirs);
|
|
4455
5333
|
if (nl) {
|
|
@@ -4459,8 +5337,9 @@ async function cmdRun(args, ctx) {
|
|
|
4459
5337
|
}
|
|
4460
5338
|
let cfg;
|
|
4461
5339
|
let creds;
|
|
5340
|
+
const notes = [];
|
|
4462
5341
|
try {
|
|
4463
|
-
cfg = loadCfg(ctx);
|
|
5342
|
+
cfg = loadCfg(ctx, notes);
|
|
4464
5343
|
creds = loadCredentials(credentialsFile(ctx.dirs));
|
|
4465
5344
|
} catch (e) {
|
|
4466
5345
|
if (e instanceof ConfigError || e instanceof CredentialsError) {
|
|
@@ -4488,8 +5367,10 @@ async function cmdRun(args, ctx) {
|
|
|
4488
5367
|
const log = stderrLogger();
|
|
4489
5368
|
const audit = fileAudit(auditFile(ctx.dirs));
|
|
4490
5369
|
const connector2 = new Connector({ config: cfg, dirs: ctx.dirs, client: client2, server, audit, log, version: VERSION, timing: ctx.timing });
|
|
5370
|
+
if (ctx.installAdapters) for (const { pkg, env } of pinnedOf(cfg.agents, ctx.env)) void adapterStore()?.install(pkg, env, (l) => ctx.out(`adapters: ${l}`));
|
|
4491
5371
|
ctx.onConnector?.(connector2);
|
|
4492
5372
|
ctx.out(`connecting ${cfg.agents.map((a) => a.unattended ? `${a.id} (no per-step approval)` : a.id).join(", ")} to ${server} as "${cfg.name}"`);
|
|
5373
|
+
for (const n of notes) ctx.out(`note: ${n}`);
|
|
4493
5374
|
for (const w of noAsk(ctx, cfg)) ctx.out(`warning: ${w}`);
|
|
4494
5375
|
try {
|
|
4495
5376
|
const result = await connector2.run();
|
|
@@ -4503,6 +5384,10 @@ async function cmdRun(args, ctx) {
|
|
|
4503
5384
|
ctx.err(`Claw keeps refusing this sign-in (${connector2.authFailure}): every agent was stopped. Run login again.`);
|
|
4504
5385
|
return EXIT.needsPerson;
|
|
4505
5386
|
}
|
|
5387
|
+
if (runFlags.supervised && !takeStopRequest(ctx)) {
|
|
5388
|
+
ctx.err(`${(/* @__PURE__ */ new Date()).toISOString()} stopped by a signal that did not come from "claw-connector stop"; exit ${EXIT.terminated}, so the supervisor starts it again`);
|
|
5389
|
+
return EXIT.terminated;
|
|
5390
|
+
}
|
|
4506
5391
|
return EXIT.ok;
|
|
4507
5392
|
} catch (e) {
|
|
4508
5393
|
if (isAuthRefusal(e) || e instanceof RevokedError || e instanceof GrantRevokedError || e instanceof CredentialsError) {
|
|
@@ -4540,7 +5425,7 @@ function cmdStatus(args, ctx) {
|
|
|
4540
5425
|
}
|
|
4541
5426
|
let connectorId = null;
|
|
4542
5427
|
try {
|
|
4543
|
-
const raw = JSON.parse(
|
|
5428
|
+
const raw = JSON.parse(fs18.readFileSync(identityFile(ctx.dirs), "utf8"));
|
|
4544
5429
|
if (isObject(raw) && typeof raw.connector_id === "string") connectorId = raw.connector_id;
|
|
4545
5430
|
} catch {
|
|
4546
5431
|
}
|
|
@@ -4548,7 +5433,7 @@ function cmdStatus(args, ctx) {
|
|
|
4548
5433
|
const status2 = {
|
|
4549
5434
|
version: VERSION,
|
|
4550
5435
|
running: runningPid(ctx),
|
|
4551
|
-
paused:
|
|
5436
|
+
paused: fs18.existsSync(pauseFile(ctx.dirs)),
|
|
4552
5437
|
signed_in: creds ? { kind: creds.kind, server: creds.server, device_name: creds.device_name ?? null } : null,
|
|
4553
5438
|
connector_id: connectorId,
|
|
4554
5439
|
agents,
|
|
@@ -4577,8 +5462,8 @@ function cmdStatus(args, ctx) {
|
|
|
4577
5462
|
return EXIT.ok;
|
|
4578
5463
|
}
|
|
4579
5464
|
async function cmdPause(ctx) {
|
|
4580
|
-
|
|
4581
|
-
|
|
5465
|
+
fs18.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
|
|
5466
|
+
fs18.writeFileSync(pauseFile(ctx.dirs), (/* @__PURE__ */ new Date()).toISOString() + "\n", { mode: 384 });
|
|
4582
5467
|
if (runningPid(ctx) === null) {
|
|
4583
5468
|
const left = await new ChildRegistry(childrenFile(ctx.dirs)).killAll();
|
|
4584
5469
|
ctx.out(`paused (the connector is not running${left.killed.length ? `; stopped ${left.killed.length} leftover agent process group(s)` : ""})`);
|
|
@@ -4588,13 +5473,15 @@ async function cmdPause(ctx) {
|
|
|
4588
5473
|
return EXIT.ok;
|
|
4589
5474
|
}
|
|
4590
5475
|
function cmdResume(ctx) {
|
|
4591
|
-
|
|
5476
|
+
fs18.rmSync(pauseFile(ctx.dirs), { force: true });
|
|
4592
5477
|
ctx.out(runningPid(ctx) === null ? "resumed; start it with: claw-connector run" : "resumed: the running connector reconnects now");
|
|
4593
5478
|
return EXIT.ok;
|
|
4594
5479
|
}
|
|
4595
5480
|
async function cmdStop(ctx, quiet = false) {
|
|
4596
5481
|
const pid = runningPid(ctx);
|
|
4597
5482
|
if (pid !== null) {
|
|
5483
|
+
fs18.mkdirSync(ctx.dirs.state, { recursive: true, mode: 448 });
|
|
5484
|
+
fs18.writeFileSync(stopRequestFile(ctx.dirs), JSON.stringify({ pid, start: processStart(pid) ?? "", at: (/* @__PURE__ */ new Date()).toISOString() }), { mode: 384 });
|
|
4598
5485
|
process.kill(pid, "SIGTERM");
|
|
4599
5486
|
const deadline = Date.now() + 15e3;
|
|
4600
5487
|
while (Date.now() < deadline && isAlive(pid) && processStart(pid) !== null) await sleep(100);
|
|
@@ -4602,34 +5489,108 @@ async function cmdStop(ctx, quiet = false) {
|
|
|
4602
5489
|
process.kill(pid, "SIGKILL");
|
|
4603
5490
|
if (!quiet) ctx.out(`the connector (pid ${pid}) did not stop in 15 s and was killed`);
|
|
4604
5491
|
}
|
|
5492
|
+
fs18.rmSync(stopRequestFile(ctx.dirs), { force: true });
|
|
4605
5493
|
}
|
|
4606
5494
|
const left = await new ChildRegistry(childrenFile(ctx.dirs)).killAll();
|
|
4607
5495
|
if (!quiet) ctx.out(pid === null ? `no connector running${left.killed.length ? `; stopped ${left.killed.length} leftover agent process group(s)` : ""}` : `stopped the connector${left.killed.length ? ` and ${left.killed.length} leftover agent process group(s)` : ""}`);
|
|
4608
5496
|
return EXIT.ok;
|
|
4609
5497
|
}
|
|
5498
|
+
function takeStopRequest(ctx) {
|
|
5499
|
+
const file = stopRequestFile(ctx.dirs);
|
|
5500
|
+
let mine = false;
|
|
5501
|
+
try {
|
|
5502
|
+
const raw = JSON.parse(fs18.readFileSync(file, "utf8"));
|
|
5503
|
+
mine = isObject(raw) && raw.pid === process.pid && raw.start === (processStart(process.pid) ?? "");
|
|
5504
|
+
} catch {
|
|
5505
|
+
return false;
|
|
5506
|
+
}
|
|
5507
|
+
if (mine) fs18.rmSync(file, { force: true });
|
|
5508
|
+
return mine;
|
|
5509
|
+
}
|
|
5510
|
+
function storedConnector(ctx) {
|
|
5511
|
+
try {
|
|
5512
|
+
const raw = JSON.parse(fs18.readFileSync(identityFile(ctx.dirs), "utf8"));
|
|
5513
|
+
if (isObject(raw) && typeof raw.server === "string" && typeof raw.connector_id === "string") {
|
|
5514
|
+
return { server: raw.server, connector_id: raw.connector_id, revoked: raw.revoked === true };
|
|
5515
|
+
}
|
|
5516
|
+
} catch {
|
|
5517
|
+
}
|
|
5518
|
+
return null;
|
|
5519
|
+
}
|
|
5520
|
+
var revokeByHand = (server, cid) => `curl -fsS -X DELETE -H "Authorization: Bearer <your Claw API key>" ${server}/v1/remote-agents/connectors/${encodeURIComponent(cid)}`;
|
|
4610
5521
|
async function cmdLogout(ctx) {
|
|
4611
5522
|
await cmdStop(ctx, true);
|
|
4612
5523
|
const file = credentialsFile(ctx.dirs);
|
|
4613
5524
|
let creds = null;
|
|
5525
|
+
let credsProblem = null;
|
|
4614
5526
|
try {
|
|
4615
5527
|
creds = loadCredentials(file);
|
|
4616
|
-
} catch {
|
|
5528
|
+
} catch (e) {
|
|
5529
|
+
credsProblem = errMessage(e);
|
|
4617
5530
|
}
|
|
4618
|
-
|
|
5531
|
+
const row = storedConnector(ctx);
|
|
5532
|
+
let latest = creds;
|
|
5533
|
+
let notRevoked = null;
|
|
5534
|
+
let rowGone = false;
|
|
5535
|
+
if (row && !row.revoked) {
|
|
5536
|
+
if (!creds) {
|
|
5537
|
+
notRevoked = credsProblem ? `the sign-in on this machine is unreadable (${credsProblem})` : "this machine is not signed in";
|
|
5538
|
+
} else {
|
|
5539
|
+
let server = null;
|
|
5540
|
+
try {
|
|
5541
|
+
let cfg = null;
|
|
5542
|
+
try {
|
|
5543
|
+
cfg = loadCfg(ctx);
|
|
5544
|
+
} catch (e) {
|
|
5545
|
+
if (!(e instanceof ConfigError)) throw e;
|
|
5546
|
+
}
|
|
5547
|
+
server = serverUrl(ctx, void 0, cfg, creds);
|
|
5548
|
+
} catch {
|
|
5549
|
+
}
|
|
5550
|
+
if (server !== row.server) {
|
|
5551
|
+
notRevoked = `it was registered at ${row.server}, but this sign-in is for ${server ?? "no usable server"}, so nothing was sent to either`;
|
|
5552
|
+
} else try {
|
|
5553
|
+
const tokens = providerFor(creds, (c) => {
|
|
5554
|
+
latest = c;
|
|
5555
|
+
saveCredentials(file, c);
|
|
5556
|
+
}, { fetch: ctx.fetch });
|
|
5557
|
+
const client2 = new ClawClient({ server: row.server, tokens, fetch: ctx.fetch, requestTimeoutMs: 15e3 });
|
|
5558
|
+
await client2.revoke(row.connector_id, creds.kind);
|
|
5559
|
+
rowGone = true;
|
|
5560
|
+
ctx.out(`revoked connector ${row.connector_id} at ${row.server}: Claw sends it nothing more`);
|
|
5561
|
+
} catch (e) {
|
|
5562
|
+
if (e instanceof RevokedError || e instanceof HttpError && e.status === 404 && e.code === "not_found") {
|
|
5563
|
+
rowGone = true;
|
|
5564
|
+
ctx.out(`Claw has no live connector ${row.connector_id} for this sign-in (already revoked, or registered by another account)`);
|
|
5565
|
+
} else {
|
|
5566
|
+
notRevoked = errMessage(e);
|
|
5567
|
+
}
|
|
5568
|
+
}
|
|
5569
|
+
}
|
|
5570
|
+
}
|
|
5571
|
+
if (latest?.kind === "device" && latest.identity_url && latest.refresh_token) {
|
|
4619
5572
|
try {
|
|
4620
5573
|
const f = ctx.fetch ?? fetch;
|
|
4621
|
-
await f(`${
|
|
5574
|
+
await f(`${latest.identity_url}/oauth/revoke`, {
|
|
4622
5575
|
method: "POST",
|
|
4623
5576
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
4624
|
-
body: new URLSearchParams({ token:
|
|
5577
|
+
body: new URLSearchParams({ token: latest.refresh_token, token_type_hint: "refresh_token", client_id: latest.client_id ?? DEFAULT_CLIENT_ID }),
|
|
4625
5578
|
signal: AbortSignal.timeout(1e4)
|
|
4626
5579
|
});
|
|
4627
5580
|
} catch (e) {
|
|
4628
5581
|
ctx.err(`could not revoke the sign-in at the identity service (${errMessage(e)}); it was removed from this machine`);
|
|
4629
5582
|
}
|
|
4630
5583
|
}
|
|
4631
|
-
|
|
4632
|
-
|
|
5584
|
+
fs18.rmSync(file, { force: true });
|
|
5585
|
+
if (rowGone) fs18.rmSync(identityFile(ctx.dirs), { force: true });
|
|
5586
|
+
else forgetRevokedIdentity(ctx);
|
|
5587
|
+
if (row && notRevoked !== null) {
|
|
5588
|
+
ctx.err(`signed out on this machine, but connector ${row.connector_id} was NOT revoked at Claw (${notRevoked}).`);
|
|
5589
|
+
ctx.err("It is still your connector there, and instructions sent to it wait for the next sign-in on this machine. Revoke it with:");
|
|
5590
|
+
ctx.err(` ${revokeByHand(row.server, row.connector_id)}`);
|
|
5591
|
+
ctx.err("or sign in again and run logout once Claw is reachable.");
|
|
5592
|
+
return EXIT.failure;
|
|
5593
|
+
}
|
|
4633
5594
|
ctx.out("signed out");
|
|
4634
5595
|
return EXIT.ok;
|
|
4635
5596
|
}
|
|
@@ -4662,6 +5623,8 @@ main(process.argv.slice(2), {
|
|
|
4662
5623
|
env: process.env,
|
|
4663
5624
|
out: (l) => process.stdout.write(l + "\n"),
|
|
4664
5625
|
err: (l) => process.stderr.write(l + "\n"),
|
|
5626
|
+
probe: probeAgents,
|
|
5627
|
+
installAdapters: true,
|
|
4665
5628
|
onConnector: (c) => {
|
|
4666
5629
|
connector = c;
|
|
4667
5630
|
for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) process.on(sig, onSignal);
|