@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.
- package/dist/agents/claude.js +52 -2
- package/dist/hub.js +75 -4
- package/dist/projects.js +97 -2
- package/dist/risk.js +4 -0
- package/package.json +1 -1
package/dist/agents/claude.js
CHANGED
|
@@ -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
|
-
|
|
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