@cosmovex/agentpager 0.1.4 → 0.1.6

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.
@@ -0,0 +1,67 @@
1
+ // "Ping me on AgentPager when you're done" — teach the agents on this computer that they can.
2
+ // `agentpager notify` already pages the phone from anything that can run a shell command; what was
3
+ // missing is that Claude Code, Codex and Gemini do not know the command exists. One short, marked
4
+ // block in each agent's own instruction file fixes that, and the markers make it idempotent and
5
+ // removable without touching a word the user wrote.
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { homedir } from 'node:os';
8
+ import { dirname, join } from 'node:path';
9
+ const BEGIN = '<!-- agentpager:begin -->';
10
+ const END = '<!-- agentpager:end -->';
11
+ export const INSTRUCTION = `${BEGIN}
12
+ ## AgentPager (the user's phone pager)
13
+ If the user says "ping me on agentpager", "tell me on my phone" or similar, then when you finish
14
+ (or when you need their decision) run \`agentpager notify "<one-line summary, under 100 characters>"\`.
15
+ When you need a yes/no before something risky, run \`agentpager ask "<question>"\`: it exits 0 if they
16
+ approve, 1 if they deny, 2 if nobody answered, so treat 2 as "do not proceed".
17
+ If \`agentpager\` is not installed, say so instead of failing silently.
18
+ ${END}
19
+ `;
20
+ export function targets(home = process.env.HOME || homedir()) {
21
+ const t = (agent, ...parts) => {
22
+ const file = join(home, ...parts);
23
+ return { agent, file, dir: dirname(file) };
24
+ };
25
+ return [
26
+ t('Claude Code', '.claude', 'CLAUDE.md'),
27
+ t('Codex', '.codex', 'AGENTS.md'),
28
+ t('Gemini CLI', '.gemini', 'GEMINI.md'),
29
+ t('OpenCode', '.config', 'opencode', 'AGENTS.md'),
30
+ ];
31
+ }
32
+ function strip(text) {
33
+ const a = text.indexOf(BEGIN);
34
+ const b = text.indexOf(END);
35
+ if (a < 0 || b < a)
36
+ return text;
37
+ // Trim what the block left around it, so add → remove gives back the original bytes and
38
+ // add → add gives back the same file.
39
+ const rest = (text.slice(0, a) + text.slice(b + END.length)).replace(/\s+$/, '');
40
+ return rest ? `${rest}\n` : '';
41
+ }
42
+ export function setup(all = targets()) {
43
+ return all.map((t) => {
44
+ if (!existsSync(t.dir))
45
+ return { agent: t.agent, file: t.file, outcome: 'not-installed' };
46
+ const old = existsSync(t.file) ? readFileSync(t.file, 'utf8') : '';
47
+ const had = old.includes(BEGIN);
48
+ const base = strip(old);
49
+ const next = base ? `${base.replace(/\s+$/, '')}\n\n${INSTRUCTION}` : INSTRUCTION;
50
+ if (next === old)
51
+ return { agent: t.agent, file: t.file, outcome: 'unchanged' };
52
+ mkdirSync(t.dir, { recursive: true });
53
+ writeFileSync(t.file, next);
54
+ return { agent: t.agent, file: t.file, outcome: had ? 'updated' : 'added' };
55
+ });
56
+ }
57
+ export function remove(all = targets()) {
58
+ return all.map((t) => {
59
+ if (!existsSync(t.file))
60
+ return { agent: t.agent, file: t.file, outcome: 'absent' };
61
+ const old = readFileSync(t.file, 'utf8');
62
+ if (!old.includes(BEGIN))
63
+ return { agent: t.agent, file: t.file, outcome: 'absent' };
64
+ writeFileSync(t.file, strip(old));
65
+ return { agent: t.agent, file: t.file, outcome: 'removed' };
66
+ });
67
+ }
package/dist/cli.js CHANGED
@@ -21,7 +21,9 @@ import { staleBuildWarning } from './freshness.js';
21
21
  import { runRuleCommand, USAGE as GUARD_USAGE } from './guard/cli.js';
22
22
  import { install as guardInstall, runDeskHook, runHook, status as guardStatus, uninstall as guardUninstall } from './guard/index.js';
23
23
  import { Channel } from './relay.js';
24
- import { installService, uninstallService } from './service.js';
24
+ import { remove as agentsRemove, setup as agentsSetup } from './agentsetup.js';
25
+ import { serveMcp } from './mcp.js';
26
+ import { installService, serviceInstalled, uninstallService } from './service.js';
25
27
  import { loadState, updateState } from './store.js';
26
28
  import { isNewer, latestVersion, UPDATE_COMMAND } from './update.js';
