agentlink-daemon 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/main.js +578 -129
- package/package.json +1 -1
package/dist/main.js
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/main.ts
|
|
4
|
-
import { spawn } from "node:child_process";
|
|
4
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
|
-
import { readFileSync, readdirSync as
|
|
6
|
+
import { readFileSync as readFileSync4, readdirSync as readdirSync3, realpathSync, statSync } from "node:fs";
|
|
7
7
|
import { hostname } from "node:os";
|
|
8
|
-
import { join as
|
|
8
|
+
import { join as join5, resolve } from "node:path";
|
|
9
|
+
import { parseArgs as nodeParseArgs } from "node:util";
|
|
9
10
|
import { WebSocket } from "ws";
|
|
10
11
|
|
|
11
|
-
// src/
|
|
12
|
+
// src/env.ts
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
12
14
|
import { existsSync, readdirSync } from "node:fs";
|
|
13
15
|
import { homedir, platform } from "node:os";
|
|
14
|
-
import {
|
|
16
|
+
import { delimiter, join } from "node:path";
|
|
15
17
|
function extraDirs(home) {
|
|
16
18
|
const dirs = [join(home, ".local", "bin"), join(home, ".npm-global", "bin")];
|
|
17
19
|
if (platform() !== "win32") dirs.push("/opt/homebrew/bin", "/usr/local/bin");
|
|
@@ -37,6 +39,44 @@ function resolveBin(name) {
|
|
|
37
39
|
function spawnEnv() {
|
|
38
40
|
return { ...process.env, PATH: PATH_DIRS.join(delimiter) };
|
|
39
41
|
}
|
|
42
|
+
function captureCommand(bin, args, timeoutMs) {
|
|
43
|
+
return new Promise((done) => {
|
|
44
|
+
let out = "";
|
|
45
|
+
let settled = false;
|
|
46
|
+
const settle = (value) => {
|
|
47
|
+
if (!settled) {
|
|
48
|
+
settled = true;
|
|
49
|
+
done(value);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
let child;
|
|
53
|
+
try {
|
|
54
|
+
child = spawn(bin, args, { env: spawnEnv(), stdio: ["ignore", "pipe", "ignore"] });
|
|
55
|
+
} catch {
|
|
56
|
+
return settle(null);
|
|
57
|
+
}
|
|
58
|
+
const timer = setTimeout(() => {
|
|
59
|
+
child.kill();
|
|
60
|
+
settle(null);
|
|
61
|
+
}, timeoutMs);
|
|
62
|
+
child.stdout?.on("data", (chunk) => out += chunk.toString("utf-8"));
|
|
63
|
+
child.on("error", () => {
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
settle(null);
|
|
66
|
+
});
|
|
67
|
+
child.on("close", (code) => {
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
settle(code === 0 ? out : null);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/adapters/claude.ts
|
|
75
|
+
import { readFileSync } from "node:fs";
|
|
76
|
+
import { homedir as homedir2 } from "node:os";
|
|
77
|
+
import { join as join2 } from "node:path";
|
|
78
|
+
|
|
79
|
+
// src/adapters/util.ts
|
|
40
80
|
function parseJsonLine(line) {
|
|
41
81
|
try {
|
|
42
82
|
return JSON.parse(line);
|
|
@@ -49,6 +89,12 @@ function resultText(content) {
|
|
|
49
89
|
if (Array.isArray(content)) return content.map((c) => c?.type === "text" ? c.text : JSON.stringify(c)).join("\n");
|
|
50
90
|
return JSON.stringify(content);
|
|
51
91
|
}
|
|
92
|
+
function sanitizeModelName(raw) {
|
|
93
|
+
const clean = raw.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").replace(/\[[0-9;]*m\]?/g, "").replace(/[\x00-\x1f\x7f]/g, "").trim();
|
|
94
|
+
return clean.length > 0 ? clean : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// src/adapters/claude.ts
|
|
52
98
|
function createClaudeParser(emit) {
|
|
53
99
|
const streamed = /* @__PURE__ */ new Set();
|
|
54
100
|
let messageId = null;
|
|
@@ -97,6 +143,60 @@ function createClaudeParser(emit) {
|
|
|
97
143
|
}
|
|
98
144
|
};
|
|
99
145
|
}
|
|
146
|
+
function probeClaudeModels() {
|
|
147
|
+
const names = [];
|
|
148
|
+
const add = (v) => {
|
|
149
|
+
if (typeof v !== "string") return;
|
|
150
|
+
const name = sanitizeModelName(v);
|
|
151
|
+
if (name && !names.includes(name)) names.push(name);
|
|
152
|
+
};
|
|
153
|
+
try {
|
|
154
|
+
const s = JSON.parse(readFileSync(join2(homedir2(), ".claude", "settings.json"), "utf-8"));
|
|
155
|
+
add(s?.model);
|
|
156
|
+
} catch {
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
const c = JSON.parse(readFileSync(join2(homedir2(), ".claude.json"), "utf-8"));
|
|
160
|
+
add(c?.model);
|
|
161
|
+
if (c?.lastModelUsage && typeof c.lastModelUsage === "object") {
|
|
162
|
+
for (const k of Object.keys(c.lastModelUsage)) add(k);
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
}
|
|
166
|
+
return names;
|
|
167
|
+
}
|
|
168
|
+
var claude = {
|
|
169
|
+
id: "claude",
|
|
170
|
+
label: "Claude Code",
|
|
171
|
+
// Suggested aliases; claude also accepts full model ids via --model.
|
|
172
|
+
// probeClaudeModels adds whatever this install is actually configured
|
|
173
|
+
// with (settings.json / ~/.claude.json), merged ahead of these.
|
|
174
|
+
models: ["opus", "sonnet", "haiku"],
|
|
175
|
+
probeModels: async () => probeClaudeModels(),
|
|
176
|
+
// --resume <id> works with -p (print) mode too, and was verified live:
|
|
177
|
+
// a fresh process, given only --resume and a new prompt, correctly
|
|
178
|
+
// recalled facts from an earlier, separate process's conversation.
|
|
179
|
+
buildInvocation: (prompt, resumeId, model) => ({
|
|
180
|
+
args: [
|
|
181
|
+
"-p",
|
|
182
|
+
"--output-format",
|
|
183
|
+
"stream-json",
|
|
184
|
+
"--verbose",
|
|
185
|
+
"--include-partial-messages",
|
|
186
|
+
"--permission-mode",
|
|
187
|
+
"bypassPermissions",
|
|
188
|
+
...model ? ["--model", model] : [],
|
|
189
|
+
...resumeId ? ["--resume", resumeId] : []
|
|
190
|
+
],
|
|
191
|
+
stdin: prompt
|
|
192
|
+
}),
|
|
193
|
+
createParser: createClaudeParser
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// src/adapters/codex.ts
|
|
197
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
198
|
+
import { homedir as homedir3 } from "node:os";
|
|
199
|
+
import { join as join3 } from "node:path";
|
|
100
200
|
function createCodexParser(emit) {
|
|
101
201
|
const seen = /* @__PURE__ */ new Set();
|
|
102
202
|
return (line) => {
|
|
@@ -132,61 +232,87 @@ function createCodexParser(emit) {
|
|
|
132
232
|
}
|
|
133
233
|
};
|
|
134
234
|
}
|
|
135
|
-
|
|
136
|
-
{
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
args: resumeId ? ["exec", "resume", resumeId, "-", "--json", "--skip-git-repo-check", "--dangerously-bypass-approvals-and-sandbox"] : ["exec", "--json", "--skip-git-repo-check", "--sandbox", "workspace-write"],
|
|
235
|
+
function probeCodexModels() {
|
|
236
|
+
try {
|
|
237
|
+
const text = readFileSync2(join3(homedir3(), ".codex", "config.toml"), "utf-8");
|
|
238
|
+
const names = [];
|
|
239
|
+
const re = /^\s*model(?:_id)?\s*=\s*"([^"]+)"/gm;
|
|
240
|
+
let m;
|
|
241
|
+
while ((m = re.exec(text)) !== null) {
|
|
242
|
+
const name = sanitizeModelName(m[1]);
|
|
243
|
+
if (name && !names.includes(name)) names.push(name);
|
|
244
|
+
}
|
|
245
|
+
return names;
|
|
246
|
+
} catch {
|
|
247
|
+
return [];
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
var codex = {
|
|
251
|
+
id: "codex",
|
|
252
|
+
label: "Codex CLI",
|
|
253
|
+
// Suggested; codex accepts any model its config knows via -m.
|
|
254
|
+
// probeCodexModels adds whatever ~/.codex/config.toml declares (incl.
|
|
255
|
+
// custom/proxy models), merged ahead of these aliases.
|
|
256
|
+
models: ["gpt-5-codex", "gpt-5", "o3"],
|
|
257
|
+
probeModels: async () => probeCodexModels(),
|
|
258
|
+
// UNVERIFIED resume (see ai-spawn HANDOFF.md): `codex exec resume <id>`
|
|
259
|
+
// is a different subcommand from `codex exec`, and its flag set doesn't
|
|
260
|
+
// include `--sandbox` — the closest equivalent for "don't block on an
|
|
261
|
+
// approval prompt" is `--dangerously-bypass-approvals-and-sandbox`,
|
|
262
|
+
// which is a stronger bypass than the workspace-write sandbox used on
|
|
263
|
+
// a fresh run. That's consistent with claude's own bypassPermissions
|
|
264
|
+
// above (this system already trusts the agent fully once it's running),
|
|
265
|
+
// but never exercised end-to-end because of an account rate limit
|
|
266
|
+
// during development — verify before relying on it.
|
|
267
|
+
buildInvocation: (prompt, resumeId, model) => {
|
|
268
|
+
const m = model ? ["-m", model] : [];
|
|
269
|
+
return {
|
|
270
|
+
args: resumeId ? ["exec", "resume", resumeId, "-", "--json", "--skip-git-repo-check", "--dangerously-bypass-approvals-and-sandbox", ...m] : ["exec", "--json", "--skip-git-repo-check", "--sandbox", "workspace-write", ...m],
|
|
171
271
|
stdin: prompt
|
|
172
|
-
}
|
|
173
|
-
createParser: createCodexParser
|
|
272
|
+
};
|
|
174
273
|
},
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
274
|
+
createParser: createCodexParser
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// src/adapters/joycode.ts
|
|
278
|
+
async function probeJoycodeModels(bin) {
|
|
279
|
+
const out = await captureCommand(bin, ["models"], 5e3);
|
|
280
|
+
if (out === null) return [];
|
|
281
|
+
const names = [];
|
|
282
|
+
const re = /-m\s+"([^"]+)"/g;
|
|
283
|
+
let m;
|
|
284
|
+
while ((m = re.exec(out)) !== null) {
|
|
285
|
+
const name = m[1];
|
|
286
|
+
if (name.includes("<") || name.includes(">")) continue;
|
|
287
|
+
if (!names.includes(name)) names.push(name);
|
|
188
288
|
}
|
|
189
|
-
|
|
289
|
+
return names;
|
|
290
|
+
}
|
|
291
|
+
var joycode = {
|
|
292
|
+
id: "joycode",
|
|
293
|
+
label: "JoyCode",
|
|
294
|
+
// Fallback only — `probeModels` below replaces this at startup with the
|
|
295
|
+
// real list scraped from `joycode models` (the names carry non-guessable
|
|
296
|
+
// suffixes like `-hq` and spaces, so a static list would drift).
|
|
297
|
+
models: [],
|
|
298
|
+
probeModels: probeJoycodeModels,
|
|
299
|
+
// Verified live (DESIGN.md §4.3): the prompt goes on argv, and on
|
|
300
|
+
// resume the flags MUST come before the `resume` subcommand —
|
|
301
|
+
// `joycode exec --json <flags> resume <thread_id> "<prompt>"`.
|
|
302
|
+
// Putting flags after `resume` is a usage error (unlike codex's
|
|
303
|
+
// `exec resume <id> -` word order). stdin feeding is unverified, so
|
|
304
|
+
// fresh runs also pass the prompt via argv for consistency.
|
|
305
|
+
buildInvocation: (prompt, resumeId, model) => {
|
|
306
|
+
const m = model ? ["-m", model] : [];
|
|
307
|
+
return {
|
|
308
|
+
args: resumeId ? ["exec", "--json", "--skip-git-repo-check", "--dangerously-bypass-approvals-and-sandbox", ...m, "resume", resumeId, prompt] : ["exec", "--json", "--skip-git-repo-check", "--sandbox", "workspace-write", ...m, prompt]
|
|
309
|
+
};
|
|
310
|
+
},
|
|
311
|
+
createParser: createCodexParser
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// src/adapters/index.ts
|
|
315
|
+
var REGISTRY = [claude, codex, joycode];
|
|
190
316
|
function detectAgents() {
|
|
191
317
|
return REGISTRY.flatMap((def) => {
|
|
192
318
|
const bin = resolveBin(def.id);
|
|
@@ -197,89 +323,372 @@ function getAgent(id) {
|
|
|
197
323
|
return detectAgents().find((a) => a.id === id) ?? null;
|
|
198
324
|
}
|
|
199
325
|
|
|
326
|
+
// src/store.ts
|
|
327
|
+
import {
|
|
328
|
+
appendFileSync,
|
|
329
|
+
existsSync as existsSync2,
|
|
330
|
+
mkdirSync,
|
|
331
|
+
readdirSync as readdirSync2,
|
|
332
|
+
readFileSync as readFileSync3,
|
|
333
|
+
renameSync,
|
|
334
|
+
rmSync,
|
|
335
|
+
writeFileSync
|
|
336
|
+
} from "node:fs";
|
|
337
|
+
import { homedir as homedir4 } from "node:os";
|
|
338
|
+
import { join as join4 } from "node:path";
|
|
339
|
+
var STATE_DIR = process.env.AGENTLINK_STATE_DIR || join4(homedir4(), ".agentlink");
|
|
340
|
+
var RUNS_DIR = join4(STATE_DIR, "runs");
|
|
341
|
+
var CONVS_FILE = join4(STATE_DIR, "conversations.json");
|
|
342
|
+
var MAX_RUN_BYTES = 512 * 1024;
|
|
343
|
+
var MAX_RUNS_PER_CONVERSATION = 100;
|
|
344
|
+
function isValidConversationId(id) {
|
|
345
|
+
return typeof id === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(id);
|
|
346
|
+
}
|
|
347
|
+
function ensureDirs() {
|
|
348
|
+
mkdirSync(RUNS_DIR, { recursive: true });
|
|
349
|
+
}
|
|
350
|
+
function loadConversations() {
|
|
351
|
+
try {
|
|
352
|
+
const parsed = JSON.parse(readFileSync3(CONVS_FILE, "utf-8"));
|
|
353
|
+
return Array.isArray(parsed) ? parsed.filter((c) => isValidConversationId(c?.id)) : [];
|
|
354
|
+
} catch {
|
|
355
|
+
return [];
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function saveConversations(convs) {
|
|
359
|
+
ensureDirs();
|
|
360
|
+
const tmp = CONVS_FILE + ".tmp";
|
|
361
|
+
writeFileSync(tmp, JSON.stringify(convs, null, 1));
|
|
362
|
+
renameSync(tmp, CONVS_FILE);
|
|
363
|
+
}
|
|
364
|
+
function listConversations() {
|
|
365
|
+
return loadConversations().sort((a, b) => b.lastActiveAt - a.lastActiveAt);
|
|
366
|
+
}
|
|
367
|
+
function putConversation(patch) {
|
|
368
|
+
if (!isValidConversationId(patch?.id)) return { error: "conversation.id is missing or malformed" };
|
|
369
|
+
const strOrUndef = (v) => typeof v === "string" ? v : void 0;
|
|
370
|
+
const numOrUndef = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
371
|
+
const convs = loadConversations();
|
|
372
|
+
const existing = convs.find((c) => c.id === patch.id);
|
|
373
|
+
const merged = {
|
|
374
|
+
id: patch.id,
|
|
375
|
+
agent: strOrUndef(patch.agent) ?? existing?.agent ?? "",
|
|
376
|
+
dir: strOrUndef(patch.dir) ?? existing?.dir ?? "",
|
|
377
|
+
title: strOrUndef(patch.title) ?? existing?.title ?? "",
|
|
378
|
+
createdAt: numOrUndef(patch.createdAt) ?? existing?.createdAt ?? 0,
|
|
379
|
+
lastActiveAt: numOrUndef(patch.lastActiveAt) ?? existing?.lastActiveAt ?? 0
|
|
380
|
+
};
|
|
381
|
+
const model = patch.model === "" ? void 0 : strOrUndef(patch.model) ?? existing?.model;
|
|
382
|
+
if (model !== void 0) merged.model = model;
|
|
383
|
+
const sessionId = patch.sessionId === "" ? void 0 : strOrUndef(patch.sessionId) ?? existing?.sessionId;
|
|
384
|
+
if (sessionId !== void 0) merged.sessionId = sessionId;
|
|
385
|
+
if (!merged.agent || !merged.dir || !merged.title || !merged.createdAt || !merged.lastActiveAt) {
|
|
386
|
+
return { error: "creating a conversation requires agent, dir, title, createdAt and lastActiveAt" };
|
|
387
|
+
}
|
|
388
|
+
const next = existing ? convs.map((c) => c.id === merged.id ? merged : c) : [...convs, merged];
|
|
389
|
+
saveConversations(next);
|
|
390
|
+
return { conversation: merged };
|
|
391
|
+
}
|
|
392
|
+
function deleteConversation(id) {
|
|
393
|
+
if (!isValidConversationId(id)) return;
|
|
394
|
+
saveConversations(loadConversations().filter((c) => c.id !== id));
|
|
395
|
+
rmSync(join4(RUNS_DIR, id), { recursive: true, force: true });
|
|
396
|
+
}
|
|
397
|
+
function touchConversation(id, patch) {
|
|
398
|
+
const convs = loadConversations();
|
|
399
|
+
const conv = convs.find((c) => c.id === id);
|
|
400
|
+
if (!conv) return;
|
|
401
|
+
if (patch.lastActiveAt) conv.lastActiveAt = patch.lastActiveAt;
|
|
402
|
+
if (patch.sessionId) conv.sessionId = patch.sessionId;
|
|
403
|
+
saveConversations(convs);
|
|
404
|
+
}
|
|
405
|
+
var openRuns = /* @__PURE__ */ new Set();
|
|
406
|
+
function recordRun(conversationId, meta) {
|
|
407
|
+
if (!isValidConversationId(conversationId)) return null;
|
|
408
|
+
const dir = join4(RUNS_DIR, conversationId);
|
|
409
|
+
const startedAt = Date.now();
|
|
410
|
+
const file = join4(dir, `${startedAt}-${meta.runId}.jsonl`);
|
|
411
|
+
try {
|
|
412
|
+
mkdirSync(dir, { recursive: true });
|
|
413
|
+
const files = readdirSync2(dir).filter((f) => f.endsWith(".jsonl")).sort();
|
|
414
|
+
for (const old of files.slice(0, Math.max(0, files.length - (MAX_RUNS_PER_CONVERSATION - 1)))) {
|
|
415
|
+
rmSync(join4(dir, old), { force: true });
|
|
416
|
+
}
|
|
417
|
+
appendFileSync(file, JSON.stringify({ kind: "meta", startedAt, ...meta }) + "\n");
|
|
418
|
+
} catch {
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
openRuns.add(meta.runId);
|
|
422
|
+
touchConversation(conversationId, { lastActiveAt: startedAt });
|
|
423
|
+
let bytes = 0;
|
|
424
|
+
let truncated = false;
|
|
425
|
+
const append = (line) => {
|
|
426
|
+
try {
|
|
427
|
+
appendFileSync(file, JSON.stringify(line) + "\n");
|
|
428
|
+
} catch {
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
return {
|
|
432
|
+
event(e) {
|
|
433
|
+
if (e.type === "session") touchConversation(conversationId, { sessionId: e.id });
|
|
434
|
+
if (truncated) return;
|
|
435
|
+
const line = JSON.stringify({ kind: "event", event: e }) + "\n";
|
|
436
|
+
bytes += line.length;
|
|
437
|
+
if (bytes > MAX_RUN_BYTES) {
|
|
438
|
+
truncated = true;
|
|
439
|
+
append({ kind: "truncated" });
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
try {
|
|
443
|
+
appendFileSync(file, line);
|
|
444
|
+
} catch {
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
done(code, error) {
|
|
448
|
+
openRuns.delete(meta.runId);
|
|
449
|
+
append({ kind: "done", code, ...error !== void 0 ? { error } : {} });
|
|
450
|
+
touchConversation(conversationId, { lastActiveAt: Date.now() });
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
function history(conversationId) {
|
|
455
|
+
if (!isValidConversationId(conversationId)) return [];
|
|
456
|
+
const dir = join4(RUNS_DIR, conversationId);
|
|
457
|
+
if (!existsSync2(dir)) return [];
|
|
458
|
+
const runs = [];
|
|
459
|
+
for (const name of readdirSync2(dir).filter((f) => f.endsWith(".jsonl")).sort()) {
|
|
460
|
+
let run = null;
|
|
461
|
+
let text;
|
|
462
|
+
try {
|
|
463
|
+
text = readFileSync3(join4(dir, name), "utf-8");
|
|
464
|
+
} catch {
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
for (const raw of text.split("\n")) {
|
|
468
|
+
if (!raw.trim()) continue;
|
|
469
|
+
let line;
|
|
470
|
+
try {
|
|
471
|
+
line = JSON.parse(raw);
|
|
472
|
+
} catch {
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
if (line.kind === "meta") {
|
|
476
|
+
run = {
|
|
477
|
+
runId: line.runId,
|
|
478
|
+
agent: line.agent,
|
|
479
|
+
prompt: line.prompt,
|
|
480
|
+
...line.cwd !== void 0 ? { cwd: line.cwd } : {},
|
|
481
|
+
...line.model !== void 0 ? { model: line.model } : {},
|
|
482
|
+
startedAt: line.startedAt,
|
|
483
|
+
status: "disconnected",
|
|
484
|
+
// upgraded below by done / openRuns
|
|
485
|
+
truncated: false,
|
|
486
|
+
events: []
|
|
487
|
+
};
|
|
488
|
+
} else if (run && line.kind === "event") {
|
|
489
|
+
if (line.event.type === "session") run.sessionId = line.event.id;
|
|
490
|
+
run.events.push(line.event);
|
|
491
|
+
} else if (run && line.kind === "truncated") {
|
|
492
|
+
run.truncated = true;
|
|
493
|
+
} else if (run && line.kind === "done") {
|
|
494
|
+
run.status = "done";
|
|
495
|
+
run.code = line.code;
|
|
496
|
+
if (line.error !== void 0) run.error = line.error;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (!run) continue;
|
|
500
|
+
if (run.status !== "done" && openRuns.has(run.runId)) run.status = "running";
|
|
501
|
+
if (run.status === "disconnected") run.error = "daemon disconnected";
|
|
502
|
+
runs.push(run);
|
|
503
|
+
}
|
|
504
|
+
return runs;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// src/tunnel.ts
|
|
508
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
509
|
+
import { connect } from "node:net";
|
|
510
|
+
import { platform as platform2 } from "node:os";
|
|
511
|
+
function portInUse(port) {
|
|
512
|
+
return new Promise((done) => {
|
|
513
|
+
const sock = connect({ port, host: "127.0.0.1" });
|
|
514
|
+
sock.once("connect", () => {
|
|
515
|
+
sock.destroy();
|
|
516
|
+
done(true);
|
|
517
|
+
});
|
|
518
|
+
sock.once("error", () => done(false));
|
|
519
|
+
sock.setTimeout(1500, () => {
|
|
520
|
+
sock.destroy();
|
|
521
|
+
done(false);
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
function waitForOutput(child, re, timeoutMs, what) {
|
|
526
|
+
return new Promise((resolve2, reject) => {
|
|
527
|
+
let out = "";
|
|
528
|
+
let settled = false;
|
|
529
|
+
const settle = (fn) => {
|
|
530
|
+
if (settled) return;
|
|
531
|
+
settled = true;
|
|
532
|
+
clearTimeout(timer);
|
|
533
|
+
child.stdout?.removeListener("data", onData);
|
|
534
|
+
child.stderr?.removeListener("data", onData);
|
|
535
|
+
child.stdout?.resume();
|
|
536
|
+
child.stderr?.resume();
|
|
537
|
+
fn();
|
|
538
|
+
};
|
|
539
|
+
const timer = setTimeout(
|
|
540
|
+
() => settle(() => reject(new Error(`${what} did not become ready within ${timeoutMs / 1e3}s:
|
|
541
|
+
${out.slice(-2e3)}`))),
|
|
542
|
+
timeoutMs
|
|
543
|
+
);
|
|
544
|
+
const onData = (chunk) => {
|
|
545
|
+
out += chunk.toString("utf-8");
|
|
546
|
+
const m = out.match(re);
|
|
547
|
+
if (m) settle(() => resolve2(m[0]));
|
|
548
|
+
};
|
|
549
|
+
child.stdout?.on("data", onData);
|
|
550
|
+
child.stderr?.on("data", onData);
|
|
551
|
+
child.on("error", (err) => settle(() => reject(new Error(`${what} failed to start: ${err.message}`))));
|
|
552
|
+
child.on("close", (code) => settle(() => reject(new Error(`${what} exited (code ${code}) before becoming ready:
|
|
553
|
+
${out.slice(-2e3)}`))));
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
async function startTunnel(hubPort) {
|
|
557
|
+
const children = [];
|
|
558
|
+
const stop = () => {
|
|
559
|
+
for (const c of children) c.kill();
|
|
560
|
+
};
|
|
561
|
+
const watchChild = (child, what) => {
|
|
562
|
+
child.on("close", (code) => {
|
|
563
|
+
console.error(`[daemon] ${what} exited (code ${code}) \u2014 shutting down (--tunnel mode has no partial recovery).`);
|
|
564
|
+
stop();
|
|
565
|
+
process.exit(1);
|
|
566
|
+
});
|
|
567
|
+
};
|
|
568
|
+
if (await portInUse(hubPort)) {
|
|
569
|
+
console.log(`[daemon] port ${hubPort} is already in use \u2014 assuming an agentlink-hub is listening there.`);
|
|
570
|
+
} else {
|
|
571
|
+
console.log(`[daemon] starting local hub on port ${hubPort} (npx agentlink-hub)...`);
|
|
572
|
+
const hub = spawn2("npx", ["-y", "agentlink-hub", "--port", String(hubPort)], {
|
|
573
|
+
env: { ...spawnEnv(), PORT: String(hubPort) },
|
|
574
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
575
|
+
shell: platform2() === "win32"
|
|
576
|
+
// npx is npx.cmd on Windows
|
|
577
|
+
});
|
|
578
|
+
children.push(hub);
|
|
579
|
+
await waitForOutput(hub, /listening on/, 12e4, "agentlink-hub");
|
|
580
|
+
watchChild(hub, "local hub");
|
|
581
|
+
}
|
|
582
|
+
const cloudflared = resolveBin("cloudflared");
|
|
583
|
+
if (!cloudflared) {
|
|
584
|
+
console.error(`[daemon] --tunnel needs the cloudflared binary, which was not found on PATH.`);
|
|
585
|
+
console.error(`[daemon] install it first: macOS: brew install cloudflared`);
|
|
586
|
+
console.error(`[daemon] other: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/`);
|
|
587
|
+
stop();
|
|
588
|
+
process.exit(1);
|
|
589
|
+
}
|
|
590
|
+
console.log(`[daemon] opening Cloudflare quick tunnel to http://localhost:${hubPort} ...`);
|
|
591
|
+
const cf = spawn2(cloudflared, ["tunnel", "--url", `http://localhost:${hubPort}`], {
|
|
592
|
+
env: spawnEnv(),
|
|
593
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
594
|
+
});
|
|
595
|
+
children.push(cf);
|
|
596
|
+
const publicBase = await waitForOutput(cf, /https:\/\/[a-z0-9-]+\.trycloudflare\.com/, 6e4, "cloudflared");
|
|
597
|
+
watchChild(cf, "cloudflared tunnel");
|
|
598
|
+
return { publicBase, stop };
|
|
599
|
+
}
|
|
600
|
+
|
|
200
601
|
// src/main.ts
|
|
201
602
|
var PROTOCOL_VERSION = 1;
|
|
202
|
-
function usage() {
|
|
603
|
+
function usage(error) {
|
|
604
|
+
if (error) console.error(`[daemon] ${error}`);
|
|
203
605
|
console.error(`usage: agentlink-daemon [--hub <ws(s)://host[:port]>] [--token <token>] [--dir <path>]...`);
|
|
606
|
+
console.error(` agentlink-daemon --tunnel [--port <local hub port>] [--token <token>] [--dir <path>]...`);
|
|
607
|
+
console.error(` (--tunnel = start a local hub + a free Cloudflare quick tunnel, all in one command)`);
|
|
204
608
|
process.exit(1);
|
|
205
609
|
}
|
|
206
610
|
function parseArgs(argv) {
|
|
207
|
-
let
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
611
|
+
let values;
|
|
612
|
+
try {
|
|
613
|
+
({ values } = nodeParseArgs({
|
|
614
|
+
args: argv,
|
|
615
|
+
options: {
|
|
616
|
+
hub: { type: "string" },
|
|
617
|
+
token: { type: "string" },
|
|
618
|
+
dir: { type: "string", multiple: true },
|
|
619
|
+
tunnel: { type: "boolean" },
|
|
620
|
+
port: { type: "string" }
|
|
621
|
+
},
|
|
622
|
+
strict: true,
|
|
623
|
+
allowPositionals: false
|
|
624
|
+
}));
|
|
625
|
+
} catch (err) {
|
|
626
|
+
usage(err instanceof Error ? err.message : String(err));
|
|
216
627
|
}
|
|
628
|
+
if (values.tunnel && values.hub) usage("--tunnel starts its own local hub; it cannot be combined with --hub");
|
|
629
|
+
if (values.port && !values.tunnel) usage("--port only applies to --tunnel mode (it is the LOCAL hub's port)");
|
|
630
|
+
const port = values.port === void 0 ? 8080 : Number(values.port);
|
|
631
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) usage(`invalid --port "${values.port}" (expected 1-65535)`);
|
|
632
|
+
const dirs = (values.dir ?? []).map((d) => resolve(d));
|
|
217
633
|
if (dirs.length === 0) dirs.push(process.cwd());
|
|
218
|
-
return {
|
|
634
|
+
return {
|
|
635
|
+
token: values.token ?? randomUUID(),
|
|
636
|
+
hub: values.hub ?? (values.tunnel ? `ws://localhost:${port}` : "ws://localhost:8080"),
|
|
637
|
+
dirs,
|
|
638
|
+
tunnel: values.tunnel ?? false,
|
|
639
|
+
port
|
|
640
|
+
};
|
|
219
641
|
}
|
|
220
|
-
var { token: TOKEN, hub: HUB, dirs: DIRS } = parseArgs(process.argv.slice(2));
|
|
221
|
-
|
|
222
|
-
try {
|
|
223
|
-
return realpathSync(d);
|
|
224
|
-
} catch {
|
|
225
|
-
return d;
|
|
226
|
-
}
|
|
227
|
-
});
|
|
228
|
-
function resolveInsideRoots(p) {
|
|
642
|
+
var { token: TOKEN, hub: HUB, dirs: DIRS, tunnel: TUNNEL, port: TUNNEL_PORT } = parseArgs(process.argv.slice(2));
|
|
643
|
+
function resolveDir(p) {
|
|
229
644
|
let real;
|
|
230
645
|
try {
|
|
231
646
|
real = realpathSync(resolve(p));
|
|
232
|
-
} catch {
|
|
233
|
-
return
|
|
647
|
+
} catch (err) {
|
|
648
|
+
return {
|
|
649
|
+
error: err.code === "EACCES" ? `permission denied for "${p}"` : `"${p}" does not exist`
|
|
650
|
+
};
|
|
234
651
|
}
|
|
235
|
-
|
|
652
|
+
try {
|
|
653
|
+
if (!statSync(real).isDirectory()) return { error: `"${p}" is not a directory` };
|
|
654
|
+
} catch (err) {
|
|
655
|
+
return {
|
|
656
|
+
error: err.code === "EACCES" ? `permission denied for "${p}"` : `cannot access "${p}"`
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
return { real };
|
|
236
660
|
}
|
|
237
661
|
function ownVersion() {
|
|
238
662
|
try {
|
|
239
|
-
return JSON.parse(
|
|
663
|
+
return JSON.parse(readFileSync4(new URL("../package.json", import.meta.url), "utf-8")).version;
|
|
240
664
|
} catch {
|
|
241
665
|
return "0.0.0";
|
|
242
666
|
}
|
|
243
667
|
}
|
|
244
668
|
function webLink(hub, token) {
|
|
669
|
+
if (PUBLIC_BASE) return `${PUBLIC_BASE}/?token=${encodeURIComponent(token)}`;
|
|
245
670
|
const u = new URL(hub);
|
|
246
671
|
u.protocol = u.protocol === "wss:" ? "https:" : "http:";
|
|
247
672
|
u.pathname = "/";
|
|
248
673
|
u.search = `?token=${encodeURIComponent(token)}`;
|
|
249
674
|
return u.toString();
|
|
250
675
|
}
|
|
251
|
-
function probeVersion(bin) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
try {
|
|
256
|
-
child = spawn(bin, ["--version"], { env: spawnEnv() });
|
|
257
|
-
} catch {
|
|
258
|
-
return done(void 0);
|
|
259
|
-
}
|
|
260
|
-
const timer = setTimeout(() => {
|
|
261
|
-
child.kill();
|
|
262
|
-
done(void 0);
|
|
263
|
-
}, 3e3);
|
|
264
|
-
child.stdout?.on("data", (chunk) => out += chunk.toString("utf-8"));
|
|
265
|
-
child.on("error", () => {
|
|
266
|
-
clearTimeout(timer);
|
|
267
|
-
done(void 0);
|
|
268
|
-
});
|
|
269
|
-
child.on("close", (code) => {
|
|
270
|
-
clearTimeout(timer);
|
|
271
|
-
const first = out.trim().split("\n")[0]?.trim();
|
|
272
|
-
done(code === 0 && first ? first : void 0);
|
|
273
|
-
});
|
|
274
|
-
});
|
|
676
|
+
async function probeVersion(bin) {
|
|
677
|
+
const out = await captureCommand(bin, ["--version"], 3e3);
|
|
678
|
+
const first = out?.trim().split("\n")[0]?.trim();
|
|
679
|
+
return first || void 0;
|
|
275
680
|
}
|
|
276
681
|
async function probeAgents() {
|
|
277
682
|
return Promise.all(
|
|
278
683
|
REGISTRY.map(async (def) => {
|
|
279
684
|
const bin = getAgent(def.id)?.bin;
|
|
280
|
-
if (!bin) return { id: def.id, label: def.label, detected: false };
|
|
281
|
-
const version = await
|
|
282
|
-
|
|
685
|
+
if (!bin) return { id: def.id, label: def.label, detected: false, models: def.models };
|
|
686
|
+
const [version, probed] = await Promise.all([
|
|
687
|
+
probeVersion(bin),
|
|
688
|
+
def.probeModels ? def.probeModels(bin) : Promise.resolve([])
|
|
689
|
+
]);
|
|
690
|
+
const models = [.../* @__PURE__ */ new Set([...probed, ...def.models])];
|
|
691
|
+
return { id: def.id, label: def.label, detected: true, bin, models, ...version ? { version } : {} };
|
|
283
692
|
})
|
|
284
693
|
);
|
|
285
694
|
}
|
|
@@ -287,7 +696,7 @@ var running = /* @__PURE__ */ new Map();
|
|
|
287
696
|
function send(ws2, obj) {
|
|
288
697
|
if (ws2.readyState === WebSocket.OPEN) ws2.send(JSON.stringify(obj));
|
|
289
698
|
}
|
|
290
|
-
function handleRun(ws2, { requestId, agent: agentId, prompt, sessionId, cwd }) {
|
|
699
|
+
function handleRun(ws2, { requestId, agent: agentId, prompt, sessionId, cwd, model, conversationId }) {
|
|
291
700
|
const fail = (error) => {
|
|
292
701
|
console.log(`[daemon] requestId=${requestId} rejected: ${error}`);
|
|
293
702
|
send(ws2, { type: "done", requestId, code: null, error });
|
|
@@ -297,26 +706,27 @@ function handleRun(ws2, { requestId, agent: agentId, prompt, sessionId, cwd }) {
|
|
|
297
706
|
if (typeof prompt !== "string") return fail(`malformed run message: prompt is not a string`);
|
|
298
707
|
let runCwd = DIRS[0];
|
|
299
708
|
if (cwd !== void 0) {
|
|
300
|
-
const real =
|
|
301
|
-
if (real
|
|
302
|
-
return fail(`cwd "${cwd}" is not inside this daemon's registered dirs (or does not exist)`);
|
|
303
|
-
}
|
|
304
|
-
try {
|
|
305
|
-
if (!statSync(real).isDirectory()) return fail(`cwd "${cwd}" is not a directory`);
|
|
306
|
-
} catch {
|
|
307
|
-
return fail(`cwd "${cwd}" is not a directory`);
|
|
308
|
-
}
|
|
709
|
+
const { real, error } = resolveDir(cwd);
|
|
710
|
+
if (!real) return fail(`cwd rejected: ${error}`);
|
|
309
711
|
runCwd = real;
|
|
310
712
|
}
|
|
311
|
-
const invocation = adapter.buildInvocation(prompt, sessionId);
|
|
713
|
+
const invocation = adapter.buildInvocation(prompt, sessionId, typeof model === "string" && model ? model : void 0);
|
|
312
714
|
console.log(`[daemon] requestId=${requestId} spawning ${adapter.bin} ${invocation.args.join(" ")} (cwd=${runCwd})`);
|
|
313
|
-
const
|
|
715
|
+
const recorder = recordRun(conversationId, {
|
|
716
|
+
runId: requestId,
|
|
717
|
+
agent: agentId,
|
|
718
|
+
prompt,
|
|
719
|
+
...cwd !== void 0 ? { cwd } : {},
|
|
720
|
+
...typeof model === "string" && model ? { model } : {}
|
|
721
|
+
});
|
|
722
|
+
const child = spawn3(adapter.bin, invocation.args, { cwd: runCwd, env: spawnEnv() });
|
|
314
723
|
running.set(requestId, child);
|
|
315
724
|
child.stdin.on("error", () => {
|
|
316
725
|
});
|
|
317
726
|
if (invocation.stdin !== void 0) child.stdin.write(invocation.stdin);
|
|
318
727
|
child.stdin.end();
|
|
319
728
|
const parseLine = adapter.createParser((evt) => {
|
|
729
|
+
recorder?.event(evt);
|
|
320
730
|
send(ws2, { type: "event", requestId, event: evt });
|
|
321
731
|
});
|
|
322
732
|
let buffer = "";
|
|
@@ -340,31 +750,62 @@ function handleRun(ws2, { requestId, agent: agentId, prompt, sessionId, cwd }) {
|
|
|
340
750
|
};
|
|
341
751
|
if (code !== 0 && stderrTail.trim()) done.error = stderrTail.trim();
|
|
342
752
|
console.log(`[daemon] requestId=${requestId} closed code=${code}${done.error ? ` error=${done.error}` : ""}`);
|
|
753
|
+
recorder?.done(code, done.error);
|
|
343
754
|
send(ws2, done);
|
|
344
755
|
});
|
|
345
756
|
child.on("error", (err) => {
|
|
346
757
|
running.delete(requestId);
|
|
347
758
|
console.log(`[daemon] requestId=${requestId} spawn error: ${err.message}`);
|
|
759
|
+
recorder?.done(null, err.message);
|
|
348
760
|
send(ws2, { type: "done", requestId, code: null, error: err.message });
|
|
349
761
|
});
|
|
350
762
|
}
|
|
351
763
|
function handleListdir(ws2, { requestId, path }) {
|
|
352
|
-
const reply = (dirs,
|
|
353
|
-
if (
|
|
354
|
-
send(ws2, { type: "listdir_result", requestId, path, dirs, ...
|
|
764
|
+
const reply = (dirs, error2) => {
|
|
765
|
+
if (error2) console.log(`[daemon] listdir requestId=${requestId} rejected: ${error2}`);
|
|
766
|
+
send(ws2, { type: "listdir_result", requestId, path, dirs, ...error2 !== void 0 ? { error: error2 } : {} });
|
|
355
767
|
};
|
|
356
768
|
if (typeof path !== "string") return reply([], "malformed listdir: path is not a string");
|
|
357
|
-
const real =
|
|
358
|
-
if (real
|
|
359
|
-
return reply([], `path "${path}" is not inside this daemon's registered dirs (or does not exist)`);
|
|
360
|
-
}
|
|
769
|
+
const { real, error } = resolveDir(path);
|
|
770
|
+
if (!real) return reply([], error);
|
|
361
771
|
try {
|
|
362
|
-
const dirs =
|
|
772
|
+
const dirs = readdirSync3(real, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => join5(real, e.name)).sort();
|
|
363
773
|
reply(dirs);
|
|
364
774
|
} catch (err) {
|
|
365
|
-
reply(
|
|
775
|
+
reply(
|
|
776
|
+
[],
|
|
777
|
+
err.code === "EACCES" ? `permission denied reading "${path}"` : `cannot list "${path}": ${err instanceof Error ? err.message : String(err)}`
|
|
778
|
+
);
|
|
366
779
|
}
|
|
367
780
|
}
|
|
781
|
+
function handleConvList(ws2, requestId) {
|
|
782
|
+
send(ws2, { type: "conv_list_result", requestId, conversations: listConversations() });
|
|
783
|
+
}
|
|
784
|
+
function handleConvPut(ws2, requestId, conversation) {
|
|
785
|
+
if (typeof conversation !== "object" || conversation === null) {
|
|
786
|
+
return send(ws2, { type: "conv_put_result", requestId, error: "malformed conv_put: conversation is not an object" });
|
|
787
|
+
}
|
|
788
|
+
const { conversation: merged, error } = putConversation(conversation);
|
|
789
|
+
send(ws2, { type: "conv_put_result", requestId, ...merged ? { conversation: merged } : {}, ...error ? { error } : {} });
|
|
790
|
+
}
|
|
791
|
+
function handleConvDelete(ws2, requestId, id) {
|
|
792
|
+
if (typeof id === "string") deleteConversation(id);
|
|
793
|
+
send(ws2, { type: "conv_delete_result", requestId });
|
|
794
|
+
}
|
|
795
|
+
function handleHistory(ws2, requestId, conversationId) {
|
|
796
|
+
send(ws2, { type: "history_result", requestId, runs: history(conversationId) });
|
|
797
|
+
}
|
|
798
|
+
var PUBLIC_BASE = null;
|
|
799
|
+
var stopTunnel = null;
|
|
800
|
+
if (TUNNEL) {
|
|
801
|
+
const t = await startTunnel(TUNNEL_PORT);
|
|
802
|
+
PUBLIC_BASE = t.publicBase;
|
|
803
|
+
stopTunnel = t.stop;
|
|
804
|
+
console.log(`[daemon] tunnel ready: ${PUBLIC_BASE}`);
|
|
805
|
+
}
|
|
806
|
+
process.on("exit", () => stopTunnel?.());
|
|
807
|
+
process.on("SIGINT", () => process.exit(130));
|
|
808
|
+
process.on("SIGTERM", () => process.exit(143));
|
|
368
809
|
var agents = await probeAgents();
|
|
369
810
|
var detected = agents.filter((a) => a.detected);
|
|
370
811
|
console.log(`[daemon] detected agents: ${detected.length ? detected.map((a) => a.id).join(", ") : "(none)"}`);
|
|
@@ -409,6 +850,14 @@ ws.on("message", (raw) => {
|
|
|
409
850
|
handleRun(ws, msg);
|
|
410
851
|
} else if (msg.type === "listdir" && typeof msg.requestId === "string") {
|
|
411
852
|
handleListdir(ws, msg);
|
|
853
|
+
} else if (msg.type === "conv_list" && typeof msg.requestId === "string") {
|
|
854
|
+
handleConvList(ws, msg.requestId);
|
|
855
|
+
} else if (msg.type === "conv_put" && typeof msg.requestId === "string") {
|
|
856
|
+
handleConvPut(ws, msg.requestId, msg.conversation);
|
|
857
|
+
} else if (msg.type === "conv_delete" && typeof msg.requestId === "string") {
|
|
858
|
+
handleConvDelete(ws, msg.requestId, msg.id);
|
|
859
|
+
} else if (msg.type === "history" && typeof msg.requestId === "string") {
|
|
860
|
+
handleHistory(ws, msg.requestId, msg.conversationId);
|
|
412
861
|
} else {
|
|
413
862
|
console.log(`[daemon] ignoring message with unknown shape: ${raw}`);
|
|
414
863
|
}
|