@cosmovex/agentpager 0.1.0 → 0.1.2

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
  },
@@ -7,6 +7,40 @@ import { findTranscript, spendOf } from '../guard/spend.js';
7
7
  import { classify, describe } from '../risk.js';
8
8
  import { SHELL, clip, withDeadline } from './types.js';
9
9
  const run = promisify(execFile);
10
+ /**
11
+ * Claude's own multi-question tool (up to 4 questions, single- or multi-select each), driven
12
+ * headlessly: there is no terminal here to prompt, so `canUseTool`'s `updatedInput` — whatever
13
+ * effect it has on this ONE tool's own execution — is unverified territory. `deny` + a message the
14
+ * model reads as data is not a guess: it is the exact mechanism every OTHER denial in this file
15
+ * already relies on, so the model already knows to treat it as an answer, not a rejection.
16
+ */
17
+ export async function askUserQuestion(sink, input) {
18
+ const raw = Array.isArray(input.questions) ? (input.questions) : [];
19
+ const questions = raw.slice(0, 4).map((q) => {
20
+ const qq = q;
21
+ const opts = Array.isArray(qq.options) ? qq.options : [];
22
+ return {
23
+ question: String(qq.question ?? '').slice(0, 300),
24
+ header: String(qq.header ?? '').slice(0, 20),
25
+ multiSelect: qq.multiSelect === true,
26
+ options: opts.slice(0, 4).map((o) => {
27
+ const oo = o;
28
+ return { label: String(oo.label ?? '').slice(0, 80), description: oo.description ? String(oo.description).slice(0, 300) : undefined };
29
+ }),
30
+ };
31
+ });
32
+ if (!questions.length)
33
+ return { behavior: 'deny', message: 'No questions were readable.' };
34
+ const summary = questions.length === 1 ? questions[0].question : `${questions.length} questions for you`;
35
+ const answer = await sink.ask({ tool: 'AskUserQuestion', summary, detail: '', risk: 'low', questions });
36
+ if (answer.decision === 'deny' || !answer.answers) {
37
+ return { behavior: 'deny', message: answer.message || 'The user closed this without answering.' };
38
+ }
39
+ const said = Object.entries(answer.answers)
40
+ .map(([q, a]) => `${q} ${a}`)
41
+ .join('\n');
42
+ return { behavior: 'deny', message: `The user answered:\n${said}` };
43
+ }
10
44
  /** An async queue the SDK reads prompts from, so one live query takes many turns. */
