@cosmovex/agentpager 0.1.1 → 0.1.3
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/cli.js +134 -38
- package/dist/guard/index.js +2 -2
- package/dist/hub.js +108 -5
- package/dist/projects.js +97 -2
- package/dist/risk.js +4 -0
- package/dist/service.js +2 -2
- package/dist/update.js +52 -0
- package/package.json +2 -2
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/cli.js
CHANGED
|
@@ -22,7 +22,8 @@ import { runRuleCommand, USAGE as GUARD_USAGE } from './guard/cli.js';
|
|
|
22
22
|
import { install as guardInstall, runDeskHook, runHook, status as guardStatus, uninstall as guardUninstall } from './guard/index.js';
|
|
23
23
|
import { Channel } from './relay.js';
|
|
24
24
|
import { installService, uninstallService } from './service.js';
|
|
25
|
-
import { loadState,
|
|
25
|
+
import { loadState, updateState } from './store.js';
|
|
26
|
+
import { isNewer, latestVersion, UPDATE_COMMAND } from './update.js';
|
|
26
27
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
27
28
|
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
28
29
|
const log = (line) => console.log(`${dim(new Date().toLocaleTimeString())} ${line}`);
|
|
@@ -34,7 +35,22 @@ function duration(v) {
|
|
|
34
35
|
const n = Number(m[1]);
|
|
35
36
|
return n * (m[2] === 'h' ? 3600_000 : m[2] === 'm' ? 60_000 : 1000);
|
|
36
37
|
}
|
|
38
|
+
/** One line per paired phone, with the id `unpair` wants. Never leaves someone guessing what is paired. */
|
|
39
|
+
function printPhones(devices, indent = ' ') {
|
|
40
|
+
for (const d of devices) {
|
|
41
|
+
console.log(`${indent}${bold(d.name)} ${dim(`id ${d.id} · paired ${new Date(d.pairedAt).toLocaleDateString()}`)}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const UNPAIR_HINT = 'Remove a phone: `agentpager unpair <id>` (or `agentpager unpair --all`). List them: `agentpager devices`.';
|
|
37
45
|
async function pair(state) {
|
|
46
|
+
// Re-read: this snapshot may be as old as the terminal. "Paired" with no way to see what, or to
|
|
47
|
+
// undo it, is what made setup feel like a trap.
|
|
48
|
+
const current = loadState().devices;
|
|
49
|
+
if (current.length) {
|
|
50
|
+
console.log(`\n${bold(`Already paired on ${state.machineName}:`)}`);
|
|
51
|
+
printPhones(current);
|
|
52
|
+
console.log(dim(`${UNPAIR_HINT}\nContinuing adds one more phone.`));
|
|
53
|
+
}
|
|
38
54
|
const offer = offerPairing(state);
|
|
39
55
|
console.log(`\n${bold('Pair your phone')} — in the AgentPager app tap "Scan the code" (or point your phone camera here):\n`);
|
|
40
56
|
qrcode.generate(offer.link, { small: true });
|
|
@@ -45,6 +61,10 @@ async function pair(state) {
|
|
|
45
61
|
updateState((s) => s.devices.push(device));
|
|
46
62
|
state.devices.push(device); // keep the in-memory copy usable for a caller that goes on to serve
|
|
47
63
|
console.log(`\n✅ Paired with ${bold(device.name)}.\n`);
|
|
64
|
+
const all = loadState().devices;
|
|
65
|
+
console.log(`${bold(`Paired phones (${all.length}):`)}`);
|
|
66
|
+
printPhones(all);
|
|
67
|
+
console.log(dim(`${UNPAIR_HINT}\n`));
|
|
48
68
|
// Pairing does not make this computer reachable — serving does. Standalone `agentpager pair`
|
|
49
69
|
// printed this tick and exited, leaving the phone attached to nothing: it showed "waiting for the
|
|
50
70
|
// computer to say which agents it has", then "computer is offline". Both were true, and neither
|
|
@@ -87,37 +107,78 @@ async function serve(state, keepAwake) {
|
|
|
87
107
|
['codex', codex],
|
|
88
108
|
]);
|
|
89
109
|
const hub = new Hub(agents, state.machineName, log, keepAwake);
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
110
|
+
// Persist only what THIS process owns (sequence numbers, push tokens) and merge it into the file
|
|
111
|
+
// as it is now. Saving our startup snapshot would resurrect a phone that `agentpager unpair`
|
|
112
|
+
// just removed — a revoked credential coming back to life.
|
|
113
|
+
const channels = new Map();
|
|
114
|
+
let dirty = false;
|
|
115
|
+
const flush = () => {
|
|
116
|
+
if (!dirty)
|
|
117
|
+
return;
|
|
118
|
+
dirty = false;
|
|
119
|
+
updateState((s) => {
|
|
120
|
+
for (const d of s.devices) {
|
|
121
|
+
const live = channels.get(d.id)?.device;
|
|
122
|
+
if (live)
|
|
123
|
+
Object.assign(d, { sendSeq: live.sendSeq, recvSeq: live.recvSeq, pushToken: live.pushToken });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
};
|
|
127
|
+
let flushTimer;
|
|
128
|
+
const persist = () => {
|
|
129
|
+
dirty = true;
|
|
130
|
+
flushTimer ??= setTimeout(() => {
|
|
131
|
+
flushTimer = undefined;
|
|
132
|
+
flush();
|
|
133
|
+
}, 250);
|
|
134
|
+
};
|
|
135
|
+
const connect = (d) => {
|
|
136
|
+
if (channels.has(d.id))
|
|
137
|
+
return;
|
|
138
|
+
const ch = new Channel(d, persist, (msg, c) => hub.handle(msg, c));
|
|
139
|
+
channels.set(d.id, ch);
|
|
140
|
+
hub.attach(ch);
|
|
141
|
+
ch.start();
|
|
142
|
+
};
|
|
143
|
+
const disconnect = async (id, told) => {
|
|
144
|
+
const ch = channels.get(id);
|
|
145
|
+
if (!ch)
|
|
146
|
+
return;
|
|
147
|
+
channels.delete(id);
|
|
148
|
+
hub.detach(ch);
|
|
149
|
+
if (told) {
|
|
150
|
+
ch.send({ type: 'unpaired', machine: state.machineName });
|
|
151
|
+
await new Promise((r) => setTimeout(r, 1500)); // let the message leave before the channel closes
|
|
106
152
|
}
|
|
107
|
-
|
|
108
|
-
|
|
153
|
+
await ch.stop();
|
|
154
|
+
};
|
|
155
|
+
for (const d of state.devices)
|
|
156
|
+
connect(d);
|
|
157
|
+
// `agentpager unpair` / `agentpager pair` in ANOTHER terminal edit the file. Without this, an
|
|
158
|
+
// unpaired phone stayed connected (and showed "online") until this window was restarted, and a
|
|
159
|
+
// newly paired one got nothing until then too.
|
|
160
|
+
const watch = setInterval(() => {
|
|
161
|
+
const now = loadState().devices;
|
|
162
|
+
for (const id of [...channels.keys()]) {
|
|
163
|
+
if (!now.some((d) => d.id === id)) {
|
|
164
|
+
log(`unpaired: ${channels.get(id)?.deviceName}`);
|
|
165
|
+
void disconnect(id, true);
|
|
166
|
+
}
|
|
109
167
|
}
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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}`);
|
|
168
|
+
for (const d of now) {
|
|
169
|
+
if (!channels.has(d.id)) {
|
|
170
|
+
log(`paired while running: ${d.name}`);
|
|
171
|
+
connect(d);
|
|
172
|
+
}
|
|
119
173
|
}
|
|
120
|
-
},
|
|
174
|
+
}, 2000);
|
|
175
|
+
hub.onUnpair = (ch) => {
|
|
176
|
+
// The phone forgot us in the app: drop it from disk first, then close the channel.
|
|
177
|
+
updateState((s) => {
|
|
178
|
+
s.devices = s.devices.filter((d) => d.id !== ch.device.id);
|
|
179
|
+
});
|
|
180
|
+
void disconnect(ch.device.id, false);
|
|
181
|
+
};
|
|
121
182
|
const stopControl = serveControl((req) => hub.job(req));
|
|
122
183
|
// Detect the ACP agents in the background and announce them as they appear.
|
|
123
184
|
void (async () => {
|
|
@@ -136,18 +197,33 @@ async function serve(state, keepAwake) {
|
|
|
136
197
|
console.log(` ⚠️ ${stale}`);
|
|
137
198
|
for (const s of statuses)
|
|
138
199
|
console.log(` ${s.available ? '●' : '○'} ${s.name}${s.version ? ` ${dim(s.version)}` : ''}${s.available ? '' : dim(` ${s.reason}`)}`);
|
|
139
|
-
console.log(` ${state.devices.length} phone${state.devices.length === 1 ? '' : 's'} paired
|
|
200
|
+
console.log(` ${state.devices.length} phone${state.devices.length === 1 ? '' : 's'} paired:`);
|
|
201
|
+
printPhones(state.devices, ' ');
|
|
140
202
|
console.log(dim('\nKeep this running (or run `agentpager service install` to start it at login).'));
|
|
141
203
|
// Someone who reinstalls the app, or picks up a second phone, reasonably runs `agentpager` again
|
|
142
204
|
// and waits for a code that never comes — this is the only place they will look for why.
|
|
143
205
|
console.log(dim('Pairing another phone (or the same one after a reinstall)? Run `agentpager pair`.'));
|
|
206
|
+
console.log(dim(UNPAIR_HINT));
|
|
144
207
|
console.log(dim('Your sessions stay on this computer; the relay only sees encrypted bytes.\n'));
|
|
208
|
+
// Tell the terminal AND the phone when a newer AgentPager is out.
|
|
209
|
+
const checkUpdate = async () => {
|
|
210
|
+
const latest = await latestVersion();
|
|
211
|
+
if (latest && isNewer(latest, BRIDGE_VERSION)) {
|
|
212
|
+
console.log(`\n⬆️ ${bold(`AgentPager ${latest} is available`)} (you have ${BRIDGE_VERSION}). Update: ${bold(UPDATE_COMMAND)}, then restart.\n`);
|
|
213
|
+
await hub.setLatest(latest);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
void checkUpdate();
|
|
217
|
+
const updateTimer = setInterval(() => void checkUpdate(), 12 * 3600_000);
|
|
145
218
|
const quit = async () => {
|
|
146
219
|
console.log(dim('\nDisconnecting…'));
|
|
147
220
|
hub.shutdown();
|
|
148
221
|
stopControl();
|
|
149
222
|
codex.shutdown();
|
|
150
|
-
|
|
223
|
+
clearInterval(watch);
|
|
224
|
+
clearInterval(updateTimer);
|
|
225
|
+
flush();
|
|
226
|
+
await Promise.all([...channels.values()].map((c) => c.stop()));
|
|
151
227
|
process.exit(0);
|
|
152
228
|
};
|
|
153
229
|
process.on('SIGINT', quit);
|
|
@@ -195,16 +271,36 @@ async function main() {
|
|
|
195
271
|
case 'devices':
|
|
196
272
|
if (!state.devices.length)
|
|
197
273
|
console.log('No phones paired. Run `agentpager pair`.');
|
|
198
|
-
|
|
199
|
-
console.log(`${
|
|
274
|
+
else {
|
|
275
|
+
console.log(bold(`${state.devices.length} phone${state.devices.length === 1 ? '' : 's'} paired with ${state.machineName}:`));
|
|
276
|
+
printPhones(state.devices);
|
|
277
|
+
console.log(dim(`\n${UNPAIR_HINT}`));
|
|
278
|
+
}
|
|
200
279
|
process.exit(0);
|
|
201
280
|
case 'unpair': {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
281
|
+
if (!arg) {
|
|
282
|
+
console.log('Usage: agentpager unpair <id | name> | --all\n');
|
|
283
|
+
if (state.devices.length)
|
|
284
|
+
printPhones(state.devices, '');
|
|
285
|
+
else
|
|
286
|
+
console.log('No phones are paired.');
|
|
287
|
+
process.exit(1);
|
|
288
|
+
}
|
|
289
|
+
const q = arg.toLowerCase();
|
|
290
|
+
const hit = (d) => arg === '--all' || d.id === arg || d.name.toLowerCase() === q;
|
|
291
|
+
const removed = state.devices.filter(hit);
|
|
292
|
+
if (!removed.length) {
|
|
293
|
+
console.log(`No phone matches "${arg}". Paired phones:`);
|
|
294
|
+
printPhones(state.devices, ' ');
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
297
|
+
updateState((s) => {
|
|
298
|
+
s.devices = s.devices.filter((d) => !hit(d));
|
|
205
299
|
});
|
|
206
|
-
|
|
207
|
-
|
|
300
|
+
for (const d of removed)
|
|
301
|
+
console.log(`Removed ${bold(d.name)}. It can no longer reach this computer.`);
|
|
302
|
+
const running = await bridgeRunning();
|
|
303
|
+
console.log(dim(running ? 'The running AgentPager drops it within a couple of seconds and tells the phone.' : 'Nothing is running here, so it is gone for good.'));
|
|
208
304
|
process.exit(0);
|
|
209
305
|
}
|
|
210
306
|
case 'notify':
|
package/dist/guard/index.js
CHANGED
|
@@ -204,7 +204,7 @@ function HEADLESS_WAYS_OUT(rule) {
|
|
|
204
204
|
/**
|
|
205
205
|
* Running from npx's throwaway cache?
|
|
206
206
|
*
|
|
207
|
-
* `npx agentpager` is the right way to try this — it needs no commitment and leaves nothing behind.
|
|
207
|
+
* `npx @cosmovex/agentpager` is the right way to try this — it needs no commitment and leaves nothing behind.
|
|
208
208
|
* But "leaves nothing behind" is fatal for a hook: the absolute path we register would point into a
|
|
209
209
|
* directory npx is free to delete, and the guard would stop running without ever saying so. Rules
|
|
210
210
|
* need the package to still exist tomorrow.
|
|
@@ -217,7 +217,7 @@ export function install() {
|
|
|
217
217
|
console.log('✖ This copy is running from npx\u2019s temporary cache, which gets deleted.');
|
|
218
218
|
console.log(' A hook pointing there would stop working silently, so it is not written.');
|
|
219
219
|
console.log(' Install it properly first, then rules will stick:');
|
|
220
|
-
console.log(' npm install -g agentpager');
|
|
220
|
+
console.log(' npm install -g @cosmovex/agentpager');
|
|
221
221
|
console.log(' agentpager-guard install');
|
|
222
222
|
process.exitCode = 1;
|
|
223
223
|
return;
|
package/dist/hub.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
1
3
|
// Routes phone messages to agents and agent events back to every paired phone.
|
|
2
4
|
// Margin rules live here: stream text only to a phone someone is looking at; approvals and
|
|
3
5
|
// "done" always go out (those are what make the app worth having), and they are tiny.
|
|
@@ -6,11 +8,22 @@ import { basename } from 'node:path';
|
|
|
6
8
|
import { random, b64u } from './crypto.js';
|
|
7
9
|
import { record as recordLimits } from './guard/limits.js';
|
|
8
10
|
import { PROTOCOL } from './protocol.js';
|
|
9
|
-
import { addRoot, allowedCwd, browseFolders, discoverProjects } from './projects.js';
|
|
11
|
+
import { addRoot, allowedCwd, browseFolders, discoverProjects, MAX_PHONE_FILE_BYTES, readFileForPhone, writeUploadedFile } from './projects.js';
|
|
12
|
+
import { isNewer } from './update.js';
|
|
10
13
|
import { AwakeLock } from './awake.js';
|
|
11
14
|
import { deskActive, pruneDesk } from './guard/desk.js';
|
|
12
15
|
import { loadRules, PRESETS, saveRules, validateRules } from './guard/rules.js';
|
|
13
|
-
|
|
16
|
+
// Read from package.json, never typed twice: this constant sat at 0.1.0 while npm had 0.1.2, and
|
|
17
|
+
// the update check would have told every user, for ever, that the version they run is out of date.
|
|
18
|
+
export const BRIDGE_VERSION = (() => {
|
|
19
|
+
try {
|
|
20
|
+
const pkg = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'));
|
|
21
|
+
return String(pkg.version);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return '0.0.0';
|
|
25
|
+
}
|
|
26
|
+
})();
|
|
14
27
|
const FLUSH_MS = 400;
|
|
15
28
|
// A script that asks and is never answered must give up on its own. Ten minutes is long enough to
|
|
16
29
|
// walk back from the kitchen and short enough that a CI job doesn't sit on a runner all night.
|
|
@@ -22,6 +35,11 @@ export class Hub {
|
|
|
22
35
|
channels = [];
|
|
23
36
|
live = new Map();
|
|
24
37
|
known = new Map(); // last list, for cwd lookups
|
|
38
|
+
// In-flight uploads FROM the phone, keyed by reqId — the mirror of `sendFile`'s chunking, going
|
|
39
|
+
// the other way. Cleared on completion or error; a reqId that never completes just ages out with
|
|
40
|
+
// the process (no size cap consequence: file_put_start's own `size` already came from
|
|
41
|
+
// MAX_PHONE_FILE_BYTES-checked FilePicker output on the phone, not from anything the bridge trusts blindly here — see receiveUploadChunk).
|
|
42
|
+
uploads = new Map();
|
|
25
43
|
projects = new Set();
|
|
26
44
|
discovered; // git repos on this computer; scanned once, on the first hello
|
|
27
45
|
projectCache;
|
|
@@ -38,6 +56,21 @@ export class Hub {
|
|
|
38
56
|
attach(channel) {
|
|
39
57
|
this.channels.push(channel);
|
|
40
58
|
}
|
|
59
|
+
/** Stop talking to a phone. Anything it still has pending stays pending; it can no longer answer. */
|
|
60
|
+
detach(channel) {
|
|
61
|
+
this.channels = this.channels.filter((c) => c !== channel);
|
|
62
|
+
}
|
|
63
|
+
/** Called with the phone that asked to be unpaired; the CLI owns the state file. */
|
|
64
|
+
onUnpair;
|
|
65
|
+
/** Newest version on npm, once known. Sent with every hello so the phone can say "update". */
|
|
66
|
+
latest;
|
|
67
|
+
async setLatest(v) {
|
|
68
|
+
if (!v || v === this.latest)
|
|
69
|
+
return;
|
|
70
|
+
this.latest = v;
|
|
71
|
+
for (const c of this.channels)
|
|
72
|
+
await this.hello(c);
|
|
73
|
+
}
|
|
41
74
|
/** Agents detected after startup (probing them must never delay the relay). */
|
|
42
75
|
async addAgent(agent) {
|
|
43
76
|
this.agents.set(agent.id, agent);
|
|
@@ -64,6 +97,9 @@ export class Hub {
|
|
|
64
97
|
switch (msg.type) {
|
|
65
98
|
case 'hello':
|
|
66
99
|
return await this.hello(from);
|
|
100
|
+
case 'unpair':
|
|
101
|
+
this.log(`${from.deviceName} unpaired itself from the app`);
|
|
102
|
+
return this.onUnpair?.(from);
|
|
67
103
|
case 'list':
|
|
68
104
|
return from.send({ type: 'sessions', items: await this.list() });
|
|
69
105
|
case 'open':
|
|
@@ -87,6 +123,15 @@ export class Hub {
|
|
|
87
123
|
}
|
|
88
124
|
case 'browse':
|
|
89
125
|
return from.send({ type: 'folders', ...browseFolders(msg.path) });
|
|
126
|
+
case 'file_get':
|
|
127
|
+
return this.sendFile(msg.reqId, msg.path, from);
|
|
128
|
+
case 'file_put_start':
|
|
129
|
+
// `.fill(undefined)`, not a bare `new Array(n)`: a sparse array's holes are invisible to
|
|
130
|
+
// `.some()`/`.reduce()` below, so a 2-chunk upload looked "complete" after just chunk 0.
|
|
131
|
+
this.uploads.set(msg.reqId, { sid: msg.sid, name: msg.name, size: msg.size, chunks: msg.chunks, received: new Array(msg.chunks).fill(undefined) });
|
|
132
|
+
return;
|
|
133
|
+
case 'file_put_chunk':
|
|
134
|
+
return this.receiveUploadChunk(msg.reqId, msg.index, msg.data, from);
|
|
90
135
|
case 'prompt': {
|
|
91
136
|
// Two processes writing one Claude session corrupt it. If the desk touched this session in
|
|
92
137
|
// the last 20 s (the guard's heartbeat), refuse — never touch a session with no heartbeat.
|
|
@@ -114,7 +159,7 @@ export class Hub {
|
|
|
114
159
|
return from.send({ type: 'asked', sid: msg.sid, reqId: msg.reqId });
|
|
115
160
|
live.asks.delete(msg.reqId);
|
|
116
161
|
this.log(`${msg.decision} → ${ask.msg.summary}`);
|
|
117
|
-
ask.resolve({ decision: msg.decision, message: msg.message });
|
|
162
|
+
ask.resolve({ decision: msg.decision, message: msg.message, answers: msg.answers });
|
|
118
163
|
this.broadcast({ type: 'asked', sid: msg.sid, reqId: msg.reqId });
|
|
119
164
|
this.setState(live, live.asks.size ? 'needs_you' : 'running');
|
|
120
165
|
return;
|
|
@@ -249,6 +294,7 @@ export class Hub {
|
|
|
249
294
|
protocol: PROTOCOL,
|
|
250
295
|
agents: await this.statuses(),
|
|
251
296
|
projects: await this.offerProjects(),
|
|
297
|
+
...(this.latest && isNewer(this.latest, BRIDGE_VERSION) ? { latest: this.latest } : {}),
|
|
252
298
|
});
|
|
253
299
|
// A phone that reconnects must see every approval still waiting — never miss one.
|
|
254
300
|
for (const live of this.live.values()) {
|
|
@@ -360,6 +406,63 @@ export class Hub {
|
|
|
360
406
|
clearTimeout(timer);
|
|
361
407
|
return result;
|
|
362
408
|
}
|
|
409
|
+
/**
|
|
410
|
+
* "Claude made a PDF, how do I read it" — one named file, base64-chunked over the same small
|
|
411
|
+
* encrypted channel everything else uses (the relay's own rules cap one message's ciphertext at
|
|
412
|
+
* 24,000 chars; 10,000 raw bytes per chunk leaves room for the base64 + encryption overhead on
|
|
413
|
+
* top of that, twice — once for the chunk data, once for the envelope). Fire-and-forget: the
|
|
414
|
+
* phone reassembles by `index`, and a chunk arriving out of order costs nothing to wait for.
|
|
415
|
+
*/
|
|
416
|
+
sendFile(reqId, path, from) {
|
|
417
|
+
const CHUNK_BYTES = 10_000;
|
|
418
|
+
const file = readFileForPhone(path);
|
|
419
|
+
if (!file.ok) {
|
|
420
|
+
from.send({ type: 'file_error', reqId, message: file.message });
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const chunks = Math.max(1, Math.ceil(file.bytes.length / CHUNK_BYTES));
|
|
424
|
+
from.send({ type: 'file_start', reqId, name: file.name, mime: file.mime, size: file.bytes.length, chunks });
|
|
425
|
+
for (let i = 0; i < chunks; i++) {
|
|
426
|
+
const slice = file.bytes.subarray(i * CHUNK_BYTES, (i + 1) * CHUNK_BYTES);
|
|
427
|
+
from.send({ type: 'file_chunk', reqId, index: i, data: slice.toString('base64') });
|
|
428
|
+
}
|
|
429
|
+
this.log(`sent ${file.name} (${(file.bytes.length / 1024).toFixed(0)} KB, ${chunks} chunk${chunks === 1 ? '' : 's'})`);
|
|
430
|
+
}
|
|
431
|
+
/** One chunk of a phone upload. Never trusts the phone's own claimed `size` for enforcement —
|
|
432
|
+
* counts real received bytes against the same cap `readFileForPhone` uses the other way. */
|
|
433
|
+
receiveUploadChunk(reqId, index, data, from) {
|
|
434
|
+
const up = this.uploads.get(reqId);
|
|
435
|
+
if (!up || index < 0 || index >= up.chunks)
|
|
436
|
+
return; // unknown or stale transfer: say nothing, ask nothing
|
|
437
|
+
up.received[index] = Buffer.from(data, 'base64');
|
|
438
|
+
const soFar = up.received.reduce((n, b) => n + (b?.length ?? 0), 0);
|
|
439
|
+
if (soFar > MAX_PHONE_FILE_BYTES) {
|
|
440
|
+
this.uploads.delete(reqId);
|
|
441
|
+
from.send({ type: 'file_put_error', reqId, message: 'That file is too big to send this way.' });
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
if (up.received.some((b) => !b))
|
|
445
|
+
return; // still waiting on other chunks
|
|
446
|
+
this.uploads.delete(reqId);
|
|
447
|
+
const cwd = this.known.get(up.sid)?.cwd;
|
|
448
|
+
if (!cwd) {
|
|
449
|
+
from.send({ type: 'file_put_error', reqId, message: 'That session is not open on this computer right now.' });
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
const bytes = Buffer.concat(up.received);
|
|
453
|
+
const result = writeUploadedFile(cwd, up.name, bytes);
|
|
454
|
+
if (!result.ok) {
|
|
455
|
+
from.send({ type: 'file_put_error', reqId, message: result.message });
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
this.log(`received ${up.name} from the phone (${(bytes.length / 1024).toFixed(0)} KB) → ${result.path}`);
|
|
459
|
+
from.send({ type: 'file_put_done', reqId, sid: up.sid, path: result.path });
|
|
460
|
+
// Tell the agent, if one is actually running here — a file sitting on disk it was never told
|
|
461
|
+
// about is indistinguishable from a file that does not exist.
|
|
462
|
+
const live = this.live.get(up.sid);
|
|
463
|
+
if (live?.session)
|
|
464
|
+
void live.session.send(`[The user sent a file from their phone: ${result.path}]`);
|
|
465
|
+
}
|
|
363
466
|
/** Take a pending request off the phone and say why it is gone. */
|
|
364
467
|
withdraw(live, reqId, reason) {
|
|
365
468
|
const ask = live.asks.get(reqId);
|
|
@@ -488,9 +591,9 @@ export class Hub {
|
|
|
488
591
|
if (!live.flush)
|
|
489
592
|
live.flush = setTimeout(() => this.flush(live), FLUSH_MS);
|
|
490
593
|
},
|
|
491
|
-
tool: (tool, detail) => {
|
|
594
|
+
tool: (tool, detail, path) => {
|
|
492
595
|
this.flush(live);
|
|
493
|
-
this.broadcast({ type: 'tool', sid: live.sid, tool, detail: detail.slice(0, 300) }, true);
|
|
596
|
+
this.broadcast({ type: 'tool', sid: live.sid, tool, detail: detail.slice(0, 300), path }, true);
|
|
494
597
|
},
|
|
495
598
|
state: (state) => this.setState(live, state),
|
|
496
599
|
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/dist/service.js
CHANGED
|
@@ -16,14 +16,14 @@ function run(cmd, args) {
|
|
|
16
16
|
/** npx runs from a throwaway cache; a service pointing there breaks on the next cache clean. */
|
|
17
17
|
function assertStableInstall() {
|
|
18
18
|
if (/[\\/]_npx[\\/]/.test(cli)) {
|
|
19
|
-
throw new Error('Install it permanently first: npm install -g agentpager then run: agentpager service install');
|
|
19
|
+
throw new Error('Install it permanently first: npm install -g @cosmovex/agentpager then run: agentpager service install');
|
|
20
20
|
}
|
|
21
21
|
// A macOS LaunchAgent cannot read an external/removable volume: the job starts and hangs before
|
|
22
22
|
// the first line of code, with no error anywhere. Measured 2026-09-23.
|
|
23
23
|
if (platform() === 'darwin' && cli.startsWith('/Volumes/')) {
|
|
24
24
|
throw new Error(`This copy lives on an external volume (${cli}).\n` +
|
|
25
25
|
'macOS login items cannot read those, so the service would start and hang silently.\n' +
|
|
26
|
-
'Install it on the internal disk instead: npm install -g agentpager then: agentpager service install');
|
|
26
|
+
'Install it on the internal disk instead: npm install -g @cosmovex/agentpager then: agentpager service install');
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
export function installService() {
|
package/dist/update.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Is there a newer AgentPager on npm? The phone can only say "update" if the computer knows.
|
|
2
|
+
// One small GET, cached on disk for 12 hours, and every failure means "no news" — an update
|
|
3
|
+
// check must never be able to slow down or break a bridge that is otherwise working.
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { stateDir } from './store.js';
|
|
7
|
+
const REGISTRY = 'https://registry.npmjs.org/@cosmovex%2Fagentpager/latest';
|
|
8
|
+
const CACHE_MS = 12 * 3600_000;
|
|
9
|
+
const cacheFile = () => join(stateDir, 'update.json');
|
|
10
|
+
/** True when `latest` is a strictly newer x.y.z than `current`. Pre-release tags are ignored. */
|
|
11
|
+
export function isNewer(latest, current) {
|
|
12
|
+
const parse = (v) => v.split('-')[0].split('.').map((n) => parseInt(n, 10) || 0);
|
|
13
|
+
const a = parse(latest);
|
|
14
|
+
const b = parse(current);
|
|
15
|
+
for (let i = 0; i < 3; i++) {
|
|
16
|
+
if ((a[i] ?? 0) !== (b[i] ?? 0))
|
|
17
|
+
return (a[i] ?? 0) > (b[i] ?? 0);
|
|
18
|
+
}
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
/** The newest published version, or undefined when we cannot tell. */
|
|
22
|
+
export async function latestVersion(fetchImpl = fetch) {
|
|
23
|
+
try {
|
|
24
|
+
if (existsSync(cacheFile())) {
|
|
25
|
+
const c = JSON.parse(readFileSync(cacheFile(), 'utf8'));
|
|
26
|
+
if (Date.now() - c.at < CACHE_MS && c.latest)
|
|
27
|
+
return c.latest;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
/* unreadable cache: ask again */
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const res = await fetchImpl(REGISTRY, { signal: AbortSignal.timeout(5000) });
|
|
35
|
+
if (!res.ok)
|
|
36
|
+
return undefined; // 404 until the first publish
|
|
37
|
+
const latest = String((await res.json()).version ?? '');
|
|
38
|
+
if (!/^\d+\.\d+\.\d+/.test(latest))
|
|
39
|
+
return undefined;
|
|
40
|
+
try {
|
|
41
|
+
writeFileSync(cacheFile(), JSON.stringify({ at: Date.now(), latest }));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
/* read-only home: fine, we just ask again next time */
|
|
45
|
+
}
|
|
46
|
+
return latest;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export const UPDATE_COMMAND = 'npm install -g @cosmovex/agentpager@latest';
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmovex/agentpager",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Pager for your AI coding agents
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Pager for your AI coding agents — approve, reply and hear results on your phone. End-to-end encrypted.",
|
|
5
5
|
"main": "dist/cli.js",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"build": "tsc && node -e \"const fs=require('fs');for(const f of ['dist/cli.js','dist/guard/bin.js'])fs.chmodSync(f,0o755)\"",
|