@cosmovex/agentpager 0.1.2 → 0.1.3

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/dist/cli.js CHANGED
@@ -22,7 +22,8 @@ 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
24
  import { installService, uninstallService } from './service.js';
25
- import { loadState, saveState, updateState } from './store.js';
25
+ import { loadState, updateState } from './store.js';
26
+ import { isNewer, latestVersion, UPDATE_COMMAND } from './update.js';
26
27
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
27
28
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
28
29
  const log = (line) => console.log(`${dim(new Date().toLocaleTimeString())} ${line}`);
@@ -34,7 +35,22 @@ function duration(v) {
34
35
  const n = Number(m[1]);
35
36
  return n * (m[2] === 'h' ? 3600_000 : m[2] === 'm' ? 60_000 : 1000);
36
37
  }
38
+ /** One line per paired phone, with the id `unpair` wants. Never leaves someone guessing what is paired. */
39
+ function printPhones(devices, indent = ' ') {
40
+ for (const d of devices) {
41
+ console.log(`${indent}${bold(d.name)} ${dim(`id ${d.id} · paired ${new Date(d.pairedAt).toLocaleDateString()}`)}`);
42
+ }
43
+ }
44
+ const UNPAIR_HINT = 'Remove a phone: `agentpager unpair <id>` (or `agentpager unpair --all`). List them: `agentpager devices`.';
37
45
  async function pair(state) {
46
+ // Re-read: this snapshot may be as old as the terminal. "Paired" with no way to see what, or to
47
+ // undo it, is what made setup feel like a trap.
48
+ const current = loadState().devices;
49
+ if (current.length) {
50
+ console.log(`\n${bold(`Already paired on ${state.machineName}:`)}`);
51
+ printPhones(current);
52
+ console.log(dim(`${UNPAIR_HINT}\nContinuing adds one more phone.`));
53
+ }
38
54
  const offer = offerPairing(state);
39
55
  console.log(`\n${bold('Pair your phone')} — in the AgentPager app tap "Scan the code" (or point your phone camera here):\n`);
40
56
  qrcode.generate(offer.link, { small: true });
@@ -45,6 +61,10 @@ async function pair(state) {
45
61
  updateState((s) => s.devices.push(device));
46
62
  state.devices.push(device); // keep the in-memory copy usable for a caller that goes on to serve
47
63
  console.log(`\n✅ Paired with ${bold(device.name)}.\n`);
64
+ const all = loadState().devices;
65
+ console.log(`${bold(`Paired phones (${all.length}):`)}`);
66
+ printPhones(all);
67
+ console.log(dim(`${UNPAIR_HINT}\n`));
48
68
  // Pairing does not make this computer reachable — serving does. Standalone `agentpager pair`
49
69
  // printed this tick and exited, leaving the phone attached to nothing: it showed "waiting for the
50
70
  // computer to say which agents it has", then "computer is offline". Both were true, and neither
@@ -87,37 +107,78 @@ async function serve(state, keepAwake) {
87
107
  ['codex', codex],
88
108
  ]);
89
109
  const hub = new Hub(agents, state.machineName, log, keepAwake);
90
- const persist = () => saveState(state);
91
- const channels = state.devices.map((d) => new Channel(d, persist, (msg, ch) => hub.handle(msg, ch)));
92
- for (const c of channels) {
93
- hub.attach(c);
94
- c.start();
95
- }
96
- // `agentpager pair` (a SEPARATE process, e.g. re-pairing after a reinstall while this one keeps
97
- // running) writes the new device straight to disk. Without this, that pairing succeeds — the phone
98
- // gets its "ok" — and then never connects, because this process's channel list was built once at
99
- // startup and nothing ever told it a new device exists. The phone just sits on "Connecting…"
100
- // forever with no way to know a restart was the missing step. Poll instead of a restart: cheap,
101
- // and it means pairing a second (or reinstalled) phone never requires touching this window.
102
- setInterval(() => {
103
- let onDisk;
104
- try {
105
- onDisk = loadState();
110
+ // Persist only what THIS process owns (sequence numbers, push tokens) and merge it into the file
111
+ // as it is now. Saving our startup snapshot would resurrect a phone that `agentpager unpair`
112
+ // just removed — a revoked credential coming back to life.
113
+ const channels = new Map();
114
+ let dirty = false;
115
+ const flush = () => {
116
+ if (!dirty)
117
+ return;
118
+ dirty = false;
119
+ updateState((s) => {
120
+ for (const d of s.devices) {
121
+ const live = channels.get(d.id)?.device;
122
+ if (live)
123
+ Object.assign(d, { sendSeq: live.sendSeq, recvSeq: live.recvSeq, pushToken: live.pushToken });
124
+ }
125
+ });
126
+ };
127
+ let flushTimer;
128
+ const persist = () => {
129
+ dirty = true;
130
+ flushTimer ??= setTimeout(() => {
131
+ flushTimer = undefined;
132
+ flush();
133
+ }, 250);
134
+ };
135
+ const connect = (d) => {
136
+ if (channels.has(d.id))
137
+ return;
138
+ const ch = new Channel(d, persist, (msg, c) => hub.handle(msg, c));
139
+ channels.set(d.id, ch);
140
+ hub.attach(ch);
141
+ ch.start();
142
+ };
143
+ const disconnect = async (id, told) => {
144
+ const ch = channels.get(id);
145
+ if (!ch)
146
+ return;
147
+ channels.delete(id);
148
+ hub.detach(ch);
149
+ if (told) {
150
+ ch.send({ type: 'unpaired', machine: state.machineName });
151
+ await new Promise((r) => setTimeout(r, 1500)); // let the message leave before the channel closes
106
152
  }
107
- catch {
108
- return; // mid-write on another process; try again next tick
153
+ await ch.stop();
154
+ };
155
+ for (const d of state.devices)
156
+ connect(d);
157
+ // `agentpager unpair` / `agentpager pair` in ANOTHER terminal edit the file. Without this, an
158
+ // unpaired phone stayed connected (and showed "online") until this window was restarted, and a
159
+ // newly paired one got nothing until then too.
160
+ const watch = setInterval(() => {
161
+ const now = loadState().devices;
162
+ for (const id of [...channels.keys()]) {
163
+ if (!now.some((d) => d.id === id)) {
164
+ log(`unpaired: ${channels.get(id)?.deviceName}`);
165
+ void disconnect(id, true);
166
+ }
109
167
  }
110
- const known = new Set(state.devices.map((d) => d.id));
111
- for (const d of onDisk.devices) {
112
- if (known.has(d.id))
113
- continue;
114
- state.devices.push(d);
115
- const c = new Channel(d, persist, (msg, ch) => hub.handle(msg, ch));
116
- hub.attach(c);
117
- c.start();
118
- log(`paired while running: ${d.name}`);
168
+ for (const d of now) {
169
+ if (!channels.has(d.id)) {
170
+ log(`paired while running: ${d.name}`);
171
+ connect(d);
172
+ }
119
173
  }
120
- }, 5000);
174
+ }, 2000);
175
+ hub.onUnpair = (ch) => {
176
+ // The phone forgot us in the app: drop it from disk first, then close the channel.
177
+ updateState((s) => {
178
+ s.devices = s.devices.filter((d) => d.id !== ch.device.id);
179
+ });
180
+ void disconnect(ch.device.id, false);
181
+ };
121
182
  const stopControl = serveControl((req) => hub.job(req));
122
183
  // Detect the ACP agents in the background and announce them as they appear.
123
184
  void (async () => {
@@ -136,18 +197,33 @@ async function serve(state, keepAwake) {
136
197
  console.log(` ⚠️ ${stale}`);
137
198
  for (const s of statuses)
138
199
  console.log(` ${s.available ? '●' : '○'} ${s.name}${s.version ? ` ${dim(s.version)}` : ''}${s.available ? '' : dim(` ${s.reason}`)}`);
139
- console.log(` ${state.devices.length} phone${state.devices.length === 1 ? '' : 's'} paired`);
200
+ console.log(` ${state.devices.length} phone${state.devices.length === 1 ? '' : 's'} paired:`);
201
+ printPhones(state.devices, ' ');
140
202
  console.log(dim('\nKeep this running (or run `agentpager service install` to start it at login).'));
141
203
  // Someone who reinstalls the app, or picks up a second phone, reasonably runs `agentpager` again
142
204
  // and waits for a code that never comes — this is the only place they will look for why.
143
205
  console.log(dim('Pairing another phone (or the same one after a reinstall)? Run `agentpager pair`.'));
206
+ console.log(dim(UNPAIR_HINT));
144
207
  console.log(dim('Your sessions stay on this computer; the relay only sees encrypted bytes.\n'));
208
+ // Tell the terminal AND the phone when a newer AgentPager is out.
209
+ const checkUpdate = async () => {
210
+ const latest = await latestVersion();
211
+ if (latest && isNewer(latest, BRIDGE_VERSION)) {
212
+ console.log(`\n⬆️ ${bold(`AgentPager ${latest} is available`)} (you have ${BRIDGE_VERSION}). Update: ${bold(UPDATE_COMMAND)}, then restart.\n`);
213
+ await hub.setLatest(latest);
214
+ }
215
+ };
216
+ void checkUpdate();
217
+ const updateTimer = setInterval(() => void checkUpdate(), 12 * 3600_000);
145
218
  const quit = async () => {
146
219
  console.log(dim('\nDisconnecting…'));
147
220
  hub.shutdown();
148
221
  stopControl();
149
222
  codex.shutdown();
150
- await Promise.all(channels.map((c) => c.stop()));
223
+ clearInterval(watch);
224
+ clearInterval(updateTimer);
225
+ flush();
226
+ await Promise.all([...channels.values()].map((c) => c.stop()));
151
227
  process.exit(0);
152
228
  };
153
229
  process.on('SIGINT', quit);
@@ -195,16 +271,36 @@ async function main() {
195
271
  case 'devices':
196
272
  if (!state.devices.length)
197
273
  console.log('No phones paired. Run `agentpager pair`.');
198
- for (const d of state.devices)
199
- console.log(`${d.id} ${d.name} ${dim(`paired ${new Date(d.pairedAt).toLocaleDateString()}`)}`);
274
+ else {
275
+ console.log(bold(`${state.devices.length} phone${state.devices.length === 1 ? '' : 's'} paired with ${state.machineName}:`));
276
+ printPhones(state.devices);
277
+ console.log(dim(`\n${UNPAIR_HINT}`));
278
+ }
200
279
  process.exit(0);
201
280
  case 'unpair': {
202
- const before = state.devices.length;
203
- const after = updateState((s) => {
204
- s.devices = s.devices.filter((d) => d.id !== arg);
281
+ if (!arg) {
282
+ console.log('Usage: agentpager unpair <id | name> | --all\n');
283
+ if (state.devices.length)
284
+ printPhones(state.devices, '');
285
+ else
286
+ console.log('No phones are paired.');
287
+ process.exit(1);
288
+ }
289
+ const q = arg.toLowerCase();
290
+ const hit = (d) => arg === '--all' || d.id === arg || d.name.toLowerCase() === q;
291
+ const removed = state.devices.filter(hit);
292
+ if (!removed.length) {
293
+ console.log(`No phone matches "${arg}". Paired phones:`);
294
+ printPhones(state.devices, ' ');
295
+ process.exit(1);
296
+ }
297
+ updateState((s) => {
298
+ s.devices = s.devices.filter((d) => !hit(d));
205
299
  });
206
- state.devices = after.devices;
207
- console.log(before === state.devices.length ? `No phone with id ${arg}.` : 'Removed. That phone can no longer reach this computer.');
300
+ for (const d of removed)
301
+ console.log(`Removed ${bold(d.name)}. It can no longer reach this computer.`);
302
+ const running = await bridgeRunning();
303
+ 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.'));
208
304
  process.exit(0);
209
305
  }
210
306
  case 'notify':
@@ -204,7 +204,7 @@ function HEADLESS_WAYS_OUT(rule) {
204
204
  /**
205
205
  * Running from npx's throwaway cache?
206
206
  *
207
- * `npx agentpager` is the right way to try this — it needs no commitment and leaves nothing behind.
207
+ * `npx @cosmovex/agentpager` is the right way to try this — it needs no commitment and leaves nothing behind.
208
208
  * But "leaves nothing behind" is fatal for a hook: the absolute path we register would point into a
209
209
  * directory npx is free to delete, and the guard would stop running without ever saying so. Rules
210
210
  * need the package to still exist tomorrow.
@@ -217,7 +217,7 @@ export function install() {
217
217
  console.log('✖ This copy is running from npx\u2019s temporary cache, which gets deleted.');
218
218
  console.log(' A hook pointing there would stop working silently, so it is not written.');
219
219
  console.log(' Install it properly first, then rules will stick:');
220
- console.log(' npm install -g agentpager');
220
+ console.log(' npm install -g @cosmovex/agentpager');
221
221
  console.log(' agentpager-guard install');
222
222
  process.exitCode = 1;
223
223
  return;
package/dist/hub.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
1
3
  // Routes phone messages to agents and agent events back to every paired phone.
2
4
  // Margin rules live here: stream text only to a phone someone is looking at; approvals and
3
5
  // "done" always go out (those are what make the app worth having), and they are tiny.
@@ -7,10 +9,21 @@ import { random, b64u } from './crypto.js';
7
9
  import { record as recordLimits } from './guard/limits.js';
8
10
  import { PROTOCOL } from './protocol.js';
9
11
  import { addRoot, allowedCwd, browseFolders, discoverProjects, MAX_PHONE_FILE_BYTES, readFileForPhone, writeUploadedFile } from './projects.js';
12
+ import { isNewer } from './update.js';
10
13
  import { AwakeLock } from './awake.js';
11
14
  import { deskActive, pruneDesk } from './guard/desk.js';
12
15
  import { loadRules, PRESETS, saveRules, validateRules } from './guard/rules.js';
13
- export const BRIDGE_VERSION = '0.1.0';
16
+ // Read from package.json, never typed twice: this constant sat at 0.1.0 while npm had 0.1.2, and
17
+ // the update check would have told every user, for ever, that the version they run is out of date.
18
+ export const BRIDGE_VERSION = (() => {
19
+ try {
20
+ const pkg = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'));
21
+ return String(pkg.version);
22
+ }
23
+ catch {
24
+ return '0.0.0';
25
+ }
26
+ })();
14
27
  const FLUSH_MS = 400;
15
28
  // A script that asks and is never answered must give up on its own. Ten minutes is long enough to
16
29
  // walk back from the kitchen and short enough that a CI job doesn't sit on a runner all night.
@@ -43,6 +56,21 @@ export class Hub {
43
56
  attach(channel) {
44
57
  this.channels.push(channel);
45
58
  }
59
+ /** Stop talking to a phone. Anything it still has pending stays pending; it can no longer answer. */
60
+ detach(channel) {
61
+ this.channels = this.channels.filter((c) => c !== channel);
62
+ }
63
+ /** Called with the phone that asked to be unpaired; the CLI owns the state file. */
64
+ onUnpair;
65
+ /** Newest version on npm, once known. Sent with every hello so the phone can say "update". */
66
+ latest;
67
+ async setLatest(v) {
68
+ if (!v || v === this.latest)
69
+ return;
70
+ this.latest = v;
71
+ for (const c of this.channels)
72
+ await this.hello(c);
73
+ }
46
74
  /** Agents detected after startup (probing them must never delay the relay). */
47
75
  async addAgent(agent) {
48
76
  this.agents.set(agent.id, agent);
@@ -69,6 +97,9 @@ export class Hub {
69
97
  switch (msg.type) {
70
98
  case 'hello':
71
99
  return await this.hello(from);
100
+ case 'unpair':
101
+ this.log(`${from.deviceName} unpaired itself from the app`);
102
+ return this.onUnpair?.(from);
72
103
  case 'list':
73
104
  return from.send({ type: 'sessions', items: await this.list() });
74
105
  case 'open':
@@ -263,6 +294,7 @@ export class Hub {
263
294
  protocol: PROTOCOL,
264
295
  agents: await this.statuses(),
265
296
  projects: await this.offerProjects(),
297
+ ...(this.latest && isNewer(this.latest, BRIDGE_VERSION) ? { latest: this.latest } : {}),
266
298
  });
267
299
  // A phone that reconnects must see every approval still waiting — never miss one.
268
300
  for (const live of this.live.values()) {
package/dist/service.js CHANGED
@@ -16,14 +16,14 @@ function run(cmd, args) {
16
16
  /** npx runs from a throwaway cache; a service pointing there breaks on the next cache clean. */
17
17
  function assertStableInstall() {
18
18
  if (/[\\/]_npx[\\/]/.test(cli)) {
19
- throw new Error('Install it permanently first: npm install -g agentpager then run: agentpager service install');
19
+ throw new Error('Install it permanently first: npm install -g @cosmovex/agentpager then run: agentpager service install');
20
20
  }
21
21
  // A macOS LaunchAgent cannot read an external/removable volume: the job starts and hangs before
22
22
  // the first line of code, with no error anywhere. Measured 2026-09-23.
23
23
  if (platform() === 'darwin' && cli.startsWith('/Volumes/')) {
24
24
  throw new Error(`This copy lives on an external volume (${cli}).\n` +
25
25
  'macOS login items cannot read those, so the service would start and hang silently.\n' +
26
- 'Install it on the internal disk instead: npm install -g agentpager then: agentpager service install');
26
+ 'Install it on the internal disk instead: npm install -g @cosmovex/agentpager then: agentpager service install');
27
27
  }
28
28
  }
29
29
  export function installService() {
package/dist/update.js ADDED
@@ -0,0 +1,52 @@
1
+ // Is there a newer AgentPager on npm? The phone can only say "update" if the computer knows.
2
+ // One small GET, cached on disk for 12 hours, and every failure means "no news" — an update
3
+ // check must never be able to slow down or break a bridge that is otherwise working.
4
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { stateDir } from './store.js';
7
+ const REGISTRY = 'https://registry.npmjs.org/@cosmovex%2Fagentpager/latest';
8
+ const CACHE_MS = 12 * 3600_000;
9
+ const cacheFile = () => join(stateDir, 'update.json');
10
+ /** True when `latest` is a strictly newer x.y.z than `current`. Pre-release tags are ignored. */
11
+ export function isNewer(latest, current) {
12
+ const parse = (v) => v.split('-')[0].split('.').map((n) => parseInt(n, 10) || 0);
13
+ const a = parse(latest);
14
+ const b = parse(current);
15
+ for (let i = 0; i < 3; i++) {
16
+ if ((a[i] ?? 0) !== (b[i] ?? 0))
17
+ return (a[i] ?? 0) > (b[i] ?? 0);
18
+ }
19
+ return false;
20
+ }
21
+ /** The newest published version, or undefined when we cannot tell. */
22
+ export async function latestVersion(fetchImpl = fetch) {
23
+ try {
24
+ if (existsSync(cacheFile())) {
25
+ const c = JSON.parse(readFileSync(cacheFile(), 'utf8'));
26
+ if (Date.now() - c.at < CACHE_MS && c.latest)
27
+ return c.latest;
28
+ }
29
+ }
30
+ catch {
31
+ /* unreadable cache: ask again */
32
+ }
33
+ try {
34
+ const res = await fetchImpl(REGISTRY, { signal: AbortSignal.timeout(5000) });
35
+ if (!res.ok)
36
+ return undefined; // 404 until the first publish
37
+ const latest = String((await res.json()).version ?? '');
38
+ if (!/^\d+\.\d+\.\d+/.test(latest))
39
+ return undefined;
40
+ try {
41
+ writeFileSync(cacheFile(), JSON.stringify({ at: Date.now(), latest }));
42
+ }
43
+ catch {
44
+ /* read-only home: fine, we just ask again next time */
45
+ }
46
+ return latest;
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ }
52
+ export const UPDATE_COMMAND = 'npm install -g @cosmovex/agentpager@latest';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cosmovex/agentpager",
3
- "version": "0.1.2",
4
- "description": "Pager for your AI coding agents \u2014 approve, reply and hear results on your phone. End-to-end encrypted.",
3
+ "version": "0.1.3",
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": {
7
7
  "build": "tsc && node -e \"const fs=require('fs');for(const f of ['dist/cli.js','dist/guard/bin.js'])fs.chmodSync(f,0o755)\"",