11
45
  class PromptQueue {
12
46
  items = [];
@@ -138,6 +172,35 @@ export class ClaudeAgent {
138
172
  let lastText = '';
139
173
  /** What the deltas have already sent for the message currently being written. */
140
174
  let streamed = '';
175
+ /** The model in use, from the init message and from a switch the CLI accepted. */
176
+ let current = null;
177
+ // A resumed session's model, read from its own transcript.
178
+ //
179
+ // The SDK emits `system`/`init` — the one message that states the model — only when the first
180
+ // turn starts. So a session you opened and have not typed into yet would show no model at all,
181
+ // which is exactly the complaint this feature answers. Every assistant message in the transcript
182
+ // records the model that produced it, so the last one is what this session last ran on: a fact,
183
+ // not a guess, and init overwrites it the moment the real thing arrives.
184
+ if (id) {
185
+ void (async () => {
186
+ try {
187
+ const messages = await getSessionMessages(id);
188
+ for (let i = messages.length - 1; i >= 0; i--) {
189
+ const model = messages[i]?.message?.model;
190
+ if (typeof model === 'string' && model) {
191
+ if (!current)
192
+ current = model; // never clobber an init that already landed
193
+ break;
194
+ }
195
+ }
196
+ if (current)
197
+ await reportModels();
198
+ }
199
+ catch {
200
+ /* no transcript, or an unreadable one: the model simply shows up after the first turn */
201
+ }
202
+ })();
203
+ }
141
204
  const q = query({
142
205
  prompt: prompts,
143
206
  options: {
@@ -158,6 +221,8 @@ export class ClaudeAgent {
158
221
  promptSuggestions: true,
159
222
  systemPrompt: { type: 'preset', preset: 'claude_code' },
160
223
  canUseTool: async (tool, input, opts) => {
224
+ if (tool === 'AskUserQuestion')
225
+ return askUserQuestion(sink, input);
161
226
  const { summary, detail } = describe(tool, input);
162
227
  const answer = await sink.ask({ tool, summary, detail, risk: classify(tool, input) });
163
228
  if (answer.decision === 'deny') {
@@ -193,10 +258,28 @@ export class ClaudeAgent {
193
258
  const msg = m;
194
259
  if (msg.type === 'system' && msg.subtype === 'init' && msg.session_id) {
195
260
  sink.identified(msg.session_id);
261
+ // The only message that states which model this session is on. The initialize RESPONSE
262
+ // lists the available models but not the chosen one, so this is the source of truth.
263
+ if (typeof msg.model === 'string' && msg.model) {
264
+ current = msg.model;
265
+ void reportModels();
266
+ }
196
267
  }
197
268
  else if (msg.type === 'system' && msg.subtype === 'session_state_changed') {
198
269
  sink.state(msg.state === 'requires_action' ? 'needs_you' : msg.state === 'running' ? 'running' : 'idle');
199
270
  }
271
+ else if (msg.type === 'system' && msg.subtype === 'compact_boundary') {
272
+ // The SDK auto-summarizes older turns to keep going near the context limit — silently,
273
+ // until now. "I lose context when I come back" (Sumanth's survey, 2026-09-28) is partly
274
+ // THIS: the agent's own memory of earlier turns just became a summary, and nothing said
275
+ // so. pre/post_tokens are on the message itself; no hook needed for that much.
276
+ const meta = msg.compact_metadata ?? {};
277
+ const auto = meta.trigger !== 'manual';
278
+ const pre = typeof meta.pre_tokens === 'number' ? meta.pre_tokens : null;
279
+ const post = typeof meta.post_tokens === 'number' ? meta.post_tokens : null;
280
+ const shrink = pre != null && post != null ? ` (${pre.toLocaleString()} → ${post.toLocaleString()} tokens)` : '';
281
+ sink.tool('Context', `${auto ? 'Auto-compacted' : 'Compacted'} — older turns summarized to keep working${shrink}.`);
282
+ }
200
283
  else if (msg.type === 'prompt_suggestion' || msg.subtype === 'prompt_suggestion') {
201
284
  const t = String(msg.suggestion ?? msg.text ?? msg.prompt ?? '').trim();
202
285
  if (t)
@@ -229,8 +312,10 @@ export class ClaudeAgent {
229
312
  }
230
313
  if (Array.isArray(content)) {
231
314
  for (const b of content) {
232
- if (b?.type === 'tool_use')
233
- sink.tool(b.name, describe(b.name, b.input ?? {}).detail);
315
+ if (b?.type === 'tool_use') {
316
+ const d = describe(b.name, b.input ?? {});
317
+ sink.tool(b.name, d.detail, d.path);
318
+ }
234
319
  }
235
320
  }
236
321
  }
@@ -242,14 +327,19 @@ export class ClaudeAgent {
242
327
  // The one number people are desperate for. "I used up Max 5 in 1 hour of working,
243
328
  // before I could work 8 hours" — the agent knew all along; nothing told the phone.
244
329
  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
- }
330
+ // Report the PLAN on every result, even when there are no rate-limit windows to report.
331
+ //
332
+ // This used to be inside `if (rate_limits_available)`, which made an API key — the one
333
+ // case where dollars are real money — indistinguishable from "this session has not said
334
+ // anything yet". The phone then fell back to showing a dollar estimate, and a $200/month
335
+ // Max subscriber was told "$2092.12 of your $20.00 limit" in red. A plan of null now
336
+ // positively means an API key, and no message at all means we do not know yet.
337
+ const win = (w) => (w ? { used: w.utilization ?? null, resetsAt: w.resets_at ?? null } : null);
338
+ sink.limits({
339
+ plan: msg.subscription_type ?? null,
340
+ fiveHour: msg.rate_limits_available && rl ? win(rl.five_hour) : null,
341
+ sevenDay: msg.rate_limits_available && rl ? win(rl.seven_day) : null,
342
+ });
253
343
  const ok = msg.subtype === 'success' && !msg.is_error;
254
344
  sink.done(ok, clip(String(msg.result ?? lastText ?? ''), 1200));
255
345
  }
@@ -262,11 +352,61 @@ export class ClaudeAgent {
262
352
  }
263
353
  }
264
354
  })();
355
+ /**
356
+ * Ask Claude Code what it can run, and report it.
357
+ *
358
+ * The list comes from the SDK every time rather than from anything we keep: model names change
359
+ * under us, and offering a model this account cannot run is worse than offering none. Failure is
360
+ * silent on purpose — not knowing the model must never stop a session from starting.
361
+ */
362
+ async function reportModels() {
363
+ try {
364
+ const models = await q.supportedModels();
365
+ if (!models?.length)
366
+ return;
367
+ sink.models({
368
+ items: models.map((m) => ({ id: m.value, name: m.displayName, description: m.description })),
369
+ // Match the alias row's resolved id too: a session pinned to 'claude-sonnet-5' has to light
370
+ // up the 'sonnet' row that covers it, or the picker shows nothing selected at all.
371
+ current: current ? (models.find((m) => m.value === current || m.resolvedModel === current)?.value ?? current) : null,
372
+ canSet: true,
373
+ });
374
+ }
375
+ catch {
376
+ /* an older CLI without supportedModels: no picker, everything else unaffected */
377
+ }
378
+ }
379
+ if (!id) {
380
+ // A session that has just been created has no pinned model, so the row the CLI itself labels
381
+ // `default` IS what it will run — a fact about a new session, not a guess. Without this the
382
+ // model bar stays empty until the first turn, which is the same "no model anywhere" the
383
+ // feature exists to fix.
384
+ void (async () => {
385
+ try {
386
+ const models = await q.supportedModels();
387
+ if (models?.some((m) => m.value === 'default') && !current) {
388
+ current = 'default';
389
+ await reportModels();
390
+ }
391
+ }
392
+ catch {
393
+ /* older CLI: the model appears after the first turn instead */
394
+ }
395
+ })();
396
+ }
265
397
  return {
266
398
  send: (text) => {
267
399
  sink.state('running');
268
400
  prompts.push(text);
269
401
  },
402
+ setModel: async (id) => {
403
+ // A control request the CLI resolves is its acceptance — it rejects a model the account
404
+ // cannot run, which is the difference from Codex, where the equivalent call returns {} for
405
+ // a bogus name and for `banana: 1` alike and is therefore not offered at all.
406
+ await q.setModel(id);
407
+ current = id;
408
+ await reportModels();
409
+ },
270
410
  interrupt: async () => {
271
411
  await q.interrupt();
272
412
  },
@@ -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
@@ -6,7 +6,7 @@ import { basename } from 'node:path';
6
6
  import { random, b64u } from './crypto.js';
7
7
  import { record as recordLimits } from './guard/limits.js';
8
8
  import { PROTOCOL } from './protocol.js';
9
- import { addRoot, allowedCwd, browseFolders, discoverProjects } from './projects.js';
9
+ import { addRoot, allowedCwd, browseFolders, discoverProjects, MAX_PHONE_FILE_BYTES, readFileForPhone, writeUploadedFile } from './projects.js';
10
10
  import { AwakeLock } from './awake.js';
11
11
  import { deskActive, pruneDesk } from './guard/desk.js';
12
12
  import { loadRules, PRESETS, saveRules, validateRules } from './guard/rules.js';
@@ -22,6 +22,11 @@ export class Hub {
22
22
  channels = [];
23
23
  live = new Map();
24
24
  known = new Map(); // last list, for cwd lookups
25
+ // In-flight uploads FROM the phone, keyed by reqId — the mirror of `sendFile`'s chunking, going
26
+ // the other way. Cleared on completion or error; a reqId that never completes just ages out with
27
+ // the process (no size cap consequence: file_put_start's own `size` already came from
28
+ // MAX_PHONE_FILE_BYTES-checked FilePicker output on the phone, not from anything the bridge trusts blindly here — see receiveUploadChunk).
29
+ uploads = new Map();
25
30
  projects = new Set();
26
31
  discovered; // git repos on this computer; scanned once, on the first hello
27
32
  projectCache;
@@ -71,6 +76,8 @@ export class Hub {
71
76
  return;
72
77
  case 'new':
73
78
  return await this.startNew(msg.agent, msg.cwd, from, msg.ref);
79
+ case 'set_model':
80
+ return await this.setModel(msg, from);
74
81
  case 'rules_get':
75
82
  return from.send(this.rulesReply());
76
83
  case 'rules_set':
@@ -85,6 +92,15 @@ export class Hub {
85
92
  }
86
93
  case 'browse':
87
94
  return from.send({ type: 'folders', ...browseFolders(msg.path) });
95
+ case 'file_get':
96
+ return this.sendFile(msg.reqId, msg.path, from);
97
+ case 'file_put_start':
98
+ // `.fill(undefined)`, not a bare `new Array(n)`: a sparse array's holes are invisible to
99
+ // `.some()`/`.reduce()` below, so a 2-chunk upload looked "complete" after just chunk 0.
100
+ this.uploads.set(msg.reqId, { sid: msg.sid, name: msg.name, size: msg.size, chunks: msg.chunks, received: new Array(msg.chunks).fill(undefined) });
101
+ return;
102
+ case 'file_put_chunk':
103
+ return this.receiveUploadChunk(msg.reqId, msg.index, msg.data, from);
88
104
  case 'prompt': {
89
105
  // Two processes writing one Claude session corrupt it. If the desk touched this session in
90
106
  // the last 20 s (the guard's heartbeat), refuse — never touch a session with no heartbeat.
@@ -112,7 +128,7 @@ export class Hub {
112
128
  return from.send({ type: 'asked', sid: msg.sid, reqId: msg.reqId });
113
129
  live.asks.delete(msg.reqId);
114
130
  this.log(`${msg.decision} → ${ask.msg.summary}`);
115
- ask.resolve({ decision: msg.decision, message: msg.message });
131
+ ask.resolve({ decision: msg.decision, message: msg.message, answers: msg.answers });
116
132
  this.broadcast({ type: 'asked', sid: msg.sid, reqId: msg.reqId });
117
133
  this.setState(live, live.asks.size ? 'needs_you' : 'running');
118
134
  return;
@@ -358,6 +374,63 @@ export class Hub {
358
374
  clearTimeout(timer);
359
375
  return result;
360
376
  }
377
+ /**
378
+ * "Claude made a PDF, how do I read it" — one named file, base64-chunked over the same small
379
+ * encrypted channel everything else uses (the relay's own rules cap one message's ciphertext at
380
+ * 24,000 chars; 10,000 raw bytes per chunk leaves room for the base64 + encryption overhead on
381
+ * top of that, twice — once for the chunk data, once for the envelope). Fire-and-forget: the
382
+ * phone reassembles by `index`, and a chunk arriving out of order costs nothing to wait for.
383
+ */
384
+ sendFile(reqId, path, from) {
385
+ const CHUNK_BYTES = 10_000;
386
+ const file = readFileForPhone(path);
387
+ if (!file.ok) {
388
+ from.send({ type: 'file_error', reqId, message: file.message });
389
+ return;
390
+ }
391
+ const chunks = Math.max(1, Math.ceil(file.bytes.length / CHUNK_BYTES));
392
+ from.send({ type: 'file_start', reqId, name: file.name, mime: file.mime, size: file.bytes.length, chunks });
393
+ for (let i = 0; i < chunks; i++) {
394
+ const slice = file.bytes.subarray(i * CHUNK_BYTES, (i + 1) * CHUNK_BYTES);
395
+ from.send({ type: 'file_chunk', reqId, index: i, data: slice.toString('base64') });
396
+ }
397
+ this.log(`sent ${file.name} (${(file.bytes.length / 1024).toFixed(0)} KB, ${chunks} chunk${chunks === 1 ? '' : 's'})`);
398
+ }
399
+ /** One chunk of a phone upload. Never trusts the phone's own claimed `size` for enforcement —
400
+ * counts real received bytes against the same cap `readFileForPhone` uses the other way. */
401
+ receiveUploadChunk(reqId, index, data, from) {
402
+ const up = this.uploads.get(reqId);
403
+ if (!up || index < 0 || index >= up.chunks)
404
+ return; // unknown or stale transfer: say nothing, ask nothing
405
+ up.received[index] = Buffer.from(data, 'base64');
406
+ const soFar = up.received.reduce((n, b) => n + (b?.length ?? 0), 0);
407
+ if (soFar > MAX_PHONE_FILE_BYTES) {
408
+ this.uploads.delete(reqId);
409
+ from.send({ type: 'file_put_error', reqId, message: 'That file is too big to send this way.' });
410
+ return;
411
+ }
412
+ if (up.received.some((b) => !b))
413
+ return; // still waiting on other chunks
414
+ this.uploads.delete(reqId);
415
+ const cwd = this.known.get(up.sid)?.cwd;
416
+ if (!cwd) {
417
+ from.send({ type: 'file_put_error', reqId, message: 'That session is not open on this computer right now.' });
418
+ return;
419
+ }
420
+ const bytes = Buffer.concat(up.received);
421
+ const result = writeUploadedFile(cwd, up.name, bytes);
422
+ if (!result.ok) {
423
+ from.send({ type: 'file_put_error', reqId, message: result.message });
424
+ return;
425
+ }
426
+ this.log(`received ${up.name} from the phone (${(bytes.length / 1024).toFixed(0)} KB) → ${result.path}`);
427
+ from.send({ type: 'file_put_done', reqId, sid: up.sid, path: result.path });
428
+ // Tell the agent, if one is actually running here — a file sitting on disk it was never told
429
+ // about is indistinguishable from a file that does not exist.
430
+ const live = this.live.get(up.sid);
431
+ if (live?.session)
432
+ void live.session.send(`[The user sent a file from their phone: ${result.path}]`);
433
+ }
361
434
  /** Take a pending request off the phone and say why it is gone. */
362
435
  withdraw(live, reqId, reason) {
363
436
  const ask = live.asks.get(reqId);
@@ -486,9 +559,9 @@ export class Hub {
486
559
  if (!live.flush)
487
560
  live.flush = setTimeout(() => this.flush(live), FLUSH_MS);
488
561
  },
489
- tool: (tool, detail) => {
562
+ tool: (tool, detail, path) => {
490
563
  this.flush(live);
491
- this.broadcast({ type: 'tool', sid: live.sid, tool, detail: detail.slice(0, 300) }, true);
564
+ this.broadcast({ type: 'tool', sid: live.sid, tool, detail: detail.slice(0, 300), path }, true);
492
565
  },
493
566
  state: (state) => this.setState(live, state),
494
567
  limits: (l) => {
@@ -500,6 +573,12 @@ export class Hub {
500
573
  // and it is a tiny message.
501
574
  this.broadcast({ type: 'limits', sid: live.sid, plan: l.plan, fiveHour: l.fiveHour, sevenDay: l.sevenDay });
502
575
  },
576
+ models: (m) => {
577
+ // Not onlyActive: this is small, it changes rarely, and it is the first thing the session
578
+ // header shows when the app comes back — a phone that reopens to "model: —" looks broken.
579
+ live.models = m;
580
+ this.broadcast({ type: 'models', sid: live.sid, items: m.items.slice(0, 40), current: m.current, canSet: m.canSet });
581
+ },
503
582
  commands: (items) => {
504
583
  this.flush(live);
505
584
  this.broadcast({ type: 'commands', sid: live.sid, items: items.slice(0, 120) }, true);
@@ -542,6 +621,31 @@ export class Hub {
542
621
  },
543
622
  };
544
623
  }
624
+ /**
625
+ * Change a live session's model, then say what is actually in use.
626
+ *
627
+ * The reply is always a fresh `models` read back from the agent rather than an echo of what was
628
+ * asked for: a switch that silently did not take must show as the old model, or the phone reports
629
+ * a change that never happened — and the model is the one setting that decides how fast the plan
630
+ * window empties.
631
+ */
632
+ async setModel(msg, from) {
633
+ const live = this.live.get(msg.sid);
634
+ if (!live?.session?.setModel) {
635
+ return from.send({ type: 'error', sid: msg.sid, code: 'model', message: 'This agent cannot change model from the phone.' });
636
+ }
637
+ try {
638
+ await live.session.setModel(msg.model);
639
+ this.log(`model → ${msg.model}`);
640
+ }
641
+ catch (e) {
642
+ from.send({ type: 'error', sid: msg.sid, code: 'model', message: `Could not switch model: ${String(e?.message ?? e)}` });
643
+ // Fall through: re-report anyway, so the phone snaps back to the model still in use.
644
+ }
645
+ if (live.models) {
646
+ this.broadcast({ type: 'models', sid: live.sid, items: live.models.items.slice(0, 40), current: live.models.current, canSet: live.models.canSet });
647
+ }
648
+ }
545
649
  drop(live) {
546
650
  if (Hub.busy(live.state))
547
651
  this.awake.release();
package/dist/projects.js CHANGED
@@ -12,9 +12,9 @@
12
12
  // Roots are your home folder, the folder you started the bridge in, and the parent of any folder
13
13
  // you already have a session in. That last one matters: this machine keeps its projects on an
14
14
  // external drive, where a home-folder-only scan finds nothing at all.
15
- import { readdirSync, realpathSync, statSync } from 'node:fs';
15
+ import { mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
16
16
  import { homedir } from 'node:os';
17
- import { join, resolve, sep } from 'node:path';
17
+ import { extname, join, resolve, sep } from 'node:path';
18
18
  // The REAL path of the home folder: every check below compares against realpath()s, so a home that
19
19
  // sits behind a symlink (macOS /tmp, Fedora Silverblue's /home -> /var/home) would otherwise reject
20
20
  // its own owner's home folder as "outside your home folder".
@@ -127,6 +127,24 @@ export function allowedCwd(p, known = []) {
127
127
  .split(sep)
128
128
  .some((part) => part && (hidden(part) || SKIP.has(part)));
129
129
  }
130
+ /**
131
+ * A file the phone may read (not browse — a specific path, e.g. one it just watched an agent
132
+ * write): not a directory, and its own folder inside the same roots and hidden/SKIP rules as
133
+ * `allowedCwd`. One promise, checked one way, so the two can never quietly drift apart.
134
+ */
135
+ export function allowedFile(p) {
136
+ const full = real(resolve(p));
137
+ if (!full)
138
+ return false;
139
+ try {
140
+ if (!statSync(full).isFile())
141
+ return false;
142
+ }
143
+ catch {
144
+ return false;
145
+ }
146
+ return allowedCwd(resolve(full, '..'));
147
+ }
130
148
  /** Git repositories in your roots, most recently touched first. */
131
149
  export function discoverProjects() {
132
150
  const found = [];
@@ -193,3 +211,80 @@ export function browseFolders(path) {
193
211
  return { path: at, parent: atRoot ? (all.length > 1 ? '' : undefined) : resolve(at, '..'), dirs };
194
212
  }
195
213
  export const homeFolder = () => HOME;
214
+ /** "Claude made a PDF, how do I read it" (real request, 2026-09-29) — not a file browser, one
215
+ * named file at a time, and never bigger than what the relay's small encrypted messages can carry
216
+ * chunked. Bump this only alongside the chunk math in hub.ts; they are the same promise. */
217
+ export const MAX_PHONE_FILE_BYTES = 4 * 1024 * 1024;
218
+ const MIME_BY_EXT = {
219
+ '.pdf': 'application/pdf',
220
+ '.png': 'image/png',
221
+ '.jpg': 'image/jpeg',
222
+ '.jpeg': 'image/jpeg',
223
+ '.gif': 'image/gif',
224
+ '.webp': 'image/webp',
225
+ '.md': 'text/markdown',
226
+ '.txt': 'text/plain',
227
+ '.json': 'application/json',
228
+ '.csv': 'text/csv',
229
+ '.html': 'text/html',
230
+ '.svg': 'image/svg+xml',
231
+ };
232
+ /** The file behind a "get this file" tap. Never anything `allowedFile` refuses — checked the same
233
+ * way as every other path this bridge reads, not a second guess at the rule. */
234
+ export function readFileForPhone(p) {
235
+ if (!allowedFile(p)) {
236
+ return { ok: false, reason: 'not_allowed', message: "That file is outside what this computer lets the phone reach." };
237
+ }
238
+ const full = real(resolve(p));
239
+ let bytes;
240
+ try {
241
+ bytes = readFileSync(full);
242
+ }
243
+ catch {
244
+ return { ok: false, reason: 'unreadable', message: 'Could not read that file.' };
245
+ }
246
+ if (bytes.length > MAX_PHONE_FILE_BYTES) {
247
+ const mb = (n) => (n / (1024 * 1024)).toFixed(1);
248
+ return {
249
+ ok: false,
250
+ reason: 'too_big',
251
+ message: `That file is ${mb(bytes.length)} MB — too big to send this way (limit ${mb(MAX_PHONE_FILE_BYTES)} MB).`,
252
+ };
253
+ }
254
+ const ext = extname(full).toLowerCase();
255
+ return { ok: true, name: full.split(sep).pop() || full, mime: MIME_BY_EXT[ext] ?? 'application/octet-stream', bytes };
256
+ }
257
+ /**
258
+ * The other direction: a file the phone picked, landed where the agent can actually find it.
259
+ * `cwd` is the session's own folder — never chosen by the phone — so this can only ever write
260
+ * inside a folder that folder's own session already had. `name` is untrusted input from the phone;
261
+ * `basename()`-only strips any `../` before it ever touches a path.
262
+ */
263
+ export function writeUploadedFile(cwd, name, bytes) {
264
+ const full = real(cwd);
265
+ if (!full || !isDir(full))
266
+ return { ok: false, message: 'That session has no folder to put a file into.' };
267
+ const safeName = (name.split(/[\\/]/).pop() || 'file').replace(/^\.+/, '') || 'file';
268
+ const dir = join(full, '.agentpager-uploads');
269
+ try {
270
+ mkdirSync(dir, { recursive: true });
271
+ // Never silently overwrite: two screenshots named the same thing an hour apart are both real.
272
+ let path = join(dir, safeName);
273
+ if (entries(dir).includes(safeName) || (() => { try {
274
+ statSync(path);
275
+ return true;
276
+ }
277
+ catch {
278
+ return false;
279
+ } })()) {
280
+ const dot = safeName.lastIndexOf('.');
281
+ const stamp = Date.now();
282
+ path = join(dir, dot > 0 ? `${safeName.slice(0, dot)}-${stamp}${safeName.slice(dot)}` : `${safeName}-${stamp}`);
283
+ }
284
+ writeFileSync(path, bytes);
285
+ return { ok: true, path };
286
+ }
287
+ catch (e) {
288
+ return { ok: false, message: `Could not save that file: ${String(e?.message ?? e)}` };
289
+ }
290
+ }
package/dist/risk.js CHANGED
@@ -26,20 +26,64 @@ 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
+ // "Claude made a PDF, how do I read it" — a real path, not re-parsed out of `detail` later,
72
+ // is what lets the phone offer "Get file" on the row that actually produced one.
73
+ path: path || undefined,
74
+ };
75
+ }
76
+ case 'Write': {
77
+ const file = basename(path);
78
+ const body = String(input.content ?? '');
79
+ return {
80
+ summary: said() ?? `Write ${file}`,
81
+ // The path alone was the whole detail here, so the card named a file and showed nothing of
82
+ // what was going into it.
83
+ detail: clip(body ? `${path}\n\n${body}` : path, 2000),
84
+ path: path || undefined,
85
+ };
86
+ }
43
87
  case 'WebFetch':
44
88
  return { summary: 'Fetch a web page', detail: clip(String(input.url ?? ''), 500) };
45
89
  default:
@@ -76,3 +120,12 @@ export function readable(input) {
76
120
  .join('\n');
77
121
  }
78
122
  const basename = (p) => p.split(/[\\/]/).filter(Boolean).pop() ?? '';
123
+ /** An Edit's before/after, short enough to read on a phone. */
124
+ function diffOf(input) {
125
+ const before = typeof input.old_string === 'string' ? input.old_string : '';
126
+ const after = typeof input.new_string === 'string' ? input.new_string : '';
127
+ if (!before && !after)
128
+ return '';
129
+ const side = (s) => (s.length > 400 ? `${s.slice(0, 399)}…` : s);
130
+ return `- ${side(before)}\n+ ${side(after)}`;
131
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmovex/agentpager",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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": {