@cosmovex/agentpager 0.1.0
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/LICENSE +8 -0
- package/README.md +98 -0
- package/dist/agents/acp.js +311 -0
- package/dist/agents/claude.js +279 -0
- package/dist/agents/codex.js +271 -0
- package/dist/agents/explain.js +39 -0
- package/dist/agents/types.js +29 -0
- package/dist/awake.js +73 -0
- package/dist/cli.js +333 -0
- package/dist/control.js +110 -0
- package/dist/crypto.js +65 -0
- package/dist/freshness.js +60 -0
- package/dist/guard/bin.js +41 -0
- package/dist/guard/cli.js +174 -0
- package/dist/guard/decide.js +165 -0
- package/dist/guard/desk.js +63 -0
- package/dist/guard/explain.js +49 -0
- package/dist/guard/index.js +341 -0
- package/dist/guard/limits.js +74 -0
- package/dist/guard/match.js +363 -0
- package/dist/guard/rules.js +267 -0
- package/dist/guard/spend.js +158 -0
- package/dist/hub.js +558 -0
- package/dist/pair.js +51 -0
- package/dist/projects.js +195 -0
- package/dist/protocol.js +1 -0
- package/dist/relay.js +134 -0
- package/dist/risk.js +78 -0
- package/dist/service.js +117 -0
- package/dist/store.js +83 -0
- package/package.json +58 -0
package/dist/hub.js
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
// Routes phone messages to agents and agent events back to every paired phone.
|
|
2
|
+
// Margin rules live here: stream text only to a phone someone is looking at; approvals and
|
|
3
|
+
// "done" always go out (those are what make the app worth having), and they are tiny.
|
|
4
|
+
import { platform } from 'node:os';
|
|
5
|
+
import { basename } from 'node:path';
|
|
6
|
+
import { random, b64u } from './crypto.js';
|
|
7
|
+
import { record as recordLimits } from './guard/limits.js';
|
|
8
|
+
import { PROTOCOL } from './protocol.js';
|
|
9
|
+
import { addRoot, allowedCwd, browseFolders, discoverProjects } from './projects.js';
|
|
10
|
+
import { AwakeLock } from './awake.js';
|
|
11
|
+
import { deskActive, pruneDesk } from './guard/desk.js';
|
|
12
|
+
import { loadRules, PRESETS, saveRules, validateRules } from './guard/rules.js';
|
|
13
|
+
export const BRIDGE_VERSION = '0.1.0';
|
|
14
|
+
const FLUSH_MS = 400;
|
|
15
|
+
// A script that asks and is never answered must give up on its own. Ten minutes is long enough to
|
|
16
|
+
// walk back from the kitchen and short enough that a CI job doesn't sit on a runner all night.
|
|
17
|
+
const DEFAULT_ASK_MS = 10 * 60_000;
|
|
18
|
+
export class Hub {
|
|
19
|
+
agents;
|
|
20
|
+
machine;
|
|
21
|
+
log;
|
|
22
|
+
channels = [];
|
|
23
|
+
live = new Map();
|
|
24
|
+
known = new Map(); // last list, for cwd lookups
|
|
25
|
+
projects = new Set();
|
|
26
|
+
discovered; // git repos on this computer; scanned once, on the first hello
|
|
27
|
+
projectCache;
|
|
28
|
+
statusCache;
|
|
29
|
+
jobs = new Map(); // `agentpager notify|ask` from any script
|
|
30
|
+
constructor(agents, machine, log, keepAwake = true) {
|
|
31
|
+
this.agents = agents;
|
|
32
|
+
this.machine = machine;
|
|
33
|
+
this.log = log;
|
|
34
|
+
this.awake = new AwakeLock(keepAwake, log);
|
|
35
|
+
pruneDesk();
|
|
36
|
+
}
|
|
37
|
+
awake;
|
|
38
|
+
attach(channel) {
|
|
39
|
+
this.channels.push(channel);
|
|
40
|
+
}
|
|
41
|
+
/** Agents detected after startup (probing them must never delay the relay). */
|
|
42
|
+
async addAgent(agent) {
|
|
43
|
+
this.agents.set(agent.id, agent);
|
|
44
|
+
this.statusCache = undefined;
|
|
45
|
+
for (const c of this.channels)
|
|
46
|
+
await this.hello(c);
|
|
47
|
+
}
|
|
48
|
+
notify(kind, note) {
|
|
49
|
+
for (const c of this.channels)
|
|
50
|
+
c.notify(kind, note);
|
|
51
|
+
}
|
|
52
|
+
label(live) {
|
|
53
|
+
const cwd = this.known.get(live.sid)?.cwd ?? '';
|
|
54
|
+
const project = cwd.split(/[\\/]/).filter(Boolean).pop();
|
|
55
|
+
return project ? `${live.agent.name} · ${project}` : live.agent.name;
|
|
56
|
+
}
|
|
57
|
+
broadcast(msg, onlyActive = false) {
|
|
58
|
+
for (const c of this.channels)
|
|
59
|
+
if (!onlyActive || c.phoneActive)
|
|
60
|
+
c.send(msg);
|
|
61
|
+
}
|
|
62
|
+
async handle(msg, from) {
|
|
63
|
+
try {
|
|
64
|
+
switch (msg.type) {
|
|
65
|
+
case 'hello':
|
|
66
|
+
return await this.hello(from);
|
|
67
|
+
case 'list':
|
|
68
|
+
return from.send({ type: 'sessions', items: await this.list() });
|
|
69
|
+
case 'open':
|
|
70
|
+
await this.open(msg.sid, from);
|
|
71
|
+
return;
|
|
72
|
+
case 'new':
|
|
73
|
+
return await this.startNew(msg.agent, msg.cwd, from, msg.ref);
|
|
74
|
+
case 'rules_get':
|
|
75
|
+
return from.send(this.rulesReply());
|
|
76
|
+
case 'rules_set':
|
|
77
|
+
return this.setRules(msg, from);
|
|
78
|
+
case 'testpush': {
|
|
79
|
+
const wait = Math.min(Math.max(msg.delayMs ?? 5000, 0), 30_000);
|
|
80
|
+
this.log('test page queued — the phone asked us to prove notifications arrive');
|
|
81
|
+
setTimeout(() => {
|
|
82
|
+
from.notify('done', { sid: 'test', title: 'AgentPager', body: 'Notifications are working on this phone.' }, true);
|
|
83
|
+
}, wait);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
case 'browse':
|
|
87
|
+
return from.send({ type: 'folders', ...browseFolders(msg.path) });
|
|
88
|
+
case 'prompt': {
|
|
89
|
+
// Two processes writing one Claude session corrupt it. If the desk touched this session in
|
|
90
|
+
// the last 20 s (the guard's heartbeat), refuse — never touch a session with no heartbeat.
|
|
91
|
+
const { agent: target, id: sessionId } = this.split(msg.sid);
|
|
92
|
+
if (target?.id === 'claude' && deskActive(sessionId)) {
|
|
93
|
+
this.log(`prompt refused: the desk is active in ${msg.sid}`);
|
|
94
|
+
return from.send({
|
|
95
|
+
type: 'error',
|
|
96
|
+
sid: msg.sid,
|
|
97
|
+
code: 'desk_busy',
|
|
98
|
+
message: 'You are active on this computer in that session. Try again when it is idle.',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
const live = this.live.get(msg.sid) ?? (await this.open(msg.sid, from));
|
|
102
|
+
if (!live?.session)
|
|
103
|
+
return from.send({ type: 'error', sid: msg.sid, code: 'not_open', message: 'That session could not be opened.' });
|
|
104
|
+
this.log(`${msg.voice ? '🎙 ' : ''}prompt → ${msg.sid}`);
|
|
105
|
+
live.session.send(msg.text);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
case 'decide': {
|
|
109
|
+
const live = this.live.get(msg.sid);
|
|
110
|
+
const ask = live?.asks.get(msg.reqId);
|
|
111
|
+
if (!live || !ask)
|
|
112
|
+
return from.send({ type: 'asked', sid: msg.sid, reqId: msg.reqId });
|
|
113
|
+
live.asks.delete(msg.reqId);
|
|
114
|
+
this.log(`${msg.decision} → ${ask.msg.summary}`);
|
|
115
|
+
ask.resolve({ decision: msg.decision, message: msg.message });
|
|
116
|
+
this.broadcast({ type: 'asked', sid: msg.sid, reqId: msg.reqId });
|
|
117
|
+
this.setState(live, live.asks.size ? 'needs_you' : 'running');
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
case 'interrupt':
|
|
121
|
+
await this.live.get(msg.sid)?.session?.interrupt();
|
|
122
|
+
return;
|
|
123
|
+
case 'push':
|
|
124
|
+
from.setPushToken(typeof msg.token === 'string' && msg.token.length < 4096 ? msg.token : null);
|
|
125
|
+
this.log(msg.token ? 'notifications on for this phone' : 'notifications off for this phone');
|
|
126
|
+
return;
|
|
127
|
+
case 'close': {
|
|
128
|
+
const live = this.live.get(msg.sid);
|
|
129
|
+
if (live)
|
|
130
|
+
this.drop(live);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
from.send({ type: 'error', code: 'bridge', message: String(e?.message ?? e) });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
rulesReply(reqId, error) {
|
|
140
|
+
return { type: 'rules', rules: loadRules(), presets: PRESETS, ...(reqId ? { reqId } : {}), ...(error ? { error } : {}) };
|
|
141
|
+
}
|
|
142
|
+
/** Validate, write atomically, answer. A bad request is answered with `error`, never thrown. */
|
|
143
|
+
setRules(msg, from) {
|
|
144
|
+
const reqId = typeof msg.reqId === 'string' && msg.reqId.length <= 64 ? msg.reqId : undefined;
|
|
145
|
+
const v = validateRules(msg.rules);
|
|
146
|
+
if (!v.ok) {
|
|
147
|
+
this.log(`rules refused: ${v.error}`);
|
|
148
|
+
return from.send(this.rulesReply(reqId, v.error));
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
saveRules(v.rules);
|
|
152
|
+
}
|
|
153
|
+
catch (e) {
|
|
154
|
+
this.log(`rules could not be saved: ${e?.message ?? e}`);
|
|
155
|
+
return from.send(this.rulesReply(reqId, 'This computer could not save the rules file. Check that ~/.agentpager is writable.'));
|
|
156
|
+
}
|
|
157
|
+
this.log('guard rules updated from the phone');
|
|
158
|
+
from.send(this.rulesReply(reqId));
|
|
159
|
+
// Any other paired phone should see the change without asking.
|
|
160
|
+
for (const c of this.channels)
|
|
161
|
+
if (c !== from)
|
|
162
|
+
c.send(this.rulesReply());
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Spend so far for one session: Claude's own transcript, priced; Codex/ACP tokens when they report
|
|
166
|
+
* them (no dollars — there is no honest per-token price for a plan). `silent` only sets the
|
|
167
|
+
* warning baseline, so re-opening an old expensive session does not page anyone.
|
|
168
|
+
*/
|
|
169
|
+
async sendUsage(live, to, silent = false) {
|
|
170
|
+
let spend = null;
|
|
171
|
+
try {
|
|
172
|
+
spend = (await live.agent.usage?.(this.split(live.sid).id)) ?? null;
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
/* unreadable transcript: fall back to what the agent reported */
|
|
176
|
+
}
|
|
177
|
+
if (!spend && live.reported)
|
|
178
|
+
spend = { tokens: live.reported.tokens, usd: live.reported.usd, turns: live.turns };
|
|
179
|
+
if (!spend)
|
|
180
|
+
return;
|
|
181
|
+
const rules = loadRules();
|
|
182
|
+
const usd = spend.usd === undefined ? undefined : Math.round(spend.usd * 10_000) / 10_000;
|
|
183
|
+
const msg = {
|
|
184
|
+
type: 'usage',
|
|
185
|
+
sid: live.sid,
|
|
186
|
+
...(usd === undefined ? {} : { usd, estimated: true }),
|
|
187
|
+
tokens: spend.tokens,
|
|
188
|
+
turns: spend.turns,
|
|
189
|
+
budgetUsd: rules.budgetUsd,
|
|
190
|
+
};
|
|
191
|
+
if (to)
|
|
192
|
+
to.send(msg);
|
|
193
|
+
else
|
|
194
|
+
this.broadcast(msg);
|
|
195
|
+
if (usd === undefined || rules.budgetUsd <= 0)
|
|
196
|
+
return;
|
|
197
|
+
const level = usd >= rules.budgetUsd ? 2 : usd >= rules.budgetUsd * rules.warnAt ? 1 : 0;
|
|
198
|
+
if (level <= live.warned)
|
|
199
|
+
return;
|
|
200
|
+
live.warned = level;
|
|
201
|
+
if (silent)
|
|
202
|
+
return;
|
|
203
|
+
const money = (n) => `$${n.toFixed(2)}`;
|
|
204
|
+
this.notify(level === 2 ? 'error' : 'done', {
|
|
205
|
+
sid: live.sid,
|
|
206
|
+
title: level === 2 ? `${this.label(live)} reached its budget` : `${this.label(live)} is spending`,
|
|
207
|
+
body: level === 2
|
|
208
|
+
? `About ${money(usd)} of the ${money(rules.budgetUsd)} budget. ${live.agent.id === 'claude' ? 'The guard now refuses further tool calls.' : 'Consider stopping it.'}`
|
|
209
|
+
: `About ${money(usd)} so far — ${Math.round(rules.warnAt * 100)}% of the ${money(rules.budgetUsd)} budget.`,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
async statuses() {
|
|
213
|
+
if (this.statusCache && Date.now() - this.statusCache.at < 60_000)
|
|
214
|
+
return this.statusCache.items;
|
|
215
|
+
const items = await Promise.all([...this.agents.values()].map((a) => a.status()));
|
|
216
|
+
this.statusCache = { at: Date.now(), items };
|
|
217
|
+
return items;
|
|
218
|
+
}
|
|
219
|
+
/** Folders to offer the phone: where you already have sessions, then the repos on this computer. */
|
|
220
|
+
async offerProjects() {
|
|
221
|
+
// `hello` fires on every foreground and reconnect. Listing every agent's sessions each time made
|
|
222
|
+
// the phone wait on a Codex thread/list before it could show anything — and list() clears the
|
|
223
|
+
// known-session map while it rebuilds, which briefly strips project names off notifications.
|
|
224
|
+
if (this.projectCache && Date.now() - this.projectCache.at < 60_000)
|
|
225
|
+
return this.projectCache.items;
|
|
226
|
+
await this.list().catch(() => { });
|
|
227
|
+
// Learn where this computer keeps its work before scanning for repos.
|
|
228
|
+
addRoot(process.cwd());
|
|
229
|
+
for (const p of this.projects)
|
|
230
|
+
addRoot(p);
|
|
231
|
+
if (!this.discovered)
|
|
232
|
+
this.discovered = discoverProjects();
|
|
233
|
+
const out = [];
|
|
234
|
+
for (const p of [...this.projects, ...this.discovered])
|
|
235
|
+
if (!out.includes(p))
|
|
236
|
+
out.push(p);
|
|
237
|
+
const items = out.slice(0, 40);
|
|
238
|
+
this.projectCache = { at: Date.now(), items };
|
|
239
|
+
return items;
|
|
240
|
+
}
|
|
241
|
+
async hello(from) {
|
|
242
|
+
from.send({
|
|
243
|
+
type: 'hello',
|
|
244
|
+
machine: this.machine,
|
|
245
|
+
os: platform(),
|
|
246
|
+
bridge: BRIDGE_VERSION,
|
|
247
|
+
protocol: PROTOCOL,
|
|
248
|
+
agents: await this.statuses(),
|
|
249
|
+
projects: await this.offerProjects(),
|
|
250
|
+
});
|
|
251
|
+
// A phone that reconnects must see every approval still waiting — never miss one.
|
|
252
|
+
for (const live of this.live.values()) {
|
|
253
|
+
from.send({ type: 'state', sid: live.sid, state: live.state });
|
|
254
|
+
for (const ask of live.asks.values())
|
|
255
|
+
from.send(ask.msg);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
async list() {
|
|
259
|
+
const statuses = await this.statuses();
|
|
260
|
+
const results = await Promise.all([...this.agents.values()]
|
|
261
|
+
.filter((a) => statuses.find((s) => s.id === a.id)?.available)
|
|
262
|
+
.map(async (a) => {
|
|
263
|
+
try {
|
|
264
|
+
return (await a.list()).map((s) => ({ agent: a.id, ...s }));
|
|
265
|
+
}
|
|
266
|
+
catch (e) {
|
|
267
|
+
this.log(`${a.name}: could not list sessions (${e?.message ?? e})`);
|
|
268
|
+
return [];
|
|
269
|
+
}
|
|
270
|
+
}));
|
|
271
|
+
const items = results.flat().map((s) => {
|
|
272
|
+
const sid = `${s.agent}:${s.id}`;
|
|
273
|
+
const live = this.live.get(sid);
|
|
274
|
+
return { sid, agent: s.agent, title: s.title, cwd: s.cwd, updatedAt: s.updatedAt, live: !!live, state: live?.state ?? 'idle' };
|
|
275
|
+
});
|
|
276
|
+
items.push(...this.jobs.values());
|
|
277
|
+
items.sort((a, b) => Number(b.live) - Number(a.live) || b.updatedAt - a.updatedAt);
|
|
278
|
+
this.known.clear();
|
|
279
|
+
for (const s of items) {
|
|
280
|
+
this.known.set(s.sid, s);
|
|
281
|
+
if (s.cwd)
|
|
282
|
+
this.projects.add(s.cwd);
|
|
283
|
+
}
|
|
284
|
+
return items.slice(0, 60);
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* A script on this computer paging you: `agentpager notify` (tell me) or
|
|
288
|
+
* `agentpager ask` (wait for my answer). Same queue, same notifications as an agent.
|
|
289
|
+
*/
|
|
290
|
+
async job(req) {
|
|
291
|
+
const reach = {
|
|
292
|
+
phones: this.channels.length,
|
|
293
|
+
pushable: this.channels.filter((c) => c.canPush).length,
|
|
294
|
+
phone: this.channels.find((c) => c.canPush)?.deviceName ?? this.channels[0]?.deviceName,
|
|
295
|
+
};
|
|
296
|
+
if (req.kind === 'ping')
|
|
297
|
+
return { decision: 'allow', ...reach };
|
|
298
|
+
// Nobody to ask is not a denial and not a wait — it's a setup problem, and the script must
|
|
299
|
+
// learn that in milliseconds, not after an hour of blocking a deploy.
|
|
300
|
+
if (!reach.phones)
|
|
301
|
+
return { decision: 'timeout', ...reach };
|
|
302
|
+
const sid = `job:${b64u(random(6))}`;
|
|
303
|
+
const title = req.title?.trim() || (req.kind === 'ask' ? 'A script needs your OK' : 'A script finished');
|
|
304
|
+
const cwd = req.cwd;
|
|
305
|
+
const summary = { sid, agent: 'job', title, cwd, updatedAt: Date.now(), live: true, state: 'idle' };
|
|
306
|
+
this.jobs.set(sid, summary);
|
|
307
|
+
this.known.set(sid, summary);
|
|
308
|
+
const live = { sid, agent: { id: 'job', name: 'Job' }, state: 'idle', asks: new Map(), buffer: [], turns: 0, warned: 0 };
|
|
309
|
+
this.live.set(sid, live);
|
|
310
|
+
const sink = this.sink(live);
|
|
311
|
+
try {
|
|
312
|
+
if (req.kind === 'notify') {
|
|
313
|
+
this.log(`notify → ${title}`);
|
|
314
|
+
sink.done(true, req.text.slice(0, 1200));
|
|
315
|
+
return { decision: 'allow', ...reach };
|
|
316
|
+
}
|
|
317
|
+
summary.state = 'needs_you';
|
|
318
|
+
const answer = await this.askWithDeadline(live, sink, {
|
|
319
|
+
tool: 'job',
|
|
320
|
+
summary: title,
|
|
321
|
+
detail: req.text.slice(0, 2000),
|
|
322
|
+
risk: req.risk ?? 'shell',
|
|
323
|
+
}, req.timeoutMs);
|
|
324
|
+
if (answer === 'timeout') {
|
|
325
|
+
sink.done(false, 'Nobody answered in time, so nothing was run.');
|
|
326
|
+
this.log(`no answer in time · ${title}`);
|
|
327
|
+
return { decision: 'timeout', ...reach };
|
|
328
|
+
}
|
|
329
|
+
sink.done(answer !== 'deny', answer === 'deny' ? 'You denied it.' : 'You approved it.');
|
|
330
|
+
return { decision: answer, ...reach };
|
|
331
|
+
}
|
|
332
|
+
finally {
|
|
333
|
+
setTimeout(() => {
|
|
334
|
+
this.jobs.delete(sid);
|
|
335
|
+
this.live.delete(sid);
|
|
336
|
+
}, 60_000);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Ask, but never forever. When the deadline passes the card is WITHDRAWN from the phone with a
|
|
341
|
+
* reason — otherwise the user taps Allow on a deploy an hour later and believes it happened.
|
|
342
|
+
*/
|
|
343
|
+
async askWithDeadline(live, sink, req, timeoutMs) {
|
|
344
|
+
const ms = Math.min(Math.max(timeoutMs ?? DEFAULT_ASK_MS, 5_000), 24 * 3600_000);
|
|
345
|
+
let timer;
|
|
346
|
+
const before = new Set(live.asks.keys());
|
|
347
|
+
const timeout = new Promise((resolve) => {
|
|
348
|
+
timer = setTimeout(() => {
|
|
349
|
+
for (const reqId of live.asks.keys())
|
|
350
|
+
if (!before.has(reqId))
|
|
351
|
+
this.withdraw(live, reqId, 'timeout');
|
|
352
|
+
resolve('timeout');
|
|
353
|
+
}, ms);
|
|
354
|
+
});
|
|
355
|
+
const answered = sink.ask(req).then((a) => (a.decision === 'deny' ? 'deny' : 'allow'));
|
|
356
|
+
const result = await Promise.race([answered, timeout]);
|
|
357
|
+
if (timer)
|
|
358
|
+
clearTimeout(timer);
|
|
359
|
+
return result;
|
|
360
|
+
}
|
|
361
|
+
/** Take a pending request off the phone and say why it is gone. */
|
|
362
|
+
withdraw(live, reqId, reason) {
|
|
363
|
+
const ask = live.asks.get(reqId);
|
|
364
|
+
if (!ask)
|
|
365
|
+
return;
|
|
366
|
+
live.asks.delete(reqId);
|
|
367
|
+
this.broadcast({ type: 'asked', sid: live.sid, reqId, reason });
|
|
368
|
+
ask.resolve({ decision: 'deny', message: reason === 'timeout' ? 'Nobody answered in time.' : 'Withdrawn.' });
|
|
369
|
+
}
|
|
370
|
+
split(sid) {
|
|
371
|
+
const i = sid.indexOf(':');
|
|
372
|
+
return { agent: this.agents.get(sid.slice(0, i)), id: sid.slice(i + 1) };
|
|
373
|
+
}
|
|
374
|
+
async open(sid, from) {
|
|
375
|
+
const existing = this.live.get(sid);
|
|
376
|
+
const { agent, id } = this.split(sid);
|
|
377
|
+
if (!agent) {
|
|
378
|
+
from.send({ type: 'error', sid, code: 'unknown_agent', message: 'This computer does not have that agent.' });
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (!this.known.has(sid))
|
|
382
|
+
await this.list().catch(() => { });
|
|
383
|
+
const history = await agent.history(id).catch(() => []);
|
|
384
|
+
if (existing) {
|
|
385
|
+
from.send({ type: 'opened', sid });
|
|
386
|
+
from.send({ type: 'history', sid, items: history });
|
|
387
|
+
if (existing.session)
|
|
388
|
+
void this.sendUsage(existing, from, true);
|
|
389
|
+
return existing;
|
|
390
|
+
}
|
|
391
|
+
const live = { sid, agent, state: 'idle', asks: new Map(), buffer: [], turns: 0, warned: 0 };
|
|
392
|
+
this.live.set(sid, live);
|
|
393
|
+
live.session = await agent.open(id, this.known.get(sid)?.cwd, this.sink(live));
|
|
394
|
+
this.log(`opened ${agent.name} · ${this.known.get(sid)?.title ?? id}`);
|
|
395
|
+
from.send({ type: 'opened', sid });
|
|
396
|
+
from.send({ type: 'history', sid, items: history });
|
|
397
|
+
void this.sendUsage(live, from, true);
|
|
398
|
+
return live;
|
|
399
|
+
}
|
|
400
|
+
async startNew(agentId, cwd, from, ref) {
|
|
401
|
+
const agent = this.agents.get(agentId);
|
|
402
|
+
// Every failure carries `ref`, or the phone shows a row that starts nothing and says nothing.
|
|
403
|
+
if (!agent) {
|
|
404
|
+
return from.send({ type: 'error', ref, code: 'unknown_agent', message: 'This computer does not have that agent.' });
|
|
405
|
+
}
|
|
406
|
+
// Your home folder, or any folder you already have a session in. Not the whole disk.
|
|
407
|
+
if (!allowedCwd(cwd, this.projects)) {
|
|
408
|
+
return from.send({ type: 'error', ref, code: 'unknown_project', message: 'That folder is not on this computer, or is outside your home folder.' });
|
|
409
|
+
}
|
|
410
|
+
const sid = `${agentId}:new-${b64u(random(6))}`;
|
|
411
|
+
const live = { sid, agent, state: 'idle', asks: new Map(), buffer: [], turns: 0, warned: 0 };
|
|
412
|
+
this.live.set(sid, live);
|
|
413
|
+
try {
|
|
414
|
+
live.session = await agent.open(null, cwd, this.sink(live));
|
|
415
|
+
}
|
|
416
|
+
catch (e) {
|
|
417
|
+
this.live.delete(sid);
|
|
418
|
+
return from.send({ type: 'error', ref, code: 'agent', message: `${agent.name} could not start here: ${String(e?.message ?? e)}` });
|
|
419
|
+
}
|
|
420
|
+
// Codex and every ACP agent call sink.identified() SYNCHRONOUSLY inside open(), so by now the
|
|
421
|
+
// session has its real id — sending the temporary one hands the phone a row that never receives
|
|
422
|
+
// anything, and typing into it starts a second session.
|
|
423
|
+
const realSid = live.sid;
|
|
424
|
+
this.known.set(realSid, { sid: realSid, agent: agentId, title: basename(cwd) || cwd, cwd, updatedAt: Date.now(), live: true, state: 'idle' });
|
|
425
|
+
this.projects.add(cwd);
|
|
426
|
+
from.send({ type: 'opened', sid: realSid, ref });
|
|
427
|
+
this.log(`new ${agent.name} session in ${cwd}`);
|
|
428
|
+
}
|
|
429
|
+
/** Working, or waiting for an answer that can still arrive — both mean "don't nap". */
|
|
430
|
+
static busy(state) {
|
|
431
|
+
return state === 'running' || state === 'needs_you';
|
|
432
|
+
}
|
|
433
|
+
setState(live, state) {
|
|
434
|
+
if (live.state === state)
|
|
435
|
+
return;
|
|
436
|
+
const was = Hub.busy(live.state);
|
|
437
|
+
const now = Hub.busy(state);
|
|
438
|
+
if (!was && now)
|
|
439
|
+
this.awake.acquire();
|
|
440
|
+
if (was && !now)
|
|
441
|
+
this.awake.release();
|
|
442
|
+
live.state = state;
|
|
443
|
+
// needs_you/failed matter even when nobody is looking; running/idle only to a watching phone.
|
|
444
|
+
this.broadcast({ type: 'state', sid: live.sid, state }, !(state === 'needs_you' || state === 'failed'));
|
|
445
|
+
}
|
|
446
|
+
flush(live) {
|
|
447
|
+
if (live.flush)
|
|
448
|
+
clearTimeout(live.flush);
|
|
449
|
+
live.flush = undefined;
|
|
450
|
+
if (!live.buffer.length)
|
|
451
|
+
return;
|
|
452
|
+
// Complete messages are separate paragraphs; deltas are one message still being written, so
|
|
453
|
+
// they are joined with nothing at all or the seams show up as blank lines mid-sentence.
|
|
454
|
+
const text = live.buffer.join(live.streaming ? '' : '\n\n');
|
|
455
|
+
live.buffer = [];
|
|
456
|
+
const streaming = live.streaming;
|
|
457
|
+
live.streaming = false;
|
|
458
|
+
this.broadcast({ type: 'text', sid: live.sid, text, ...(streaming ? { delta: true } : {}) }, true);
|
|
459
|
+
}
|
|
460
|
+
sink(live) {
|
|
461
|
+
return {
|
|
462
|
+
identified: (id) => {
|
|
463
|
+
const newSid = `${live.agent.id}:${id}`;
|
|
464
|
+
if (newSid === live.sid)
|
|
465
|
+
return;
|
|
466
|
+
const previousSid = live.sid;
|
|
467
|
+
this.live.delete(previousSid);
|
|
468
|
+
live.sid = newSid;
|
|
469
|
+
this.live.set(newSid, live);
|
|
470
|
+
this.broadcast({ type: 'opened', sid: newSid, previousSid });
|
|
471
|
+
},
|
|
472
|
+
text: (text) => {
|
|
473
|
+
// A complete message. Anything still growing belongs to the previous one, so close it first
|
|
474
|
+
// or the two run together in a single bubble.
|
|
475
|
+
if (live.streaming)
|
|
476
|
+
this.flush(live);
|
|
477
|
+
live.buffer.push(text);
|
|
478
|
+
if (!live.flush)
|
|
479
|
+
live.flush = setTimeout(() => this.flush(live), FLUSH_MS);
|
|
480
|
+
},
|
|
481
|
+
delta: (text) => {
|
|
482
|
+
if (!live.streaming && live.buffer.length)
|
|
483
|
+
this.flush(live); // a finished message first
|
|
484
|
+
live.streaming = true;
|
|
485
|
+
live.buffer.push(text);
|
|
486
|
+
if (!live.flush)
|
|
487
|
+
live.flush = setTimeout(() => this.flush(live), FLUSH_MS);
|
|
488
|
+
},
|
|
489
|
+
tool: (tool, detail) => {
|
|
490
|
+
this.flush(live);
|
|
491
|
+
this.broadcast({ type: 'tool', sid: live.sid, tool, detail: detail.slice(0, 300) }, true);
|
|
492
|
+
},
|
|
493
|
+
state: (state) => this.setState(live, state),
|
|
494
|
+
limits: (l) => {
|
|
495
|
+
// The guard is a hook with no access to the SDK stream, and the transcript carries no
|
|
496
|
+
// rate-limit field at all. This is the only place the real numbers exist, so write them
|
|
497
|
+
// down for it — a cap in dollars is meaningless to somebody on a subscription.
|
|
498
|
+
recordLimits({ plan: l.plan, fiveHour: l.fiveHour ?? null, sevenDay: l.sevenDay ?? null });
|
|
499
|
+
// Not onlyActive: knowing you have 6% of the week left matters most when the app is closed,
|
|
500
|
+
// and it is a tiny message.
|
|
501
|
+
this.broadcast({ type: 'limits', sid: live.sid, plan: l.plan, fiveHour: l.fiveHour, sevenDay: l.sevenDay });
|
|
502
|
+
},
|
|
503
|
+
commands: (items) => {
|
|
504
|
+
this.flush(live);
|
|
505
|
+
this.broadcast({ type: 'commands', sid: live.sid, items: items.slice(0, 120) }, true);
|
|
506
|
+
},
|
|
507
|
+
suggestion: (text) => {
|
|
508
|
+
this.flush(live);
|
|
509
|
+
this.broadcast({ type: 'suggestion', sid: live.sid, text: text.slice(0, 300) }, true);
|
|
510
|
+
},
|
|
511
|
+
ask: (req) => new Promise((resolve) => {
|
|
512
|
+
this.flush(live);
|
|
513
|
+
const reqId = b64u(random(8));
|
|
514
|
+
const msg = { type: 'ask', sid: live.sid, reqId, ...req };
|
|
515
|
+
live.asks.set(reqId, { reqId, msg, resolve });
|
|
516
|
+
this.setState(live, 'needs_you');
|
|
517
|
+
this.broadcast(msg);
|
|
518
|
+
// For commands, the command itself is the useful line — "Run a command" says nothing.
|
|
519
|
+
const what = (req.risk === 'shell' || req.risk === 'danger') && req.detail ? req.detail.slice(0, 160) : req.summary;
|
|
520
|
+
this.notify('ask', { sid: live.sid, reqId, title: `${this.label(live)} needs your OK`, body: req.risk === 'danger' ? `⚠️ ${what}` : what });
|
|
521
|
+
this.log(`waiting for you: ${req.summary} ${req.risk === 'danger' ? '⚠️' : ''}`);
|
|
522
|
+
}),
|
|
523
|
+
usage: (u) => {
|
|
524
|
+
live.reported = u;
|
|
525
|
+
},
|
|
526
|
+
done: (ok, summary) => {
|
|
527
|
+
live.turns += 1;
|
|
528
|
+
this.flush(live);
|
|
529
|
+
this.setState(live, ok ? 'idle' : 'failed');
|
|
530
|
+
this.broadcast({ type: 'done', sid: live.sid, ok, summary });
|
|
531
|
+
this.notify(ok ? 'done' : 'error', { sid: live.sid, title: `${this.label(live)} ${ok ? 'finished' : 'stopped with a problem'}`, body: summary.slice(0, 300) });
|
|
532
|
+
this.log(`${ok ? 'done' : 'failed'} · ${live.sid}`);
|
|
533
|
+
if (live.session)
|
|
534
|
+
void this.sendUsage(live);
|
|
535
|
+
},
|
|
536
|
+
error: (message, code) => {
|
|
537
|
+
this.flush(live);
|
|
538
|
+
this.setState(live, 'failed');
|
|
539
|
+
this.broadcast({ type: 'error', sid: live.sid, code: code ?? 'agent', message });
|
|
540
|
+
this.notify('error', { sid: live.sid, title: `${this.label(live)} hit a problem`, body: message.slice(0, 300) });
|
|
541
|
+
this.log(`error · ${message}`);
|
|
542
|
+
},
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
drop(live) {
|
|
546
|
+
if (Hub.busy(live.state))
|
|
547
|
+
this.awake.release();
|
|
548
|
+
for (const ask of live.asks.values())
|
|
549
|
+
ask.resolve({ decision: 'deny', message: 'Session closed from AgentPager.' });
|
|
550
|
+
live.session?.close();
|
|
551
|
+
this.live.delete(live.sid);
|
|
552
|
+
}
|
|
553
|
+
shutdown() {
|
|
554
|
+
this.awake.stop();
|
|
555
|
+
for (const live of [...this.live.values()])
|
|
556
|
+
this.drop(live);
|
|
557
|
+
}
|
|
558
|
+
}
|
package/dist/pair.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Pairing: the QR code is the trusted channel. It carries this computer's public key and a
|
|
2
|
+
// one-time secret, so the relay can neither read the exchange nor slip in its own phone.
|
|
3
|
+
import { platform } from 'node:os';
|
|
4
|
+
import { onValue, ref, remove, set } from 'firebase/database';
|
|
5
|
+
import { b64u, deriveChannelKeys, openJson, pairKey, random, sealJson, sharedSecret, } from './crypto.js';
|
|
6
|
+
import { db } from './relay.js';
|
|
7
|
+
const PAIR_TIMEOUT_MS = 10 * 60_000;
|
|
8
|
+
export function offerPairing(state) {
|
|
9
|
+
const pairId = b64u(random(16));
|
|
10
|
+
const otk = random(32);
|
|
11
|
+
const link = `agentpager://pair?v=1&p=${pairId}&k=${state.identity.pub}&s=${b64u(otk)}` +
|
|
12
|
+
`&m=${encodeURIComponent(state.machineName)}`;
|
|
13
|
+
const node = ref(db, `pair/${pairId}`);
|
|
14
|
+
let stop = () => { };
|
|
15
|
+
const wait = new Promise((resolve, reject) => {
|
|
16
|
+
const timer = setTimeout(() => {
|
|
17
|
+
stop();
|
|
18
|
+
reject(new Error('Pairing timed out. Run `agentpager pair` again.'));
|
|
19
|
+
}, PAIR_TIMEOUT_MS);
|
|
20
|
+
const unsub = onValue(node, async (snap) => {
|
|
21
|
+
const v = snap.val();
|
|
22
|
+
if (!v?.pk || v.ok)
|
|
23
|
+
return;
|
|
24
|
+
const info = openJson(pairKey(otk), { n: v.n, c: v.c }, `pair|${pairId}`);
|
|
25
|
+
if (!info)
|
|
26
|
+
return; // someone without the QR code
|
|
27
|
+
const keys = deriveChannelKeys(sharedSecret(state.identity, v.pk), otk);
|
|
28
|
+
const device = {
|
|
29
|
+
id: b64u(random(9)),
|
|
30
|
+
name: String(info.device || 'Phone').slice(0, 60),
|
|
31
|
+
channel: b64u(random(18)),
|
|
32
|
+
kB2P: b64u(keys.b2p),
|
|
33
|
+
kP2B: b64u(keys.p2b),
|
|
34
|
+
sendSeq: 0,
|
|
35
|
+
recvSeq: 0,
|
|
36
|
+
pairedAt: Date.now(),
|
|
37
|
+
};
|
|
38
|
+
await set(ref(db, `pair/${pairId}/ok`), sealJson(keys.b2p, { channel: device.channel, deviceId: device.id, machine: state.machineName, os: platform() }, `pairok|${pairId}`));
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
unsub();
|
|
41
|
+
setTimeout(() => remove(node).catch(() => { }), 60_000);
|
|
42
|
+
resolve(device);
|
|
43
|
+
});
|
|
44
|
+
stop = () => {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
unsub();
|
|
47
|
+
remove(node).catch(() => { });
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
return { link, wait, cancel: () => stop() };
|
|
51
|
+
}
|