@cosmovex/agentpager 0.1.0 → 0.1.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 CHANGED
@@ -4,7 +4,7 @@ A pager for your AI coding agents. Approve what they want to do, reply, and hear
4
4
  on your phone, with the [AgentPager Android app](https://play.google.com/store/apps/details?id=com.cosmovex.mdpilot).
5
5
 
6
6
  ```bash
7
- npx agentpager
7
+ npx @cosmovex/agentpager
8
8
  ```
9
9
 
10
10
  A QR code appears. Scan it in the app. Your sessions show up in a few seconds.
@@ -80,7 +80,7 @@ folder names only, so the picker can list your projects without sending any of t
80
80
  ## Keep it running
81
81
 
82
82
  ```bash
83
- npm install -g agentpager
83
+ npm install -g @cosmovex/agentpager
84
84
  agentpager service install # starts at login: macOS LaunchAgent, systemd user unit, Windows task
85
85
  agentpager service uninstall
86
86
  ```
@@ -261,6 +261,17 @@ export class AcpAgent {
261
261
  clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
262
262
  });
263
263
  let sessionId;
264
+ /** The agent's own model state, when it publishes one. Null means: this agent has no models API. */
265
+ let newModels = null;
266
+ const reportModels = () => {
267
+ const rows = newModels?.availableModels ?? [];
268
+ const items = rows
269
+ .filter((m) => m && typeof m.modelId === 'string')
270
+ .map((m) => ({ id: m.modelId, name: m.name || m.modelId, description: m.description ?? undefined }));
271
+ if (!items.length)
272
+ return;
273
+ sink.models({ items, current: newModels?.currentModelId ?? null, canSet: true });
274
+ };
264
275
  const canLoad = init?.agentCapabilities?.loadSession === true;