27
29
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
@@ -41,6 +43,34 @@ function printPhones(devices, indent = ' ') {
41
43
  console.log(`${indent}${bold(d.name)} ${dim(`id ${d.id} · paired ${new Date(d.pairedAt).toLocaleDateString()}`)}`);
42
44
  }
43
45
  }
46
+ const HELP = `
47
+ ${bold('AgentPager')} — your phone, one tap from your coding agents.
48
+
49
+ ${bold('Everyday')}
50
+ agentpager start it (keep the window open)
51
+ agentpager stop stop it
52
+ agentpager status is it running, who is paired, is there an update
53
+
54
+ ${bold('Phones')}
55
+ agentpager pair add a phone (or the same one after a reinstall)
56
+ agentpager devices who is paired
57
+ agentpager unpair remove a phone
58
+
59
+ ${bold('Always on')}
60
+ agentpager service install start by itself at login
61
+ agentpager service uninstall turn that off
62
+
63
+ ${bold('Let your AI agents ping you')}
64
+ agentpager setup-agents teach Claude, Codex, Gemini... to say "done" on your phone
65
+ agentpager setup-agents --remove
66
+ agentpager mcp the same as MCP tools (Cursor, Claude Desktop)
67
+
68
+ ${bold('For scripts')}
69
+ agentpager notify "text" page your phone
70
+ agentpager ask "question" wait for your yes or no
71
+
72
+ More: agentpager doctor · agentpager guard
73
+ `;
44
74
  const UNPAIR_HINT = 'Remove a phone: `agentpager unpair <id>` (or `agentpager unpair --all`). List them: `agentpager devices`.';
45
75
  async function pair(state) {
46
76
  // Re-read: this snapshot may be as old as the terminal. "Paired" with no way to see what, or to
@@ -172,6 +202,7 @@ async function serve(state, keepAwake) {
172
202
  }
173
203
  }
174
204
  }, 2000);
