@cosmovex/agentpager 0.1.1 → 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.
@@ -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 = [];
@@ -187,6 +221,8 @@ export class ClaudeAgent {
187
221
  promptSuggestions: true,
188
222
  systemPrompt: { type: 'preset', preset: 'claude_code' },
189
223
  canUseTool: async (tool, input, opts) => {
224
+ if (tool === 'AskUserQuestion')
225
+ return askUserQuestion(sink, input);
190
226
  const { summary, detail } = describe(tool, input);
191
227
  const answer = await sink.ask({ tool, summary, detail, risk: classify(tool, input) });
192
228
  if (answer.decision === 'deny') {
@@ -232,6 +268,18 @@ export class ClaudeAgent {
232
268
  else if (msg.type === 'system' && msg.subtype === 'session_state_changed') {
233
269
  sink.state(msg.state === 'requires_action' ? 'needs_you' : msg.state === 'running' ? 'running' : 'idle');
234
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
+ }
235
283
  else if (msg.type === 'prompt_suggestion' || msg.subtype === 'prompt_suggestion') {
236
284
  const t = String(msg.suggestion ?? msg.text ?? msg.prompt ?? '').trim();
237
285
  if (t)
@@ -264,8 +312,10 @@ export class ClaudeAgent {
264
312
  }
265
313
  if (Array.isArray(content)) {
266
314
  for (const b of content) {
267
- if (b?.type === 'tool_use')
268
- 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
+ }
269
319
  }
270
320
  }
271
321
  }
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;
@@ -87,6 +92,15 @@ export class Hub {
87
92
  }
88
93
  case 'browse':
89
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);
90
104
  case 'prompt': {
91
105
  // Two processes writing one Claude session corrupt it. If the desk touched this session in
92
106
  // the last 20 s (the guard's heartbeat), refuse — never touch a session with no heartbeat.
@@ -114,7 +128,7 @@ export class Hub {
114
128
  return from.send({ type: 'asked', sid: msg.sid, reqId: msg.reqId });
115
129
  live.asks.delete(msg.reqId);
116
130
  this.log(`${msg.decision} → ${ask.msg.summary}`);
117
- ask.resolve({ decision: msg.decision, message: msg.message });
131
+ ask.resolve({ decision: msg.decision, message: msg.message, answers: msg.answers });
118
132
  this.broadcast({ type: 'asked', sid: msg.sid, reqId: msg.reqId });
119
133
  this.setState(live, live.asks.size ? 'needs_you' : 'running');
120
134
  return;
@@ -360,6 +374,63 @@ export class Hub {
360
374
  clearTimeout(timer);
361
375
  return result;
362
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
+ }
363
434
  /** Take a pending request off the phone and say why it is gone. */
364
435
  withdraw(live, reqId, reason) {
365
436
  const ask = live.asks.get(reqId);
@@ -488,9 +559,9 @@ export class Hub {
488
559
  if (!live.flush)
489
560
  live.flush = setTimeout(() => this.flush(live), FLUSH_MS);
490
561
  },
491
- tool: (tool, detail) => {
562
+ tool: (tool, detail, path) => {
492
563
  this.flush(live);
493
- 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);
494
565
  },
495
566
  state: (state) => this.setState(live, state),
496
567
  limits: (l) => {
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
@@ -68,6 +68,9 @@ export function describe(tool, input) {
68
68
  // What the edit actually changes, not just which file it lands in. Approving a change you
69
69
  // cannot see is a tap, not a decision.
70
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,
71
74
  };
72
75
  }
73
76
  case 'Write': {
@@ -78,6 +81,7 @@ export function describe(tool, input) {
78
81
  // The path alone was the whole detail here, so the card named a file and showed nothing of
79
82
  // what was going into it.
80
83
  detail: clip(body ? `${path}\n\n${body}` : path, 2000),
84
+ path: path || undefined,
81
85
  };
82
86
  }
83
87
  case 'WebFetch':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmovex/agentpager",
3
- "version": "0.1.1",
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": {