agentlink-daemon 0.1.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -0
  3. package/dist/main.js +429 -0
  4. package/package.json +50 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 agentlink contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,18 @@
1
+ # agentlink-daemon
2
+
3
+ The AgentLink **daemon** — run it on the machine your coding agents live on. It dials out to your [agentlink-hub](https://www.npmjs.com/package/agentlink-hub), probes which agent CLIs are installed (Claude Code / Codex CLI / JoyCode), and executes the tasks you send from the web page.
4
+
5
+ ```bash
6
+ npx agentlink-daemon --hub wss://your-host:8080
7
+ # → prints: open https://your-host:8080/?token=<uuid>
8
+ ```
9
+
10
+ Options:
11
+
12
+ - `--hub <ws(s)://host[:port]>` — the hub to connect to.
13
+ - `--dir <path>` — a workdir root (repeatable; defaults to the current directory). Runs may use any directory inside a root; the web picker browses them as a tree. Tip: `cd ~/Projects && npx agentlink-daemon --hub …` makes every project selectable.
14
+ - `--token <token>` — pin the token instead of generating a fresh one per start.
15
+
16
+ The connection is **outbound** — no port forwarding, no VPN, works behind NAT. The printed token is a remote-code-execution credential for this machine: use `wss://` (TLS) for anything public, and restart the daemon to revoke a leaked token.
17
+
18
+ Full documentation: [github.com/baichen99/agentlink](https://github.com/baichen99/agentlink) · MIT
package/dist/main.js ADDED
@@ -0,0 +1,429 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/main.ts
4
+ import { spawn } from "node:child_process";
5
+ import { randomUUID } from "node:crypto";
6
+ import { readFileSync, readdirSync as readdirSync2, realpathSync, statSync } from "node:fs";
7
+ import { hostname } from "node:os";
8
+ import { join as join2, resolve, sep } from "node:path";
9
+ import { WebSocket } from "ws";
10
+
11
+ // src/agents.ts
12
+ import { existsSync, readdirSync } from "node:fs";
13
+ import { homedir, platform } from "node:os";
14
+ import { join, delimiter } from "node:path";
15
+ function extraDirs(home) {
16
+ const dirs = [join(home, ".local", "bin"), join(home, ".npm-global", "bin")];
17
+ if (platform() !== "win32") dirs.push("/opt/homebrew/bin", "/usr/local/bin");
18
+ for (const root of [join(home, ".nvm", "versions", "node")]) {
19
+ try {
20
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
21
+ if (entry.isDirectory()) dirs.push(join(root, entry.name, "bin"));
22
+ }
23
+ } catch {
24
+ }
25
+ }
26
+ return dirs;
27
+ }
28
+ var PATH_DIRS = [.../* @__PURE__ */ new Set([...(process.env.PATH ?? "").split(delimiter), ...extraDirs(homedir())])];
29
+ function resolveBin(name) {
30
+ const ext = platform() === "win32" ? ".exe" : "";
31
+ for (const dir of PATH_DIRS) {
32
+ const full = join(dir, name + ext);
33
+ if (existsSync(full)) return full;
34
+ }
35
+ return null;
36
+ }
37
+ function spawnEnv() {
38
+ return { ...process.env, PATH: PATH_DIRS.join(delimiter) };
39
+ }
40
+ function parseJsonLine(line) {
41
+ try {
42
+ return JSON.parse(line);
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+ function resultText(content) {
48
+ if (typeof content === "string") return content;
49
+ if (Array.isArray(content)) return content.map((c) => c?.type === "text" ? c.text : JSON.stringify(c)).join("\n");
50
+ return JSON.stringify(content);
51
+ }
52
+ function createClaudeParser(emit) {
53
+ const streamed = /* @__PURE__ */ new Set();
54
+ let messageId = null;
55
+ let lastSessionId = null;
56
+ return (line) => {
57
+ const obj = parseJsonLine(line);
58
+ if (!obj) return;
59
+ if (obj.type === "system" && typeof obj.session_id === "string") {
60
+ if (obj.session_id !== lastSessionId) {
61
+ lastSessionId = obj.session_id;
62
+ emit({ type: "session", id: obj.session_id });
63
+ }
64
+ return;
65
+ }
66
+ if (obj.type === "stream_event") {
67
+ const ev = obj.event;
68
+ if (ev?.type === "message_start") messageId = ev.message?.id ?? null;
69
+ if (ev?.type === "content_block_delta" && ev.delta?.type === "text_delta") {
70
+ if (messageId) streamed.add(messageId);
71
+ emit({ type: "text", text: ev.delta.text });
72
+ }
73
+ return;
74
+ }
75
+ if (obj.type === "assistant" && obj.message?.content) {
76
+ const already = obj.message.id && streamed.has(obj.message.id);
77
+ for (const block of obj.message.content) {
78
+ if (block.type === "tool_use") {
79
+ emit({ type: "tool_use", id: block.id, name: block.name, input: block.input ?? null });
80
+ } else if (!already && block.type === "text" && block.text) {
81
+ emit({ type: "text", text: block.text });
82
+ }
83
+ }
84
+ return;
85
+ }
86
+ if (obj.type === "user" && obj.message?.content) {
87
+ for (const block of obj.message.content) {
88
+ if (block.type === "tool_result") {
89
+ emit({
90
+ type: "tool_result",
91
+ toolUseId: block.tool_use_id,
92
+ content: resultText(block.content),
93
+ isError: Boolean(block.is_error)
94
+ });
95
+ }
96
+ }
97
+ }
98
+ };
99
+ }
100
+ function createCodexParser(emit) {
101
+ const seen = /* @__PURE__ */ new Set();
102
+ return (line) => {
103
+ const obj = parseJsonLine(line);
104
+ if (!obj) return;
105
+ const item = obj.item;
106
+ if (obj.type === "thread.started" && typeof obj.thread_id === "string") {
107
+ emit({ type: "session", id: obj.thread_id });
108
+ return;
109
+ }
110
+ if (item?.type === "command_execution" && typeof item.id === "string") {
111
+ if (!seen.has(item.id)) {
112
+ seen.add(item.id);
113
+ emit({ type: "tool_use", id: item.id, name: "Bash", input: { command: item.command ?? "" } });
114
+ }
115
+ if (obj.type === "item.completed") {
116
+ emit({
117
+ type: "tool_result",
118
+ toolUseId: item.id,
119
+ content: resultText(item.aggregated_output ?? ""),
120
+ isError: item.exit_code ? item.exit_code !== 0 : item.status === "failed"
121
+ });
122
+ }
123
+ return;
124
+ }
125
+ if (obj.type === "item.completed" && item?.type === "agent_message" && item.text) {
126
+ emit({ type: "text", text: item.text });
127
+ return;
128
+ }
129
+ const errMessage = obj.message ?? obj.error?.message ?? item?.message;
130
+ if ((obj.type === "error" || obj.type === "turn.failed" || item?.type === "error") && typeof errMessage === "string") {
131
+ emit({ type: "error", message: errMessage });
132
+ }
133
+ };
134
+ }
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"],
171
+ stdin: prompt
172
+ }),
173
+ createParser: createCodexParser
174
+ },
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
188
+ }
189
+ ];
190
+ function detectAgents() {
191
+ return REGISTRY.flatMap((def) => {
192
+ const bin = resolveBin(def.id);
193
+ return bin ? [{ ...def, bin }] : [];
194
+ });
195
+ }
196
+ function getAgent(id) {
197
+ return detectAgents().find((a) => a.id === id) ?? null;
198
+ }
199
+
200
+ // src/main.ts
201
+ var PROTOCOL_VERSION = 1;
202
+ function usage() {
203
+ console.error(`usage: agentlink-daemon [--hub <ws(s)://host[:port]>] [--token <token>] [--dir <path>]...`);
204
+ process.exit(1);
205
+ }
206
+ 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();
216
+ }
217
+ if (dirs.length === 0) dirs.push(process.cwd());
218
+ return { token: token ?? randomUUID(), hub, dirs };
219
+ }
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) {
229
+ let real;
230
+ try {
231
+ real = realpathSync(resolve(p));
232
+ } catch {
233
+ return null;
234
+ }
235
+ return REAL_DIRS.some((root) => real === root || real.startsWith(root + sep)) ? real : null;
236
+ }
237
+ function ownVersion() {
238
+ try {
239
+ return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8")).version;
240
+ } catch {
241
+ return "0.0.0";
242
+ }
243
+ }
244
+ function webLink(hub, token) {
245
+ const u = new URL(hub);
246
+ u.protocol = u.protocol === "wss:" ? "https:" : "http:";
247
+ u.pathname = "/";
248
+ u.search = `?token=${encodeURIComponent(token)}`;
249
+ return u.toString();
250
+ }
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
+ });
275
+ }
276
+ async function probeAgents() {
277
+ return Promise.all(
278
+ REGISTRY.map(async (def) => {
279
+ 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 } : {} };
283
+ })
284
+ );
285
+ }
286
+ var running = /* @__PURE__ */ new Map();
287
+ function send(ws2, obj) {
288
+ if (ws2.readyState === WebSocket.OPEN) ws2.send(JSON.stringify(obj));
289
+ }
290
+ function handleRun(ws2, { requestId, agent: agentId, prompt, sessionId, cwd }) {
291
+ const fail = (error) => {
292
+ console.log(`[daemon] requestId=${requestId} rejected: ${error}`);
293
+ send(ws2, { type: "done", requestId, code: null, error });
294
+ };
295
+ const adapter = getAgent(agentId);
296
+ if (!adapter) return fail(`agent "${agentId}" not found`);
297
+ if (typeof prompt !== "string") return fail(`malformed run message: prompt is not a string`);
298
+ let runCwd = DIRS[0];
299
+ 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
+ }
309
+ runCwd = real;
310
+ }
311
+ const invocation = adapter.buildInvocation(prompt, sessionId);
312
+ 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() });
314
+ running.set(requestId, child);
315
+ child.stdin.on("error", () => {
316
+ });
317
+ if (invocation.stdin !== void 0) child.stdin.write(invocation.stdin);
318
+ child.stdin.end();
319
+ const parseLine = adapter.createParser((evt) => {
320
+ send(ws2, { type: "event", requestId, event: evt });
321
+ });
322
+ let buffer = "";
323
+ child.stdout.on("data", (chunk) => {
324
+ buffer += chunk.toString("utf-8");
325
+ const lines = buffer.split("\n");
326
+ buffer = lines.pop() ?? "";
327
+ for (const line of lines) parseLine(line);
328
+ });
329
+ let stderrTail = "";
330
+ child.stderr.on("data", (chunk) => {
331
+ stderrTail = (stderrTail + chunk.toString("utf-8")).slice(-4e3);
332
+ });
333
+ child.on("close", (code) => {
334
+ if (buffer) parseLine(buffer);
335
+ running.delete(requestId);
336
+ const done = {
337
+ type: "done",
338
+ requestId,
339
+ code
340
+ };
341
+ if (code !== 0 && stderrTail.trim()) done.error = stderrTail.trim();
342
+ console.log(`[daemon] requestId=${requestId} closed code=${code}${done.error ? ` error=${done.error}` : ""}`);
343
+ send(ws2, done);
344
+ });
345
+ child.on("error", (err) => {
346
+ running.delete(requestId);
347
+ console.log(`[daemon] requestId=${requestId} spawn error: ${err.message}`);
348
+ send(ws2, { type: "done", requestId, code: null, error: err.message });
349
+ });
350
+ }
351
+ 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 } : {} });
355
+ };
356
+ 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
+ }
361
+ try {
362
+ const dirs = readdirSync2(real, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => join2(real, e.name)).sort();
363
+ reply(dirs);
364
+ } catch (err) {
365
+ reply([], `cannot list "${path}": ${err instanceof Error ? err.message : String(err)}`);
366
+ }
367
+ }
368
+ var agents = await probeAgents();
369
+ var detected = agents.filter((a) => a.detected);
370
+ console.log(`[daemon] detected agents: ${detected.length ? detected.map((a) => a.id).join(", ") : "(none)"}`);
371
+ var url = `${HUB}/daemon?token=${encodeURIComponent(TOKEN)}&v=${PROTOCOL_VERSION}`;
372
+ console.log(`[daemon] connecting to ${url} ...`);
373
+ var ws = new WebSocket(url);
374
+ var registered = false;
375
+ var helloTimer = setTimeout(() => {
376
+ console.error(`[daemon] no hello from hub within 5s \u2014 the hub at ${HUB} is unreachable or too old for this daemon. Check --hub, or upgrade the hub (npx agentlink-hub@latest).`);
377
+ process.exit(1);
378
+ }, 5e3);
379
+ ws.on("message", (raw) => {
380
+ let msg;
381
+ try {
382
+ msg = JSON.parse(raw.toString());
383
+ } catch {
384
+ console.log(`[daemon] ignoring malformed message: ${raw}`);
385
+ return;
386
+ }
387
+ if (!registered) {
388
+ if (msg.type === "error" && msg.error === "unsupported_version") {
389
+ clearTimeout(helloTimer);
390
+ const supported = Array.isArray(msg.supported) ? msg.supported.join(", ") : "?";
391
+ console.error(`[daemon] the hub rejected this connection: it only speaks protocol version(s) ${supported}, this daemon offers v${PROTOCOL_VERSION}. Upgrade the older side (npx agentlink-daemon@latest / npx agentlink-hub@latest).`);
392
+ process.exit(1);
393
+ }
394
+ if (msg.type === "hello") {
395
+ clearTimeout(helloTimer);
396
+ registered = true;
397
+ console.log(`[daemon] hub says hello (protocol v${msg.v}, hub ${msg.hubVersion ?? "?"})`);
398
+ send(ws, { type: "register", hostname: hostname(), daemonVersion: ownVersion(), dirs: DIRS, agents });
399
+ console.log(`[daemon] registered. Waiting for work pushed from the hub...`);
400
+ console.log(``);
401
+ console.log(` Open this link to control this machine from anywhere:`);
402
+ console.log(``);
403
+ console.log(` ${webLink(HUB, TOKEN)}`);
404
+ console.log(``);
405
+ }
406
+ return;
407
+ }
408
+ if (msg.type === "run" && typeof msg.requestId === "string") {
409
+ handleRun(ws, msg);
410
+ } else if (msg.type === "listdir" && typeof msg.requestId === "string") {
411
+ handleListdir(ws, msg);
412
+ } else {
413
+ console.log(`[daemon] ignoring message with unknown shape: ${raw}`);
414
+ }
415
+ });
416
+ ws.on("error", (err) => {
417
+ console.error(`[daemon] socket error: ${err.message}`);
418
+ });
419
+ ws.on("close", (code, reason) => {
420
+ clearTimeout(helloTimer);
421
+ for (const [requestId, child] of running) {
422
+ console.log(`[daemon] killing in-flight requestId=${requestId} (hub connection closed)`);
423
+ child.kill();
424
+ }
425
+ running.clear();
426
+ const detail = code ? ` (code ${code}${reason?.length ? `, ${reason}` : ""})` : "";
427
+ console.error(`[daemon] connection to hub closed${detail} \u2014 v1 has no auto-reconnect, please start the daemon again.`);
428
+ process.exit(1);
429
+ });
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "agentlink-daemon",
3
+ "version": "0.1.0",
4
+ "description": "Control your coding agents from anywhere — the AgentLink daemon.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/baichen99/agentlink.git",
9
+ "directory": "packages/daemon"
10
+ },
11
+ "homepage": "https://github.com/baichen99/agentlink#readme",
12
+ "bugs": "https://github.com/baichen99/agentlink/issues",
13
+ "engines": {
14
+ "node": ">=20"
15
+ },
16
+ "publishConfig": {
17
+ "registry": "https://registry.npmjs.org/"
18
+ },
19
+ "keywords": [
20
+ "claude-code",
21
+ "codex",
22
+ "joycode",
23
+ "coding-agent",
24
+ "remote-control",
25
+ "cli",
26
+ "self-hosted"
27
+ ],
28
+ "type": "module",
29
+ "bin": {
30
+ "agentlink": "dist/main.js"
31
+ },
32
+ "files": [
33
+ "dist"
34
+ ],
35
+ "scripts": {
36
+ "build": "esbuild src/main.ts --bundle --platform=node --format=esm --external:ws --outfile=dist/main.js",
37
+ "typecheck": "tsc",
38
+ "test": "npm run build && node test/self-test.js",
39
+ "test:real": "npm run build && node test/real-run.js"
40
+ },
41
+ "dependencies": {
42
+ "ws": "^8.18.0"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^24.0.0",
46
+ "@types/ws": "^8.5.0",
47
+ "esbuild": "^0.25.0",
48
+ "typescript": "^5.8.0"
49
+ }
50
+ }