@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 +2 -2
- package/dist/agents/acp.js +23 -0
- package/dist/agents/claude.js +150 -10
- package/dist/agents/codex.js +20 -0
- package/dist/cli.js +25 -0
- package/dist/guard/limits.js +11 -2
- package/dist/hub.js +108 -4
- package/dist/projects.js +97 -2
- package/dist/risk.js +59 -6
- package/package.json +1 -1
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
|
```
|
package/dist/agents/acp.js
CHANGED
|
@@ -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
|
},
|
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 = [];
|
|
@@ -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
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
},
|
package/dist/agents/codex.js
CHANGED
|
@@ -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 () => {
|
package/dist/guard/limits.js
CHANGED
|
@@ -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
|
-
/**
|
|
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)
|
|
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
|
-
/**
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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