265
276
  if (id && canLoad) {
266
277
  await conn.loadSession({ sessionId: id, cwd: cwd ?? process.cwd(), mcpServers: [] });
@@ -270,7 +281,12 @@ export class AcpAgent {
270
281
  const created = await conn.newSession({ cwd: cwd ?? process.cwd(), mcpServers: [] });
271
282
  sessionId = created.sessionId;
272
283
  sink.identified(sessionId);
284
+ // ACP carries the model state on the session it just made, when the agent supports it at all
285
+ // (it is an unstable part of the protocol, so most do not). Unlike Codex there IS a verifiable
286
+ // setter here — session/set_model — so where the agent offers the state, the phone may choose.
287
+ newModels = created?.models ?? null;
273
288
  }
289
+ reportModels();
274
290
  proc.on('exit', () => {
275
291
  flush();
276
292
  sink.error(`${this.name} stopped on this computer.`);
@@ -300,6 +316,13 @@ export class AcpAgent {
300
316
  sink.error(x.message, x.code);
301
317
  });
302
318
  },
319
+ setModel: async (id) => {
320
+ await conn.setSessionModel({ sessionId, modelId: id });
321
+ // Believe the agent, not the tap: re-publish from our own state only after it accepted.
322
+ if (newModels)
323
+ newModels.currentModelId = id;
324
+ reportModels();
325
+ },
303
326
  interrupt: async () => {
304
327
  await conn.cancel({ sessionId }).catch(() => { });
305
328
  },
@@ -138,6 +138,35 @@ export class ClaudeAgent {
138
138
  let lastText = '';
139
139
  /** What the deltas have already sent for the message currently being written. */
140
140
  let streamed = '';
141
+ /** The model in use, from the init message and from a switch the CLI accepted. */
142
+ let current = null;
143
+ // A resumed session's model, read from its own transcript.
144
+ //
145
+ // The SDK emits `system`/`init` — the one message that states the model — only when the first
146
+ // turn starts. So a session you opened and have not typed into yet would show no model at all,
147
+ // which is exactly the complaint this feature answers. Every assistant message in the transcript
148
+ // records the model that produced it, so the last one is what this session last ran on: a fact,
149
+ // not a guess, and init overwrites it the moment the real thing arrives.
150
+ if (id) {
151
+ void (async () => {
152
+ try {
153
+ const messages = await getSessionMessages(id);
154
+ for (let i = messages.length - 1; i >= 0; i--) {
155
+ const model = messages[i]?.message?.model;
156
+ if (typeof model === 'string' && model) {
157
+ if (!current)
158
+ current = model; // never clobber an init that already landed
159
+ break;
160
+ }
161
+ }
162
+ if (current)
163
+ await reportModels();
164
+ }
165
+ catch {
166
+ /* no transcript, or an unreadable one: the model simply shows up after the first turn */
167
+ }
168
+ })();
169
+ }
141
170
  const q = query({
142
171
  prompt: prompts,
143
172
  options: {
@@ -193,6 +222,12 @@ export class ClaudeAgent {
193
222
  const msg = m;
194
223
  if (msg.type === 'system' && msg.subtype === 'init' && msg.session_id) {
195
224
  sink.identified(msg.session_id);
225
+ // The only message that states which model this session is on. The initialize RESPONSE
226
+ // lists the available models but not the chosen one, so this is the source of truth.
227
+ if (typeof msg.model === 'string' && msg.model) {
228
+ current = msg.model;
229
+ void reportModels();
230
+ }
196
231
  }
197
232
  else if (msg.type === 'system' && msg.subtype === 'session_state_changed') {
198
233
  sink.state(msg.state === 'requires_action' ? 'needs_you' : msg.state === 'running' ? 'running' : 'idle');
@@ -242,14 +277,19 @@ export class ClaudeAgent {
242
277
  // The one number people are desperate for. "I used up Max 5 in 1 hour of working,
243
278
  // before I could work 8 hours" — the agent knew all along; nothing told the phone.
244
279
  const rl = msg.rate_limits;
245
- if (msg.rate_limits_available && rl) {
246
- const win = (w) => (w ? { used: w.utilization ?? null, resetsAt: w.resets_at ?? null } : null);
247
- sink.limits({
248
- plan: msg.subscription_type ?? null,
249
- fiveHour: win(rl.five_hour),
250
- sevenDay: win(rl.seven_day),
251
- });
252
- }
280
+ // Report the PLAN on every result, even when there are no rate-limit windows to report.
281
+ //
282
+ // This used to be inside `if (rate_limits_available)`, which made an API key — the one
283
+ // case where dollars are real money — indistinguishable from "this session has not said
284
+ // anything yet". The phone then fell back to showing a dollar estimate, and a $200/month
285
+ // Max subscriber was told "$2092.12 of your $20.00 limit" in red. A plan of null now
286
+ // positively means an API key, and no message at all means we do not know yet.
287
+ const win = (w) => (w ? { used: w.utilization ?? null, resetsAt: w.resets_at ?? null } : null);
288
+ sink.limits({
289
+ plan: msg.subscription_type ?? null,
290
+ fiveHour: msg.rate_limits_available && rl ? win(rl.five_hour) : null,
291
+ sevenDay: msg.rate_limits_available && rl ? win(rl.seven_day) : null,
292
+ });
253
293
  const ok = msg.subtype === 'success' && !msg.is_error;
254
294
  sink.done(ok, clip(String(msg.result ?? lastText ?? ''), 1200));
255
295
  }
@@ -262,11 +302,61 @@ export class ClaudeAgent {
262
302
  }
263
303
  }
264
304
  })();
305
+ /**
306
+ * Ask Claude Code what it can run, and report it.
307
+ *
308
+ * The list comes from the SDK every time rather than from anything we keep: model names change
309
+ * under us, and offering a model this account cannot run is worse than offering none. Failure is
310
+ * silent on purpose — not knowing the model must never stop a session from starting.
311
+ */
312
+ async function reportModels() {
313
+ try {
314
+ const models = await q.supportedModels();
315
+ if (!models?.length)
316
+ return;
317
+ sink.models({
318
+ items: models.map((m) => ({ id: m.value, name: m.displayName, description: m.description })),
319
+ // Match the alias row's resolved id too: a session pinned to 'claude-sonnet-5' has to light
320
+ // up the 'sonnet' row that covers it, or the picker shows nothing selected at all.
321
+ current: current ? (models.find((m) => m.value === current || m.resolvedModel === current)?.value ?? current) : null,
322
+ canSet: true,
323
+ });
324
+ }
325
+ catch {
326
+ /* an older CLI without supportedModels: no picker, everything else unaffected */
327
+ }
328
+ }
329
+ if (!id) {
330
+ // A session that has just been created has no pinned model, so the row the CLI itself labels
331
+ // `default` IS what it will run — a fact about a new session, not a guess. Without this the
332
+ // model bar stays empty until the first turn, which is the same "no model anywhere" the
333
+ // feature exists to fix.
334
+ void (async () => {
335
+ try {
336
+ const models = await q.supportedModels();
337
+ if (models?.some((m) => m.value === 'default') && !current) {
338
+ current = 'default';
339
+ await reportModels();
340
+ }
341
+ }
342
+ catch {
343
+ /* older CLI: the model appears after the first turn instead */
344
+ }
345
+ })();
346
+ }
265
347
  return {
266
348
  send: (text) => {
267
349
  sink.state('running');
268
350
  prompts.push(text);
269
351
  },
352
+ setModel: async (id) => {
353
+ // A control request the CLI resolves is its acceptance — it rejects a model the account
354
+ // cannot run, which is the difference from Codex, where the equivalent call returns {} for
355
+ // a bogus name and for `banana: 1` alike and is therefore not offered at all.
356
+ await q.setModel(id);
357
+ current = id;
358
+ await reportModels();
359
+ },
270
360
  interrupt: async () => {
271
361
  await q.interrupt();
272
362
  },
@@ -149,6 +149,26 @@ export class CodexAgent {
149
149
  this.lastRoute = created;
150
150
  if (!id)
151
151
  sink.identified(threadId);
152
+ // Which model this thread runs. Reported, never offered as a choice: `model/list` is real and
153
+ // honest, but the only way to change it — thread/settings/update — answers {} to a model that
154
+ // does not exist AND to `banana: 1`, and nothing Codex reports afterwards names a model. A
155
+ // switch that cannot be verified must not be presented as one, so canSet is false and the phone
156
+ // shows the model as a fact rather than a control. (Probed against codex-cli 0.138.0.)
157
+ void (async () => {
158
+ try {
159
+ const list = await rpc.request('model/list', {});
160
+ const rows = list?.data ?? [];
161
+ const items = rows
162
+ .filter((m) => m && !m.hidden && typeof m.id === 'string')
163
+ .map((m) => ({ id: m.id, name: m.displayName || m.id, description: m.description }));
164
+ if (!items.length)
165
+ return;
166
+ sink.models({ items, current: items.length === 1 ? items[0].id : null, canSet: false });
167
+ }
168
+ catch {
169
+ /* an older codex without model/list: no model shown, nothing else affected */
170
+ }
171
+ })();
152
172
  return {
153
173
  send: (text) => {
154
174
  sink.state('running');
package/dist/cli.js CHANGED
@@ -93,6 +93,31 @@ async function serve(state, keepAwake) {
93
93
  hub.attach(c);
94
94
  c.start();
95
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();
106
+ }
107
+ catch {
108
+ return; // mid-write on another process; try again next tick
109
+ }
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}`);
119
+ }
120
+ }, 5000);
96
121
  const stopControl = serveControl((req) => hub.job(req));
97
122
  // Detect the ACP agents in the background and announce them as they appear.
98
123
  void (async () => {
@@ -43,14 +43,23 @@ export function read() {
43
43
  }
44
44
  }
45
45
  export const fresh = (s, now = Date.now()) => !!s && now - s.at <= MAX_AGE_MS;
46
- /** The window closest to running out, as a percentage, or null when we cannot say. */
46
+ /**
47
+ * The window closest to running out, as a percentage, or null when we cannot say.
48
+ *
49
+ * 🧨 This used to multiply by 100, on the assumption that `utilization` was a 0..1 fraction. It is
50
+ * not — the SDK says 0-100, and the phone has always read it that way (a 77 shows as "23% of your
51
+ * plan left"). So a real reading of 45 became 4500, and ANY planCapPercent denied every tool call
52
+ * the moment one usage reading existed: the feature built for "claude has usage limit, it is not in
53
+ * dollars" would have bricked the agent instead of capping it. The unit tests missed it because they
54
+ * fed fractions and asserted the product — they agreed with the bug rather than with the provider.
55
+ */
47
56
  export function worstUsedPct(s, now = Date.now()) {
48
57
  if (!fresh(s, now))
49
58
  return null;
50
59
  const vals = [s.fiveHour?.used, s.sevenDay?.used].filter((v) => typeof v === 'number');
51
60
  if (!vals.length)
52
61
  return null;
53
- return Math.round(Math.max(...vals) * 100);
62
+ return Math.round(Math.max(...vals));
54
63
  }
55
64
  /** Which window is the one running out, for a message that tells you something you can act on. */
56
65
  export function worstWindow(s, now = Date.now()) {
package/dist/hub.js CHANGED
@@ -71,6 +71,8 @@ export class Hub {
71
71
  return;
72
72
  case 'new':
73
73
  return await this.startNew(msg.agent, msg.cwd, from, msg.ref);
74
+ case 'set_model':
75
+ return await this.setModel(msg, from);
74
76
  case 'rules_get':
75
77
  return from.send(this.rulesReply());
76
78
  case 'rules_set':
@@ -500,6 +502,12 @@ export class Hub {
500
502
  // and it is a tiny message.
501
503
  this.broadcast({ type: 'limits', sid: live.sid, plan: l.plan, fiveHour: l.fiveHour, sevenDay: l.sevenDay });
502
504
  },
505
+ models: (m) => {
506
+ // Not onlyActive: this is small, it changes rarely, and it is the first thing the session
507
+ // header shows when the app comes back — a phone that reopens to "model: —" looks broken.
508
+ live.models = m;
509
+ this.broadcast({ type: 'models', sid: live.sid, items: m.items.slice(0, 40), current: m.current, canSet: m.canSet });
510
+ },
503
511
  commands: (items) => {
504
512
  this.flush(live);
505
513
  this.broadcast({ type: 'commands', sid: live.sid, items: items.slice(0, 120) }, true);
@@ -542,6 +550,31 @@ export class Hub {
542
550
  },
543
551
  };
544
552
  }
553
+ /**
554
+ * Change a live session's model, then say what is actually in use.
555
+ *
556
+ * The reply is always a fresh `models` read back from the agent rather than an echo of what was
557
+ * asked for: a switch that silently did not take must show as the old model, or the phone reports
558
+ * a change that never happened — and the model is the one setting that decides how fast the plan
559
+ * window empties.
560
+ */
561
+ async setModel(msg, from) {
562
+ const live = this.live.get(msg.sid);
563
+ if (!live?.session?.setModel) {
564
+ return from.send({ type: 'error', sid: msg.sid, code: 'model', message: 'This agent cannot change model from the phone.' });
565
+ }
566
+ try {
567
+ await live.session.setModel(msg.model);
568
+ this.log(`model → ${msg.model}`);
569
+ }
570
+ catch (e) {
571
+ from.send({ type: 'error', sid: msg.sid, code: 'model', message: `Could not switch model: ${String(e?.message ?? e)}` });
572
+ // Fall through: re-report anyway, so the phone snaps back to the model still in use.
573
+ }
574
+ if (live.models) {
575
+ this.broadcast({ type: 'models', sid: live.sid, items: live.models.items.slice(0, 40), current: live.models.current, canSet: live.models.canSet });
576
+ }
577
+ }
545
578
  drop(live) {
546
579
  if (Hub.busy(live.state))
547
580
  this.awake.release();
package/dist/risk.js CHANGED
@@ -26,20 +26,60 @@ export function classify(tool, input) {
26
26
  return 'danger';
27
27
  return 'shell';
28
28
  }
29
- /** One short line a person can read at a glance (or hear): never a whole file. */
29
+ /**
30
+ * One short line a person can read at a glance (or hear): never a whole file.
31
+ *
32
+ * The summary carries the *point* of the call, not its syntax. This used to say "Run a command" for
33
+ * every single Bash approval — so a page at a red light read "Run a command" over a line of shell,
34
+ * and deciding meant parsing the shell yourself. Sumanth: "claude asked for approval for execution
35
+ * but it should tell context of what the command does".
36
+ *
37
+ * The context was already in the payload: Claude Code's Bash tool takes a `description` written by
38
+ * the model in plain English for exactly this reader ("Discard all local changes and match remote
39
+ * main"), and we were dropping it on the floor. Prefer it, always — the command still travels as
40
+ * the detail for anyone who wants to read it.
41
+ */
30
42
  export function describe(tool, input) {
31
43
  const clip = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
32
44
  const path = String(input.file_path ?? input.path ?? input.notebook_path ?? '');
45
+ /** The agent's own one-line account of what it is about to do, if it wrote one. */
46
+ const said = () => {
47
+ for (const key of ['description', 'reason', 'explanation', 'justification']) {
48
+ const v = input[key];
49
+ if (typeof v !== 'string')
50
+ continue;
51
+ // One line only: a paragraph is not a summary, and the card has a detail pane for the rest.
52
+ const line = v.replace(/\s+/g, ' ').trim();
53
+ if (line.length > 2)
54
+ return clip(line, 140);
55
+ }
56
+ return null;
57
+ };
33
58
  switch (tool) {
34
59
  case 'Bash':
35
60
  case 'shell':
36
- return { summary: 'Run a command', detail: clip(String(input.command ?? ''), 2000) };
61
+ return { summary: said() ?? 'Run a command', detail: clip(String(input.command ?? ''), 2000) };
37
62
  case 'Edit':
38
63
  case 'MultiEdit':
39
- case 'edit':
40
- return { summary: `Edit ${basename(path) || 'files'}`, detail: clip(String(input.detail ?? '') || path, 2000) };
41
- case 'Write':
42
- return { summary: `Write ${basename(path)}`, detail: clip(path, 2000) };
64
+ case 'edit': {
65
+ const file = basename(path) || 'files';
66
+ return {
67
+ summary: said() ?? `Edit ${file}`,
68
+ // What the edit actually changes, not just which file it lands in. Approving a change you
69
+ // cannot see is a tap, not a decision.
70
+ detail: clip(String(input.detail ?? '') || diffOf(input) || path, 2000),
71
+ };
72
+ }
73
+ case 'Write': {
74
+ const file = basename(path);
75
+ const body = String(input.content ?? '');
76
+ return {
77
+ summary: said() ?? `Write ${file}`,
78
+ // The path alone was the whole detail here, so the card named a file and showed nothing of
79
+ // what was going into it.
80
+ detail: clip(body ? `${path}\n\n${body}` : path, 2000),
81
+ };
82
+ }
43
83
  case 'WebFetch':
44
84
  return { summary: 'Fetch a web page', detail: clip(String(input.url ?? ''), 500) };
45
85
  default:
@@ -76,3 +116,12 @@ export function readable(input) {
76
116
  .join('\n');
77
117
  }
78
118
  const basename = (p) => p.split(/[\\/]/).filter(Boolean).pop() ?? '';
119
+ /** An Edit's before/after, short enough to read on a phone. */
120
+ function diffOf(input) {
121
+ const before = typeof input.old_string === 'string' ? input.old_string : '';
122
+ const after = typeof input.new_string === 'string' ? input.new_string : '';
123
+ if (!before && !after)
124
+ return '';
125
+ const side = (s) => (s.length > 400 ? `${s.slice(0, 399)}…` : s);
126
+ return `- ${side(before)}\n+ ${side(after)}`;
127
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmovex/agentpager",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Pager for your AI coding agents \u2014 approve, reply and hear results on your phone. End-to-end encrypted.",
5
5
  "main": "dist/cli.js",
6
6
  "scripts": {