@yanzc/claude-pro 0.4.6 → 0.5.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/src/remote.js ADDED
@@ -0,0 +1,540 @@
1
+ // Remote control: watch and steer an interactive session from a browser (a phone, another computer), as Claude
2
+ // Code's /remote-control does with claude.ai. The session never listens on the network: it connects out to the
3
+ // codex-gateway the user is logged in to (with the gateway API key of /login), which relays between it and the
4
+ // gateway's user pages ("远程控制", for the same account only). See the protocol in ARCHITECTURE.md (3.1.2).
5
+ // The same connection carries the messages of the account's sessions on other computers (`@公司 ...`, see peers.js).
6
+ //
7
+ // The page is told what happens as a list of transcript items (user messages, assistant text, thinking, tool
8
+ // calls, other terminal output) that events update. The same reducer (applyEvent) runs here, to send a snapshot
9
+ // when one is needed, and in the page, which carries a copy of it.
10
+
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import crypto from 'node:crypto';
14
+ import { stripAnsi } from './ui/ansi.js';
15
+ import { toolHeader, renderToolResult } from './ui/render.js';
16
+ import { uploadFile, downloadFile } from './transfer.js';
17
+
18
+ export const REMOTE_WIDTH = 100; // columns that tool headers and results are laid out for on the page
19
+ const MAX_ITEMS = 800; // kept for a snapshot; the oldest are dropped beyond that
20
+ const SEED_ITEMS = 500;
21
+ const SNAPSHOT_ITEMS = 400; // sent in a snapshot
22
+ const SNAPSHOT_BYTES = 1_500_000; // ... at most: the gateway takes 2 MB a POST
23
+ const ITEM_CHARS = 60_000; // a longer text or output is cut down in a snapshot
24
+ const SNAPSHOT_EVERY = 500; // events after which a fresh snapshot replaces them on the gateway
25
+ const FLUSH_MS = 150;
26
+ const STATUS_MS = 500; // how often the status is compared with what the gateway has
27
+ const IDLE_STATUS_MS = 3000; // ... and sent at most this often while nobody watches
28
+ const REQUEST_TIMEOUT_MS = 15_000;
29
+ const STREAM_SILENCE_MS = 50_000; // the gateway pings every 15 s: a command stream silent this long is dead
30
+
31
+ // ---------------------------------------------------------------- the transcript model (shared with the page)
32
+
33
+ // state = {items, open, tools}: `open` is the text or thinking item being streamed, `tools` the running tool items by id.
34
+ // Returns the item the event changed (null when none). Self-contained: its source is sent to the page as it is.
35
+ export function applyEvent(state, ev) {
36
+ const items = state.items;
37
+ const push = (item) => { items.push(item); return item; };
38
+ switch (ev.t) {
39
+ case 'user': return push({ k: 'user', text: ev.text });
40
+ case 'output': {
41
+ const last = items[items.length - 1];
42
+ if (last && last.k === 'output') { last.lines.push(...ev.lines); return last; }
43
+ return push({ k: 'output', lines: ev.lines.slice() });
44
+ }
45
+ case 'start':
46
+ if (state.open) state.open.open = false;
47
+ state.open = push({ k: ev.kind === 'thinking' ? 'thinking' : 'text', text: '', open: true });
48
+ return state.open;
49
+ case 'delta':
50
+ if (!state.open) state.open = push({ k: ev.kind === 'thinking' ? 'thinking' : 'text', text: '', open: true });
51
+ state.open.text += ev.text;
52
+ return state.open;
53
+ case 'stop': {
54
+ const it = state.open;
55
+ if (!it) return null;
56
+ it.open = false;
57
+ state.open = null;
58
+ return it;
59
+ }
60
+ case 'tool': {
61
+ const it = push({ k: 'tool', id: ev.id, head: ev.head, status: 'running', progress: '', sub: [], subCount: 0, lines: [] });
62
+ state.tools[ev.id] = it;
63
+ return it;
64
+ }
65
+ case 'progress': {
66
+ const it = state.tools[ev.id];
67
+ if (!it || it.status !== 'running') return null;
68
+ it.progress = ev.text;
69
+ return it;
70
+ }
71
+ case 'sub': {
72
+ const it = state.tools[ev.id];
73
+ if (!it || it.status !== 'running') return null;
74
+ it.sub.push(ev.head);
75
+ if (it.sub.length > 3) it.sub.shift();
76
+ it.subCount++;
77
+ return it;
78
+ }
79
+ case 'done': {
80
+ const it = state.tools[ev.id] || push({ k: 'tool', id: ev.id, sub: [], subCount: 0 });
81
+ it.head = ev.head ?? it.head;
82
+ it.status = ev.status;
83
+ it.lines = ev.lines || [];
84
+ it.progress = '';
85
+ delete state.tools[ev.id];
86
+ return it;
87
+ }
88
+ default: return null;
89
+ }
90
+ }
91
+
92
+ export function hydrate(items) {
93
+ const state = { items, open: null, tools: {} };
94
+ for (const it of items) {
95
+ if (it.open) state.open = it;
96
+ if (it.k === 'tool' && it.status === 'running') state.tools[it.id] = it;
97
+ }
98
+ return state;
99
+ }
100
+
101
+ // What a user message of the conversation looks like on the page: without the notes for the model.
102
+ function shownUserText(text) {
103
+ return String(text)
104
+ .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
105
+ .replace(/<peer-message from="([^"]*)" kind="(task|reply)">\n?/g, (_, from, kind) => `✉ ${kind === 'reply' ? 'answer from' : 'task from'} &${from}: `)
106
+ .replace(/\n?<\/peer-message>/g, '')
107
+ .trim();
108
+ }
109
+
110
+ // Transcript items for a conversation (agent.messages), as the page shows them when it connects.
111
+ export function transcriptItems(messages, { tools = [], ctx, cwd, width = REMOTE_WIDTH } = {}) {
112
+ const toolMap = new Map(tools.map((t) => [t.name, t]));
113
+ const items = [];
114
+ const calls = new Map();
115
+ for (const m of messages || []) {
116
+ const blocks = typeof m.content === 'string' ? [{ type: 'text', text: m.content }] : (m.content || []);
117
+ for (const b of blocks) {
118
+ if (m.role === 'user') {
119
+ if (b.type === 'text') {
120
+ const raw = String(b.text || '');
121
+ if (raw.startsWith('This session is being continued')) { items.push({ k: 'summary', text: raw }); continue; }
122
+ const text = raw.startsWith('<system-reminder>') ? '' : shownUserText(raw);
123
+ if (text) items.push({ k: 'user', text });
124
+ } else if (b.type === 'image') {
125
+ items.push({ k: 'user', text: '[image]' });
126
+ } else if (b.type === 'tool_result') {
127
+ const call = calls.get(b.tool_use_id);
128
+ if (!call) continue;
129
+ let lines = [];
130
+ try { lines = renderToolResult({ tool: call.tool, input: call.input, result: { content: b.content, isError: Boolean(b.is_error) } }, { width, cwd }); } catch { /* shown without */ }
131
+ Object.assign(call.item, { status: b.is_error ? 'error' : 'done', lines });
132
+ }
133
+ } else if (m.role === 'assistant') {
134
+ if (b.type === 'text' && b.text?.trim()) items.push({ k: 'text', text: b.text });
135
+ else if (b.type === 'thinking' && b.thinking?.trim()) items.push({ k: 'thinking', text: b.thinking });
136
+ else if (b.type === 'tool_use') {
137
+ const tool = toolMap.get(b.name) || { name: b.name };
138
+ let head = b.name;
139
+ try { head = toolHeader(tool, b.input, ctx, { width, dot: false }); } catch { /* the name will do */ }
140
+ const item = { k: 'tool', id: b.id, head, status: 'done', sub: [], subCount: 0, lines: [] };
141
+ calls.set(b.id, { item, tool, input: b.input });
142
+ items.push(item);
143
+ }
144
+ }
145
+ }
146
+ }
147
+ return items.slice(-SEED_ITEMS);
148
+ }
149
+
150
+ // The newest items that fit in a snapshot the gateway takes, as copies (the POST may be serialized later, when
151
+ // the live items have moved on, and the events after the snapshot's seq would then be applied twice). Very long
152
+ // texts and outputs are cut down, and the oldest items are left out until it fits.
153
+ export function snapshotItems(items, { max = SNAPSHOT_ITEMS, bytes = SNAPSHOT_BYTES } = {}) {
154
+ const half = ITEM_CHARS / 2;
155
+ const clip = (it) => {
156
+ const out = { ...it };
157
+ if (typeof out.text === 'string' && out.text.length > ITEM_CHARS) out.text = `${out.text.slice(0, half)}\n…\n${out.text.slice(-half)}`;
158
+ if (Array.isArray(out.lines)) {
159
+ let lines = out.lines.map((l) => (String(l).length > 2000 ? `${String(l).slice(0, 2000)}…` : l));
160
+ if (lines.length > 300) lines = [...lines.slice(0, 100), `… ${lines.length - 300} lines …`, ...lines.slice(-200)];
161
+ out.lines = lines;
162
+ }
163
+ return out;
164
+ };
165
+ const picked = [];
166
+ let size = 0;
167
+ for (let i = items.length - 1; i >= 0 && picked.length < max; i--) {
168
+ const json = JSON.stringify(clip(items[i]));
169
+ size += Buffer.byteLength(json) + 1;
170
+ if (size > bytes) break;
171
+ picked.push(JSON.parse(json));
172
+ }
173
+ return picked.reverse();
174
+ }
175
+
176
+ // The last lines of a running tool's output, for the page.
177
+ function progressTail(text) {
178
+ const lines = String(text || '').slice(-6000).replace(/\r\n/g, '\n').trimEnd().split('\n');
179
+ return lines.slice(-8).map((l) => l.replace(/^.*\r/, '').slice(0, 400)).join('\n');
180
+ }
181
+
182
+ // ---------------------------------------------------------------- the connection to the gateway
183
+
184
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
185
+
186
+ export const newRemoteId = () => crypto.randomBytes(9).toString('base64url');
187
+
188
+ // Whether an interactive session logged in to the gateway goes online there by itself (CLAUDE_PRO_ONLINE=0: only with /rc).
189
+ export const onlineEnabled = (env = process.env) => !/^(0|false|no|off)$/i.test(env.CLAUDE_PRO_ONLINE || '');
190
+
191
+ export class RemoteControl {
192
+ // gateway: {url, apiKey}. handlers: info() -> {name, title, cwd, model}, seed() -> items, status() -> object,
193
+ // send(text), interrupt(), answer(body) (each returns an object or throws), changed() (online or viewers changed).
194
+ constructor({ gateway, id, handlers, fetchImpl = globalThis.fetch }) {
195
+ this.base = String(gateway.url).replace(/\/+$/, '');
196
+ this.apiKey = gateway.apiKey;
197
+ this.id = /^[\w-]{8,64}$/.test(id || '') ? id : newRemoteId();
198
+ this.handlers = handlers;
199
+ this.fetch = fetchImpl;
200
+ this.url = null; // the session's page on the gateway
201
+ this.online = false; // the command stream is open
202
+ this.viewers = null; // pages watching, as the gateway counts them (null: not told yet)
203
+ this.closed = false;
204
+ this.seq = 0;
205
+ this.state = hydrate(handlers.seed?.() || []);
206
+ this.events = []; // waiting to be uploaded
207
+ this.progress = new Map(); // tool id -> its live entry, whose output goes out at the next flush
208
+ this.outbox = []; // messages for the next POST /events
209
+ this.sending = false;
210
+ this.needSnapshot = true;
211
+ this.sinceSnapshot = 0;
212
+ this.lastStatus = '';
213
+ this.lastStatusAt = 0;
214
+ this.flushTimer = null;
215
+ this.streamAbort = null;
216
+ this.registered = false;
217
+ this.seenPeer = new Set(); // ids of the messages from other sessions already handled (the gateway sends again what was not acknowledged)
218
+ this.peerPending = new Set(); // messages being handled now, acknowledged when done
219
+ }
220
+
221
+ get viewing() { return this.viewers !== 0; } // nobody watching: events are not uploaded (a snapshot follows when someone comes)
222
+
223
+ // `persistent`: keep trying when the gateway cannot be reached now (a restart); otherwise the first failure is thrown.
224
+ async start({ persistent = false } = {}) {
225
+ try {
226
+ await this.register();
227
+ } catch (e) {
228
+ if (!persistent || e.status === 401 || e.status === 403) throw e;
229
+ }
230
+ this.loop();
231
+ this.statusTimer = setInterval(() => this.flush(), STATUS_MS);
232
+ this.statusTimer.unref?.();
233
+ return this;
234
+ }
235
+
236
+ handoff() { return { id: this.id }; }
237
+
238
+ // `retry: false`: not again after a failed connection (a task sent twice would be done twice).
239
+ async request(method, route, body, { timeout = REQUEST_TIMEOUT_MS, retry = true } = {}) {
240
+ const send = () => this.fetch(`${this.base}${route}`, {
241
+ method,
242
+ headers: { authorization: `Bearer ${this.apiKey}`, 'content-type': 'application/json' },
243
+ body: body === undefined ? undefined : JSON.stringify(body),
244
+ signal: AbortSignal.timeout(timeout),
245
+ });
246
+ let res;
247
+ // Once more when the connection failed before any answer (a kept-alive socket the server had just closed):
248
+ // every request here can be repeated (registering is idempotent, the pages drop events they already have).
249
+ try { res = await send(); } catch (e) { if (!retry || e.name === 'TimeoutError' || this.closed) throw e; res = await send(); }
250
+ const data = await res.json().catch(() => ({}));
251
+ if (!res.ok) {
252
+ const message = data?.error?.message || (typeof data?.error === 'string' ? data.error : `HTTP ${res.status}`);
253
+ throw Object.assign(new Error(res.status === 404 && route === '/v1/remote/sessions' ? `the gateway ${this.base} does not offer remote control (yet)` : String(message)), { status: res.status });
254
+ }
255
+ return data;
256
+ }
257
+
258
+ async register() {
259
+ const info = this.handlers.info?.() || {};
260
+ const data = await this.request('POST', '/v1/remote/sessions', { id: this.id, host: os.hostname(), version: info.version, ...info });
261
+ if (data.id) this.id = data.id;
262
+ this.url = data.url || this.url || `${this.base}/user`;
263
+ this.registered = true;
264
+ this.needSnapshot = true;
265
+ }
266
+
267
+ // The command stream, kept open (and opened again) until close(): while it is open the session is online.
268
+ async loop() {
269
+ let wait = 1000;
270
+ while (!this.closed) {
271
+ try {
272
+ if (!this.registered) await this.register();
273
+ await this.listen();
274
+ wait = 1000;
275
+ } catch (e) {
276
+ if (e.status === 404) this.registered = false; // the gateway forgot the session (restarted): register again
277
+ }
278
+ if (this.online) { this.online = false; this.handlers.changed?.(); }
279
+ if (this.closed) break;
280
+ await sleep(wait);
281
+ wait = Math.min(wait * 2, 30_000);
282
+ }
283
+ }
284
+
285
+ async listen() {
286
+ const abort = new AbortController();
287
+ this.streamAbort = abort;
288
+ const res = await this.fetch(`${this.base}/v1/remote/sessions/${encodeURIComponent(this.id)}/commands`, {
289
+ headers: { authorization: `Bearer ${this.apiKey}`, accept: 'text/event-stream' },
290
+ signal: abort.signal,
291
+ });
292
+ if (!res.ok) throw Object.assign(new Error(`HTTP ${res.status}`), { status: res.status });
293
+ this.online = true;
294
+ this.needSnapshot = true; // the gateway may have been restarted: it gets the whole conversation again
295
+ this.handlers.changed?.();
296
+ this.scheduleFlush(0);
297
+ let last = Date.now();
298
+ const watchdog = setInterval(() => { if (Date.now() - last > STREAM_SILENCE_MS) abort.abort(); }, 5000);
299
+ watchdog.unref?.();
300
+ try {
301
+ const decoder = new TextDecoder();
302
+ let buf = '';
303
+ for await (const chunk of res.body) {
304
+ last = Date.now();
305
+ buf += decoder.decode(chunk, { stream: true });
306
+ let i;
307
+ while ((i = buf.search(/\r?\n\r?\n/)) >= 0) {
308
+ const block = buf.slice(0, i);
309
+ buf = buf.slice(i).replace(/^\r?\n\r?\n/, '');
310
+ const data = block.split(/\r?\n/).filter((l) => l.startsWith('data:')).map((l) => l.slice(5).replace(/^ /, '')).join('\n');
311
+ if (!data) continue;
312
+ let cmd;
313
+ try { cmd = JSON.parse(data); } catch { continue; }
314
+ this.command(cmd);
315
+ }
316
+ }
317
+ } catch (e) {
318
+ if (!abort.signal.aborted) throw e;
319
+ } finally {
320
+ clearInterval(watchdog);
321
+ }
322
+ }
323
+
324
+ command(cmd) {
325
+ if (cmd.type === 'viewers') {
326
+ const was = this.viewers;
327
+ this.viewers = Math.max(0, Number(cmd.count) || 0);
328
+ if (!was && this.viewers) this.needSnapshot = true; // someone came: what they see starts from now
329
+ this.handlers.changed?.();
330
+ this.scheduleFlush(0);
331
+ return;
332
+ }
333
+ if (cmd.type === 'snapshot') { this.needSnapshot = true; this.scheduleFlush(0); return; }
334
+ if (!cmd.cid) return;
335
+ if (cmd.type === 'peer') { this.peerMessage(cmd); return; }
336
+ let result;
337
+ try {
338
+ const body = cmd.body || {};
339
+ let r;
340
+ if (cmd.type === 'send') r = this.handlers.send(String(body.text ?? ''));
341
+ else if (cmd.type === 'interrupt') r = this.handlers.interrupt();
342
+ else if (cmd.type === 'dialog') r = this.handlers.answer(body);
343
+ else throw new Error(`Unknown command: ${cmd.type}`);
344
+ result = { cid: cmd.cid, ok: true, ...(r || {}) };
345
+ } catch (e) {
346
+ result = { cid: cmd.cid, ok: false, error: e.message };
347
+ }
348
+ this.request('POST', `/v1/remote/sessions/${encodeURIComponent(this.id)}/results`, result).catch(() => { /* the page times out */ });
349
+ this.scheduleFlush(30);
350
+ }
351
+
352
+ // ------------------------------------------------------------ messages of the account's other sessions
353
+
354
+ // A message from a session on another computer: handed to the App once, then acknowledged, so that the gateway
355
+ // drops it from the session's mailbox (it sends what was not acknowledged again when the stream reconnects).
356
+ peerMessage(cmd) {
357
+ if (this.peerPending.has(cmd.cid)) return; // still being handled (files on their way): acknowledged when done
358
+ const ack = () => this.request('POST', `/v1/remote/sessions/${encodeURIComponent(this.id)}/results`, { cid: cmd.cid, ok: true }).catch(() => { /* sent again on reconnect, and dropped here as seen */ });
359
+ let handling = null;
360
+ try {
361
+ if (this.seenPeer.has(cmd.cid)) return;
362
+ this.seenPeer.add(cmd.cid);
363
+ if (this.seenPeer.size > 500) this.seenPeer.delete(this.seenPeer.values().next().value);
364
+ const f = cmd.from || {};
365
+ const machine = f.machine || f.host || 'computer';
366
+ const files = (Array.isArray(cmd.files) ? cmd.files : [])
367
+ .filter((x) => x && typeof x.id === 'string')
368
+ .map((x) => ({ id: x.id, name: String(x.name || 'file'), size: Number(x.size) || 0, sha256: typeof x.sha256 === 'string' ? x.sha256 : null }));
369
+ handling = this.handlers.peerMessage?.({
370
+ id: cmd.cid,
371
+ kind: cmd.kind === 'reply' ? 'reply' : 'task',
372
+ text: String(cmd.text ?? ''),
373
+ inReplyTo: cmd.inReplyTo || null,
374
+ from: { id: f.id, name: `${machine}/${f.name || 'session'}`, cwd: f.cwd || '', machine, remote: true },
375
+ ...(files.length ? { files } : {}),
376
+ });
377
+ } catch { /* the App's problem: the message is acknowledged anyway, or it would come back forever */ } finally {
378
+ if (typeof handling?.then === 'function') {
379
+ // until it is done the gateway keeps it: a session that ends meanwhile gets it again, and continues
380
+ this.peerPending.add(cmd.cid);
381
+ handling.catch(() => {}).finally(() => { this.peerPending.delete(cmd.cid); if (!this.closed) ack(); });
382
+ } else {
383
+ ack();
384
+ }
385
+ }
386
+ }
387
+
388
+ // The sessions of this gateway account, on every computer, as the gateway lists them (this one included).
389
+ async sessions() {
390
+ const data = await this.request('GET', '/v1/remote/sessions');
391
+ return Array.isArray(data?.data) ? data.data : [];
392
+ }
393
+
394
+ // A message to another session of the account: resolves with {id} (the message's id, which a reply names).
395
+ // `files`: uploaded ones ({id}, see uploadFile), which the gateway hands over with the message.
396
+ async sendPeer(targetId, { kind = 'task', text, inReplyTo = null, files = [] }) {
397
+ if (!this.registered) await this.register();
398
+ const body = { from: this.id, kind, text, ...(inReplyTo ? { inReplyTo } : {}), ...(files.length ? { files: files.map((x) => ({ id: x.id })) } : {}) };
399
+ const data = await this.request('POST', `/v1/remote/sessions/${encodeURIComponent(targetId)}/messages`, body, { retry: false });
400
+ return { id: data.id, delivered: data.delivered !== false };
401
+ }
402
+
403
+ // ------------------------------------------------------------ files (for another computer's session)
404
+
405
+ get client() { return { base: this.base, apiKey: this.apiKey, fetch: this.fetch }; }
406
+
407
+ // A file of this computer (`{path, name, size}`) onto the gateway, resumably (see transfer.js): resolves with
408
+ // {id, name, size, sha256}, which a message's files refer to.
409
+ uploadFile(file, options) { return uploadFile(this.client, file, options); }
410
+
411
+ // A file another session sent (`{id, name, size, sha256}`) into `dir`: resolves with the path it got.
412
+ downloadFile(file, dir, options) { return downloadFile(this.client, file, dir, options); }
413
+
414
+ deleteFile(id) {
415
+ return this.fetch(`${this.base}/v1/remote/files/${encodeURIComponent(id)}`, {
416
+ method: 'DELETE', headers: { authorization: `Bearer ${this.apiKey}` }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
417
+ }).then(() => {}, () => {});
418
+ }
419
+
420
+ // ------------------------------------------------------------ events
421
+
422
+ emit(ev) {
423
+ ev.seq = ++this.seq;
424
+ applyEvent(this.state, ev);
425
+ if (this.state.items.length > MAX_ITEMS) this.state.items.splice(0, this.state.items.length - MAX_ITEMS + 100);
426
+ if (this.viewing && !this.needSnapshot) {
427
+ this.events.push(ev);
428
+ this.scheduleFlush();
429
+ }
430
+ }
431
+
432
+ // Lines the terminal printed that no other event stands for (command output, notices, errors).
433
+ output(lines) {
434
+ const last = this.state.items[this.state.items.length - 1];
435
+ if (!lines.length || (last?.k !== 'output' && lines.every((l) => !stripAnsi(l).trim()))) return;
436
+ this.emit({ t: 'output', lines });
437
+ }
438
+
439
+ toolProgress(id, entry) {
440
+ if (!id) return;
441
+ this.progress.set(id, entry);
442
+ if (this.viewing) this.scheduleFlush();
443
+ }
444
+
445
+ // The conversation was replaced (/clear, /resume, /rewind): the pages start again from it.
446
+ resync() {
447
+ this.progress.clear();
448
+ this.events = [];
449
+ this.state = hydrate(this.handlers.seed?.() || []);
450
+ this.seq++;
451
+ this.needSnapshot = true;
452
+ this.scheduleFlush(0);
453
+ }
454
+
455
+ scheduleFlush(ms = FLUSH_MS) {
456
+ if (this.closed) return;
457
+ if (this.flushTimer && ms > 0) return;
458
+ clearTimeout(this.flushTimer);
459
+ this.flushTimer = setTimeout(() => { this.flushTimer = null; this.flush(); }, ms);
460
+ }
461
+
462
+ takeProgress() {
463
+ for (const [id, entry] of this.progress) {
464
+ const ev = { t: 'progress', id, text: progressTail(entry.progress), seq: ++this.seq };
465
+ if (applyEvent(this.state, ev) && this.viewing && !this.needSnapshot) this.events.push(ev);
466
+ }
467
+ this.progress.clear();
468
+ }
469
+
470
+ status() {
471
+ try { return this.handlers.status?.() || {}; } catch (e) { return { error: e.message }; }
472
+ }
473
+
474
+ // What decides whether the status is sent again: all of it while a page watches; while none does, only what
475
+ // the gateway's session list shows (the token count of a busy session would otherwise go out every few seconds).
476
+ statusKey(status) {
477
+ if (this.viewing) return JSON.stringify({ ...status, elapsed: 0 });
478
+ const { busy, title, model, cwd, name } = status;
479
+ return JSON.stringify({ busy, title, model, cwd, name });
480
+ }
481
+
482
+ // What has changed goes into the next upload: a snapshot when one is due, else the events; and the status.
483
+ flush() {
484
+ if (this.closed || !this.registered) return;
485
+ this.takeProgress();
486
+ const status = this.status();
487
+ if (this.needSnapshot || this.sinceSnapshot > SNAPSHOT_EVERY) {
488
+ this.needSnapshot = false;
489
+ this.events = [];
490
+ this.sinceSnapshot = 0;
491
+ this.lastStatus = this.statusKey(status);
492
+ this.lastStatusAt = Date.now();
493
+ this.outbox.push({ t: 'snapshot', seq: this.seq, items: snapshotItems(this.state.items), status });
494
+ } else if (this.events.length) {
495
+ this.sinceSnapshot += this.events.length;
496
+ this.outbox.push({ t: 'batch', events: this.events });
497
+ this.events = [];
498
+ }
499
+ const key = this.statusKey(status);
500
+ if (key !== this.lastStatus && Date.now() - this.lastStatusAt >= (this.viewing ? 0 : IDLE_STATUS_MS)) {
501
+ this.lastStatus = key;
502
+ this.lastStatusAt = Date.now();
503
+ this.outbox.push({ t: 'status', status });
504
+ }
505
+ this.pump();
506
+ }
507
+
508
+ // One POST at a time, so that the gateway gets the messages in order. A failed one is not sent again: the
509
+ // next upload is a snapshot, which stands for everything that was lost.
510
+ async pump() {
511
+ if (this.sending || this.closing || !this.outbox.length) return;
512
+ this.sending = true;
513
+ const messages = this.outbox.splice(0);
514
+ try {
515
+ await this.request('POST', `/v1/remote/sessions/${encodeURIComponent(this.id)}/events`, { messages });
516
+ } catch (e) {
517
+ this.needSnapshot = true;
518
+ this.events = [];
519
+ if (e.status === 404) { this.registered = false; this.streamAbort?.abort(); }
520
+ } finally {
521
+ this.sending = false;
522
+ }
523
+ if (this.outbox.length) this.pump();
524
+ }
525
+
526
+ // Ends the connection; resolves once the gateway was told (or gave up waiting). `restarting`: the session comes
527
+ // back at once with the same id, and the pages wait for it.
528
+ async close({ restarting = false } = {}) {
529
+ if (this.closed) return;
530
+ this.closing = true; // what flush() collects now goes out with the last message, not on its own
531
+ this.flush();
532
+ this.closed = true;
533
+ clearTimeout(this.flushTimer);
534
+ clearInterval(this.statusTimer);
535
+ this.streamAbort?.abort();
536
+ if (!this.registered) return;
537
+ const messages = [...this.outbox.splice(0), { t: 'bye', restarting }];
538
+ await this.request('POST', `/v1/remote/sessions/${encodeURIComponent(this.id)}/events`, { messages }, { timeout: 1500 }).catch(() => {});
539
+ }
540
+ }
package/src/session.js CHANGED
@@ -74,6 +74,8 @@ export class SessionStore {
74
74
  message(message) { this.append({ type: 'message', message, at: Date.now() }); }
75
75
  // What the conversation is about now (the tab title); the newest one wins when the session is loaded.
76
76
  setTopic(topic) { this.append({ type: 'meta', topic, at: Date.now() }); }
77
+ // The name the user gave the conversation with /rename (null: none again); it wins over the topic.
78
+ setName(name) { this.append({ type: 'meta', name, at: Date.now() }); }
77
79
  reset(messages) { this.append({ type: 'reset', messages, at: Date.now() }); }
78
80
  // The system prompt this conversation runs behind, kept so that a resumed one continues behind the same prompt
79
81
  // and so with the prompt cache it already has (see runtime.resumeSystemPrompt). Written when it first appears
@@ -110,6 +112,7 @@ export class SessionStore {
110
112
  return {
111
113
  messages, times, lastAt, meta, title,
112
114
  topic: typeof meta.topic === 'string' && meta.topic.trim() ? meta.topic : null,
115
+ name: typeof meta.name === 'string' && meta.name.trim() ? meta.name : null,
113
116
  systemPrompt: typeof meta.systemPrompt === 'string' && meta.systemPrompt.trim() ? meta.systemPrompt : null,
114
117
  };
115
118
  }
@@ -124,7 +127,7 @@ export class SessionStore {
124
127
  let title = null, count = 0;
125
128
  try {
126
129
  const loaded = SessionStore.load(file);
127
- title = loaded.topic || loaded.title; // what it was about last, else its first message
130
+ title = loaded.name || loaded.topic || loaded.title; // its name, else what it was about last, else its first message
128
131
  count = loaded.messages.length;
129
132
  } catch { /* ignore */ }
130
133
  return { id: f.slice(0, -6), file, mtime: st.mtimeMs, title: title || '(no prompt)', count };
@@ -5,6 +5,7 @@ import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { resolvePath, displayPath } from '../utils/paths.js';
7
7
  import { diffLines, makeHunks, countChanges, splitLines } from '../utils/diff.js';
8
+ import { fitImage } from '../utils/images.js';
8
9
 
9
10
  const MAX_LINES = 10_000;
10
11
  const MAX_LINE_CHARS = 20_000;
@@ -94,12 +95,20 @@ Usage:
94
95
  const ext = path.extname(file).toLowerCase();
95
96
 
96
97
  if (IMAGE_TYPES[ext]) {
97
- if (stat.size > 5 * 1024 * 1024) return { isError: true, content: `Image too large (${Math.round(stat.size / 1024)}KB, max 5MB).` };
98
- const data = fs.readFileSync(file).toString('base64');
98
+ if (stat.size > 30 * 1024 * 1024) return { isError: true, content: `Image too large (${Math.round(stat.size / 1024)}KB, max 30MB).` };
99
+ // Scaled to what the API takes (see utils/images.js): a full-page screenshot would otherwise make every
100
+ // later request of the session fail once it has many images.
101
+ const fit = await fitImage({ data: fs.readFileSync(file).toString('base64'), media_type: IMAGE_TYPES[ext] });
102
+ if (fit.error) return { isError: true, content: `Cannot show ${file}: ${fit.error}. Crop it or take a smaller screenshot.` };
99
103
  ctx.fileState.set(file, { mtimeMs: stat.mtimeMs });
104
+ const dims = fit.width ? `${fit.width}×${fit.height}, ` : '';
105
+ const kb = Math.round((fit.data.length * 3) / 4 / 1024);
100
106
  return {
101
- content: [{ type: 'image', source: { type: 'base64', media_type: IMAGE_TYPES[ext], data } }],
102
- display: { summary: `Read image (${Math.round(stat.size / 1024)}KB)` },
107
+ content: [
108
+ { type: 'image', source: { type: 'base64', media_type: fit.media_type, data: fit.data } },
109
+ ...(fit.from ? [{ type: 'text', text: `[Scaled from ${fit.from.width}×${fit.from.height} to ${fit.width}×${fit.height} px to fit the API's image size limit. For detail, crop the area or take a smaller screenshot.]` }] : []),
110
+ ],
111
+ display: { summary: fit.from ? `Read image (${fit.from.width}×${fit.from.height}, scaled to ${fit.width}×${fit.height}, ${kb}KB)` : `Read image (${dims}${kb}KB)` },
103
112
  };
104
113
  }
105
114
  if (ext === '.ipynb') {