@amkentech/agent-channel 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -0
- package/bin/agent-channel.mjs +46 -0
- package/db/ledger.sql +85 -0
- package/db/supabase-ca.pem +24 -0
- package/hooks/claude-status.mjs +31 -0
- package/hooks/inbox.mjs +285 -0
- package/hooks/notify.mjs +18 -0
- package/hooks/statusline.mjs +60 -0
- package/lib/adapters.mjs +170 -0
- package/lib/artifacts.mjs +29 -0
- package/lib/codex-push.mjs +43 -0
- package/lib/crypto.mjs +100 -0
- package/lib/inspect.mjs +0 -0
- package/lib/paths.mjs +45 -0
- package/package.json +16 -0
- package/scripts/artifact.mjs +126 -0
- package/scripts/audit-verify.mjs +63 -0
- package/scripts/cli.mjs +16 -0
- package/scripts/codex-probe.mjs +33 -0
- package/scripts/export-conversation.mjs +128 -0
- package/scripts/inbox-view.mjs +64 -0
- package/scripts/join.mjs +29 -0
- package/scripts/listen.mjs +137 -0
- package/scripts/setup.mjs +234 -0
- package/scripts/share.mjs +67 -0
- package/scripts/verify.mjs +87 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// One-command onboarding and health check for a person joining Agent Channel.
|
|
3
|
+
//
|
|
4
|
+
// node scripts/setup.mjs join <inv_code> <handle> "<Display Name>" [--runtime claude|codex|both] [--email you@x.com]
|
|
5
|
+
// -> creates your identity + one agent token per runtime, connected to whoever invited you, then wires everything below
|
|
6
|
+
// node scripts/setup.mjs wire [--runtime claude|codex] [--token ac_...]
|
|
7
|
+
// -> MCP server in the client, hooks (type-to-send, waiting banner, status), token storage, listener at logon, listener now
|
|
8
|
+
// node scripts/setup.mjs doctor
|
|
9
|
+
// -> checks every piece and says exactly what is missing
|
|
10
|
+
//
|
|
11
|
+
// Nothing here needs the admin key. Runtime-specific behavior lives in lib/adapters.mjs.
|
|
12
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, readdirSync } from "node:fs";
|
|
13
|
+
import { join, resolve, dirname } from "node:path";
|
|
14
|
+
import { homedir, platform } from "node:os";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
17
|
+
import { ADAPTERS, adapterFor, mergeHooks, which } from "../lib/adapters.mjs";
|
|
18
|
+
import { readTok as readTokStore, saveTok as saveTokStore, tokFileHome, CLIENT_HOME, IN_NPX_CACHE, HOME_STORE } from "../lib/paths.mjs";
|
|
19
|
+
|
|
20
|
+
let REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
21
|
+
// Running from an npx cache (npx @amkentech/agent-channel join ...)? That folder can vanish, and hooks/listener need a stable path:
|
|
22
|
+
// copy this package to ~/.agentchan/client and run from there. A git checkout or a global install stays where it is.
|
|
23
|
+
if (IN_NPX_CACHE && !process.env.AGENTCHAN_NO_SELF_INSTALL) {
|
|
24
|
+
const { cpSync } = await import("node:fs");
|
|
25
|
+
cpSync(REPO, CLIENT_HOME, { recursive: true, force: true, filter: (src) => !/[\\/](\.git|\.tok\.[^\\/]+\.json|\.env[^\\/]*)$/.test(src) });
|
|
26
|
+
// the listener needs the package's own deps (ws, MCP sdk); hooks need none. Install them once in the persistent copy.
|
|
27
|
+
try { const { execFileSync: x } = await import("node:child_process"); x(platform() === "win32" ? "npm.cmd" : "npm", ["install", "--omit=dev", "--no-audit", "--no-fund", "--silent"], { cwd: CLIENT_HOME, stdio: "ignore", shell: platform() === "win32" }); }
|
|
28
|
+
catch { console.log(" note: could not npm install in " + CLIENT_HOME + "; run it there by hand before starting the listener"); }
|
|
29
|
+
console.log(" installed client to " + CLIENT_HOME + " (hooks and the listener run from there; re-run join/wire from any npx to update)");
|
|
30
|
+
REPO = CLIENT_HOME;
|
|
31
|
+
}
|
|
32
|
+
const BASE = (process.env.AGENTCHAN_URL || "https://agent-channel-production.up.railway.app").replace(/\/mcp$/, "");
|
|
33
|
+
const args = process.argv.slice(2);
|
|
34
|
+
const cmd = args[0];
|
|
35
|
+
const opt = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d; };
|
|
36
|
+
const H = homedir();
|
|
37
|
+
const WIN = platform() === "win32";
|
|
38
|
+
const say = (s) => console.log(s);
|
|
39
|
+
const ok = (s) => say(" ok " + s);
|
|
40
|
+
const bad = (s) => say(" MISSING " + s);
|
|
41
|
+
const warn = (s) => say(" note " + s);
|
|
42
|
+
|
|
43
|
+
const tokFile = (key) => tokFileHome(key); // tokens live in ~/.agentchan, not next to the code
|
|
44
|
+
const readTok = (key) => readTokStore(key);
|
|
45
|
+
const saveTok = (key, obj) => saveTokStore(key, obj);
|
|
46
|
+
const tokenFor = (ad) => process.env[ad.tokenEnv] || readTok(ad.key)?.token || (ad.key === "claude-desktop" ? readTok("claude")?.token : null) || null;
|
|
47
|
+
|
|
48
|
+
async function api(path, body, token, retry = 1) {
|
|
49
|
+
try { return await api1(path, body, token); }
|
|
50
|
+
catch (e) { if (retry > 0 && /fetch failed/i.test(e.message)) return api(path, body, token, retry - 1); throw e; } // keep-alive socket reset after a long sync exec
|
|
51
|
+
}
|
|
52
|
+
async function api1(path, body, token) {
|
|
53
|
+
const r = await fetch(BASE + path, { method: body ? "POST" : "GET", headers: { "content-type": "application/json", ...(token ? { authorization: "Bearer " + token } : {}) }, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(15000) });
|
|
54
|
+
const j = await r.json().catch(() => ({}));
|
|
55
|
+
if (!r.ok) throw new Error(path + " -> " + r.status + " " + (j.error || ""));
|
|
56
|
+
return j;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function runtimesWanted() {
|
|
60
|
+
const r = (opt("--runtime", "") || "").toLowerCase();
|
|
61
|
+
if (r === "both") return [ADAPTERS.claude, ADAPTERS.codex];
|
|
62
|
+
if (r === "all") return [ADAPTERS.claude, ADAPTERS.codex, ADAPTERS["claude-desktop"], ADAPTERS.cursor, ADAPTERS.gemini, ADAPTERS.windsurf].filter((a) => a.detect());
|
|
63
|
+
if (r === "desktop" || r === "claude-desktop") return [ADAPTERS["claude-desktop"]];
|
|
64
|
+
if (r) return [adapterFor(r)];
|
|
65
|
+
const found = [ADAPTERS.claude, ADAPTERS.codex].filter((a) => a.detect());
|
|
66
|
+
return found.length ? found : [ADAPTERS.claude];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------- join ----------------
|
|
70
|
+
async function join_() {
|
|
71
|
+
const [code, handle, display_name] = args.slice(1).filter((x, i, arr) => !x.startsWith("--") && arr[i - 1] !== "--runtime" && arr[i - 1] !== "--email");
|
|
72
|
+
if (!code || !handle || !display_name) { say('usage: setup.mjs join <inv_code> <handle> "<Display Name>" [--runtime claude|codex|both] [--email you@x.com]'); process.exit(1); }
|
|
73
|
+
const ads = runtimesWanted();
|
|
74
|
+
const first = ads[0];
|
|
75
|
+
say("Joining Agent Channel as @" + handle.replace(/^@/, "") + " (" + ads.map((a) => a.label).join(" + ") + ")...");
|
|
76
|
+
const j = await api("/join", { code, handle, display_name, runtime: first.runtime, email: opt("--email"), agent_name: first.key });
|
|
77
|
+
saveTok(first.key, { handle: j.handle.replace(/^@/, ""), agent_id: j.agent.id, runtime: first.runtime, token: j.token, base: BASE });
|
|
78
|
+
say("Welcome, " + j.handle + ". Connected to " + j.connected_to + ". Token for " + first.label + " saved to " + tokFile(first.key) + " (gitignored; shown nowhere else).");
|
|
79
|
+
for (const ad of ads.slice(1)) {
|
|
80
|
+
const a2 = await api("/agents", { name: ad.key, runtime: ad.runtime }, j.token);
|
|
81
|
+
saveTok(ad.key, { handle: j.handle.replace(/^@/, ""), agent_id: a2.id, runtime: ad.runtime, token: a2.token, base: BASE });
|
|
82
|
+
say("Second agent for " + ad.label + " minted and saved to " + tokFile(ad.key) + ".");
|
|
83
|
+
}
|
|
84
|
+
if (!args.includes("--no-wire")) for (const ad of ads) await wire(ad, tokenFor(ad));
|
|
85
|
+
say("");
|
|
86
|
+
say("Done. Open " + ads.map((a) => a.label).join(" or ") + " and type: @" + j.connected_to.replace(/^@/, "") + " hi, I'm in.");
|
|
87
|
+
say("Then run node scripts/setup.mjs doctor any time.");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------- wire ----------------
|
|
91
|
+
async function wire(ad, token) {
|
|
92
|
+
if (!token) {
|
|
93
|
+
// no token for this runtime yet, but the person is already here under another: mint an agent for this runtime
|
|
94
|
+
const seed = readTok("claude")?.token || readTok("codex")?.token || process.env.AGENTCHAN_TOKEN || null;
|
|
95
|
+
if (seed && !args.includes("--dry-run")) {
|
|
96
|
+
try { const a2 = await api("/agents", { name: ad.key, runtime: ad.runtime }, seed); const h = readTok("claude")?.handle || readTok("codex")?.handle || null; saveTok(ad.key, { handle: h, agent_id: a2.id, runtime: ad.runtime, token: a2.token, base: BASE }); token = a2.token; ok(ad.label + ": minted its own agent (" + ad.runtime + ") for your handle"); }
|
|
97
|
+
catch (e) { bad(ad.label + ": could not mint an agent (" + e.message.slice(0, 120) + ")"); return; }
|
|
98
|
+
} else if (seed) { token = seed; }
|
|
99
|
+
else { bad(ad.label + ": no token. Pass --token or join first."); return; }
|
|
100
|
+
}
|
|
101
|
+
say("");
|
|
102
|
+
say("Wiring " + ad.label + ":");
|
|
103
|
+
if (args.includes("--dry-run")) {
|
|
104
|
+
const m = ad.mcpWire({ url: BASE, token: token ? token.slice(0, 6) + "..." : token }); const hw = ad.hooksWire({ repo: REPO });
|
|
105
|
+
say(" would: save token to " + tokFile(ad.key) + (WIN && ["claude", "codex"].includes(ad.key) ? " and setx " + ad.tokenEnv : ""));
|
|
106
|
+
say(" would: " + m.command.split(String.fromCharCode(10))[0]);
|
|
107
|
+
if (ad.hooksFile) say(" would: merge hooks into " + ad.hooksFile + " (" + Object.keys(hw.hooks || {}).join(", ") + ")"); else say(" note: " + (hw.note || "no hooks for this runtime"));
|
|
108
|
+
if (ad.key !== "claude-desktop") say(" would: install the listener to start at login (" + (WIN ? "Startup folder + run_listen_" + ad.key + ".cmd" : platform() === "darwin" ? "LaunchAgent com.agentchannel.listen." + ad.key : "systemd --user unit") + ") and start it now");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
// 1. token where the hooks and listener can find it
|
|
112
|
+
if (!readTok(ad.key)) saveTok(ad.key, { token, runtime: ad.runtime, base: BASE });
|
|
113
|
+
if (WIN && ["claude", "codex"].includes(ad.key)) { try { execFileSync("setx", [ad.tokenEnv, token], { stdio: "ignore" }); ok("user env var " + ad.tokenEnv + " set (new terminals)"); } catch { warn("could not setx " + ad.tokenEnv + "; the .tok file is enough for the hooks and listener"); } }
|
|
114
|
+
else warn("export " + ad.tokenEnv + "=" + token.slice(0, 8) + "... in your shell profile (the .tok file covers hooks/listener)");
|
|
115
|
+
// 2. MCP server in the client
|
|
116
|
+
const m = ad.mcpWire({ url: BASE, token, oauth: args.includes("--oauth") });
|
|
117
|
+
const r = m.apply();
|
|
118
|
+
if (r.ok) ok("MCP server registered in " + ad.label + (r.note ? " (" + r.note + ")" : "")); else { warn("MCP not auto-registered (" + r.why + "). Run:\n " + m.command); }
|
|
119
|
+
// 3. hooks
|
|
120
|
+
const hw = ad.hooksWire({ repo: REPO });
|
|
121
|
+
if (ad.hooksFile && hw.hooks) {
|
|
122
|
+
let cur = {}; try { cur = JSON.parse(readFileSync(ad.hooksFile, "utf8")); } catch {}
|
|
123
|
+
const merged = mergeHooks(cur, hw);
|
|
124
|
+
mkdirSync(dirname(ad.hooksFile), { recursive: true });
|
|
125
|
+
writeFileSync(ad.hooksFile, JSON.stringify(merged, null, 2));
|
|
126
|
+
ok("hooks merged into " + ad.hooksFile + " (" + Object.keys(hw.hooks).join(", ") + (hw.statusLine ? ", statusLine" : "") + ")");
|
|
127
|
+
if (hw.note) warn(hw.note);
|
|
128
|
+
} else if (hw.note) warn(hw.note);
|
|
129
|
+
// 4. listener launcher + startup (Claude Desktop shares the Claude Code listener/token; nothing of its own)
|
|
130
|
+
if (["claude-desktop", "cursor", "gemini", "windsurf"].includes(ad.key)) { warn("no listener of its own; if Claude Code or Codex is wired on this machine its listener already covers toasts and files"); return; }
|
|
131
|
+
const cmdFile = join(REPO, "run_listen_" + ad.key + ".cmd");
|
|
132
|
+
if (WIN) {
|
|
133
|
+
if (!existsSync(cmdFile)) writeFileSync(cmdFile, "@echo off\r\ncd /d " + REPO + "\r\nnode scripts\\listen.mjs --runtime " + ad.key + " >> \"%USERPROFILE%\\.agentchan\\listen-" + ad.key + ".log\" 2>&1\r\n");
|
|
134
|
+
const startup = join(process.env.APPDATA || join(H, "AppData", "Roaming"), "Microsoft", "Windows", "Start Menu", "Programs", "Startup");
|
|
135
|
+
const vbs = join(startup, "agent-channel-" + ad.key + ".vbs");
|
|
136
|
+
try { mkdirSync(startup, { recursive: true }); writeFileSync(vbs, 'Set sh = CreateObject("WScript.Shell")\r\nsh.Run """' + cmdFile + '""", 0, False\r\n'); ok("listener starts at logon (" + vbs + ")"); }
|
|
137
|
+
catch (e) { warn("could not write startup entry: " + e.message); }
|
|
138
|
+
// start now if not running
|
|
139
|
+
if (!listenerFresh(ad)) { try { spawn("wscript", [vbs], { detached: true, stdio: "ignore", windowsHide: true }).unref(); ok("listener started now"); } catch { warn("start the listener: " + cmdFile); } }
|
|
140
|
+
else ok("listener already running");
|
|
141
|
+
} else if (platform() === "darwin") {
|
|
142
|
+
// macOS: a per-user LaunchAgent keeps the listener alive across logins (KeepAlive) and starts it now
|
|
143
|
+
const label = "com.agentchannel.listen." + ad.key;
|
|
144
|
+
const plistDir = join(H, "Library", "LaunchAgents"), plist = join(plistDir, label + ".plist");
|
|
145
|
+
const nodeBin = which("node") || process.execPath;
|
|
146
|
+
const logDir = join(H, ".agentchan"); mkdirSync(logDir, { recursive: true });
|
|
147
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
148
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
149
|
+
<plist version="1.0"><dict>
|
|
150
|
+
<key>Label</key><string>${label}</string>
|
|
151
|
+
<key>ProgramArguments</key><array><string>${nodeBin}</string><string>${join(REPO, "scripts", "listen.mjs")}</string><string>--runtime</string><string>${ad.key}</string></array>
|
|
152
|
+
<key>WorkingDirectory</key><string>${REPO}</string>
|
|
153
|
+
<key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
|
|
154
|
+
<key>StandardOutPath</key><string>${join(logDir, "listen-" + ad.key + ".log")}</string>
|
|
155
|
+
<key>StandardErrorPath</key><string>${join(logDir, "listen-" + ad.key + ".log")}</string>
|
|
156
|
+
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${process.env.PATH || "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"}</string><key>HOME</key><string>${H}</string></dict>
|
|
157
|
+
</dict></plist>
|
|
158
|
+
`;
|
|
159
|
+
try {
|
|
160
|
+
mkdirSync(plistDir, { recursive: true }); writeFileSync(plist, xml);
|
|
161
|
+
try { execFileSync("launchctl", ["bootout", "gui/" + process.getuid() + "/" + label], { stdio: "ignore" }); } catch {}
|
|
162
|
+
try { execFileSync("launchctl", ["bootstrap", "gui/" + process.getuid(), plist], { stdio: "ignore" }); }
|
|
163
|
+
catch { execFileSync("launchctl", ["load", "-w", plist], { stdio: "ignore" }); }
|
|
164
|
+
ok("listener installed as LaunchAgent " + label + " (starts at login, restarts if it dies, log ~/.agentchan/listen-" + ad.key + ".log)");
|
|
165
|
+
} catch (e) { warn("could not install the LaunchAgent (" + e.message.slice(0, 120) + "). Run by hand: node " + join(REPO, "scripts/listen.mjs") + " --runtime " + ad.key); }
|
|
166
|
+
} else {
|
|
167
|
+
// Linux: systemd --user unit when available, otherwise tell them how
|
|
168
|
+
const unitDir = join(H, ".config", "systemd", "user"), unit = join(unitDir, "agent-channel-" + ad.key + ".service");
|
|
169
|
+
const nodeBin = which("node") || process.execPath;
|
|
170
|
+
if (which("systemctl")) {
|
|
171
|
+
try {
|
|
172
|
+
mkdirSync(unitDir, { recursive: true });
|
|
173
|
+
writeFileSync(unit, "[Unit]\nDescription=Agent Channel listener (" + ad.key + ")\nAfter=network-online.target\n\n[Service]\nExecStart=" + nodeBin + " " + join(REPO, "scripts", "listen.mjs") + " --runtime " + ad.key + "\nWorkingDirectory=" + REPO + "\nRestart=always\nRestartSec=5\nEnvironment=HOME=" + H + "\n\n[Install]\nWantedBy=default.target\n");
|
|
174
|
+
execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
|
|
175
|
+
execFileSync("systemctl", ["--user", "enable", "--now", "agent-channel-" + ad.key + ".service"], { stdio: "ignore" });
|
|
176
|
+
ok("listener installed as systemd user service agent-channel-" + ad.key + " (enable-linger to survive logout: loginctl enable-linger $USER)");
|
|
177
|
+
} catch (e) { warn("could not install the systemd unit (" + e.message.slice(0, 120) + "). Run by hand: node " + join(REPO, "scripts/listen.mjs") + " --runtime " + ad.key); }
|
|
178
|
+
} else warn("run the listener in the background: node " + join(REPO, "scripts/listen.mjs") + " --runtime " + ad.key + " (tmux/nohup; the token is read from ~/.agentchan/tok." + ad.key + ".json)");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const startHint = (ad) => WIN ? "run_listen_" + ad.key + ".cmd (in " + REPO + ")" : platform() === "darwin" ? "launchctl kickstart -k gui/$(id -u)/com.agentchannel.listen." + ad.key + " (or: agent-channel wire)" : "systemctl --user restart agent-channel-" + ad.key + " (or: agent-channel wire)";
|
|
183
|
+
function ownerHandle(ad) {
|
|
184
|
+
const root = join(H, ".agentchan");
|
|
185
|
+
try { for (const h of readdirSync(root)) { try { if (readFileSync(join(root, h, "owner." + ad.key), "utf8") === "1") return h; } catch {} } } catch {}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
function listenerFresh(ad) {
|
|
189
|
+
const h = ownerHandle(ad); if (!h) return false;
|
|
190
|
+
const fresh = (f, ms) => { try { return Date.now() - statSync(join(H, ".agentchan", h, f)).mtimeMs < ms; } catch { return false; } };
|
|
191
|
+
return fresh("heartbeat", 3 * 60_000) || fresh("peek.json", 10 * 60_000); // heartbeat every 30s (listeners started before v0.3 only refresh peek.json on events)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------- doctor ----------------
|
|
195
|
+
async function doctor() {
|
|
196
|
+
say("Agent Channel doctor (server " + BASE + ")");
|
|
197
|
+
try { const h = await api("/health"); ok("server reachable, listeners connected: " + h.listeners); } catch (e) { bad("server unreachable: " + e.message); }
|
|
198
|
+
const ads = [ADAPTERS.claude, ADAPTERS.codex, ADAPTERS["claude-desktop"]].filter((a) => a.detect());
|
|
199
|
+
if (!ads.length) warn("no Claude Code, Codex, or Claude Desktop install detected");
|
|
200
|
+
for (const ad of ads) {
|
|
201
|
+
say("");
|
|
202
|
+
say(ad.label + ":");
|
|
203
|
+
const token = tokenFor(ad);
|
|
204
|
+
if (!token) { bad("no token (" + ad.tokenEnv + " or " + tokFile(ad.key) + "). Join with an invite: setup.mjs join <code> <handle> \"<Name>\" --runtime " + ad.key); continue; }
|
|
205
|
+
let me = null;
|
|
206
|
+
try { me = await api("/peek", null, token); ok("token valid, you are @" + me.handle + " (" + (me.unread_messages + me.proposals_awaiting_you + me.artifacts_waiting) + " waiting)"); }
|
|
207
|
+
catch (e) { bad("token rejected: " + e.message + (e.cause ? " (" + (e.cause.code || e.cause.message) + ")" : "")); }
|
|
208
|
+
const m = ad.mcpWire({ url: BASE, token }); const c = m.check();
|
|
209
|
+
if (c === true) ok("MCP server registered"); else if (c === false) bad("MCP server not registered. " + (ad.key === "claude-desktop" ? "Run: node scripts/setup.mjs wire --runtime desktop (or Desktop > Settings > Connectors > Add custom connector > " + BASE + "/mcp)" : m.command.split(String.fromCharCode(10))[0])); else warn("could not check MCP registration (client CLI not on PATH)");
|
|
210
|
+
if (ad.hooksFile) {
|
|
211
|
+
let txt = ""; try { txt = readFileSync(ad.hooksFile, "utf8"); } catch {}
|
|
212
|
+
const has = (name) => txt.includes("hooks/" + name) || txt.includes("hooks\\\\" + name) || txt.includes("hooks\\" + name);
|
|
213
|
+
has("inbox.mjs") ? ok("inbox hook (type-to-send + waiting banner) wired") : bad("inbox hook missing in " + ad.hooksFile + " (setup.mjs wire --runtime " + ad.key + ")");
|
|
214
|
+
if (ad.supportsFileChanged) has("notify.mjs") ? ok("idle notifications (FileChanged) wired") : warn("FileChanged notify hook missing");
|
|
215
|
+
if (ad.key === "claude") has("claude-status.mjs") ? ok("status hooks wired") : warn("status hooks missing");
|
|
216
|
+
}
|
|
217
|
+
if (["claude-desktop", "cursor", "gemini", "windsurf"].includes(ad.key)) continue;
|
|
218
|
+
const h = ownerHandle(ad);
|
|
219
|
+
if (!h) bad("listener has never connected for this runtime (no owner marker). Start it: " + startHint(ad));
|
|
220
|
+
else if (listenerFresh(ad)) ok("listener running as @" + h);
|
|
221
|
+
else bad("listener not running or started before v0.3 (no fresh heartbeat). Restart it: " + startHint(ad));
|
|
222
|
+
if (h) { const keys = join(H, ".agentchan", h, "keys"); existsSync(keys) && readdirSync(keys).length ? ok("E2E key present (files can be received)") : warn("no E2E key yet; the listener registers one on first connect"); }
|
|
223
|
+
if (me && ad.key === "claude") {
|
|
224
|
+
const cc = which("claude"); if (!cc) warn("claude CLI not on PATH (fine if you use the desktop app)");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
say("");
|
|
228
|
+
say("Type @<handle> hello in a prompt to send a message with no model turn. Ask your agent for my_work to see the ledger.");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (cmd === "join") await join_();
|
|
232
|
+
else if (cmd === "wire") { const ads = runtimesWanted(); for (const ad of ads) await wire(ad, opt("--token") || tokenFor(ad)); }
|
|
233
|
+
else if (cmd === "doctor" || cmd === "status") await doctor();
|
|
234
|
+
else { say("usage: setup.mjs join <inv_code> <handle> \"<Display Name>\" [--runtime claude|codex|both] [--email x]\n setup.mjs wire [--runtime claude|codex|desktop|both|all] [--token ac_...] [--oauth] [--dry-run]\n setup.mjs doctor"); process.exit(1); }
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Make a read-only link for a file or for this session's conversation. The receiver installs nothing: they open the link in a
|
|
3
|
+
// browser and it decrypts there. The key is generated here, put after # in the link, and never sent to the server.
|
|
4
|
+
//
|
|
5
|
+
// node scripts/share.mjs <path> [--expires 72h|3d] [--views N] [--runtime claude|codex]
|
|
6
|
+
// node scripts/share.mjs --conversation [--last N] [--full] [--expires 72h] [--views N]
|
|
7
|
+
// node scripts/share.mjs --list | --revoke <id>
|
|
8
|
+
import { readFileSync, statSync } from "node:fs";
|
|
9
|
+
import { basename, extname, resolve, dirname } from "node:path";
|
|
10
|
+
import { webcrypto as wc } from "node:crypto";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { execFileSync } from "node:child_process";
|
|
13
|
+
import { tokenFor, BASE } from "../lib/paths.mjs";
|
|
14
|
+
|
|
15
|
+
const args = process.argv.slice(2);
|
|
16
|
+
const opt = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d; };
|
|
17
|
+
const has = (k) => args.includes(k);
|
|
18
|
+
const runtime = (opt("--runtime", process.env.AGENTCHAN_RUNTIME || "claude")).replace(/-code$/, "");
|
|
19
|
+
let token = tokenFor(runtime);
|
|
20
|
+
if (!token) {
|
|
21
|
+
// No invite needed to share: mint an anonymous sender token (links only) and keep it for next time. `join` replaces it.
|
|
22
|
+
const { saveTok } = await import("../lib/paths.mjs");
|
|
23
|
+
try {
|
|
24
|
+
const r = await fetch(BASE + "/anon", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ runtime }), signal: AbortSignal.timeout(15000) });
|
|
25
|
+
const j = await r.json().catch(() => ({}));
|
|
26
|
+
if (!r.ok || !j.token) throw new Error(j.error || ("HTTP " + r.status));
|
|
27
|
+
token = j.token;
|
|
28
|
+
const f = saveTok(runtime, { token, runtime, base: BASE, handle: j.handle, anon: true });
|
|
29
|
+
console.error("(no account needed: using an anonymous share token, saved to " + f + ". Links only; join with an invite to message people or send files.)");
|
|
30
|
+
} catch (e) { console.error("Could not get a share token: " + e.message); process.exit(1); }
|
|
31
|
+
}
|
|
32
|
+
const H = { authorization: "Bearer " + token, "content-type": "application/json" };
|
|
33
|
+
const api = async (path, body, method) => { const r = await fetch(BASE + path, { method: method || (body ? "POST" : "GET"), headers: H, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(30000) }); const j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(path + " -> " + r.status + " " + (j.error || "")); return j; };
|
|
34
|
+
const b64u = (buf) => Buffer.from(buf).toString("base64url");
|
|
35
|
+
const hours = (s) => { const m = String(s || "72h").match(/^(\d+)\s*([hd])?$/i); if (!m) return 72; return m[2]?.toLowerCase() === "d" ? Number(m[1]) * 24 : Number(m[1]); };
|
|
36
|
+
|
|
37
|
+
if (has("--list")) { const j = await api("/links"); for (const l of j.links) console.log((l.revoked_at ? "revoked " : new Date(l.expires_at) < new Date() ? "expired " : "active ") + l.id + " " + (l.filename || l.kind) + " " + l.size + "b views " + l.views + (l.max_views ? "/" + l.max_views : "") + " expires " + l.expires_at); process.exit(0); }
|
|
38
|
+
if (has("--revoke")) { await api("/links/" + opt("--revoke") + "/revoke", {}); console.log("revoked"); process.exit(0); }
|
|
39
|
+
|
|
40
|
+
let bytes, filename, kind, contentType;
|
|
41
|
+
if (has("--conversation")) {
|
|
42
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
43
|
+
const a = [resolve(here, "export-conversation.mjs"), "--runtime", runtime];
|
|
44
|
+
if (opt("--last")) a.push("--last", opt("--last")); if (has("--full")) a.push("--full"); if (opt("--cwd")) a.push("--cwd", opt("--cwd")); if (opt("--session")) a.push("--session", opt("--session")); if (opt("--since")) a.push("--since", opt("--since"));
|
|
45
|
+
const outText = execFileSync(process.execPath, a, { encoding: "utf8", env: process.env });
|
|
46
|
+
const m = outText.match(/-> (.+\.(?:md|txt))\s*$/m);
|
|
47
|
+
const file = opt("--out") || m?.[1];
|
|
48
|
+
if (!file) { console.error("could not find the exported transcript path in export-conversation output:\n" + outText); process.exit(1); }
|
|
49
|
+
bytes = readFileSync(file); filename = basename(file); kind = "conversation"; contentType = "text/markdown";
|
|
50
|
+
} else {
|
|
51
|
+
const p = args.find((x) => !x.startsWith("--") && args[args.indexOf(x) - 1] !== "--expires" && args[args.indexOf(x) - 1] !== "--views" && args[args.indexOf(x) - 1] !== "--runtime");
|
|
52
|
+
if (!p) { console.error("usage: share <path> [--expires 72h] [--views N] | share --conversation [--last N] | --list | --revoke <id>"); process.exit(1); }
|
|
53
|
+
const st = statSync(p); if (!st.isFile()) { console.error("not a file: " + p); process.exit(1); }
|
|
54
|
+
bytes = readFileSync(p); filename = basename(p); kind = "file";
|
|
55
|
+
const ext = extname(p).toLowerCase();
|
|
56
|
+
contentType = ({ ".md": "text/markdown", ".txt": "text/plain", ".json": "application/json", ".jsonl": "application/x-ndjson", ".csv": "text/csv", ".pdf": "application/pdf", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".html": "text/html", ".log": "text/plain" })[ext] || "application/octet-stream";
|
|
57
|
+
}
|
|
58
|
+
if (bytes.length > 6 * 1024 * 1024) { console.error("too large (6 MB max for links; use `send @handle` for bigger files)"); process.exit(1); }
|
|
59
|
+
|
|
60
|
+
const key = await wc.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt"]);
|
|
61
|
+
const iv = wc.getRandomValues(new Uint8Array(12));
|
|
62
|
+
const ct = await wc.subtle.encrypt({ name: "AES-GCM", iv }, key, bytes);
|
|
63
|
+
const raw = await wc.subtle.exportKey("raw", key);
|
|
64
|
+
const r = await api("/links", { kind, filename, content_type: contentType, size: bytes.length, iv: b64u(iv), ciphertext: b64u(ct), expires_in_hours: hours(opt("--expires")), max_views: opt("--views") ? Number(opt("--views")) : undefined });
|
|
65
|
+
const link = r.url + "#" + b64u(raw);
|
|
66
|
+
console.log(link);
|
|
67
|
+
console.error("(" + filename + ", " + bytes.length + " bytes, expires " + r.expires_at + (opt("--views") ? ", " + opt("--views") + " view(s)" : "") + ". The part after # is the key; the server never sees it. Revoke: agent-channel share --revoke " + r.id + ")");
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Receiving-side deterministic verification of a returned proposal, run in YOUR local clone
|
|
2
|
+
// (credentials stay on the machine that has them). Produces facts, then posts them via post_checks.
|
|
3
|
+
//
|
|
4
|
+
// Usage: AGENTCHAN_TOKEN=ac_... node scripts/verify.mjs <proposal_id> [--repo-dir <path>] [--no-post] [--no-tests]
|
|
5
|
+
//
|
|
6
|
+
// Checks:
|
|
7
|
+
// ref_exists the returned commit/branch is fetchable in this clone
|
|
8
|
+
// scope_respected every file changed between merge-base(main) and the ref matches a declared scope glob
|
|
9
|
+
// tests `npm test` exits 0 (skipped with --no-tests or if no test script)
|
|
10
|
+
// build `npm run build` exits 0 if a build script exists (else skipped)
|
|
11
|
+
// no_change_needed if outcome=no_change_needed, asserts the ref (if any) has no diff vs main
|
|
12
|
+
|
|
13
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
14
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
15
|
+
import { execSync } from "node:child_process";
|
|
16
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
const BASE = (process.env.AGENTCHAN_URL || "https://agent-channel-production.up.railway.app").replace(/\/mcp$/, "");
|
|
20
|
+
const token = process.env.AGENTCHAN_TOKEN;
|
|
21
|
+
const args = process.argv.slice(2);
|
|
22
|
+
const proposalId = args.find((a) => !a.startsWith("--"));
|
|
23
|
+
const opt = (k) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : null; };
|
|
24
|
+
const repoDir = opt("--repo-dir") || process.cwd();
|
|
25
|
+
const noPost = args.includes("--no-post"), noTests = args.includes("--no-tests");
|
|
26
|
+
if (!token || !proposalId) { console.error("usage: AGENTCHAN_TOKEN=... verify.mjs <proposal_id> [--repo-dir p] [--no-post] [--no-tests]"); process.exit(1); }
|
|
27
|
+
|
|
28
|
+
const client = new Client({ name: "agentchan-verify", version: "0.0.1" });
|
|
29
|
+
await client.connect(new StreamableHTTPClientTransport(new URL(BASE + "/mcp"), { requestInit: { headers: { authorization: "Bearer " + token } } }));
|
|
30
|
+
const call = async (name, a = {}) => { const r = await client.callTool({ name, arguments: a }); const t = r.content?.[0]?.text ?? ""; if (r.isError) throw new Error(name + ": " + t); try { return JSON.parse(t); } catch { return t; } };
|
|
31
|
+
|
|
32
|
+
const inbox = await call("my_inbox");
|
|
33
|
+
const p = inbox.proposals_for_you.find((x) => x.id === proposalId);
|
|
34
|
+
if (!p) { console.error("Proposal not in your inbox (must be addressed to you and returned)."); process.exit(1); }
|
|
35
|
+
if (p.status !== "returned") { console.error("Proposal status is " + p.status + ", not returned."); process.exit(1); }
|
|
36
|
+
const ref = p.return_ref || {};
|
|
37
|
+
const scope = (p.counter?.scope || p.scope || []);
|
|
38
|
+
console.log("Verifying " + p.id + "\n task: " + p.task + "\n scope: " + JSON.stringify(scope) + "\n return: " + JSON.stringify(ref));
|
|
39
|
+
|
|
40
|
+
const sh = (cmd) => execSync(cmd, { cwd: repoDir, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" }).trim();
|
|
41
|
+
const checks = [];
|
|
42
|
+
const add = (name, pass, detail) => { checks.push({ name, pass, detail }); console.log((pass ? " ok " : " FAIL ") + name + (detail ? " - " + detail : "")); };
|
|
43
|
+
|
|
44
|
+
// ref_exists
|
|
45
|
+
let target = ref.commit || ref.branch || null;
|
|
46
|
+
try { sh("git fetch --all --quiet"); } catch {}
|
|
47
|
+
if (!target) {
|
|
48
|
+
add("ref_exists", ref.outcome === "no_change_needed", ref.outcome === "no_change_needed" ? "no ref, outcome=no_change_needed" : "no commit/branch/pr in return");
|
|
49
|
+
} else {
|
|
50
|
+
let ok = false, detail = "";
|
|
51
|
+
try { sh("git cat-file -e " + target + "^{commit}"); ok = true; detail = target; }
|
|
52
|
+
catch { try { sh("git cat-file -e origin/" + target + "^{commit}"); ok = true; target = "origin/" + target; detail = target; } catch { detail = "not found: " + target; } }
|
|
53
|
+
add("ref_exists", ok, detail);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// scope_respected
|
|
57
|
+
const globToRe = (g) => new RegExp("^" + g.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*\//g, "(.*/)?").replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*") + "$");
|
|
58
|
+
if (target && checks[0].pass) {
|
|
59
|
+
try {
|
|
60
|
+
const base = sh("git merge-base " + target + " origin/main 2>nul || git merge-base " + target + " main");
|
|
61
|
+
const files = sh("git diff --name-only " + base + " " + target).split("\n").filter(Boolean);
|
|
62
|
+
const res = scope.map(globToRe);
|
|
63
|
+
const outside = files.filter((f) => !res.some((r) => r.test(f)) && !scope.includes(f));
|
|
64
|
+
add("scope_respected", outside.length === 0, files.length + " file(s) changed" + (outside.length ? "; outside scope: " + outside.join(", ") : ""));
|
|
65
|
+
if (ref.outcome === "no_change_needed") add("no_change_needed", files.length === 0, files.length ? "claims no change but " + files.length + " file(s) differ" : "no diff");
|
|
66
|
+
} catch (e) { add("scope_respected", false, "could not diff: " + e.message.split("\n")[0]); }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// tests / build (against the ref, in a temporary worktree so the working copy is untouched)
|
|
70
|
+
if (target && checks[0].pass && !noTests) {
|
|
71
|
+
const wt = join(repoDir, ".agentchan-verify-wt");
|
|
72
|
+
try {
|
|
73
|
+
try { sh("git worktree remove --force " + JSON.stringify(wt)); } catch {}
|
|
74
|
+
sh("git worktree add --detach " + JSON.stringify(wt) + " " + target);
|
|
75
|
+
const pkgPath = join(wt, "package.json");
|
|
76
|
+
const pkg = existsSync(pkgPath) ? JSON.parse(readFileSync(pkgPath, "utf8")) : {};
|
|
77
|
+
const run = (name, cmd) => { try { execSync(cmd, { cwd: wt, stdio: "pipe", encoding: "utf8", timeout: 300_000 }); add(name, true); } catch (e) { add(name, false, (e.stdout || e.stderr || e.message).toString().slice(-300)); } };
|
|
78
|
+
if (pkg.scripts?.test) { if (existsSync(join(wt, "package-lock.json"))) run("install", "npm ci --silent"); run("tests", "npm test --silent"); } else add("tests", true, "skipped: no test script");
|
|
79
|
+
if (pkg.scripts?.build) run("build", "npm run build --silent");
|
|
80
|
+
} finally { try { sh("git worktree remove --force " + JSON.stringify(wt)); } catch {} }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const allPass = checks.every((c) => c.pass);
|
|
84
|
+
console.log(allPass ? "ALL CHECKS PASS" : "CHECKS FAILED");
|
|
85
|
+
if (!noPost) { const r = await call("post_checks", { proposal_id: p.id, checks }); console.log("posted:", JSON.stringify(r)); }
|
|
86
|
+
await client.close();
|
|
87
|
+
process.exit(allPass ? 0 : 3);
|