@cosmovex/agentpager 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/agents/acp.js +23 -0
- package/dist/agents/claude.js +98 -8
- package/dist/agents/codex.js +20 -0
- package/dist/cli.js +25 -0
- package/dist/guard/limits.js +11 -2
- package/dist/hub.js +33 -0
- package/dist/risk.js +55 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ A pager for your AI coding agents. Approve what they want to do, reply, and hear
|
|
|
4
4
|
on your phone, with the [AgentPager Android app](https://play.google.com/store/apps/details?id=com.cosmovex.mdpilot).
|
|
5
5
|
|
|
6
6
|
```bash
|
|
7
|
-
npx agentpager
|
|
7
|
+
npx @cosmovex/agentpager
|
|
8
8
|
```
|
|
9
9
|
|
|
10
10
|
A QR code appears. Scan it in the app. Your sessions show up in a few seconds.
|
|
@@ -80,7 +80,7 @@ folder names only, so the picker can list your projects without sending any of t
|
|
|
80
80
|
## Keep it running
|
|
81
81
|
|
|
82
82
|
```bash
|
|
83
|
-
npm install -g agentpager
|
|
83
|
+
npm install -g @cosmovex/agentpager
|
|
84
84
|
agentpager service install # starts at login: macOS LaunchAgent, systemd user unit, Windows task
|
|
85
85
|
agentpager service uninstall
|
|
86
86
|
```
|
package/dist/agents/acp.js
CHANGED
|
@@ -261,6 +261,17 @@ export class AcpAgent {
|
|
|
261
261
|
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
|
|
262
262
|
});
|
|
263
263
|
let sessionId;
|
|
264
|
+
/** The agent's own model state, when it publishes one. Null means: this agent has no models API. */
|
|
265
|
+
let newModels = null;
|
|
266
|
+
const reportModels = () => {
|
|
267
|
+
const rows = newModels?.availableModels ?? [];
|
|
268
|
+
const items = rows
|
|
269
|
+
.filter((m) => m && typeof m.modelId === 'string')
|
|
270
|
+
.map((m) => ({ id: m.modelId, name: m.name || m.modelId, description: m.description ?? undefined }));
|
|
271
|
+
if (!items.length)
|
|
272
|
+
return;
|
|
273
|
+
sink.models({ items, current: newModels?.currentModelId ?? null, canSet: true });
|
|
274
|
+
};
|
|
264
275
|
const canLoad = init?.agentCapabilities?.loadSession === true;
|
|
265
276
|
if (id && canLoad) {
|
|
266
277
|
await conn.loadSession({ sessionId: id, cwd: cwd ?? process.cwd(), mcpServers: [] });
|
|
@@ -270,7 +281,12 @@ export class AcpAgent {
|
|
|
270
281
|
const created = await conn.newSession({ cwd: cwd ?? process.cwd(), mcpServers: [] });
|
|
271
282
|
sessionId = created.sessionId;
|
|
272
283
|
sink.identified(sessionId);
|
|
284
|
+
// ACP carries the model state on the session it just made, when the agent supports it at all
|
|
285
|
+
// (it is an unstable part of the protocol, so most do not). Unlike Codex there IS a verifiable
|
|
286
|
+
// setter here — session/set_model — so where the agent offers the state, the phone may choose.
|
|
287
|
+
newModels = created?.models ?? null;
|
|
273
288
|
}
|
|
289
|
+
reportModels();
|
|
274
290
|
proc.on('exit', () => {
|
|
275
291
|
flush();
|
|
276
292
|
sink.error(`${this.name} stopped on this computer.`);
|
|
@@ -300,6 +316,13 @@ export class AcpAgent {
|
|
|
300
316
|
sink.error(x.message, x.code);
|
|
301
317
|
});
|
|
302
318
|
},
|
|
319
|
+
setModel: async (id) => {
|
|
320
|
+
await conn.setSessionModel({ sessionId, modelId: id });
|
|
321
|
+
// Believe the agent, not the tap: re-publish from our own state only after it accepted.
|
|
322
|
+
if (newModels)
|
|
323
|
+
newModels.currentModelId = id;
|
|
324
|
+
reportModels();
|
|
325
|
+
},
|
|
303
326
|
interrupt: async () => {
|
|
304
327
|
await conn.cancel({ sessionId }).catch(() => { });
|
|
305
328
|
},
|
package/dist/agents/claude.js
CHANGED
|
@@ -138,6 +138,35 @@ export class ClaudeAgent {
|
|
|
138
138
|
let lastText = '';
|
|
139
139
|
/** What the deltas have already sent for the message currently being written. */
|
|
140
140
|
let streamed = '';
|
|
141
|
+
/** The model in use, from the init message and from a switch the CLI accepted. */
|
|
142
|
+
let current = null;
|
|
143
|
+
// A resumed session's model, read from its own transcript.
|
|
144
|
+
//
|
|
145
|
+
// The SDK emits `system`/`init` — the one message that states the model — only when the first
|
|
146
|
+
// turn starts. So a session you opened and have not typed into yet would show no model at all,
|
|
147
|
+
// which is exactly the complaint this feature answers. Every assistant message in the transcript
|
|
148
|
+
// records the model that produced it, so the last one is what this session last ran on: a fact,
|
|
149
|
+
// not a guess, and init overwrites it the moment the real thing arrives.
|
|
150
|
+
if (id) {
|
|
151
|
+
void (async () => {
|
|
152
|
+
try {
|
|
153
|
+
const messages = await getSessionMessages(id);
|
|
154
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
155
|
+
const model = messages[i]?.message?.model;
|
|
156
|
+
if (typeof model === 'string' && model) {
|
|
157
|
+
if (!current)
|
|
158
|
+
current = model; // never clobber an init that already landed
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (current)
|
|
163
|
+
await reportModels();
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
/* no transcript, or an unreadable one: the model simply shows up after the first turn */
|
|
167
|
+
}
|
|
168
|
+
})();
|
|
169
|
+
}
|
|
141
170
|
const q = query({
|
|
142
171
|
prompt: prompts,
|
|
143
172
|
options: {
|
|
@@ -193,6 +222,12 @@ export class ClaudeAgent {
|
|
|
193
222
|
const msg = m;
|
|
194
223
|
if (msg.type === 'system' && msg.subtype === 'init' && msg.session_id) {
|
|
195
224
|
sink.identified(msg.session_id);
|
|
225
|
+
// The only message that states which model this session is on. The initialize RESPONSE
|
|
226
|
+
// lists the available models but not the chosen one, so this is the source of truth.
|
|
227
|
+
if (typeof msg.model === 'string' && msg.model) {
|
|
228
|
+
current = msg.model;
|
|
229
|
+
void reportModels();
|
|
230
|
+
}
|
|
196
231
|
}
|
|
197
232
|
else if (msg.type === 'system' && msg.subtype === 'session_state_changed') {
|
|
198
233
|
sink.state(msg.state === 'requires_action' ? 'needs_you' : msg.state === 'running' ? 'running' : 'idle');
|
|
@@ -242,14 +277,19 @@ export class ClaudeAgent {
|
|
|
242
277
|
// The one number people are desperate for. "I used up Max 5 in 1 hour of working,
|
|
243
278
|
// before I could work 8 hours" — the agent knew all along; nothing told the phone.
|
|
244
279
|
const rl = msg.rate_limits;
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}
|
|
280
|
+
// Report the PLAN on every result, even when there are no rate-limit windows to report.
|
|
281
|
+
//
|
|
282
|
+
// This used to be inside `if (rate_limits_available)`, which made an API key — the one
|
|
283
|
+
// case where dollars are real money — indistinguishable from "this session has not said
|
|
284
|
+
// anything yet". The phone then fell back to showing a dollar estimate, and a $200/month
|
|
285
|
+
// Max subscriber was told "$2092.12 of your $20.00 limit" in red. A plan of null now
|
|
286
|
+
// positively means an API key, and no message at all means we do not know yet.
|
|
287
|
+
const win = (w) => (w ? { used: w.utilization ?? null, resetsAt: w.resets_at ?? null } : null);
|
|
288
|
+
sink.limits({
|
|
289
|
+
plan: msg.subscription_type ?? null,
|
|
290
|
+
fiveHour: msg.rate_limits_available && rl ? win(rl.five_hour) : null,
|
|
291
|
+
sevenDay: msg.rate_limits_available && rl ? win(rl.seven_day) : null,
|
|
292
|
+
});
|
|
253
293
|
const ok = msg.subtype === 'success' && !msg.is_error;
|
|
254
294
|
sink.done(ok, clip(String(msg.result ?? lastText ?? ''), 1200));
|
|
255
295
|
}
|
|
@@ -262,11 +302,61 @@ export class ClaudeAgent {
|
|
|
262
302
|
}
|
|
263
303
|
}
|
|
264
304
|
})();
|
|
305
|
+
/**
|
|
306
|
+
* Ask Claude Code what it can run, and report it.
|
|
307
|
+
*
|
|
308
|
+
* The list comes from the SDK every time rather than from anything we keep: model names change
|
|
309
|
+
* under us, and offering a model this account cannot run is worse than offering none. Failure is
|
|
310
|
+
* silent on purpose — not knowing the model must never stop a session from starting.
|
|
311
|
+
*/
|
|
312
|
+
async function reportModels() {
|
|
313
|
+
try {
|
|
314
|
+
const models = await q.supportedModels();
|
|
315
|
+
if (!models?.length)
|
|
316
|
+
return;
|
|
317
|
+
sink.models({
|
|
318
|
+
items: models.map((m) => ({ id: m.value, name: m.displayName, description: m.description })),
|
|
319
|
+
// Match the alias row's resolved id too: a session pinned to 'claude-sonnet-5' has to light
|
|
320
|
+
// up the 'sonnet' row that covers it, or the picker shows nothing selected at all.
|
|
321
|
+
current: current ? (models.find((m) => m.value === current || m.resolvedModel === current)?.value ?? current) : null,
|
|
322
|
+
canSet: true,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
/* an older CLI without supportedModels: no picker, everything else unaffected */
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (!id) {
|
|
330
|
+
// A session that has just been created has no pinned model, so the row the CLI itself labels
|
|
331
|
+
// `default` IS what it will run — a fact about a new session, not a guess. Without this the
|
|
332
|
+
// model bar stays empty until the first turn, which is the same "no model anywhere" the
|
|
333
|
+
// feature exists to fix.
|
|
334
|
+
void (async () => {
|
|
335
|
+
try {
|
|
336
|
+
const models = await q.supportedModels();
|
|
337
|
+
if (models?.some((m) => m.value === 'default') && !current) {
|
|
338
|
+
current = 'default';
|
|
339
|
+
await reportModels();
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
/* older CLI: the model appears after the first turn instead */
|
|
344
|
+
}
|
|
345
|
+
})();
|
|
346
|
+
}
|
|
265
347
|
return {
|
|
266
348
|
send: (text) => {
|
|
267
349
|
sink.state('running');
|
|
268
350
|
prompts.push(text);
|
|
269
351
|
},
|
|
352
|
+
setModel: async (id) => {
|
|
353
|
+
// A control request the CLI resolves is its acceptance — it rejects a model the account
|
|
354
|
+
// cannot run, which is the difference from Codex, where the equivalent call returns {} for
|
|
355
|
+
// a bogus name and for `banana: 1` alike and is therefore not offered at all.
|
|
356
|
+
await q.setModel(id);
|
|
357
|
+
current = id;
|
|
358
|
+
await reportModels();
|
|
359
|
+
},
|
|
270
360
|
interrupt: async () => {
|
|
271
361
|
await q.interrupt();
|
|
272
362
|
},
|
package/dist/agents/codex.js
CHANGED
|
@@ -149,6 +149,26 @@ export class CodexAgent {
|
|
|
149
149
|
this.lastRoute = created;
|
|
150
150
|
if (!id)
|
|
151
151
|
sink.identified(threadId);
|
|
152
|
+
// Which model this thread runs. Reported, never offered as a choice: `model/list` is real and
|
|
153
|
+
// honest, but the only way to change it — thread/settings/update — answers {} to a model that
|
|
154
|
+
// does not exist AND to `banana: 1`, and nothing Codex reports afterwards names a model. A
|
|
155
|
+
// switch that cannot be verified must not be presented as one, so canSet is false and the phone
|
|
156
|
+
// shows the model as a fact rather than a control. (Probed against codex-cli 0.138.0.)
|
|
157
|
+
void (async () => {
|
|
158
|
+
try {
|
|
159
|
+
const list = await rpc.request('model/list', {});
|
|
160
|
+
const rows = list?.data ?? [];
|
|
161
|
+
const items = rows
|
|
162
|
+
.filter((m) => m && !m.hidden && typeof m.id === 'string')
|
|
163
|
+
.map((m) => ({ id: m.id, name: m.displayName || m.id, description: m.description }));
|
|
164
|
+
if (!items.length)
|
|
165
|
+
return;
|
|
166
|
+
sink.models({ items, current: items.length === 1 ? items[0].id : null, canSet: false });
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
/* an older codex without model/list: no model shown, nothing else affected */
|
|
170
|
+
}
|
|
171
|
+
})();
|
|
152
172
|
return {
|
|
153
173
|
send: (text) => {
|
|
154
174
|
sink.state('running');
|
package/dist/cli.js
CHANGED
|
@@ -93,6 +93,31 @@ async function serve(state, keepAwake) {
|
|
|
93
93
|
hub.attach(c);
|
|
94
94
|
c.start();
|
|
95
95
|
}
|
|
96
|
+
// `agentpager pair` (a SEPARATE process, e.g. re-pairing after a reinstall while this one keeps
|
|
97
|
+
// running) writes the new device straight to disk. Without this, that pairing succeeds — the phone
|
|
98
|
+
// gets its "ok" — and then never connects, because this process's channel list was built once at
|
|
99
|
+
// startup and nothing ever told it a new device exists. The phone just sits on "Connecting…"
|
|
100
|
+
// forever with no way to know a restart was the missing step. Poll instead of a restart: cheap,
|
|
101
|
+
// and it means pairing a second (or reinstalled) phone never requires touching this window.
|
|
102
|
+
setInterval(() => {
|
|
103
|
+
let onDisk;
|
|
104
|
+
try {
|
|
105
|
+
onDisk = loadState();
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return; // mid-write on another process; try again next tick
|
|
109
|
+
}
|
|
110
|
+
const known = new Set(state.devices.map((d) => d.id));
|
|
111
|
+
for (const d of onDisk.devices) {
|
|
112
|
+
if (known.has(d.id))
|
|
113
|
+
continue;
|
|
114
|
+
state.devices.push(d);
|
|
115
|
+
const c = new Channel(d, persist, (msg, ch) => hub.handle(msg, ch));
|
|
116
|
+
hub.attach(c);
|
|
117
|
+
c.start();
|
|
118
|
+
log(`paired while running: ${d.name}`);
|
|
119
|
+
}
|
|
120
|
+
}, 5000);
|
|
96
121
|
const stopControl = serveControl((req) => hub.job(req));
|
|
97
122
|
// Detect the ACP agents in the background and announce them as they appear.
|
|
98
123
|
void (async () => {
|
package/dist/guard/limits.js
CHANGED
|
@@ -43,14 +43,23 @@ export function read() {
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
export const fresh = (s, now = Date.now()) => !!s && now - s.at <= MAX_AGE_MS;
|
|
46
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* The window closest to running out, as a percentage, or null when we cannot say.
|
|
48
|
+
*
|
|
49
|
+
* 🧨 This used to multiply by 100, on the assumption that `utilization` was a 0..1 fraction. It is
|
|
50
|
+
* not — the SDK says 0-100, and the phone has always read it that way (a 77 shows as "23% of your
|
|
51
|
+
* plan left"). So a real reading of 45 became 4500, and ANY planCapPercent denied every tool call
|
|
52
|
+
* the moment one usage reading existed: the feature built for "claude has usage limit, it is not in
|
|
53
|
+
* dollars" would have bricked the agent instead of capping it. The unit tests missed it because they
|
|
54
|
+
* fed fractions and asserted the product — they agreed with the bug rather than with the provider.
|
|
55
|
+
*/
|
|
47
56
|
export function worstUsedPct(s, now = Date.now()) {
|
|
48
57
|
if (!fresh(s, now))
|
|
49
58
|
return null;
|
|
50
59
|
const vals = [s.fiveHour?.used, s.sevenDay?.used].filter((v) => typeof v === 'number');
|
|
51
60
|
if (!vals.length)
|
|
52
61
|
return null;
|
|
53
|
-
return Math.round(Math.max(...vals)
|
|
62
|
+
return Math.round(Math.max(...vals));
|
|
54
63
|
}
|
|
55
64
|
/** Which window is the one running out, for a message that tells you something you can act on. */
|
|
56
65
|
export function worstWindow(s, now = Date.now()) {
|
package/dist/hub.js
CHANGED
|
@@ -71,6 +71,8 @@ export class Hub {
|
|
|
71
71
|
return;
|
|
72
72
|
case 'new':
|
|
73
73
|
return await this.startNew(msg.agent, msg.cwd, from, msg.ref);
|
|
74
|
+
case 'set_model':
|
|
75
|
+
return await this.setModel(msg, from);
|
|
74
76
|
case 'rules_get':
|
|
75
77
|
return from.send(this.rulesReply());
|
|
76
78
|
case 'rules_set':
|
|
@@ -500,6 +502,12 @@ export class Hub {
|
|
|
500
502
|
// and it is a tiny message.
|
|
501
503
|
this.broadcast({ type: 'limits', sid: live.sid, plan: l.plan, fiveHour: l.fiveHour, sevenDay: l.sevenDay });
|
|
502
504
|
},
|
|
505
|
+
models: (m) => {
|
|
506
|
+
// Not onlyActive: this is small, it changes rarely, and it is the first thing the session
|
|
507
|
+
// header shows when the app comes back — a phone that reopens to "model: —" looks broken.
|
|
508
|
+
live.models = m;
|
|
509
|
+
this.broadcast({ type: 'models', sid: live.sid, items: m.items.slice(0, 40), current: m.current, canSet: m.canSet });
|
|
510
|
+
},
|
|
503
511
|
commands: (items) => {
|
|
504
512
|
this.flush(live);
|
|
505
513
|
this.broadcast({ type: 'commands', sid: live.sid, items: items.slice(0, 120) }, true);
|
|
@@ -542,6 +550,31 @@ export class Hub {
|
|
|
542
550
|
},
|
|
543
551
|
};
|
|
544
552
|
}
|
|
553
|
+
/**
|
|
554
|
+
* Change a live session's model, then say what is actually in use.
|
|
555
|
+
*
|
|
556
|
+
* The reply is always a fresh `models` read back from the agent rather than an echo of what was
|
|
557
|
+
* asked for: a switch that silently did not take must show as the old model, or the phone reports
|
|
558
|
+
* a change that never happened — and the model is the one setting that decides how fast the plan
|
|
559
|
+
* window empties.
|
|
560
|
+
*/
|
|
561
|
+
async setModel(msg, from) {
|
|
562
|
+
const live = this.live.get(msg.sid);
|
|
563
|
+
if (!live?.session?.setModel) {
|
|
564
|
+
return from.send({ type: 'error', sid: msg.sid, code: 'model', message: 'This agent cannot change model from the phone.' });
|
|
565
|
+
}
|
|
566
|
+
try {
|
|
567
|
+
await live.session.setModel(msg.model);
|
|
568
|
+
this.log(`model → ${msg.model}`);
|
|
569
|
+
}
|
|
570
|
+
catch (e) {
|
|
571
|
+
from.send({ type: 'error', sid: msg.sid, code: 'model', message: `Could not switch model: ${String(e?.message ?? e)}` });
|
|
572
|
+
// Fall through: re-report anyway, so the phone snaps back to the model still in use.
|
|
573
|
+
}
|
|
574
|
+
if (live.models) {
|
|
575
|
+
this.broadcast({ type: 'models', sid: live.sid, items: live.models.items.slice(0, 40), current: live.models.current, canSet: live.models.canSet });
|
|
576
|
+
}
|
|
577
|
+
}
|
|
545
578
|
drop(live) {
|
|
546
579
|
if (Hub.busy(live.state))
|
|
547
580
|
this.awake.release();
|
package/dist/risk.js
CHANGED
|
@@ -26,20 +26,60 @@ export function classify(tool, input) {
|
|
|
26
26
|
return 'danger';
|
|
27
27
|
return 'shell';
|
|
28
28
|
}
|
|
29
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* One short line a person can read at a glance (or hear): never a whole file.
|
|
31
|
+
*
|
|
32
|
+
* The summary carries the *point* of the call, not its syntax. This used to say "Run a command" for
|
|
33
|
+
* every single Bash approval — so a page at a red light read "Run a command" over a line of shell,
|
|
34
|
+
* and deciding meant parsing the shell yourself. Sumanth: "claude asked for approval for execution
|
|
35
|
+
* but it should tell context of what the command does".
|
|
36
|
+
*
|
|
37
|
+
* The context was already in the payload: Claude Code's Bash tool takes a `description` written by
|
|
38
|
+
* the model in plain English for exactly this reader ("Discard all local changes and match remote
|
|
39
|
+
* main"), and we were dropping it on the floor. Prefer it, always — the command still travels as
|
|
40
|
+
* the detail for anyone who wants to read it.
|
|
41
|
+
*/
|
|
30
42
|
export function describe(tool, input) {
|
|
31
43
|
const clip = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
|
|
32
44
|
const path = String(input.file_path ?? input.path ?? input.notebook_path ?? '');
|
|
45
|
+
/** The agent's own one-line account of what it is about to do, if it wrote one. */
|
|
46
|
+
const said = () => {
|
|
47
|
+
for (const key of ['description', 'reason', 'explanation', 'justification']) {
|
|
48
|
+
const v = input[key];
|
|
49
|
+
if (typeof v !== 'string')
|
|
50
|
+
continue;
|
|
51
|
+
// One line only: a paragraph is not a summary, and the card has a detail pane for the rest.
|
|
52
|
+
const line = v.replace(/\s+/g, ' ').trim();
|
|
53
|
+
if (line.length > 2)
|
|
54
|
+
return clip(line, 140);
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
};
|
|
33
58
|
switch (tool) {
|
|
34
59
|
case 'Bash':
|
|
35
60
|
case 'shell':
|
|
36
|
-
return { summary: 'Run a command', detail: clip(String(input.command ?? ''), 2000) };
|
|
61
|
+
return { summary: said() ?? 'Run a command', detail: clip(String(input.command ?? ''), 2000) };
|
|
37
62
|
case 'Edit':
|
|
38
63
|
case 'MultiEdit':
|
|
39
|
-
case 'edit':
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
64
|
+
case 'edit': {
|
|
65
|
+
const file = basename(path) || 'files';
|
|
66
|
+
return {
|
|
67
|
+
summary: said() ?? `Edit ${file}`,
|
|
68
|
+
// What the edit actually changes, not just which file it lands in. Approving a change you
|
|
69
|
+
// cannot see is a tap, not a decision.
|
|
70
|
+
detail: clip(String(input.detail ?? '') || diffOf(input) || path, 2000),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
case 'Write': {
|
|
74
|
+
const file = basename(path);
|
|
75
|
+
const body = String(input.content ?? '');
|
|
76
|
+
return {
|
|
77
|
+
summary: said() ?? `Write ${file}`,
|
|
78
|
+
// The path alone was the whole detail here, so the card named a file and showed nothing of
|
|
79
|
+
// what was going into it.
|
|
80
|
+
detail: clip(body ? `${path}\n\n${body}` : path, 2000),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
43
83
|
case 'WebFetch':
|
|
44
84
|
return { summary: 'Fetch a web page', detail: clip(String(input.url ?? ''), 500) };
|
|
45
85
|
default:
|
|
@@ -76,3 +116,12 @@ export function readable(input) {
|
|
|
76
116
|
.join('\n');
|
|
77
117
|
}
|
|
78
118
|
const basename = (p) => p.split(/[\\/]/).filter(Boolean).pop() ?? '';
|
|
119
|
+
/** An Edit's before/after, short enough to read on a phone. */
|
|
120
|
+
function diffOf(input) {
|
|
121
|
+
const before = typeof input.old_string === 'string' ? input.old_string : '';
|
|
122
|
+
const after = typeof input.new_string === 'string' ? input.new_string : '';
|
|
123
|
+
if (!before && !after)
|
|
124
|
+
return '';
|
|
125
|
+
const side = (s) => (s.length > 400 ? `${s.slice(0, 399)}…` : s);
|
|
126
|
+
return `- ${side(before)}\n+ ${side(after)}`;
|
|
127
|
+
}
|
package/package.json
CHANGED