@cosmovex/agentpager 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.
package/LICENSE ADDED
@@ -0,0 +1,8 @@
1
+ Copyright (c) 2026 Cosmovex. All rights reserved.
2
+
3
+ You may install and run this software unmodified, free of charge, to connect your own
4
+ computers to the AgentPager app. Redistribution, modification, or use with any other
5
+ client is not permitted without written permission.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. IN NO EVENT SHALL THE
8
+ AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY ARISING FROM ITS USE.
package/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # agentpager
2
+
3
+ A pager for your AI coding agents. Approve what they want to do, reply, and hear results —
4
+ on your phone, with the [AgentPager Android app](https://play.google.com/store/apps/details?id=com.cosmovex.mdpilot).
5
+
6
+ ```bash
7
+ npx agentpager
8
+ ```
9
+
10
+ A QR code appears. Scan it in the app. Your sessions show up in a few seconds.
11
+
12
+ ## What it does
13
+
14
+ - **Continues the sessions you already have.** Claude Code and Codex are driven natively, so the
15
+ phone picks up the session you started at your desk, with its history.
16
+ - **Every other agent through the Agent Client Protocol (ACP):** Gemini CLI, OpenCode (including
17
+ DeepSeek), Qwen Code, Goose. Installed agents are detected automatically.
18
+ - **Forwards every permission prompt to your phone.** Destructive commands — push, delete, deploy —
19
+ need a press-and-hold there. Nothing is auto-approved.
20
+ - **Notifies you** when an agent needs you, finishes, or fails.
21
+
22
+ ## Any job can page you, not just agents
23
+
24
+ ```bash
25
+ agentpager notify "AAB built" # a notification on your phone
26
+ agentpager ask "Deploy build 412 to prod?" # waits for you, up to 10 minutes
27
+ agentpager ask "Drop the staging DB?" --danger # needs press-and-hold on the phone
28
+ agentpager ask "Ship it?" --timeout 30s # 90s · 5m · 2h · bare number = seconds
29
+ ```
30
+
31
+ `ask` exit codes, so a script can tell the three apart:
32
+
33
+ | Code | Meaning |
34
+ |---|---|
35
+ | `0` | you approved |
36
+ | `1` | you denied |
37
+ | `2` | nobody could answer — no phone paired, the bridge isn't running, or the deadline passed |
38
+
39
+ Nothing blocks forever: when the deadline passes, the request is **taken back off your phone** with
40
+ a reason, so a card can't be approved an hour after the script gave up. `notify` tells you which
41
+ phone it reached, and says so plainly when notifications are off or no phone is paired.
42
+
43
+ Use it in build scripts, deploy steps, long test runs or training jobs:
44
+
45
+ ```bash
46
+ npm run build && agentpager ask "Ship it?" && ./deploy.sh
47
+ ```
48
+
49
+ ## Your agent, not just ours
50
+
51
+ Claude Code and Codex are driven natively. Everything else goes through the Agent Client Protocol,
52
+ and **nothing is trusted from a list** — a candidate has to exist on your PATH *and* answer a real
53
+ ACP `initialize` before it is offered to your phone, so an agent that appears is an agent that works.
54
+
55
+ ```bash
56
+ agentpager doctor # what was found, what was tried, and why something is missing
57
+ ```
58
+
59
+ Known out of the box: Gemini CLI · OpenCode · Qwen Code · Goose · Cursor CLI · GitHub Copilot CLI ·
60
+ Kiro CLI · Cline · Factory Droid · Kimi CLI · Mistral Vibe · OpenHands · Pi.
61
+
62
+ Using something else? Teach it, without waiting for a release of ours:
63
+
64
+ ```json
65
+ // ~/.agentpager/agents.json
66
+ [{ "id": "mine", "name": "My Agent", "command": "my-agent", "args": ["acp"] }]
67
+ ```
68
+
69
+ ## Privacy
70
+
71
+ Your code never leaves your computer. Your phone and this bridge talk **end-to-end encrypted**
72
+ (X25519 + ChaCha20-Poly1305): the relay stores only ciphertext it cannot read, and each message is
73
+ deleted once delivered. No account, no sign-up, and it never gets a raw shell.
74
+
75
+ **Which folders the phone can reach.** It can start an agent in your home folder, in the folder you
76
+ started the bridge in, or beside a project you already have a session in — never anywhere else on the
77
+ disk, never in a hidden directory (`.ssh`, `.aws`, …), and never outside those trees. Browsing shows
78
+ folder names only, so the picker can list your projects without sending any of their contents.
79
+
80
+ ## Keep it running
81
+
82
+ ```bash
83
+ npm install -g agentpager
84
+ agentpager service install # starts at login: macOS LaunchAgent, systemd user unit, Windows task
85
+ agentpager service uninstall
86
+ ```
87
+
88
+ ## Commands
89
+
90
+ | | |
91
+ |---|---|
92
+ | `agentpager` | pair (first run) and stay connected |
93
+ | `agentpager pair` | pair another phone |
94
+ | `agentpager devices` · `agentpager unpair <id>` | list or remove paired phones |
95
+ | `agentpager doctor` | which agents this computer can drive |
96
+ | `agentpager notify <text>` · `agentpager ask <text>` | page your phone from any script |
97
+
98
+ Requires Node 20+ and at least one agent signed in on this computer.
@@ -0,0 +1,311 @@
1
+ // Every other agent, through one integration: the Agent Client Protocol (ACP).
2
+ // Gemini CLI, OpenCode (which can run DeepSeek), Qwen Code and Goose all speak it, so
3
+ // AgentPager is not a Claude Code + Codex accessory — those two just have native adapters.
4
+ import { explainAgentError } from './explain.js';
5
+ import { execFile, spawn } from 'node:child_process';
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import { homedir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { Readable, Writable } from 'node:stream';
10
+ import { promisify } from 'node:util';
11
+ import { ClientSideConnection, ndJsonStream } from '@zed-industries/agent-client-protocol';
12
+ import { SHELL, clip, killTree, withDeadline } from './types.js';
13
+ import { readable } from '../risk.js';
14
+ const run = promisify(execFile);
15
+ const PROTOCOL_VERSION = 1;
16
+ const FLUSH_MS = 700;
17
+ /**
18
+ * Agents we know how to start over ACP.
19
+ *
20
+ * ⭐ Nothing here is trusted. Every entry is CHECKED before the phone ever sees it: the binary has
21
+ * to exist and then actually answer an ACP `initialize`. That matters because these commands and
22
+ * flags move — "--experimental-acp" is in the name of its own instability — and a list we merely
23
+ * believed would put agents on someone's phone that fail the moment they tap Start. A wrong guess
24
+ * here costs nothing: the agent simply never appears. So the list can be generous.
25
+ *
26
+ * `agentpager doctor` prints what was found AND what was tried, so a missing agent is diagnosable
27
+ * rather than mysterious. Anything we have not heard of goes in ~/.agentpager/agents.json — see
28
+ * `userAgents()`; "support MY agent" is the single most requested thing in this category and
29
+ * waiting for us to ship a release is the wrong answer to it.
30
+ */
31
+ export const ACP_AGENTS = [
32
+ { id: 'gemini', name: 'Gemini CLI', command: 'gemini', args: ['--experimental-acp'] },
33
+ { id: 'opencode', name: 'OpenCode', command: 'opencode', args: ['acp'] },
34
+ { id: 'qwen', name: 'Qwen Code', command: 'qwen', args: ['--experimental-acp'] },
35
+ { id: 'goose', name: 'Goose', command: 'goose', args: ['acp'] },
36
+ { id: 'cursor', name: 'Cursor CLI', command: 'cursor-agent', args: ['acp'] },
37
+ { id: 'copilot', name: 'GitHub Copilot CLI', command: 'copilot', args: ['acp'] },
38
+ { id: 'kiro', name: 'Kiro CLI', command: 'kiro', args: ['acp'] },
39
+ { id: 'cline', name: 'Cline', command: 'cline', args: ['acp'] },
40
+ { id: 'droid', name: 'Factory Droid', command: 'droid', args: ['acp'] },
41
+ { id: 'kimi', name: 'Kimi CLI', command: 'kimi', args: ['--acp'] },
42
+ { id: 'vibe', name: 'Mistral Vibe', command: 'vibe', args: ['acp'] },
43
+ { id: 'openhands', name: 'OpenHands', command: 'openhands', args: ['acp'] },
44
+ { id: 'pi', name: 'Pi', command: 'pi-acp', args: [] },
45
+ ];
46
+ /**
47
+ * Agents the user taught us about, from ~/.agentpager/agents.json:
48
+ * [{ "id": "mine", "name": "My Agent", "command": "my-agent", "args": ["acp"] }]
49
+ * Unreadable or malformed file: we say so and carry on with the built-in list, because a typo in an
50
+ * optional config must never cost someone the agents that do work.
51
+ */
52
+ export function userAgents(log) {
53
+ const file = join(homedir(), '.agentpager', 'agents.json');
54
+ if (!existsSync(file))
55
+ return [];
56
+ try {
57
+ const raw = JSON.parse(readFileSync(file, 'utf8'));
58
+ const list = Array.isArray(raw) ? raw : [];
59
+ const out = [];
60
+ for (const a of list) {
61
+ if (typeof a?.command !== 'string' || !a.command.trim())
62
+ continue;
63
+ out.push({
64
+ id: String(a.id ?? a.command).slice(0, 32),
65
+ name: String(a.name ?? a.command).slice(0, 48),
66
+ command: a.command,
67
+ args: Array.isArray(a.args) ? a.args.map(String) : [],
68
+ versionArgs: Array.isArray(a.versionArgs) ? a.versionArgs.map(String) : undefined,
69
+ });
70
+ }
71
+ if (out.length)
72
+ log?.(`${out.length} agent${out.length === 1 ? '' : 's'} from ${file}`);
73
+ return out;
74
+ }
75
+ catch (e) {
76
+ log?.(`ignoring ${file}: ${e?.message ?? e}`);
77
+ return [];
78
+ }
79
+ }
80
+ /** ACP labels the kind of work; that is enough to decide how much friction an approval needs. */
81
+ function riskOf(kind, title) {
82
+ switch (kind) {
83
+ case 'read':
84
+ case 'search':
85
+ case 'think':
86
+ case 'fetch':
87
+ return 'low';
88
+ case 'edit':
89
+ case 'move':
90
+ return 'edit';
91
+ case 'delete':
92
+ return 'danger';
93
+ case 'execute':
94
+ return /\b(rm|push|deploy|publish|reset --hard|force)\b/i.test(title) ? 'danger' : 'shell';
95
+ default:
96
+ return 'shell';
97
+ }
98
+ }
99
+ export class AcpAgent {
100
+ spec;
101
+ constructor(spec) {
102
+ this.spec = spec;
103
+ }
104
+ get id() {
105
+ return this.spec.id;
106
+ }
107
+ get name() {
108
+ return this.spec.name;
109
+ }
110
+ async status() {
111
+ const missing = { id: this.id, name: this.name, available: false, reason: `${this.name} is not installed on this computer` };
112
+ return withDeadline((async () => {
113
+ let version = '';
114
+ try {
115
+ const { stdout } = await run(this.spec.command, this.spec.versionArgs ?? ['--version'], { timeout: 8000, killSignal: 'SIGKILL', shell: SHELL });
116
+ version = stdout.trim().split('\n')[0].slice(0, 24);
117
+ }
118
+ catch {
119
+ return missing; // not installed: the ordinary case, and not worth a word
120
+ }
121
+ // Installed is not the same as usable. These ACP flags move between releases, so we ask the
122
+ // binary to answer a real `initialize` before offering it — otherwise the phone shows an
123
+ // agent that dies the moment someone taps Start.
124
+ if (!(await this.speaksAcp())) {
125
+ return {
126
+ id: this.id,
127
+ name: this.name,
128
+ available: false,
129
+ reason: `${this.name} is installed but did not answer as an ACP agent (\`${[this.spec.command, ...this.spec.args].join(' ')}\`). Updating it usually fixes this.`,
130
+ };
131
+ }
132
+ return { id: this.id, name: this.name, available: true, version };
133
+ })(), 15000, missing);
134
+ }
135
+ /** Start it, say hello, and see if anything says hello back. Killed either way. */
136
+ speaksAcp() {
137
+ return new Promise((resolve) => {
138
+ let proc;
139
+ try {
140
+ proc = spawn(this.spec.command, this.spec.args, { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, TERM: 'dumb' }, shell: SHELL });
141
+ }
142
+ catch {
143
+ return resolve(false);
144
+ }
145
+ let done = false;
146
+ const finish = (ok) => {
147
+ if (done)
148
+ return;
149
+ done = true;
150
+ clearTimeout(timer);
151
+ try {
152
+ killTree(proc);
153
+ }
154
+ catch {
155
+ /* already gone */
156
+ }
157
+ resolve(ok);
158
+ };
159
+ const timer = setTimeout(() => finish(false), 6000);
160
+ proc.on('error', () => finish(false));
161
+ proc.on('exit', () => finish(false));
162
+ proc.stderr.on('data', () => { });
163
+ let buf = '';
164
+ proc.stdout.on('data', (d) => {
165
+ buf += String(d);
166
+ // Any JSON-RPC reply to our id means something on the other end speaks the protocol.
167
+ if (buf.includes('"id":1') && (buf.includes('protocolVersion') || buf.includes('"result"')))
168
+ finish(true);
169
+ if (buf.length > 64_000)
170
+ finish(false);
171
+ });
172
+ try {
173
+ proc.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: PROTOCOL_VERSION, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } } } })}\n`);
174
+ }
175
+ catch {
176
+ finish(false);
177
+ }
178
+ });
179
+ }
180
+ /** ACP has no "list my past sessions" call; these agents start fresh from a project folder. */
181
+ async list() {
182
+ return [];
183
+ }
184
+ async history() {
185
+ return [];
186
+ }
187
+ async open(id, cwd, sink) {
188
+ const proc = spawn(this.spec.command, this.spec.args, {
189
+ cwd: cwd ?? process.cwd(),
190
+ stdio: ['pipe', 'pipe', 'pipe'],
191
+ env: { ...process.env, TERM: 'dumb' },
192
+ shell: SHELL,
193
+ });
194
+ proc.stderr.on('data', () => { }); // agents log freely to stderr; not ours to surface
195
+ let buffer = '';
196
+ let flushTimer;
197
+ const flush = () => {
198
+ if (flushTimer)
199
+ clearTimeout(flushTimer);
200
+ flushTimer = undefined;
201
+ const text = buffer;
202
+ buffer = '';
203
+ // Was sink.text(): every flush became a NEW bubble on the phone, so a streamed reply arrived
204
+ // as a wall of fragments instead of one message filling in. Not trimmed either — trimming a
205
+ // mid-sentence flush eats the space between "the" and "file".
206
+ if (text.trim())
207
+ sink.delta(text);
208
+ };
209
+ const chunk = (text) => {
210
+ buffer += text;
211
+ if (!flushTimer)
212
+ flushTimer = setTimeout(flush, FLUSH_MS);
213
+ };
214
+ const client = {
215
+ async requestPermission(params) {
216
+ const call = params.toolCall ?? {};
217
+ const title = String(call.title ?? 'Do something');
218
+ // Gemini CLI, OpenCode, Qwen and Goose all arrive here. Sending their raw JSON to a phone
219
+ // is asking someone to approve a wall of braces at a traffic light.
220
+ const detail = call.rawInput ? clip(readable(call.rawInput), 1200) : '';
221
+ flush();
222
+ const answer = await sink.ask({ tool: String(call.kind ?? 'tool'), summary: title, detail, risk: riskOf(call.kind, `${title} ${detail}`) });
223
+ const want = answer.decision === 'deny'
224
+ ? ['reject_once', 'reject_always']
225
+ : answer.decision === 'allow_session'
226
+ ? ['allow_always', 'allow_once']
227
+ : ['allow_once', 'allow_always'];
228
+ const option = want.map((k) => params.options.find((o) => o.kind === k)).find(Boolean) ?? params.options[0];
229
+ if (!option)
230
+ return { outcome: { outcome: 'cancelled' } };
231
+ return { outcome: { outcome: 'selected', optionId: option.optionId } };
232
+ },
233
+ async sessionUpdate(params) {
234
+ const u = params.update;
235
+ switch (u.sessionUpdate) {
236
+ case 'agent_message_chunk':
237
+ if (u.content?.type === 'text')
238
+ chunk(u.content.text);
239
+ break;
240
+ case 'tool_call':
241
+ flush();
242
+ sink.tool(String(u.kind ?? 'tool'), clip(String(u.title ?? ''), 300));
243
+ break;
244
+ case 'tool_call_update':
245
+ if (u.status === 'failed')
246
+ sink.state('failed');
247
+ break;
248
+ }
249
+ },
250
+ async writeTextFile() {
251
+ throw new Error('AgentPager does not give agents file access of its own');
252
+ },
253
+ async readTextFile() {
254
+ throw new Error('AgentPager does not give agents file access of its own');
255
+ },
256
+ };
257
+ const stream = ndJsonStream(Writable.toWeb(proc.stdin), Readable.toWeb(proc.stdout));
258
+ const conn = new ClientSideConnection(() => client, stream);
259
+ const init = await conn.initialize({
260
+ protocolVersion: PROTOCOL_VERSION,
261
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
262
+ });
263
+ let sessionId;
264
+ const canLoad = init?.agentCapabilities?.loadSession === true;
265
+ if (id && canLoad) {
266
+ await conn.loadSession({ sessionId: id, cwd: cwd ?? process.cwd(), mcpServers: [] });
267
+ sessionId = id;
268
+ }
269
+ else {
270
+ const created = await conn.newSession({ cwd: cwd ?? process.cwd(), mcpServers: [] });
271
+ sessionId = created.sessionId;
272
+ sink.identified(sessionId);
273
+ }
274
+ proc.on('exit', () => {
275
+ flush();
276
+ sink.error(`${this.name} stopped on this computer.`);
277
+ });
278
+ let usedTokens = 0;
279
+ return {
280
+ send: (text) => {
281
+ sink.state('running');
282
+ conn
283
+ .prompt({ sessionId, prompt: [{ type: 'text', text }] })
284
+ .then((res) => {
285
+ flush();
286
+ // ACP's (still unstable) per-turn usage. Not every agent sends it; when one does, the
287
+ // numbers are added up here so the hub sees a running total.
288
+ const u = res?.usage;
289
+ const turnTokens = Number(u?.totalTokens ?? u?.total_tokens ?? 0);
290
+ if (Number.isFinite(turnTokens) && turnTokens > 0) {
291
+ usedTokens += turnTokens;
292
+ sink.usage({ tokens: usedTokens });
293
+ }
294
+ const stop = res?.stopReason;
295
+ sink.done(stop === 'end_turn' || stop === 'max_tokens', stop === 'refusal' ? 'The agent refused this request.' : '');
296
+ })
297
+ .catch((e) => {
298
+ flush();
299
+ const x = explainAgentError(e, this.name);
300
+ sink.error(x.message, x.code);
301
+ });
302
+ },
303
+ interrupt: async () => {
304
+ await conn.cancel({ sessionId }).catch(() => { });
305
+ },
306
+ close: () => {
307
+ killTree(proc);
308
+ },
309
+ };
310
+ }
311
+ }