@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.
@@ -0,0 +1,279 @@
1
+ // Claude Code through the official Agent SDK: resume the desk session by id, approvals via canUseTool.
2
+ import { explainAgentError } from './explain.js';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import { getSessionMessages, listSessions, query, } from '@anthropic-ai/claude-agent-sdk';
6
+ import { findTranscript, spendOf } from '../guard/spend.js';
7
+ import { classify, describe } from '../risk.js';
8
+ import { SHELL, clip, withDeadline } from './types.js';
9
+ const run = promisify(execFile);
10
+ /** An async queue the SDK reads prompts from, so one live query takes many turns. */
11
+ class PromptQueue {
12
+ items = [];
13
+ waiters = [];
14
+ ended = false;
15
+ push(text) {
16
+ const msg = {
17
+ type: 'user',
18
+ message: { role: 'user', content: text },
19
+ parent_tool_use_id: null,
20
+ origin: { kind: 'human' },
21
+ };
22
+ const waiter = this.waiters.shift();
23
+ if (waiter)
24
+ waiter({ value: msg, done: false });
25
+ else
26
+ this.items.push(msg);
27
+ }
28
+ end() {
29
+ this.ended = true;
30
+ for (const w of this.waiters.splice(0))
31
+ w({ value: undefined, done: true });
32
+ }
33
+ [Symbol.asyncIterator]() {
34
+ return {
35
+ next: () => {
36
+ const item = this.items.shift();
37
+ if (item)
38
+ return Promise.resolve({ value: item, done: false });
39
+ if (this.ended)
40
+ return Promise.resolve({ value: undefined, done: true });
41
+ return new Promise((resolve) => this.waiters.push(resolve));
42
+ },
43
+ };
44
+ }
45
+ }
46
+ function textOf(content) {
47
+ if (typeof content === 'string')
48
+ return content;
49
+ if (!Array.isArray(content))
50
+ return '';
51
+ return content
52
+ .filter((b) => b?.type === 'text' && typeof b.text === 'string')
53
+ .map((b) => b.text)
54
+ .join('\n')
55
+ .trim();
56
+ }
57
+ /** Turn an SDK error code into something a person can act on. */
58
+ function explainError(code) {
59
+ switch (code) {
60
+ case 'rate_limit':
61
+ return 'You have hit your plan usage limit. Work stops until the window resets.';
62
+ case 'overloaded':
63
+ return 'Anthropic is overloaded right now. This usually clears on its own — try again shortly.';
64
+ case 'billing_error':
65
+ return 'Billing problem on the account. Nothing will run until it is sorted.';
66
+ case 'authentication_failed':
67
+ case 'oauth_org_not_allowed':
68
+ return 'Claude Code is not signed in on that computer any more. Run `claude` there once to sign in.';
69
+ case 'account_on_hold':
70
+ return 'The Anthropic account is on hold.';
71
+ case 'verification_required':
72
+ return 'The account needs verification before it can run again.';
73
+ case 'max_output_tokens':
74
+ return 'The reply hit the maximum length and was cut off.';
75
+ case 'model_not_found':
76
+ return 'That model is not available to this account.';
77
+ case 'invalid_request':
78
+ return 'The agent rejected the request as malformed.';
79
+ case 'server_error':
80
+ return 'Anthropic returned a server error.';
81
+ default:
82
+ return `The agent stopped: ${code}.`;
83
+ }
84
+ }
85
+ export class ClaudeAgent {
86
+ id = 'claude';
87
+ name = 'Claude Code';
88
+ async status() {
89
+ const unavailable = { id: this.id, name: this.name, available: false, reason: 'Claude Code is not installed on this computer' };
90
+ return withDeadline((async () => {
91
+ const { stdout } = await run('claude', ['--version'], { timeout: 8000, killSignal: 'SIGKILL', shell: SHELL });
92
+ return { id: this.id, name: this.name, available: true, version: stdout.trim().split(' ')[0] };
93
+ })(), 9000, unavailable);
94
+ }
95
+ async list() {
96
+ const sessions = await listSessions({ limit: 40 });
97
+ return sessions.map((s) => ({
98
+ id: s.sessionId,
99
+ title: clip(s.customTitle || s.summary || s.firstPrompt || 'Untitled session', 120),
100
+ cwd: s.cwd,
101
+ updatedAt: s.lastModified,
102
+ }));
103
+ }
104
+ async usage(id) {
105
+ const path = findTranscript(id);
106
+ return path ? await spendOf(path) : null;
107
+ }
108
+ async history(id) {
109
+ const messages = await getSessionMessages(id);
110
+ const items = [];
111
+ for (const m of messages) {
112
+ if (m.parent_tool_use_id)
113
+ continue; // subagent chatter
114
+ const content = m.message?.content;
115
+ if (m.type === 'user') {
116
+ const text = textOf(content);
117
+ // Tool results arrive as user messages; they carry no text blocks.
118
+ if (text && !text.startsWith('<'))
119
+ items.push({ role: 'user', text: clip(text, 4000) });
120
+ }
121
+ else if (m.type === 'assistant') {
122
+ const text = textOf(content);
123
+ if (text)
124
+ items.push({ role: 'agent', text: clip(text, 4000) });
125
+ if (Array.isArray(content)) {
126
+ for (const b of content) {
127
+ if (b?.type === 'tool_use')
128
+ items.push({ role: 'tool', text: `${b.name} ${describe(b.name, b.input ?? {}).detail}`.slice(0, 200) });
129
+ }
130
+ }
131
+ }
132
+ }
133
+ return items.slice(-60);
134
+ }
135
+ async open(id, cwd, sink) {
136
+ const prompts = new PromptQueue();
137
+ const abort = new AbortController();
138
+ let lastText = '';
139
+ /** What the deltas have already sent for the message currently being written. */
140
+ let streamed = '';
141
+ const q = query({
142
+ prompt: prompts,
143
+ options: {
144
+ resume: id ?? undefined,
145
+ cwd,
146
+ // Tells the guard hook this session is driven from the phone, so it does not count as "you are
147
+ // at the desk" (src/guard/desk.ts).
148
+ env: { ...process.env, AGENTPAGER_BRIDGE: '1' },
149
+ abortController: abort,
150
+ // Behave exactly like the desk session: same settings, CLAUDE.md, permissions.
151
+ settingSources: ['user', 'project', 'local'],
152
+ // Without this the SDK only emits whole `assistant` messages, so the phone got the entire
153
+ // reply in one lump after a long silence — no different from a page load. Claude Code is the
154
+ // agent most people use here, so "live output" was untrue for exactly the headline case.
155
+ includePartialMessages: true,
156
+ // One suggestion per turn, after the result. Cheap, and it is the agent telling you what it
157
+ // thinks you should ask next — which on a phone keyboard is worth a lot more than on a desk.
158
+ promptSuggestions: true,
159
+ systemPrompt: { type: 'preset', preset: 'claude_code' },
160
+ canUseTool: async (tool, input, opts) => {
161
+ const { summary, detail } = describe(tool, input);
162
+ const answer = await sink.ask({ tool, summary, detail, risk: classify(tool, input) });
163
+ if (answer.decision === 'deny') {
164
+ return { behavior: 'deny', message: answer.message || 'Denied from AgentPager on the phone.' };
165
+ }
166
+ return {
167
+ behavior: 'allow',
168
+ updatedInput: input,
169
+ updatedPermissions: answer.decision === 'allow_session' ? opts.suggestions : undefined,
170
+ };
171
+ },
172
+ },
173
+ });
174
+ // The command list is a property of the session, not of any turn — fetch it once it exists.
175
+ void (async () => {
176
+ try {
177
+ const list = await q.supportedCommands?.();
178
+ if (Array.isArray(list) && list.length) {
179
+ sink.commands(list.map((c) => ({
180
+ name: String(c.name ?? ''),
181
+ description: String(c.description ?? ''),
182
+ argumentHint: String(c.argumentHint ?? ''),
183
+ })));
184
+ }
185
+ }
186
+ catch {
187
+ /* an older CLI has no supportedCommands; the composer simply offers no autocomplete */
188
+ }
189
+ })();
190
+ (async () => {
191
+ try {
192
+ for await (const m of q) {
193
+ const msg = m;
194
+ if (msg.type === 'system' && msg.subtype === 'init' && msg.session_id) {
195
+ sink.identified(msg.session_id);
196
+ }
197
+ else if (msg.type === 'system' && msg.subtype === 'session_state_changed') {
198
+ sink.state(msg.state === 'requires_action' ? 'needs_you' : msg.state === 'running' ? 'running' : 'idle');
199
+ }
200
+ else if (msg.type === 'prompt_suggestion' || msg.subtype === 'prompt_suggestion') {
201
+ const t = String(msg.suggestion ?? msg.text ?? msg.prompt ?? '').trim();
202
+ if (t)
203
+ sink.suggestion(t);
204
+ }
205
+ else if (msg.type === 'stream_event') {
206
+ // The Messages API streaming events. Only text deltas matter to a phone; the block
207
+ // start/stop bookkeeping is the hub's job, not ours.
208
+ const ev = msg.event;
209
+ if (ev?.type === 'content_block_delta' && ev.delta?.type === 'text_delta') {
210
+ const piece = String(ev.delta.text ?? '');
211
+ if (piece) {
212
+ streamed += piece;
213
+ sink.delta(piece);
214
+ }
215
+ }
216
+ }
217
+ else if (msg.type === 'assistant' && !msg.parent_tool_use_id) {
218
+ const content = msg.message?.content;
219
+ const text = textOf(content);
220
+ if (text) {
221
+ lastText = text;
222
+ // Do NOT re-send what the deltas already delivered — that would print the reply twice.
223
+ // The whole message still arrives here, so it remains the fallback whenever partial
224
+ // events did not (older CLI, a provider that does not stream, partials turned off).
225
+ if (streamed.trim() === text.trim())
226
+ streamed = '';
227
+ else
228
+ sink.text(text);
229
+ }
230
+ if (Array.isArray(content)) {
231
+ for (const b of content) {
232
+ if (b?.type === 'tool_use')
233
+ sink.tool(b.name, describe(b.name, b.input ?? {}).detail);
234
+ }
235
+ }
236
+ }
237
+ else if (msg.type === 'assistant' && msg.error) {
238
+ sink.error(explainError(String(msg.error)));
239
+ }
240
+ else if (msg.type === 'result') {
241
+ streamed = '';
242
+ // The one number people are desperate for. "I used up Max 5 in 1 hour of working,
243
+ // before I could work 8 hours" — the agent knew all along; nothing told the phone.
244
+ const rl = msg.rate_limits;
245
+ if (msg.rate_limits_available && rl) {
246
+ const win = (w) => (w ? { used: w.utilization ?? null, resetsAt: w.resets_at ?? null } : null);
247
+ sink.limits({
248
+ plan: msg.subscription_type ?? null,
249
+ fiveHour: win(rl.five_hour),
250
+ sevenDay: win(rl.seven_day),
251
+ });
252
+ }
253
+ const ok = msg.subtype === 'success' && !msg.is_error;
254
+ sink.done(ok, clip(String(msg.result ?? lastText ?? ''), 1200));
255
+ }
256
+ }
257
+ }
258
+ catch (e) {
259
+ if (!abort.signal.aborted) {
260
+ const x = explainAgentError(e, 'Claude Code');
261
+ sink.error(x.message, x.code);
262
+ }
263
+ }
264
+ })();
265
+ return {
266
+ send: (text) => {
267
+ sink.state('running');
268
+ prompts.push(text);
269
+ },
270
+ interrupt: async () => {
271
+ await q.interrupt();
272
+ },
273
+ close: () => {
274
+ prompts.end();
275
+ abort.abort();
276
+ },
277
+ };
278
+ }
279
+ }
@@ -0,0 +1,271 @@
1
+ // OpenAI Codex through `codex app-server` (JSON-RPC over stdio, one process for all threads).
2
+ import { explainAgentError } from './explain.js';
3
+ import { execFile, spawn } from 'node:child_process';
4
+ import { createInterface } from 'node:readline';
5
+ import { promisify } from 'node:util';
6
+ import { classify, describe } from '../risk.js';
7
+ import { SHELL, clip, killTree, withDeadline } from './types.js';
8
+ const run = promisify(execFile);
9
+ class Rpc {
10
+ proc;
11
+ nextId = 1;
12
+ pending = new Map();
13
+ onNotify = () => { };
14
+ onRequest = async () => {
15
+ throw new Error('unsupported');
16
+ };
17
+ onExit = () => { };
18
+ constructor() {
19
+ this.proc = spawn('codex', ['app-server'], { stdio: ['pipe', 'pipe', 'pipe'], shell: SHELL });
20
+ createInterface({ input: this.proc.stdout }).on('line', (line) => this.receive(line));
21
+ this.proc.stderr.on('data', () => { }); // app-server logs to stderr; not ours to surface
22
+ this.proc.on('exit', () => {
23
+ for (const p of this.pending.values())
24
+ p.reject(new Error('codex app-server exited'));
25
+ this.pending.clear();
26
+ this.onExit();
27
+ });
28
+ }
29
+ write(obj) {
30
+ this.proc.stdin.write(`${JSON.stringify(obj)}\n`);
31
+ }
32
+ request(method, params) {
33
+ const id = this.nextId++;
34
+ this.write({ id, method, params });
35
+ return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }));
36
+ }
37
+ notify(method, params) {
38
+ this.write(params === undefined ? { method } : { method, params });
39
+ }
40
+ receive(line) {
41
+ let msg;
42
+ try {
43
+ msg = JSON.parse(line);
44
+ }
45
+ catch {
46
+ return;
47
+ }
48
+ if (msg.id !== undefined && msg.method) {
49
+ // A request from the server (approvals).
50
+ this.onRequest(msg.method, msg.params).then((result) => this.write({ id: msg.id, result }), (e) => this.write({ id: msg.id, error: { code: -32601, message: String(e?.message ?? e) } }));
51
+ }
52
+ else if (msg.id !== undefined) {
53
+ const p = this.pending.get(msg.id);
54
+ if (!p)
55
+ return;
56
+ this.pending.delete(msg.id);
57
+ if (msg.error)
58
+ p.reject(new Error(msg.error.message ?? 'codex error'));
59
+ else
60
+ p.resolve(msg.result);
61
+ }
62
+ else if (msg.method) {
63
+ this.onNotify(msg.method, msg.params);
64
+ }
65
+ }
66
+ kill() {
67
+ killTree(this.proc);
68
+ }
69
+ }
70
+ export class CodexAgent {
71
+ id = 'codex';
72
+ name = 'Codex';
73
+ rpc = null;
74
+ ready = null;
75
+ routes = new Map();
76
+ /// The session we most recently heard from, so a request we cannot place still reaches a human.
77
+ lastRoute;
78
+ async status() {
79
+ const unavailable = { id: this.id, name: this.name, available: false, reason: 'Codex CLI is not installed on this computer' };
80
+ return withDeadline((async () => {
81
+ const { stdout } = await run('codex', ['--version'], { timeout: 8000, killSignal: 'SIGKILL', shell: SHELL });
82
+ return { id: this.id, name: this.name, available: true, version: stdout.trim().split(' ').pop() };
83
+ })(), 9000, unavailable);
84
+ }
85
+ connect() {
86
+ if (this.ready)
87
+ return this.ready;
88
+ this.ready = (async () => {
89
+ const rpc = new Rpc();
90
+ rpc.onExit = () => {
91
+ this.rpc = null;
92
+ this.ready = null;
93
+ for (const r of this.routes.values())
94
+ r.sink.error('Codex stopped on this computer.');
95
+ this.routes.clear();
96
+ };
97
+ rpc.onNotify = (method, params) => this.notification(method, params);
98
+ rpc.onRequest = (method, params) => this.serverRequest(method, params);
99
+ await rpc.request('initialize', { clientInfo: { name: 'mdpilot', title: 'AgentPager', version: '0.1.0' }, capabilities: null });
100
+ rpc.notify('initialized');
101
+ this.rpc = rpc;
102
+ return rpc;
103
+ })();
104
+ return this.ready;
105
+ }
106
+ async list() {
107
+ const rpc = await this.connect();
108
+ // The default source filter returns nothing on codex 0.138; name the kinds explicitly.
109
+ const res = await rpc.request('thread/list', { limit: 40, modelProviders: [], sourceKinds: ['cli', 'vscode', 'exec', 'appServer'] });
110
+ return (res?.data ?? [])
111
+ .filter((t) => !t.parentThreadId && !t.ephemeral)
112
+ .map((t) => ({
113
+ id: t.id,
114
+ title: clip(t.name || t.preview || 'Untitled thread', 120),
115
+ cwd: t.cwd,
116
+ updatedAt: (t.updatedAt ?? t.createdAt ?? 0) * 1000,
117
+ }));
118
+ }
119
+ async history(id) {
120
+ const rpc = await this.connect();
121
+ const res = await rpc.request('thread/read', { threadId: id, includeTurns: true });
122
+ const items = [];
123
+ for (const turn of res?.thread?.turns ?? []) {
124
+ for (const item of turn.items ?? []) {
125
+ if (item.type === 'userMessage') {
126
+ const text = (item.content ?? []).filter((c) => c.type === 'text').map((c) => c.text).join('\n');
127
+ if (text)
128
+ items.push({ role: 'user', text: clip(text, 4000) });
129
+ }
130
+ else if (item.type === 'agentMessage' && item.text) {
131
+ items.push({ role: 'agent', text: clip(item.text, 4000) });
132
+ }
133
+ else if (item.type === 'commandExecution') {
134
+ items.push({ role: 'tool', text: clip(`shell ${item.command}`, 200) });
135
+ }
136
+ else if (item.type === 'fileChange') {
137
+ items.push({ role: 'tool', text: clip(`edit ${(item.changes ?? []).map((c) => c.path).join(', ')}`, 200) });
138
+ }
139
+ }
140
+ }
141
+ return items.slice(-60);
142
+ }
143
+ async open(id, cwd, sink) {
144
+ const rpc = await this.connect();
145
+ const res = id ? await rpc.request('thread/resume', { threadId: id }) : await rpc.request('thread/start', { cwd: cwd ?? null });
146
+ const threadId = res.thread.id;
147
+ const created = { sink, lastText: '', files: new Map() };
148
+ this.routes.set(threadId, created);
149
+ this.lastRoute = created;
150
+ if (!id)
151
+ sink.identified(threadId);
152
+ return {
153
+ send: (text) => {
154
+ sink.state('running');
155
+ rpc.request('turn/start', { threadId, input: [{ type: 'text', text, text_elements: [] }] }).catch((e) => {
156
+ const x = explainAgentError(e, 'Codex');
157
+ sink.error(x.message, x.code);
158
+ });
159
+ },
160
+ interrupt: async () => {
161
+ const route = this.routes.get(threadId);
162
+ if (route?.turnId)
163
+ await rpc.request('turn/interrupt', { threadId, turnId: route.turnId });
164
+ },
165
+ close: () => {
166
+ this.routes.delete(threadId);
167
+ rpc.request('thread/unsubscribe', { threadId }).catch(() => { });
168
+ },
169
+ };
170
+ }
171
+ notification(method, p) {
172
+ const route = p?.threadId ? this.routes.get(p.threadId) : undefined;
173
+ if (route)
174
+ this.lastRoute = route;
175
+ if (!route)
176
+ return;
177
+ switch (method) {
178
+ case 'turn/started':
179
+ route.turnId = p.turn?.id;
180
+ route.sink.state('running');
181
+ break;
182
+ case 'item/started':
183
+ if (p.item?.type === 'commandExecution')
184
+ route.sink.tool('shell', clip(p.item.command ?? '', 500));
185
+ if (p.item?.type === 'fileChange')
186
+ route.files.set(p.item.id, (p.item.changes ?? []).map((c) => c.path).join(', '));
187
+ break;
188
+ case 'item/completed':
189
+ // Whole messages only — streaming every token would multiply relay traffic for no one.
190
+ if (p.item?.type === 'agentMessage' && p.item.text) {
191
+ route.lastText = p.item.text;
192
+ route.sink.text(p.item.text);
193
+ }
194
+ else if (p.item?.type === 'fileChange') {
195
+ route.sink.tool('edit', clip((p.item.changes ?? []).map((c) => c.path).join(', '), 500));
196
+ }
197
+ break;
198
+ case 'turn/completed': {
199
+ const status = p.turn?.status;
200
+ route.turnId = undefined;
201
+ route.sink.done(status === 'completed', clip(p.turn?.error?.message ?? route.lastText, 1200));
202
+ break;
203
+ }
204
+ case 'thread/tokenUsage/updated': {
205
+ // Cumulative for the thread. Codex reports tokens, not dollars, and a plan user has no
206
+ // per-token price — so tokens only; the hub will not invent a figure.
207
+ const total = Number(p.tokenUsage?.total?.totalTokens ?? p.usage?.total?.totalTokens);
208
+ if (Number.isFinite(total) && total > 0)
209
+ route.sink.usage({ tokens: total });
210
+ break;
211
+ }
212
+ case 'error':
213
+ {
214
+ const x = explainAgentError(p.error?.message ?? p.message ?? 'Codex error', 'Codex');
215
+ route.sink.error(x.message, x.code);
216
+ }
217
+ break;
218
+ }
219
+ }
220
+ async serverRequest(method, p) {
221
+ // Attribute an approval to a session ONLY when that is unambiguous. A card that says "this
222
+ // session needs your OK" under the wrong session buys consent for something the user never saw;
223
+ // refusing and saying so is the honest failure.
224
+ const route = p?.threadId
225
+ ? this.routes.get(p.threadId)
226
+ : this.routes.size === 1
227
+ ? [...this.routes.values()][0]
228
+ : undefined;
229
+ if (method === 'item/commandExecution/requestApproval' || method === 'item/fileChange/requestApproval') {
230
+ if (!route)
231
+ return this.cannotAsk(method);
232
+ const isShell = method.includes('commandExecution');
233
+ const paths = route.files.get(p.itemId) ?? '';
234
+ const input = isShell ? { command: p.command ?? '' } : { file_path: paths.split(', ')[0] ?? '', detail: paths || p.reason || 'File changes' };
235
+ const tool = isShell ? 'shell' : 'edit';
236
+ const { summary, detail } = describe(tool, input);
237
+ const answer = await route.sink.ask({ tool, summary: p.reason ? clip(p.reason, 120) : summary, detail, risk: classify(tool, input) });
238
+ return { decision: answer.decision === 'allow' ? 'accept' : answer.decision === 'allow_session' ? 'acceptForSession' : 'decline' };
239
+ }
240
+ // 🧨 This used to answer "decline" to every approval it did not recognise, WITHOUT telling
241
+ // anyone. The agent then stopped mid-task and the phone was never paged, so from the user's side
242
+ // it was indistinguishable from the agent simply failing — and Codex's app-server keeps renaming
243
+ // these methods, so one upstream rename turns the product into "denies everything".
244
+ // Now an unfamiliar approval is still a question: ask, at the highest friction, and say plainly
245
+ // that we don't recognise it.
246
+ if (method.endsWith('requestApproval') || method.endsWith('Approval')) {
247
+ if (!route)
248
+ return this.cannotAsk(method);
249
+ const what = String(p?.command ?? p?.reason ?? p?.path ?? p?.itemId ?? '');
250
+ const answer = await route.sink.ask({
251
+ tool: method.split('/').pop() ?? method,
252
+ summary: `Codex is asking for something this app does not recognise (${method})`,
253
+ detail: clip(what || JSON.stringify(p ?? {}), 1200),
254
+ risk: 'danger', // unknown means unknown: it takes a press-and-hold, never a stray tap
255
+ });
256
+ return { decision: answer.decision === 'deny' ? 'decline' : answer.decision === 'allow_session' ? 'acceptForSession' : 'accept' };
257
+ }
258
+ throw new Error(`unsupported request ${method}`);
259
+ }
260
+ /// An approval we cannot attach to any session. We must not silently answer for the user, so we
261
+ /// decline — but we say so, loudly, where they will see it.
262
+ cannotAsk(method) {
263
+ const message = `Codex asked for approval (${method}) on a session this app has lost track of, so it was declined. Reopen the session on your phone and try again.`;
264
+ console.error(message);
265
+ this.lastRoute?.sink.error(message);
266
+ return { decision: 'decline' };
267
+ }
268
+ shutdown() {
269
+ this.rpc?.kill();
270
+ }
271
+ }
@@ -0,0 +1,39 @@
1
+ // One plain sentence a person can act on, from whatever text an agent CLI threw.
2
+ //
3
+ // Claude Code reports machine-readable error codes and claude.ts turns those into sentences. Codex
4
+ // and the ACP agents (Gemini CLI, OpenCode, Qwen Code, Goose…) mostly just hand us a message
5
+ // string, so this matches on the text. The codes stay machine-readable for the app; the message is
6
+ // the sentence. Unknown text is passed through, clipped, rather than replaced by something vague.
7
+ import { clip } from './types.js';
8
+ // Order matters: the most specific / most actionable first. "401 Unauthorized" is an auth problem
9
+ // even though it also contains a number; "connection timed out" is a network problem, not a timeout.
10
+ const RULES = [
11
+ ['quota', /\b429\b|rate[\s_-]?limit|quota|usage limit|resource[_\s]exhausted|too many requests|insufficient[\s_](credit|fund|balance|quota)|out of (credits|tokens)|billing|exceeded your (current )?(plan|limit)|credit balance/i],
12
+ ['not_signed_in', /not (logged|signed)[\s-]?in|please (log ?in|sign ?in)|log ?in (required|first)|sign[\s-]?in (required|first)|\b401\b|unauthori[sz]ed|invalid (api[\s_-]?key|token|credentials)|api[\s_-]?key.{0,30}(missing|not set|not found|required|invalid|expired)|(missing|no|expired) (api[\s_-]?key|credentials|token)|authentication (failed|required|error)|(OPENAI|GEMINI|GOOGLE|ANTHROPIC|QWEN|DASHSCOPE|DEEPSEEK)_API_KEY|token (has )?expired|refresh token|\b403\b.{0,40}(forbidden|permission)/i],
13
+ ['model_not_found', /model[^.\n]{0,60}(not found|not available|does not exist|not supported|unsupported|unknown|no access|access denied)|(unknown|invalid|unsupported|unavailable) model|\b404\b[^.\n]{0,40}model/i],
14
+ ['not_installed', /\bENOENT\b|command not found|is not recognized as an internal|not recognized as the name of a cmdlet|no such file or directory.{0,40}(spawn|exec)|spawn [^\n]{0,60}(ENOENT|EINVAL)|not installed|executable (file )?not found/i],
15
+ ['protocol', /protocol version|unsupported protocol|incompatible (version|protocol)|method not found|unsupported request|unknown method|\-32601|\-32602|invalid params|invalid request|unexpected (token|end of json|response)|json parse|parse error|malformed|unsupported (version|capability)|initialize.{0,40}(fail|reject)/i],
16
+ ['network', /ENOTFOUND|ECONNREFUSED|ECONNRESET|EAI_AGAIN|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|EPIPE|network|fetch failed|socket hang up|getaddrinfo|unable to connect|connection (refused|reset|closed|timed out|error|lost)|offline|self[\s-]signed|certificate|\bproxy\b|\b50[234]\b|bad gateway|service unavailable|gateway time-?out|overloaded|temporarily unavailable/i],
17
+ ['timeout', /timed?[\s-]?out|timeout|deadline (exceeded|has passed)|did not (answer|respond)|no response/i],
18
+ ];
19
+ const SAYS = {
20
+ not_signed_in: (n) => `${n} is not signed in on that computer any more. Open ${n} there once, sign in, then try again.`,
21
+ quota: (n) => `${n} has hit a usage or rate limit (or the account is out of credit). Wait for the window to reset, or check the plan and billing.`,
22
+ model_not_found: (n) => `${n} says that model is not available to this account. Pick another model in ${n}'s settings on that computer.`,
23
+ not_installed: (n) => `${n} could not be started on that computer — it is missing or not on the PATH. Install it or run \`agentpager doctor\` there.`,
24
+ network: (n) => `${n} could not reach its servers. Check that computer's internet connection (or proxy) and try again; this often clears on its own.`,
25
+ protocol: (n) => `${n} answered in a way this version of AgentPager does not understand — usually the agent was updated. Update AgentPager on that computer (\`npm update -g agentpager\`).`,
26
+ timeout: (n) => `${n} took too long to answer and the request timed out. Try again; if it keeps happening, restart ${n} on that computer.`,
27
+ };
28
+ /**
29
+ * `raw` is whatever the agent said; `name` is how the person knows it ("Codex", "Gemini CLI").
30
+ * Never throws, never returns an empty message.
31
+ */
32
+ export function explainAgentError(raw, name) {
33
+ const text = String(raw?.message ?? raw ?? '').trim();
34
+ for (const [code, re] of RULES) {
35
+ if (re.test(text))
36
+ return { code, message: SAYS[code](name) };
37
+ }
38
+ return { code: 'agent', message: text ? `${name} reported a problem: ${clip(text.replace(/\s+/g, ' '), 300)}` : `${name} stopped without saying why.` };
39
+ }
@@ -0,0 +1,29 @@
1
+ import { execFile } from 'node:child_process';
2
+ /**
3
+ * A version probe must never hang the bridge. execFile's own timeout kills the child, but the
4
+ * promise still waits for grandchildren to close the pipes — which is how the login service hung
5
+ * with no output at all. Race it and move on.
6
+ */
7
+ export function withDeadline(p, ms, fallback) {
8
+ return Promise.race([
9
+ p.catch(() => fallback),
10
+ new Promise((resolve) => setTimeout(() => resolve(fallback), ms).unref?.()),
11
+ ]);
12
+ }
13
+ /**
14
+ * npm installs CLIs on Windows as `.cmd` shims, which spawn/execFile cannot start without a shell
15
+ * (ENOENT, or EINVAL on current Node). Elsewhere a shell would only add quoting risk.
16
+ */
17
+ export const SHELL = process.platform === 'win32';
18
+ /** With a shell in between, kill() only ends the shell; taskkill /T takes the agent under it too. */
19
+ export function killTree(proc) {
20
+ if (SHELL && proc.pid) {
21
+ try {
22
+ execFile('taskkill', ['/pid', String(proc.pid), '/T', '/F'], () => { });
23
+ return;
24
+ }
25
+ catch { }
26
+ }
27
+ proc.kill();
28
+ }
29
+ export const clip = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);