205
+ hub.onStop = () => void quit();
175
206
  hub.onUnpair = (ch) => {
176
207
  // The phone forgot us in the app: drop it from disk first, then close the channel.
177
208
  updateState((s) => {
@@ -202,16 +233,14 @@ async function serve(state, keepAwake) {
202
233
  if (process.argv[1]?.includes('_npx')) {
203
234
  // Started with npx: this window IS AgentPager. Say so before someone closes it.
204
235
  console.log(`\n${bold('Keep this window open.')} Closing it (or Ctrl+C) disconnects your phone until you start it again.`);
205
- console.log(dim('Want it always on, even after a restart? npm install -g @cosmovex/agentpager then: agentpager service install'));
236
+ console.log(dim('Always on, even after a restart: npm install -g @cosmovex/agentpager, then agentpager service install'));
206
237
  }
207
238
  else {
208
239
  console.log(dim('\nKeep this running (or run `agentpager service install` to start it at login).'));
209
240
  }
210
241
  // Someone who reinstalls the app, or picks up a second phone, reasonably runs `agentpager` again
211
242
  // and waits for a code that never comes — this is the only place they will look for why.
212
- console.log(dim('Pairing another phone (or the same one after a reinstall)? Run `agentpager pair`.'));
213
- console.log(dim(UNPAIR_HINT));
214
- console.log(dim('Your sessions stay on this computer; the relay only sees encrypted bytes.\n'));
243
+ console.log(dim('Need anything else? Run `agentpager help`.\n'));
215
244
  // Tell the terminal AND the phone when a newer AgentPager is out.
216
245
  const checkUpdate = async () => {
217
246
  const latest = await latestVersion();
@@ -313,6 +342,51 @@ async function main() {
313
342
  console.log(dim(running ? 'The running AgentPager drops it within a couple of seconds and tells the phone.' : 'Nothing is running here, so it is gone for good.'));
314
343
  process.exit(0);
315
344
  }
345
+ case 'setup-agents': {
346
+ const results = arg === '--remove' ? agentsRemove() : agentsSetup();
347
+ const say = {
348
+ added: 'added', updated: 'updated', unchanged: 'already set up', removed: 'removed', absent: 'nothing to remove', 'not-installed': 'not installed here, skipped',
349
+ };
350
+ for (const r of results)
351
+ console.log(`${r.outcome === 'not-installed' || r.outcome === 'absent' ? '○' : '●'} ${r.agent} ${dim(say[r.outcome])}`);
352
+ if (arg !== '--remove' && results.some((r) => ['added', 'updated', 'unchanged'].includes(r.outcome))) {
353
+ console.log(`\nNow tell any of them ${bold('"ping me on agentpager when you are done"')} and your phone will buzz.`);
354
+ console.log(dim('Assistants without a shell (Cursor, Claude Desktop): add an MCP server that runs `agentpager mcp`.'));
355
+ }
356
+ else if (arg !== '--remove') {
357
+ console.log('\nNo supported agent found. Any script can still use `agentpager notify "text"`.');
358
+ }
359
+ process.exit(0);
360
+ }
361
+ case 'mcp':
362
+ serveMcp();
363
+ return; // keep the process alive: stdin drives it
364
+ case 'help':
365
+ case '--help':
366
+ case '-h':
367
+ console.log(HELP);
368
+ process.exit(0);
369
+ case 'status': {
370
+ const up = await callControl({ kind: 'ping', title: '', text: '' }, 1500).catch(() => null);
371
+ console.log(up ? `● Running on ${bold(state.machineName)}` : `○ Not running. Start it: ${bold('agentpager')} (keep the window open)`);
372
+ console.log(` ${state.devices.length} phone${state.devices.length === 1 ? '' : 's'} paired${state.devices.length ? ':' : ''}`);
373
+ printPhones(state.devices, ' ');
374
+ console.log(` Starts by itself at login: ${serviceInstalled() ? 'yes' : 'no'}`);
375
+ const latest = await latestVersion();
376
+ console.log(latest && isNewer(latest, BRIDGE_VERSION) ? ` ⬆️ ${latest} is available (you have ${BRIDGE_VERSION}): ${bold(UPDATE_COMMAND)}` : ` Version ${BRIDGE_VERSION}, up to date`);
377
+ process.exit(0);
378
+ }
379
+ case 'stop': {
380
+ const up = await callControl({ kind: 'stop', title: '', text: '' }, 3000).catch(() => null);
381
+ if (!up)
382
+ console.log('AgentPager is not running.');
383
+ else {
384
+ console.log('Stopped. Your phone will say so.');
385
+ if (serviceInstalled())
386
+ console.log(dim('It is set to start at login and may restart by itself. To turn that off: agentpager service uninstall'));
387
+ }
388
+ process.exit(0);
389
+ }
316
390
  case 'notify':
317
391
  case 'ask': {
318
392
  // Parse flags properly: a flag's VALUE must not leak into the message body.
@@ -443,7 +517,7 @@ async function main() {
443
517
  process.exit(0);
444
518
  }
445
519
  default:
446
- console.log('Usage: agentpager [start | pair | devices | unpair <id> | doctor | guard install|status|rules|test | notify <text> | ask <text> | service install|uninstall]');
520
+ console.log(HELP);
447
521
  process.exit(1);
448
522
  }
449
523
  }
package/dist/hub.js CHANGED
@@ -60,6 +60,8 @@ export class Hub {
60
60
  detach(channel) {
61
61
  this.channels = this.channels.filter((c) => c !== channel);
62
62
  }
63
+ /** `agentpager stop` from another terminal. */
64
+ onStop;
63
65
  /** Called with the phone that asked to be unpaired; the CLI owns the state file. */
64
66
  onUnpair;
65
67
  /** Newest version on npm, once known. Sent with every hello so the phone can say "update". */
@@ -343,6 +345,10 @@ export class Hub {
343
345
  };
344
346
  if (req.kind === 'ping')
345
347
  return { decision: 'allow', ...reach };
348
+ if (req.kind === 'stop') {
349
+ setTimeout(() => this.onStop?.(), 150); // answer first, then go
350
+ return { decision: 'allow', ...reach };
351
+ }
346
352
  // Nobody to ask is not a denial and not a wait — it's a setup problem, and the script must
347
353
  // learn that in milliseconds, not after an hour of blocking a deploy.
348
354
  if (!reach.phones)
package/dist/mcp.js ADDED
@@ -0,0 +1,100 @@
1
+ // `agentpager mcp` — the same two things `notify` and `ask` do, as MCP tools, for the assistants
2
+ // that have no shell to run a command in (Claude Desktop, Cursor, …). A hand-rolled stdio JSON-RPC
3
+ // loop: the surface is three methods, and a dependency for that would be more code than this file.
4
+ import { createInterface } from 'node:readline';
5
+ import { callControl } from './control.js';
6
+ export const TOOLS = [
7
+ {
8
+ name: 'ping_phone',
9
+ description: "Send a short notification to the user's phone through AgentPager. Use it when the user asked to be pinged, on finishing a long task or when you are blocked.",
10
+ inputSchema: {
11
+ type: 'object',
12
+ properties: { message: { type: 'string', description: 'One line, under 100 characters.' } },
13
+ required: ['message'],
14
+ },
15
+ },
16
+ {
17
+ name: 'ask_phone',
18
+ description: "Ask the user a yes/no question on their phone and wait for the answer. Returns 'approved', 'denied' or 'no answer'. Treat 'no answer' as do not proceed.",
19
+ inputSchema: {
20
+ type: 'object',
21
+ properties: {
22
+ question: { type: 'string' },
23
+ timeout_seconds: { type: 'number', description: 'How long to wait. Default 600.' },
24
+ },
25
+ required: ['question'],
26
+ },
27
+ },
28
+ ];
29
+ const text = (t, isError = false) => ({ content: [{ type: 'text', text: t }], ...(isError ? { isError: true } : {}) });
30
+ export async function callTool(name, args, call = (r) => callControl(r)) {
31
+ try {
32
+ if (name === 'ping_phone') {
33
+ const message = String(args?.message ?? '').trim();
34
+ if (!message)
35
+ return text('message is required', true);
36
+ const res = await call({ kind: 'notify', title: 'Job finished', text: message, cwd: process.cwd() });
37
+ return res.phones ? text(`Sent to ${res.phone ?? 'the phone'}.`) : text('No phone is paired. Ask the user to run `agentpager pair`.', true);
38
+ }
39
+ if (name === 'ask_phone') {
40
+ const question = String(args?.question ?? '').trim();
41
+ if (!question)
42
+ return text('question is required', true);
43
+ const secs = Number(args?.timeout_seconds);
44
+ const res = await call({
45
+ kind: 'ask',
46
+ title: question.slice(0, 60),
47
+ text: question,
48
+ cwd: process.cwd(),
49
+ timeoutMs: Number.isFinite(secs) && secs > 0 ? secs * 1000 : undefined,
50
+ });
51
+ if (!res.phones)
52
+ return text('No phone is paired. Ask the user to run `agentpager pair`.', true);
53
+ return text(res.decision === 'allow' ? 'approved' : res.decision === 'deny' ? 'denied' : 'no answer');
54
+ }
55
+ return text(`unknown tool ${name}`, true);
56
+ }
57
+ catch (e) {
58
+ return text(String(e?.message ?? e), true);
59
+ }
60
+ }
61
+ /** One JSON-RPC message in, the reply out (or undefined for a notification). */
62
+ export async function handle(msg, call) {
63
+ const reply = (result) => ({ jsonrpc: '2.0', id: msg.id, result });
64
+ switch (msg.method) {
65
+ case 'initialize':
66
+ return reply({
67
+ protocolVersion: msg.params?.protocolVersion ?? '2024-11-05',
68
+ capabilities: { tools: {} },
69
+ serverInfo: { name: 'agentpager', version: '1' },
70
+ });
71
+ case 'tools/list':
72
+ return reply({ tools: TOOLS });
73
+ case 'tools/call':
74
+ return reply(await callTool(msg.params?.name, msg.params?.arguments, call));
75
+ case 'ping':
76
+ return reply({});
77
+ default:
78
+ if (msg.id === undefined)
79
+ return undefined; // notifications/initialized and friends
80
+ return { jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: `Method not found: ${msg.method}` } };
81
+ }
82
+ }
83
+ export function serveMcp() {
84
+ const rl = createInterface({ input: process.stdin });
85
+ rl.on('line', async (line) => {
86
+ if (!line.trim())
87
+ return;
88
+ let msg;
89
+ try {
90
+ msg = JSON.parse(line);
91
+ }
92
+ catch {
93
+ return;
94
+ }
95
+ const out = await handle(msg);
96
+ if (out)
97
+ process.stdout.write(JSON.stringify(out) + '\n');
98
+ });
99
+ rl.on('close', () => process.exit(0));
100
+ }
package/dist/service.js CHANGED
@@ -115,3 +115,21 @@ export function uninstallService() {
115
115
  }
116
116
  return 'Nothing to remove.';
117
117
  }
118
+ /** Is the start-at-login service set up? Cheap and offline: it only looks for the file we wrote. */
119
+ export function serviceInstalled() {
120
+ const os = platform();
121
+ if (os === 'darwin')
122
+ return existsSync(join(homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`));
123
+ if (os === 'linux')
124
+ return existsSync(join(homedir(), '.config', 'systemd', 'user', 'agentpager.service'));
125
+ if (os === 'win32') {
126
+ try {
127
+ execFileSync('schtasks', ['/Query', '/TN', 'AgentPager'], { stdio: 'ignore' });
128
+ return true;
129
+ }
130
+ catch {
131
+ return false;
132
+ }
133
+ }
134
+ return false;
135
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmovex/agentpager",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Pager for your AI coding agents — approve, reply and hear results on your phone. End-to-end encrypted.",
5
5
  "main": "dist/cli.js",
6
6
  "scripts": {