agentlink-daemon 0.1.0 → 0.3.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.
Files changed (2) hide show
  1. package/dist/main.js +578 -129
  2. 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 readdirSync2, realpathSync, statSync } from "node:fs";
6
+ import { readFileSync as readFileSync4, readdirSync as readdirSync3, realpathSync, statSync } from "node:fs";
7
7
  import { hostname } from "node:os";
8
- import { join as join2, resolve, sep } from "node:path";
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/agents.ts
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 { join, delimiter } from "node:path";
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
- var REGISTRY = [
136
- {
137
- id: "claude",
138
- label: "Claude Code",
139
- // --resume <id> works with -p (print) mode too, and was verified live:
140
- // a fresh process, given only --resume and a new prompt, correctly
141
- // recalled facts from an earlier, separate process's conversation.
142
- buildInvocation: (prompt, resumeId) => ({
143
- args: [
144
- "-p",
145
- "--output-format",
146
- "stream-json",
147
- "--verbose",
148
- "--include-partial-messages",
149
- "--permission-mode",
150
- "bypassPermissions",
151
- ...resumeId ? ["--resume", resumeId] : []
152
- ],
153
- stdin: prompt
154
- }),
155
- createParser: createClaudeParser
156
- },
157
- {
158
- id: "codex",
159
- label: "Codex CLI",
160
- // UNVERIFIED resume (see ai-spawn HANDOFF.md): `codex exec resume <id>`
161
- // is a different subcommand from `codex exec`, and its flag set doesn't
162
- // include `--sandbox` the closest equivalent for "don't block on an
163
- // approval prompt" is `--dangerously-bypass-approvals-and-sandbox`,
164
- // which is a stronger bypass than the workspace-write sandbox used on
165
- // a fresh run. That's consistent with claude's own bypassPermissions
166
- // above (this system already trusts the agent fully once it's running),
167
- // but never exercised end-to-end because of an account rate limit
168
- // during development verify before relying on it.
169
- buildInvocation: (prompt, resumeId) => ({
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
- id: "joycode",
177
- label: "JoyCode",
178
- // Verified live (DESIGN.md §4.3): the prompt goes on argv, and on
179
- // resume the flags MUST come before the `resume` subcommand —
180
- // `joycode exec --json <flags> resume <thread_id> "<prompt>"`.
181
- // Putting flags after `resume` is a usage error (unlike codex's
182
- // `exec resume <id> -` word order). stdin feeding is unverified, so
183
- // fresh runs also pass the prompt via argv for consistency.
184
- buildInvocation: (prompt, resumeId) => ({
185
- args: resumeId ? ["exec", "--json", "--skip-git-repo-check", "--dangerously-bypass-approvals-and-sandbox", "resume", resumeId, prompt] : ["exec", "--json", "--skip-git-repo-check", "--sandbox", "workspace-write", prompt]
186
- }),
187
- createParser: createCodexParser
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 token;
208
- let hub = "ws://localhost:8080";
209
- const dirs = [];
210
- for (let i = 0; i < argv.length; i++) {
211
- const next = () => argv[++i] ?? usage();
212
- if (argv[i] === "--token") token = next();
213
- else if (argv[i] === "--hub") hub = next();
214
- else if (argv[i] === "--dir") dirs.push(resolve(next()));
215
- else usage();
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 { token: token ?? randomUUID(), hub, dirs };
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
- var REAL_DIRS = DIRS.map((d) => {
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 null;
647
+ } catch (err) {
648
+ return {
649
+ error: err.code === "EACCES" ? `permission denied for "${p}"` : `"${p}" does not exist`
650
+ };
234
651
  }
235
- return REAL_DIRS.some((root) => real === root || real.startsWith(root + sep)) ? real : null;
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(readFileSync(new URL("../package.json", import.meta.url), "utf-8")).version;
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
- return new Promise((done) => {
253
- let out = "";
254
- let child;
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 probeVersion(bin);
282
- return { id: def.id, label: def.label, detected: true, bin, ...version ? { version } : {} };
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 = resolveInsideRoots(cwd);
301
- if (real === null) {
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 child = spawn(adapter.bin, invocation.args, { cwd: runCwd, env: spawnEnv() });
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, error) => {
353
- if (error) console.log(`[daemon] listdir requestId=${requestId} rejected: ${error}`);
354
- send(ws2, { type: "listdir_result", requestId, path, dirs, ...error !== void 0 ? { error } : {} });
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 = resolveInsideRoots(path);
358
- if (real === null) {
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 = readdirSync2(real, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => join2(real, e.name)).sort();
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([], `cannot list "${path}": ${err instanceof Error ? err.message : String(err)}`);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlink-daemon",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Control your coding agents from anywhere — the AgentLink daemon.",
5
5
  "license": "MIT",
6
6
  "repository": {