@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/ui/repl.js CHANGED
@@ -6,7 +6,7 @@ import path from 'node:path';
6
6
  import os from 'node:os';
7
7
  import readline from 'node:readline';
8
8
  import { spawn } from 'node:child_process';
9
- import { c, ESC, stringWidth, truncate, padEnd, wordWrap } from './ansi.js';
9
+ import { c, ESC, stringWidth, truncate, padEnd, wordWrap, colorEnabled } from './ansi.js';
10
10
  import { Screen } from './screen.js';
11
11
  import { Editor } from './editor.js';
12
12
  import { SelectDialog } from './dialogs.js';
@@ -28,8 +28,13 @@ import { describePause, formatClock, humanDuration, resumeText, shouldAnnounce }
28
28
  import { shutdownBackgroundShells, listBackgroundShells } from '../tools/bash.js';
29
29
  import { estimateTokens } from '../agent.js';
30
30
  import { readClipboardImage, imageFromFile, watchClipboard } from '../utils/clipboard.js';
31
+ import { fitImagesIn } from '../utils/images.js';
32
+ import { downloadsDir, filesToSend, formatBytes } from '../utils/downloads.js';
33
+ import { copyUnique } from '../transfer.js';
31
34
  import { checkForUpdate } from '../updater.js';
32
- import { PeerNode, peersEnabled, findPeer, peerMessagePrompt } from '../peers.js';
35
+ import { PeerNode, peersEnabled, findPeer, resolvePeer, machineName, cleanName, peerMessagePrompt } from '../peers.js';
36
+ import { RemoteControl, transcriptItems, onlineEnabled, REMOTE_WIDTH } from '../remote.js';
37
+ import { qrLines } from '../qr.js';
33
38
 
34
39
  // A model id as people say it: claude-sonnet-5-5 -> sonnet-5-5, claude-haiku-4-5-20251001 -> haiku-4-5.
35
40
  export const modelTag = (model) => String(model?.id ?? '').replace(/^claude-/, '').replace(/-\d{8}$/, '');
@@ -174,12 +179,32 @@ export class App {
174
179
  this.peers = null; // this session as seen by the other sessions on the machine (see peers.js)
175
180
  this.turnPeers = []; // tasks from other sessions that the running turn answers: [{msg}]
176
181
  this.userSentToPeers = new Set(); // ids of messages the user sent to other sessions with @name
182
+ this.remote = null; // the browser page that watches and steers this session (see remote.js), when it is on
183
+ this.remoteMuted = 0; // > 0 while printing something the page is told about in its own terms
184
+ this.remoteDialogs = 0;
185
+ this.remoteStarting = null;
186
+ this.remoteStopped = false; // /rc stop: offline until /rc, also after a restart
187
+ this.sessionName = flags?.restart?.peerName || null; // this session's name when the sessions of this computer do not know it (CLAUDE_PRO_PEERS=0)
188
+ this.customTitle = null; // the conversation's name given with /rename: the title, instead of the topic
189
+ this.remotePeers = []; // the account's sessions on other computers, as last listed by the gateway (see peers.js)
190
+ this.remotePeersAt = 0;
191
+ this.remotePeersLoading = null;
192
+ this.remotePeersTimer = null;
193
+ this.transfers = new Map(); // files on their way to or from other sessions, for the footer
194
+ this.transferSeq = 0;
177
195
  this.fileIndex = new FileIndex(cwd, () => this.runtime?.config);
178
196
  this.store = new SessionStore(cwd);
197
+ // What the terminal prints goes to the remote page too, except what it is told about as events.
198
+ this.screen.onLog = (items) => {
199
+ if (!this.remote || this.remoteMuted) return;
200
+ this.remote.output(items.flatMap((i) => (Array.isArray(i) ? i : [i])).flatMap((l) => String(l).split(/\r?\n/)));
201
+ };
179
202
  this.editor = new Editor({
180
203
  history: loadHistory(cwd),
181
204
  onSubmit: (value, raw, images) => { try { this.onSubmit(value, raw, images); } catch (e) { this.logError(e); } },
205
+ onDoubleEscape: () => this.openRewind(),
182
206
  imageFromPath: (p) => {
207
+ if (/^\s*\/(sendfile|sf)\b/i.test(this.editor?.text || '')) return null; // a file to send, not to show the model
183
208
  const r = imageFromFile(resolvePath(p, this.cwd));
184
209
  if (r.error && !/not found|Not a file/.test(r.error)) this.flash(r.error, 'warn');
185
210
  if (r.image) this.flashMessage = null;
@@ -230,6 +255,7 @@ export class App {
230
255
  if (restart) this.resumeAfterRestart(restart, resumable);
231
256
  else if (this.flags.resumeFile) this.loadSession(this.flags.resumeFile, this.flags.resumeId);
232
257
  else if (this.flags.resume) await this.commands.get('resume').run('');
258
+ this.autoStartRemote(restart);
233
259
 
234
260
  this.screen.setLive((w) => this.renderLive(w));
235
261
  this.spinnerTimer = setInterval(() => { if (this.busy || this.active.length) this.screen.schedule(); }, 120);
@@ -248,6 +274,7 @@ export class App {
248
274
  // What the session is about now: the topic the small model named last, or else the first thing asked
249
275
  // (kept when the conversation is compacted afterwards). Cleared by /clear, replaced by /resume.
250
276
  sessionLabel() {
277
+ if (this.customTitle) return this.customTitle;
251
278
  if (this.topic) return this.topic;
252
279
  if (!this.firstPrompt) {
253
280
  const first = this.agent?.messages.find((m) => m.role === 'user' && firstUserText(m));
@@ -272,7 +299,7 @@ export class App {
272
299
  // answer, and when there is none (a rate limit, no network) the title just stays as it is.
273
300
  refreshTopic(message) {
274
301
  const text = String(message ?? '').trim();
275
- if (!text || /^(1|true|yes)$/i.test(this.config.env.CLAUDE_PRO_DISABLE_TOPIC_TITLE || '')) return;
302
+ if (!text || this.customTitle || /^(1|true|yes)$/i.test(this.config.env.CLAUDE_PRO_DISABLE_TOPIC_TITLE || '')) return;
276
303
  const store = this.store;
277
304
  const seq = ++this.topicSeq;
278
305
  const current = this.topic;
@@ -327,6 +354,8 @@ export class App {
327
354
  this.runtime?.tasks.killAll();
328
355
  this.runtime?.mcp.close();
329
356
  this.peers?.close();
357
+ clearInterval(this.remotePeersTimer);
358
+ const remoteClosed = this.remote?.close({ restarting: restart });
330
359
  const u = this.agent?.totalUsage();
331
360
  if (!restart && u && u.requests) {
332
361
  this.screen.log(c.dim(`Total usage: ${formatTokens(u.input + u.cacheRead + u.cacheWrite)} input (${formatTokens(u.cacheRead)} cached), ${formatTokens(u.output)} output, ${u.requests + u.compactions} requests`));
@@ -335,7 +364,11 @@ export class App {
335
364
  this.restoreTerminal({ handOver: restart });
336
365
  this.stdin.pause();
337
366
  this.resolveExit?.(code);
338
- if (!this.noProcessExit) setTimeout(() => process.exit(code), 50).unref();
367
+ if (!this.noProcessExit) {
368
+ // with remote control on, the gateway is told first (at most a second and a half; see RemoteControl.close)
369
+ if (remoteClosed) remoteClosed.finally(() => process.exit(code));
370
+ else setTimeout(() => process.exit(code), 50).unref();
371
+ }
339
372
  }
340
373
 
341
374
  // ---------------------------------------------------------------- other sessions (see peers.js)
@@ -352,18 +385,162 @@ export class App {
352
385
  }
353
386
  }
354
387
 
355
- // `byModel`: the MessagePeer tool sent it, so the answer goes to the model; otherwise the user typed
356
- // `@name ...` and the answer is only shown (and noted for the model).
357
- async sendToPeer(name, text, { byModel = false } = {}) {
358
- if (!this.peers) throw new Error('Talking to other sessions is off in this session.');
359
- const r = await this.peers.send(name, { text });
360
- if (!byModel) this.userSentToPeers.add(`${r.peer.id}:${r.id}`);
361
- return r;
388
+ // This session's name for the others.
389
+ peerLabel() { return this.peers?.name || this.sessionName || cleanName(path.basename(this.cwd)) || 'claude-pro'; }
390
+
391
+ // /rename: the session's name, for the other sessions (&name, here and at the gateway) and as the conversation's
392
+ // title (the tab, /resume, the web page), instead of the topic. Returns the name it got: name-2... when another
393
+ // session on this computer has it.
394
+ renameSession(name) {
395
+ const clean = cleanName(name);
396
+ const got = this.peers ? this.peers.setName(clean) : (this.sessionName = clean);
397
+ this.customTitle = got;
398
+ this.store.setName(got);
399
+ this.remote?.register().catch(() => { /* the gateway takes it at the next registration */ });
400
+ this.remote?.flush();
401
+ this.screen.schedule();
402
+ return got;
403
+ }
404
+
405
+ // `target`: a peer entry, or what names one (see resolvePeer): a session on this computer, or one on another
406
+ // computer, reached through the gateway. `byModel`: the MessagePeer tool sent it, so the answer goes to the
407
+ // model; otherwise the user typed `@name ...` and the answer is only shown (and noted for the model).
408
+ // `files`: paths of files on this computer to send along (see utils/downloads.js); the other session saves
409
+ // them in its Downloads folder.
410
+ //
411
+ // `background`: with files, return as soon as they are checked and the session is found, and send in the
412
+ // background (progress in the footer; a finished or failed sending is reported in the transcript).
413
+ async sendToPeer(target, text, { byModel = false, files: paths = [], background = false } = {}) {
414
+ const files = paths.length ? filesToSend(paths) : [];
415
+ let peer = target && typeof target === 'object' ? target : null;
416
+ if (!peer) {
417
+ const local = this.peers?.peers() || [];
418
+ let found = resolvePeer(local, this.remotePeers, target);
419
+ if (!found.peer && !found.error && this.remote) found = resolvePeer(local, await this.refreshRemotePeers(), target);
420
+ if (!found.peer) {
421
+ const offline = this.remote ? '' : ' Sessions on your other computers can be reached once this one is online at the gateway (/login, then /rc).';
422
+ throw new Error(found.error || `No running session called "${String(target).replace(/^[@&]/, '')}". /peers lists them.${offline}`);
423
+ }
424
+ peer = found.peer;
425
+ }
426
+ if (peer.remote && !this.remote) throw new Error(`&${peer.label} is on another computer, and this session is not online at the gateway (/rc connects it).`);
427
+ if (!peer.remote && !this.peers) throw new Error('Talking to other sessions on this computer is off (CLAUDE_PRO_PEERS=0).');
428
+ const send = async () => {
429
+ let r;
430
+ if (peer.remote) {
431
+ const uploaded = [];
432
+ if (files.length) {
433
+ const job = this.startTransfer('up', files, peer);
434
+ try {
435
+ for (const f of files) uploaded.push(await this.remote.uploadFile(f, { signal: job.signal, onProgress: (p) => job.progress(f, p) }));
436
+ } finally {
437
+ job.end();
438
+ }
439
+ }
440
+ r = { ...(await this.remote.sendPeer(peer.id, { text, files: uploaded })), peer };
441
+ } else {
442
+ r = await this.peers.send(peer, { text, files });
443
+ }
444
+ r.files = files;
445
+ if (!byModel) this.userSentToPeers.add(`${r.peer.id}:${r.id}`);
446
+ return r;
447
+ };
448
+ if (!background || !files.length) return await send();
449
+ const label = peer.label || peer.name;
450
+ const what = `${files.length} file${files.length === 1 ? '' : 's'} (${formatBytes(files.reduce((n, f) => n + f.size, 0))})`;
451
+ send().then(
452
+ () => this.screen.log(c.dim(` ⎿ Sent ${what} to &${label}; it saves them in its Downloads folder and confirms`), ''),
453
+ (e) => {
454
+ const why = e.name === 'AbortError'
455
+ ? `Stopped sending ${what} to &${label}. Send the same files again to continue where it stopped.`
456
+ : `Could not send ${what} to &${label}: ${e.message}${peer.remote ? ' (sending the same files again continues where it stopped)' : ''}`;
457
+ this.notice(why, 'warn');
458
+ if (byModel) this.agent.addReminder(`The files you sent with MessagePeer did not go out: ${why}`);
459
+ },
460
+ );
461
+ return { peer, files, background: true };
462
+ }
463
+
464
+ // A sending or receiving of files, shown in the footer while it runs: {signal, progress(file, p), end()}.
465
+ startTransfer(direction, files, peer) {
466
+ const id = ++this.transferSeq;
467
+ const controller = new AbortController();
468
+ const total = files.reduce((n, f) => n + (Number(f.size) || 0), 0);
469
+ const t = { direction, label: peer.label || peer.name, names: files.map((f) => f.name), total, done: new Map(), phase: 'starting', retryIn: 0, at: Date.now(), controller, shown: 0 };
470
+ this.transfers.set(id, t);
471
+ this.screen.schedule();
472
+ return {
473
+ signal: controller.signal,
474
+ progress: (file, p) => {
475
+ t.done.set(file, p.phase === 'hashing' || p.phase === 'checking' ? 0 : p.done);
476
+ t.phase = p.phase;
477
+ t.file = file.name;
478
+ t.retryIn = p.phase === 'waiting' ? Date.now() + (p.retryIn || 0) : 0;
479
+ if (p.phase === 'hashing' || p.phase === 'checking') t.hashed = p.total ? p.done / p.total : 0;
480
+ if (Date.now() - t.shown > 250) { t.shown = Date.now(); this.screen.schedule(); }
481
+ },
482
+ end: () => { this.transfers.delete(id); this.screen.schedule(); },
483
+ };
484
+ }
485
+
486
+ // The footer's word on the files on their way.
487
+ transferStatus() {
488
+ const all = [...this.transfers.values()];
489
+ if (!all.length) return '';
490
+ const t = all[0];
491
+ const done = [...t.done.values()].reduce((n, v) => n + v, 0);
492
+ const pct = t.total ? Math.floor((done / t.total) * 100) : 0;
493
+ const arrow = t.direction === 'up' ? '⇡' : '⇣';
494
+ const name = truncate(t.names.length === 1 ? t.names[0] : `${t.names.length} files`, 24);
495
+ const where = t.direction === 'up' ? `→ &${t.label}` : `← &${t.label}`;
496
+ let state;
497
+ if (t.phase === 'waiting') state = c.yellow(`${pct}% · network down, retrying…`);
498
+ else if (t.phase === 'hashing') state = c.dim(`preparing ${Math.floor((t.hashed || 0) * 100)}%`);
499
+ else if (t.phase === 'checking') state = c.dim('checking');
500
+ else state = c.dim(`${pct}% · ${formatBytes(done)}/${formatBytes(t.total)}`);
501
+ return `${c.blue(`${arrow} ${name}`)} ${c.dim(where)} ${state}${all.length > 1 ? c.dim(` +${all.length - 1}`) : ''}`;
502
+ }
503
+
504
+ // The account's sessions on other computers that are online now, from the gateway (what was known stays when
505
+ // it cannot be asked). Sessions of this computer are left out: they are reached directly.
506
+ refreshRemotePeers() {
507
+ if (!this.remote?.registered) return Promise.resolve(this.remotePeers);
508
+ this.remotePeersLoading ??= (async () => {
509
+ try {
510
+ const near = new Set([this.peers?.id, ...(this.peers?.peers() || []).map((p) => p.id)].filter(Boolean));
511
+ const list = await this.remote.sessions();
512
+ this.remotePeers = list
513
+ .filter((p) => p.online && !p.ended && p.id !== this.remote?.id && !near.has(p.peerId))
514
+ .map((p) => {
515
+ const machine = p.machine || p.host || 'computer';
516
+ const name = p.name || 'session';
517
+ return { id: p.id, name, machine, cwd: p.cwd || '', remote: true, label: `${machine}/${name}`, busy: Boolean(p.busy), title: p.title || '' };
518
+ });
519
+ this.remotePeersAt = Date.now();
520
+ } catch { /* the gateway cannot be asked now */ } finally {
521
+ this.remotePeersLoading = null;
522
+ }
523
+ return this.remotePeers;
524
+ })();
525
+ return this.remotePeersLoading;
526
+ }
527
+
528
+ // This session and the others, here and on the other computers (for /peers and ListPeers).
529
+ async listAllPeers() {
530
+ if (this.remote) await this.refreshRemotePeers();
531
+ const name = this.peerLabel();
532
+ return {
533
+ me: { name, cwd: this.agent?.cwd || this.cwd, label: `${machineName(this.config.env)}/${name}` },
534
+ local: this.peers?.peers() || [],
535
+ remote: this.remotePeers,
536
+ online: Boolean(this.remote?.online),
537
+ };
362
538
  }
363
539
 
364
540
  onPeerMessage(msg) {
365
541
  if (this.exiting) return;
366
- const from = c.bold(`@${msg.from.name}`);
542
+ if (msg.files?.length && !msg.received) return this.receiveFiles(msg).catch((e) => this.logError(e)); // the gateway keeps the message until this is done
543
+ const from = c.bold(`&${msg.from.name}`);
367
544
  const width = this.width - 1;
368
545
  if (msg.kind === 'reply' && this.userSentToPeers.delete(`${msg.from.id}:${msg.inReplyTo}`)) {
369
546
  // the answer to something the user sent: shown, and noted for the model, without a turn of its own
@@ -371,31 +548,317 @@ export class App {
371
548
  this.agent.addReminder(`The user sent a message to another claude-pro session, ${msg.from.name} (${msg.from.cwd}), which answered:\n${msg.text}`);
372
549
  return;
373
550
  }
374
- const display = `✉ ${msg.kind === 'reply' ? 'answer from' : 'task from'} @${msg.from.name}: ${msg.text}`;
551
+ const display = `✉ ${msg.kind === 'reply' ? 'answer from' : 'task from'} &${msg.from.name}: ${msg.text}`;
375
552
  const item = { text: peerMessagePrompt(msg), images: [], display, peer: msg.kind === 'task' ? msg : null };
376
553
  if (this.busy) { this.queue.push(item); this.screen.schedule(); return; }
377
554
  this.startTurn(item.text, { display, peers: item.peer ? [item.peer] : [] });
378
555
  }
379
556
 
557
+ // Files another session sent: saved in the Downloads folder (never over a file that is there) at once, whatever
558
+ // this session is doing. Files alone are answered right away with where they are, without a turn of the model;
559
+ // with a task, the task runs as usual and is told where they are.
560
+ async receiveFiles(msg) {
561
+ msg.received = true;
562
+ let dir;
563
+ try { dir = await downloadsDir(this.config.env); } catch { dir = path.join(os.homedir(), 'Downloads'); }
564
+ const saved = [];
565
+ const failed = [];
566
+ const remote = this.remote;
567
+ const job = msg.from.remote ? this.startTransfer('down', msg.files, msg.from) : null;
568
+ try {
569
+ for (const f of msg.files) {
570
+ try {
571
+ let file;
572
+ if (msg.from.remote) {
573
+ if (!remote) throw new Error('this session is not online at the gateway');
574
+ file = await remote.downloadFile(f, dir, { signal: job.signal, onProgress: (p) => job.progress(f, p) });
575
+ remote.deleteFile(f.id);
576
+ } else {
577
+ file = await copyUnique(f.path, dir, f.name);
578
+ }
579
+ saved.push({ path: file, size: fs.statSync(file).size });
580
+ } catch (e) {
581
+ failed.push(`${f.name}: ${e.message}`);
582
+ }
583
+ }
584
+ } finally {
585
+ job?.end();
586
+ }
587
+ this.screen.log(
588
+ `${c.blue('✉')} ${c.bold(`&${msg.from.name}`)} ${c.dim(`sent ${msg.files.length} file${msg.files.length === 1 ? '' : 's'}${saved.length ? `, saved in ${dir}:` : ':'}`)}`,
589
+ ...saved.map((s) => ` ${path.basename(s.path)} ${c.dim(formatBytes(s.size))}`),
590
+ ...failed.map((f) => c.red(` ✗ ${f}`)),
591
+ '',
592
+ );
593
+ const where = `the Downloads folder of ${machineName(this.config.env)} (${dir})`;
594
+ const report = [
595
+ saved.length ? `Saved ${saved.length} file${saved.length === 1 ? '' : 's'} in ${where}:\n${saved.map((s) => `- ${s.path}`).join('\n')}` : '',
596
+ failed.length ? `Could not save:\n${failed.map((f) => `- ${f}`).join('\n')}` : '',
597
+ ].filter(Boolean).join('\n\n');
598
+ if (msg.kind === 'task' && !msg.text.trim()) {
599
+ this.agent.addReminder(`Another claude-pro session, ${msg.from.name}, sent files to this computer. ${report}`);
600
+ this.replyToPeer(msg, report);
601
+ return;
602
+ }
603
+ msg.text = `${msg.text}\n\n[Files that came with this message. ${report}]`;
604
+ this.onPeerMessage(msg);
605
+ }
606
+
607
+ // Send `text` as the answer to `msg`, a message of another session: here, or through the gateway.
608
+ replyToPeer(msg, text) {
609
+ const done = () => this.screen.log(c.dim(` ⎿ Answer sent to &${msg.from.name}`));
610
+ const failed = (e) => this.notice(`Could not send the answer to &${msg.from.name}: ${e.message}`, 'warn');
611
+ if (msg.from.remote) { // from another computer: back through the gateway
612
+ if (!this.remote) { this.notice(`This session is no longer online at the gateway: the answer to &${msg.from.name} was not sent.`, 'warn'); return; }
613
+ this.remote.sendPeer(msg.from.id, { kind: 'reply', text, inReplyTo: msg.id }).then(done, failed);
614
+ return;
615
+ }
616
+ if (!this.peers) return;
617
+ // by id, or by name: a session that was restarted by older code comes back with a new id
618
+ const running = this.peers.peers();
619
+ const peer = findPeer(running, msg.from.id) || running.find((p) => p.name === msg.from.name && p.cwd === msg.from.cwd);
620
+ if (!peer) { this.notice(`&${msg.from.name} is no longer running: its answer was not sent.`, 'warn'); return; }
621
+ this.peers.send(peer, { text, kind: 'reply', inReplyTo: msg.id }).then(done, failed);
622
+ }
623
+
624
+ // /sendfile &session file...: files on this computer to another session (here or on another computer), which
625
+ // saves them in its Downloads folder and confirms. Paths may be quoted, @-mentions, or have spaces unquoted.
626
+ async sendFiles(args) {
627
+ if (/^\s*(stop|cancel)\s*$/i.test(args)) {
628
+ const ups = [...this.transfers.values()].filter((t) => t.direction === 'up');
629
+ for (const t of ups) t.controller.abort();
630
+ this.screen.log(c.dim(ups.length ? ` ⎿ Stopping ${ups.length} sending${ups.length === 1 ? '' : 's'}` : ' ⎿ Nothing is being sent'));
631
+ return;
632
+ }
633
+ const tokens = [...String(args).matchAll(/@?"([^"]+)"|@?'([^']+)'|(\S+)/g)].map((m) => ({ text: m[1] ?? m[2] ?? m[3], quoted: m[3] === undefined }));
634
+ const targets = tokens.filter((t) => !t.quoted && /^[&&]./u.test(t.text));
635
+ if (targets.length !== 1) {
636
+ this.notice(targets.length ? 'Send to one session at a time.' : 'Usage: /sendfile &session file... (type & for the list of sessions)', 'warn');
637
+ return;
638
+ }
639
+ const exists = (p) => { try { return fs.statSync(p).isFile(); } catch { return false; } };
640
+ const rest = tokens.filter((t) => t !== targets[0]);
641
+ const paths = [];
642
+ for (let i = 0; i < rest.length;) {
643
+ if (rest[i].quoted) { paths.push(resolvePath(rest[i].text, this.cwd)); i++; continue; }
644
+ // the longest run of words that is a file: a path with spaces that was not quoted
645
+ let j = i + 1;
646
+ for (let k = rest.length; k > i + 1; k--) {
647
+ if (rest.slice(i, k).some((t) => t.quoted)) continue;
648
+ if (exists(resolvePath(rest.slice(i, k).map((t) => t.text).join(' ').replace(/^@/, ''), this.cwd))) { j = k; break; }
649
+ }
650
+ paths.push(resolvePath(rest.slice(i, j).map((t) => t.text).join(' ').replace(/^@/, ''), this.cwd));
651
+ i = j;
652
+ }
653
+ let files;
654
+ try { files = filesToSend(paths); } catch (e) { this.notice(e.message, 'warn'); return; }
655
+ const total = files.reduce((n, f) => n + f.size, 0);
656
+ const what = `${files.length} file${files.length === 1 ? '' : 's'} (${formatBytes(total)})`;
657
+ try {
658
+ const r = await this.sendToPeer(targets[0].text, '', { files: paths, background: true });
659
+ const label = r.peer.label || r.peer.name;
660
+ this.screen.log(c.dim(` ⎿ Sending ${what} to &${label} in the background (progress below; /sendfile stop stops it${r.peer.remote ? ', and sending the same files again later continues where it stopped' : ''})`));
661
+ } catch (e) {
662
+ this.notice(`Could not send the files: ${e.message}`, 'warn');
663
+ }
664
+ }
665
+
380
666
  // The turn that handled tasks of other sessions ended: send each its answer.
381
667
  answerPeers(result, error) {
382
668
  const tasks = this.turnPeers.splice(0);
383
- if (!tasks.length || !this.peers) return;
669
+ if (!tasks.length) return;
384
670
  const text = error ? `The session failed before finishing: ${error.message || error}`
385
- : result?.interrupted ? `The user of ${this.peers.name} interrupted the work before it was finished.${result.text ? `\n\nWhat it had written:\n${result.text}` : ''}`
671
+ : result?.interrupted ? `The user of ${this.peerLabel()} interrupted the work before it was finished.${result.text ? `\n\nWhat it had written:\n${result.text}` : ''}`
386
672
  : (result?.text || '(no answer text)');
387
673
  // One answer per session, to its last message: several messages from it that waited for this turn were all
388
674
  // handled in it, and sending the whole answer once for each would hand that session the same text again and again.
389
675
  const lastOfEach = new Map();
390
676
  for (const msg of tasks) lastOfEach.set(`${msg.from.id}|${msg.from.name}|${msg.from.cwd}`, msg);
391
- for (const msg of lastOfEach.values()) {
392
- // by id, or by name: a session that was restarted by older code comes back with a new id
393
- const running = this.peers.peers();
394
- const peer = findPeer(running, msg.from.id) || running.find((p) => p.name === msg.from.name && p.cwd === msg.from.cwd);
395
- if (!peer) { this.notice(`@${msg.from.name} is no longer running: its answer was not sent.`, 'warn'); continue; }
396
- this.peers.send(peer, { text, kind: 'reply', inReplyTo: msg.id })
397
- .then(() => this.screen.log(c.dim(` ⎿ Answer sent to @${msg.from.name}`)), (e) => this.notice(`Could not send the answer to @${msg.from.name}: ${e.message}`, 'warn'));
677
+ for (const msg of lastOfEach.values()) this.replyToPeer(msg, text);
678
+ }
679
+
680
+ // ---------------------------------------------------------------- remote control (see remote.js)
681
+
682
+ // Print to the terminal only: the remote page is told about this as an event of its own.
683
+ logLocal(...items) {
684
+ this.remoteMuted++;
685
+ try { this.screen.log(...items); } finally { this.remoteMuted--; }
686
+ }
687
+
688
+ // A message of the user in the transcript (typed here, on the remote page, or sent by another session).
689
+ echoUser(text, images = 0) {
690
+ this.logLocal(renderUserMessage(text, this.width));
691
+ this.remote?.emit({ t: 'user', text: images ? `${text}\n[${images} image${images === 1 ? '' : 's'}]` : String(text) });
692
+ }
693
+
694
+ // Online at the gateway (see remote.js) from the start: by itself when this computer is logged in to the gateway
695
+ // (CLAUDE_PRO_ONLINE=0 turns that off), with --rc (which prints the link too), and after a restart as before (with
696
+ // the same id, so that open pages and messages on their way find it again). It does not hold up the start. Never
697
+ // in the tests and the self-check, which drive the App themselves.
698
+ autoStartRemote(restart) {
699
+ const carried = restart?.remote;
700
+ if (carried?.off) { this.remoteStopped = true; return; }
701
+ const explicit = Boolean(this.flags.remoteControl);
702
+ if (this.noProcessExit && !explicit) return;
703
+ if (!explicit && !carried?.id && !(onlineEnabled(this.config.env) && readGatewayLogin(this.config.gatewayUrl))) return;
704
+ this.startRemote({ id: carried?.id, quiet: !explicit || Boolean(carried?.id), persistent: !explicit }).catch((e) => {
705
+ this.notice(`${explicit ? 'Remote control could not start' : 'This session could not go online at the gateway'}: ${e.message}${e.code === 'NO_GATEWAY_LOGIN' ? '' : ' (/rc tries again)'}`, 'warn');
706
+ });
707
+ }
708
+
709
+ // Connect this session to the gateway (see remote.js): the web page and the sessions on the account's other
710
+ // computers reach it from then on. Needs the gateway login of /login (the provider can be another one); throws
711
+ // {code: 'NO_GATEWAY_LOGIN'} without it. `persistent`: a gateway that cannot be reached now is tried again in
712
+ // the background instead of failing.
713
+ startRemote({ id, quiet = false, persistent = quiet } = {}) {
714
+ if (this.remote) return Promise.resolve(this.remote);
715
+ this.remoteStarting ??= this.connectRemote({ id, quiet, persistent }).finally(() => { this.remoteStarting = null; });
716
+ return this.remoteStarting;
717
+ }
718
+
719
+ async connectRemote({ id, quiet, persistent }) {
720
+ const login = readGatewayLogin(this.config.gatewayUrl);
721
+ if (!login) throw Object.assign(new Error(`it goes through the gateway ${this.config.gatewayUrl}, and this machine is not logged in to it: run /rc to log in`), { code: 'NO_GATEWAY_LOGIN' });
722
+ const remote = new RemoteControl({
723
+ gateway: { url: login.url || this.config.gatewayUrl, apiKey: login.apiKey },
724
+ id,
725
+ handlers: {
726
+ info: () => ({ name: this.peerLabel(), machine: machineName(this.config.env), peerId: this.peers?.id || null, title: this.sessionLabel() || '', cwd: (this.agent.cwd || this.cwd).replace(/\\/g, '/'), model: modelInfo(this.config).model, version: VERSION }),
727
+ seed: () => transcriptItems(this.agent.messages, { tools: this.runtime.tools, ctx: this.agent.makeCtx(), cwd: this.cwd }),
728
+ status: () => this.remoteStatus(),
729
+ send: (text) => this.remoteSend(text),
730
+ interrupt: () => this.remoteInterrupt(),
731
+ answer: (body) => this.answerDialog(body),
732
+ peerMessage: (msg) => this.onPeerMessage(msg),
733
+ changed: () => {
734
+ this.screen.schedule();
735
+ if (remote.online && Date.now() - this.remotePeersAt > 10_000) this.refreshRemotePeers();
736
+ },
737
+ },
738
+ });
739
+ remote.account = login.account?.email || login.account?.name || null;
740
+ await remote.start({ persistent });
741
+ if (this.exiting) { remote.close(); return remote; }
742
+ this.remote = remote;
743
+ this.remoteStopped = false;
744
+ this.remotePeersTimer ??= setInterval(() => { if (this.remote?.online) this.refreshRemotePeers(); }, 60_000);
745
+ this.remotePeersTimer.unref?.();
746
+ if (!quiet) this.logLocal(this.remoteInfo({ fresh: true }));
747
+ this.screen.schedule();
748
+ return remote;
749
+ }
750
+
751
+ stopRemote() {
752
+ this.remote?.close();
753
+ this.remote = null;
754
+ this.remotePeers = [];
755
+ this.remoteStopped = true;
756
+ this.screen.schedule();
757
+ }
758
+
759
+ // Where the session is on the gateway (and a QR code of it, for a phone), for the terminal.
760
+ remoteInfo({ fresh = false } = {}) {
761
+ const r = this.remote;
762
+ const state = !r.online ? (fresh ? c.dim(' · connecting') : c.yellow(' · not connected now, retrying')) : r.viewers ? c.dim(` · ${r.viewers} page${r.viewers === 1 ? '' : 's'} open now`) : '';
763
+ const lines = [`${c.green('⇄')} ${c.bold('This session is online at the gateway')}${r.account ? ` (${r.account})` : ''}${state}. In a browser:`];
764
+ lines.push(` ${c.blue(r.url || r.base)}`);
765
+ const qr = r.url ? qrLines(r.url, { color: colorEnabled }) : null;
766
+ if (qr) lines.push('', c.dim(' Scan with a phone (log in to the gateway there):'), ...qr.map((l) => ' ' + l));
767
+ const notes = [
768
+ `It is under 远程控制 in the gateway's user pages (${r.base}), for this gateway account only. Messages typed there go in as if typed here (/commands and !shell too); questions and plan approvals can be answered there; Stop = Esc.`,
769
+ `Sessions on your other computers send it tasks with &${machineName(this.config.env)}/${this.peerLabel()} (or &${machineName(this.config.env)} when it is the only session on this computer); /peers lists them all, /rename names this one.`,
770
+ 'Whoever can log in to this gateway account, or holds one of its API keys, can run anything on this computer through it.',
771
+ '/rc stop takes this session offline · /rc open opens the page in the browser here',
772
+ ];
773
+ lines.push('', ...notes.flatMap((n) => wordWrap(n, Math.max(20, this.width - 4))).map((l) => c.dim(` ${l.trimEnd()}`)), '');
774
+ return lines;
775
+ }
776
+
777
+ // The state the page shows below the transcript; sent whenever it changes.
778
+ remoteStatus() {
779
+ const cfg = this.config;
780
+ const { model, effort } = modelInfo(cfg);
781
+ const w = this.rateWait;
782
+ let rateWait = null;
783
+ if (w) {
784
+ const label = w.label.charAt(0).toUpperCase() + w.label.slice(1);
785
+ rateWait = {
786
+ text: w.waitingForLogin ? 'Waiting for the Claude Code login to be renewed' : `${w.overloaded ? label : `${label} reached`}${w.polling ? ' · checking again' : ` · resumes at ${formatClock(w.resumeAt)}`}`,
787
+ resumeIn: w.resumeAt - Date.now(),
788
+ };
398
789
  }
790
+ return {
791
+ busy: this.busy,
792
+ verb: this.status || (this.thinking ? 'Thinking' : this.verb),
793
+ elapsed: this.busy ? Date.now() - this.turnStart : 0,
794
+ tokens: this.turnTokens,
795
+ aborting: Boolean(this.controller?.signal.aborted),
796
+ rateWait,
797
+ todos: (this.agent.todos || []).map((t) => ({ content: t.content, status: t.status })),
798
+ queue: this.queue.map((q) => q.display ?? q.text),
799
+ dialog: this.remoteDialog(),
800
+ mode: this.runtime.modes.mode,
801
+ model,
802
+ effort: effort || defaultEffort(cfg.mainModel.id, cfg),
803
+ title: this.sessionLabel() || path.basename(this.cwd),
804
+ cwd: (this.agent.cwd || this.cwd).replace(/\\/g, '/'),
805
+ name: this.peerLabel(),
806
+ tasks: this.runtime.tasks.running().length,
807
+ restarting: Boolean(this.restartRequest),
808
+ };
809
+ }
810
+
811
+ // The dialog open in the terminal, for the page to answer (options by index: values need not be strings).
812
+ remoteDialog() {
813
+ const d = this.dialogs[0];
814
+ if (!d) return null;
815
+ d.remoteId ??= ++this.remoteDialogs;
816
+ const base = { id: d.remoteId, title: d.title || '', question: d.question || '', body: d.body || [], markdown: d.markdown || null };
817
+ if (!Array.isArray(d.options)) return base;
818
+ return { ...base, multi: Boolean(d.multi), options: d.options.map((o) => ({ label: String(o.label ?? ''), description: o.description || '', other: o.value === '__other__' })) };
819
+ }
820
+
821
+ answerDialog({ id, index, indices, text, cancel } = {}) {
822
+ const d = this.dialogs[0];
823
+ if (!d || d.remoteId !== id) throw new Error('That question is no longer open.');
824
+ if (cancel) { d.resolve(d.cancelValue); return {}; }
825
+ if (!Array.isArray(d.options)) throw new Error('This dialog can only be answered in the terminal.');
826
+ const valueAt = (i) => {
827
+ const o = d.options[i];
828
+ if (!Number.isInteger(i) || !o || o.value === '__other__') throw new Error('No such option.');
829
+ return o.value;
830
+ };
831
+ if (typeof text === 'string') {
832
+ if (!text.trim()) throw new Error('Type an answer first.');
833
+ d.resolve(d.multi ? [...(indices || []).map(valueAt), text.trim()] : text.trim());
834
+ } else if (d.multi) {
835
+ if (!indices?.length) throw new Error('Choose at least one option.');
836
+ d.resolve(indices.map(valueAt));
837
+ } else {
838
+ d.resolve(valueAt(index));
839
+ }
840
+ this.screen.schedule();
841
+ return {};
842
+ }
843
+
844
+ // A message from the page: as if typed in the input box.
845
+ remoteSend(text) {
846
+ const value = String(text ?? '').replace(/\r\n/g, '\n');
847
+ if (!value.trim()) throw new Error('The message is empty.');
848
+ if (this.exiting) throw new Error('The session is ending.');
849
+ if (this.busy && value.trim().startsWith('/')) throw new Error('Commands wait until the current task is done (or stopped).');
850
+ if (this.dialogs.length && !this.busy) throw new Error('A question is open: answer it first.');
851
+ const queued = this.busy;
852
+ this.onSubmit(value, value);
853
+ this.screen.schedule();
854
+ return { queued };
855
+ }
856
+
857
+ remoteInterrupt() {
858
+ for (const d of this.dialogs.splice(0)) d.resolve(d.cancelValue);
859
+ this.interrupt();
860
+ this.screen.schedule();
861
+ return {};
399
862
  }
400
863
 
401
864
  // ---------------------------------------------------------------- restarting (see restart.js)
@@ -448,8 +911,9 @@ export class App {
448
911
  queue: this.queue.map((q) => ({ text: q.text, images: q.images || [] })),
449
912
  checkpoints: this.runtime.checkpoints.snapshot(),
450
913
  restarts: [...this.restarts.filter((t) => now - t < RESTART_WINDOW_MS), now],
451
- peerName: this.peers?.name,
914
+ peerName: this.peers?.name || this.sessionName,
452
915
  peerId: this.peers?.id,
916
+ remote: this.remote?.handoff() || (this.remoteStopped ? { off: true } : null),
453
917
  };
454
918
  try {
455
919
  writeRestartState(state);
@@ -571,7 +1035,7 @@ export class App {
571
1035
  lines.push(c.dim(` MCP: ${ok}/${mcp.length} servers connected${ok < mcp.length ? ' · /mcp for details' : ''}`));
572
1036
  }
573
1037
  lines.push('', c.dim(' Tips for getting started:'), c.dim(' 1. Ask questions, edit files, or run commands'), c.dim(' 2. Be as specific as you would with another engineer for the best results'), c.dim(` 3. Run /init to create a CLAUDE.md file with instructions for ${APP_NAME}`), '');
574
- this.screen.log(lines);
1038
+ this.logLocal(lines);
575
1039
  }
576
1040
 
577
1041
  // ---------------------------------------------------------------- input
@@ -661,20 +1125,41 @@ export class App {
661
1125
  const quoted = m[2] !== undefined;
662
1126
  const q = quoted ? m[2] : m[3];
663
1127
  const files = this.fileIndex.search(q);
664
- // At the start of the message `@name` sends it to another session.
665
- const peers = !quoted && m.index === 0 && this.peers
666
- ? this.peers.peers().filter((p) => p.name.toLowerCase().startsWith(q.toLowerCase()))
667
- .map((p) => ({ value: '@' + p.name, label: `@${p.name}`, description: `session · ${displayPath(p.cwd, this.cwd)}` }))
668
- : [];
669
- if (!files.length && !peers.length) return null;
1128
+ if (!files.length) return null;
670
1129
  // A name with spaces has to be quoted for the mention to be read back as one file. A directory
671
1130
  // leaves the quote open, so that completion goes on inside it; a file closes it.
672
1131
  const value = (f) => (quoted || /\s/.test(f) ? (f.endsWith('/') ? `@"${f}` : `@"${f}"`) : '@' + f);
673
- return { items: [...peers, ...files.map((f) => ({ value: value(f), label: f, noSpace: f.endsWith('/') }))], from: cursor - q.length - (quoted ? 2 : 1), to: cursor };
1132
+ return { items: files.map((f) => ({ value: value(f), label: f, noSpace: f.endsWith('/') })), from: cursor - q.length - (quoted ? 2 : 1), to: cursor };
674
1133
  }
1134
+ // `&name`: the other sessions, on this computer and (through the gateway) on the account's others. At the start
1135
+ // it sends them the message; elsewhere it names them (/sendfile, or for the model).
1136
+ const amp = /(^|\s)[&&]([^\s&&]*)$/u.exec(before);
1137
+ if (amp) return this.peerSuggestions(amp[2], cursor, amp.index === 0 && !amp[1]);
675
1138
  return null;
676
1139
  }
677
1140
 
1141
+ peerSuggestions(q, cursor, atStart = true) {
1142
+ if (this.remote?.online && Date.now() - this.remotePeersAt > 10_000) {
1143
+ this.refreshRemotePeers().then(() => {
1144
+ if (!/[&&][^\s&&]*$/u.test(this.editor.text.slice(0, this.editor.cursor))) return;
1145
+ this.editor.refreshSuggestions();
1146
+ this.screen.schedule();
1147
+ });
1148
+ }
1149
+ const lq = q.toLowerCase();
1150
+ const items = [
1151
+ ...(this.peers?.peers() || []).filter((p) => p.name.toLowerCase().startsWith(lq))
1152
+ .map((p) => ({ value: '&' + p.name, label: `&${p.name}`, description: `this computer · ${displayPath(p.cwd, this.cwd)}` })),
1153
+ ...this.remotePeers.filter((p) => [p.label, p.name, p.machine].some((v) => v.toLowerCase().startsWith(lq)))
1154
+ .map((p) => ({ value: '&' + p.label, label: `&${p.label}`, description: `${p.machine} · ${p.cwd}${p.busy ? ' · busy' : ''}` })),
1155
+ ];
1156
+ if (!items.length) {
1157
+ if (!q && atStart) this.flash(this.remote ? 'No other session is running here or online at the gateway' : 'No other session is running on this computer (/login to the gateway reaches your other computers)');
1158
+ return null;
1159
+ }
1160
+ return { items, from: cursor - q.length - 1, to: cursor };
1161
+ }
1162
+
678
1163
  onSubmit(value, raw, images = []) {
679
1164
  const text = value.trim();
680
1165
  if (!text) { this.screen.schedule(); return; }
@@ -695,7 +1180,7 @@ export class App {
695
1180
  const args = text.slice(1 + name.length).trim();
696
1181
  const cmd = this.commands.get(name.toLowerCase()) || [...this.commands.values()].find((x) => x.aliases?.includes(name.toLowerCase()));
697
1182
  if (cmd) {
698
- this.screen.log(renderUserMessage(text, this.width));
1183
+ this.echoUser(text);
699
1184
  Promise.resolve(cmd.run(args, rest)).catch((e) => this.logError(e)).finally(() => this.screen.schedule());
700
1185
  return;
701
1186
  }
@@ -706,15 +1191,21 @@ export class App {
706
1191
  this.startTurn(value);
707
1192
  }
708
1193
 
709
- // `@name message`, name being another session: the message goes to that session, as a task for it.
1194
+ // `&name message`: the message goes to that session, as a task for it. A session on another computer of the
1195
+ // gateway account: `&machine/name message`, or `&machine message` when that computer runs one session.
710
1196
  sendTypedToPeer(text) {
711
- const m = /^@([\w.-]+)\s+([\s\S]+)$/.exec(text);
712
- if (!m || !this.peers) return false;
713
- const peer = findPeer(this.peers.peers(), m[1]);
714
- if (!peer || peer.name.toLowerCase() !== m[1].toLowerCase()) return false;
715
- this.screen.log(renderUserMessage(text, this.width));
716
- this.sendToPeer(peer, m[2].trim()).then(
717
- () => this.screen.log(c.dim(` ⎿ Sent to @${peer.name} (${displayPath(peer.cwd, this.cwd)}); its answer will be shown here`), ''),
1197
+ if (!/^[&&]/u.test(text)) return false;
1198
+ const m = /^[&&](\S+)(?:\s+([\s\S]*))?$/u.exec(text);
1199
+ const task = m?.[2]?.trim();
1200
+ if (!m || !task) { this.notice('Type the task after the name: &name task (/peers lists the sessions).', 'warn'); return true; }
1201
+ this.echoUser(text);
1202
+ const found = resolvePeer(this.peers?.peers() || [], this.remotePeers, m[1], { ids: false });
1203
+ if (found.ambiguous) { this.notice(found.error, 'warn'); return true; }
1204
+ this.sendToPeer(found.peer || m[1], task).then(
1205
+ ({ peer }) => {
1206
+ const where = peer.remote ? `on ${peer.machine}: ${peer.cwd}` : displayPath(peer.cwd, this.cwd);
1207
+ this.screen.log(c.dim(` ⎿ Sent to &${peer.label || peer.name} (${where}); its answer will be shown here`), '');
1208
+ },
718
1209
  (e) => this.notice(e.message, 'warn'),
719
1210
  );
720
1211
  return true;
@@ -724,7 +1215,7 @@ export class App {
724
1215
  const rows = [
725
1216
  ['! for bash mode', 'double tap esc to clear input'],
726
1217
  ['/ for commands', 'shift + tab to cycle permission modes'],
727
- ['@ for file paths or sessions', 'ctrl + o to toggle verbose output'],
1218
+ ['@ for file paths, & for other sessions', 'ctrl + o to toggle verbose output'],
728
1219
  ['# to memorize', 'ctrl + t to toggle todos'],
729
1220
  ['\\⏎ or alt+⏎ for newline', 'ctrl + l to redraw, ctrl + d to exit'],
730
1221
  ['alt + v to paste an image', ''],
@@ -799,11 +1290,19 @@ export class App {
799
1290
  // `topic`: what to name the conversation's topic after (default: the message as shown).
800
1291
  // `peers`: tasks of other sessions that this turn answers.
801
1292
  async startTurn(prompt, { echo = true, display, images = [], topic, peers = [] } = {}) {
802
- if (echo) this.screen.log(renderUserMessage(display ?? prompt, this.width));
1293
+ if (echo) this.echoUser(display ?? prompt, images.length);
803
1294
  this.refreshTopic(topic ?? display ?? prompt);
804
1295
  let content;
805
1296
  try {
806
- content = [{ type: 'text', text: prompt }, ...images.map(imageBlock), ...(await this.attachments(prompt))];
1297
+ const sources = images.map((i) => i.source).filter(Boolean);
1298
+ content = [
1299
+ { type: 'text', text: prompt },
1300
+ ...images.map(imageBlock),
1301
+ ...(sources.length ? [{ type: 'text', text: `(The attached image${sources.length === 1 ? ' is the file' : 's are the files'} ${sources.join(', ')})` }] : []),
1302
+ ...(await this.attachments(prompt)),
1303
+ ...(await this.peerMentions(prompt)),
1304
+ ];
1305
+ await fitImagesIn(content); // a dropped or attached image file at full size would be refused later
807
1306
  } catch (e) {
808
1307
  this.logError(e); // nobody awaits this method: an error left to escape would end the program
809
1308
  return;
@@ -811,6 +1310,23 @@ export class App {
811
1310
  await this.runAgent(content, { peers });
812
1311
  }
813
1312
 
1313
+ // What `&name` in a message the user wrote stands for, told to the model: which session, and how to reach it.
1314
+ async peerMentions(prompt) {
1315
+ const refs = [...new Set([...String(prompt).matchAll(/(?:^|\s)[&&]([^\s&&,,。;;::!!??、))]+)/gu)].map((m) => m[1]))];
1316
+ if (!refs.length) return [];
1317
+ const local = this.peers?.peers() || [];
1318
+ const notes = [];
1319
+ for (const ref of refs) {
1320
+ let found = resolvePeer(local, this.remotePeers, ref, { ids: false });
1321
+ if (!found.peer && !found.ambiguous && this.remote) found = resolvePeer(local, await this.refreshRemotePeers(), ref, { ids: false });
1322
+ const p = found.peer;
1323
+ if (p) notes.push(`&${ref} is the claude-pro session "${p.label || p.name}" ${p.remote ? `on the user's computer "${p.machine}"` : 'on this computer'}, working in ${p.cwd}`);
1324
+ else if (found.ambiguous) notes.push(found.error);
1325
+ }
1326
+ if (!notes.length) return [];
1327
+ return [{ type: 'text', text: `<system-reminder>The user refers to other sessions: ${notes.join('; ')}. MessagePeer (to: the name in quotes) sends a session a task; its files parameter sends files of this computer along, which the session saves in its computer's Downloads folder (files without a message are only saved and confirmed).</system-reminder>` }];
1328
+ }
1329
+
814
1330
  // alt+v (ctrl+v where the terminal passes it through): attach an image from the clipboard.
815
1331
  async pasteImage() {
816
1332
  if (this.readingClipboard) return;
@@ -986,8 +1502,16 @@ export class App {
986
1502
  // `quiet`: do not print the transcript (a restart: it is still on screen).
987
1503
  loadSession(file, id, { quiet = false } = {}) {
988
1504
  const loaded = SessionStore.load(file);
1505
+ this.previousSession = null; // the conversation before a /clear: resuming another one ends the offer
989
1506
  this.store = new SessionStore(this.cwd, id);
990
1507
  this.topic = loaded.topic;
1508
+ this.customTitle = loaded.name;
1509
+ // a conversation that was given a name brings it back: the other sessions reach this one by it again
1510
+ if (loaded.name && loaded.name !== this.peerLabel()) {
1511
+ if (this.peers) this.peers.setName(loaded.name);
1512
+ else this.sessionName = loaded.name;
1513
+ this.remote?.register().catch(() => {});
1514
+ }
991
1515
  this.firstPrompt = loaded.title;
992
1516
  this.agent.messages = loaded.messages;
993
1517
  this.agent.restoreFileState(loaded.messages, loaded.times);
@@ -997,9 +1521,11 @@ export class App {
997
1521
  this.store.systemPrompt = loaded.systemPrompt;
998
1522
  this.runtime.resumeSystemPrompt(loaded.systemPrompt);
999
1523
  if (!this.topic) this.refreshTopic(this.latestPrompt()); // a session from before topics were kept
1524
+ this.remote?.resync();
1000
1525
  if (quiet) return;
1001
- this.screen.log(c.dim(`Resumed session ${id} (${loaded.messages.length} messages)`), '');
1002
- this.printTranscript(loaded.messages.slice(-30));
1526
+ this.logLocal(c.dim(`Resumed session ${id} (${loaded.messages.length} messages)`), '');
1527
+ this.remoteMuted++;
1528
+ try { this.printTranscript(loaded.messages.slice(-30)); } finally { this.remoteMuted--; }
1003
1529
  }
1004
1530
 
1005
1531
  printTranscript(messages) {
@@ -1022,7 +1548,9 @@ export class App {
1022
1548
  newSession() {
1023
1549
  this.store = new SessionStore(this.cwd);
1024
1550
  this.topic = null;
1551
+ this.customTitle = null;
1025
1552
  this.firstPrompt = null;
1553
+ this.remote?.resync();
1026
1554
  }
1027
1555
 
1028
1556
  // ---------------------------------------------------------------- dialogs
@@ -1046,8 +1574,8 @@ export class App {
1046
1574
  const md = renderMarkdown(plan, { width: this.width - 8 });
1047
1575
  const max = Math.max(8, this.screen.height - 14);
1048
1576
  const body = md.length > max ? [...md.slice(0, max), c.dim(`… +${md.length - max} lines (the full plan is printed above)`)] : md;
1049
- if (md.length > max) this.screen.log(c.plan(`${DOT} Plan:`), ...md.map((l) => ' ' + l), '');
1050
- const choice = await this.showDialog(new SelectDialog({
1577
+ if (md.length > max) this.logLocal(c.plan(`${DOT} Plan:`), ...md.map((l) => ' ' + l), '');
1578
+ const dialog = new SelectDialog({
1051
1579
  title: 'Ready to code?',
1052
1580
  body: ["Here is the plan:", '', ...body],
1053
1581
  question: 'Would you like to proceed?',
@@ -1057,8 +1585,9 @@ export class App {
1057
1585
  { label: 'Yes, start coding', value: 'full' },
1058
1586
  { label: 'No, keep planning', value: 'reject' },
1059
1587
  ],
1060
- }));
1061
- return choice;
1588
+ });
1589
+ dialog.markdown = plan; // the remote page shows the whole plan
1590
+ return await this.showDialog(dialog);
1062
1591
  }
1063
1592
 
1064
1593
  async askQuestion(q) {
@@ -1086,7 +1615,9 @@ export class App {
1086
1615
  if (ev.block.type === 'text') {
1087
1616
  this.flushThinking();
1088
1617
  this.md = new MarkdownStream({ width: w, firstPrefix: `${DOT} `, indent: ' ' });
1618
+ this.remote?.emit({ t: 'start', kind: 'text' });
1089
1619
  } else if (ev.block.type === 'thinking') {
1620
+ this.remote?.emit({ t: 'start', kind: 'thinking' });
1090
1621
  this.thinking = { start: Date.now(), text: '', md: this.verbose ? new MarkdownStream({ width: w, firstPrefix: c.dim('✻ '), indent: ' ', style: (s) => c.dim(c.italic(s)) }) : null };
1091
1622
  } else if (ev.block.type === 'tool_use') {
1092
1623
  this.flushThinking();
@@ -1097,16 +1628,18 @@ export class App {
1097
1628
  this.turnTokens += estimateTokens(ev.text);
1098
1629
  if (this.md) {
1099
1630
  const lines = this.md.write(ev.text);
1100
- if (lines.length) this.screen.log(lines);
1631
+ if (lines.length) this.logLocal(lines);
1632
+ this.remote?.emit({ t: 'delta', kind: 'text', text: ev.text });
1101
1633
  }
1102
1634
  break;
1103
1635
  case 'thinking':
1104
1636
  this.turnTokens += estimateTokens(ev.text);
1105
1637
  if (this.thinking) {
1106
1638
  this.thinking.text += ev.text;
1639
+ this.remote?.emit({ t: 'delta', kind: 'thinking', text: ev.text });
1107
1640
  if (this.thinking.md) {
1108
1641
  const lines = this.thinking.md.write(ev.text);
1109
- if (lines.length) this.screen.log(lines);
1642
+ if (lines.length) this.logLocal(lines);
1110
1643
  }
1111
1644
  }
1112
1645
  break;
@@ -1119,9 +1652,10 @@ export class App {
1119
1652
  case 'block_stop':
1120
1653
  if (ev.block.type === 'text' && this.md) {
1121
1654
  const lines = this.md.end();
1122
- if (lines.length) this.screen.log(lines);
1123
- this.screen.log('');
1655
+ if (lines.length) this.logLocal(lines);
1656
+ this.logLocal('');
1124
1657
  this.md = null;
1658
+ this.remote?.emit({ t: 'stop' });
1125
1659
  } else if (ev.block.type === 'thinking') {
1126
1660
  this.flushThinking();
1127
1661
  } else if (ev.block.type === 'tool_use') {
@@ -1136,6 +1670,7 @@ export class App {
1136
1670
  this.rateLimitEvent(ev);
1137
1671
  break;
1138
1672
  case 'reset':
1673
+ if (this.md || this.thinking) this.remote?.emit({ t: 'stop' });
1139
1674
  this.md = null;
1140
1675
  this.thinking = null;
1141
1676
  this.pendingToolBlocks.clear();
@@ -1164,13 +1699,14 @@ export class App {
1164
1699
  const t = this.thinking;
1165
1700
  if (!t) return;
1166
1701
  this.thinking = null;
1702
+ this.remote?.emit({ t: 'stop' });
1167
1703
  if (t.md) {
1168
1704
  const lines = t.md.end();
1169
- if (lines.length) this.screen.log(lines);
1170
- this.screen.log('');
1705
+ if (lines.length) this.logLocal(lines);
1706
+ this.logLocal('');
1171
1707
  } else if (t.text.trim()) {
1172
1708
  const secs = Math.max(1, Math.round((Date.now() - t.start) / 1000));
1173
- this.screen.log(c.dim(c.italic(`✻ Thought for ${secs}s (ctrl+o to show thinking)`)), '');
1709
+ this.logLocal(c.dim(c.italic(`✻ Thought for ${secs}s (ctrl+o to show thinking)`)), '');
1174
1710
  }
1175
1711
  }
1176
1712
 
@@ -1178,8 +1714,9 @@ export class App {
1178
1714
  this.flushThinking();
1179
1715
  if (this.md) {
1180
1716
  const lines = this.md.end();
1181
- if (lines.length) this.screen.log(lines, '');
1717
+ if (lines.length) this.logLocal(lines, '');
1182
1718
  this.md = null;
1719
+ this.remote?.emit({ t: 'stop' });
1183
1720
  }
1184
1721
  this.pendingToolBlocks.clear();
1185
1722
  }
@@ -1192,16 +1729,18 @@ export class App {
1192
1729
  if (!this.isMain(agent)) return {};
1193
1730
  const entry = { tu, tool, input, status: 'running', progress: '', sub: [], subCount: 0, ctx: agent.makeCtx(), started: Date.now() };
1194
1731
  this.active.push(entry);
1732
+ this.remote?.emit({ t: 'tool', id: tu.id, head: toolHeader(tool, input, entry.ctx, { width: REMOTE_WIDTH, dot: false }) });
1195
1733
  this.screen.schedule();
1196
1734
  return {
1197
1735
  progress: (p) => {
1198
- if (typeof p === 'string') entry.progress = p;
1199
- else if (p?.type === 'subtool') { entry.sub.push(p.header); entry.subCount++; }
1736
+ if (typeof p === 'string') { entry.progress = p; this.remote?.toolProgress(tu.id, entry); }
1737
+ else if (p?.type === 'subtool') { entry.sub.push(p.header); entry.subCount++; this.remote?.emit({ t: 'sub', id: tu.id, head: p.header }); }
1200
1738
  this.screen.schedule();
1201
1739
  },
1202
1740
  done: (result) => {
1203
1741
  entry.result = result;
1204
1742
  entry.status = result.isError ? 'error' : 'done';
1743
+ this.remoteToolDone(entry);
1205
1744
  this.flushActive();
1206
1745
  },
1207
1746
  };
@@ -1211,7 +1750,20 @@ export class App {
1211
1750
  if (!this.isMain(agent)) return;
1212
1751
  const ctx = agent.makeCtx();
1213
1752
  const entry = { tu, tool, input: tu.input, status: 'rejected', rejected: true, rejectMessage: message, ctx };
1214
- this.screen.log(toolHeader(tool, tu.input, ctx, { status: 'rejected', width: this.width }), renderToolResult(entry, { width: this.width, cwd: this.cwd }), '');
1753
+ if (this.remote) {
1754
+ this.remote.emit({ t: 'tool', id: tu.id, head: toolHeader(tool, tu.input, ctx, { width: REMOTE_WIDTH, dot: false }) });
1755
+ this.remoteToolDone(entry);
1756
+ }
1757
+ this.logLocal(toolHeader(tool, tu.input, ctx, { status: 'rejected', width: this.width }), renderToolResult(entry, { width: this.width, cwd: this.cwd }), '');
1758
+ }
1759
+
1760
+ // A tool call finished (or was refused, or interrupted): its result on the remote page, once.
1761
+ remoteToolDone(entry) {
1762
+ if (!this.remote || !entry.tu?.id || entry.remoteDone) return;
1763
+ entry.remoteDone = true;
1764
+ let lines = [];
1765
+ try { lines = renderToolResult(entry, { width: REMOTE_WIDTH, cwd: this.cwd }); } catch { /* shown without */ }
1766
+ this.remote.emit({ t: 'done', id: entry.tu.id, status: entry.result?.isError || entry.rejected ? 'error' : 'done', lines });
1215
1767
  }
1216
1768
 
1217
1769
  // Move finished tool entries (in call order) from the live region to the transcript.
@@ -1220,7 +1772,8 @@ export class App {
1220
1772
  const e = this.active.shift();
1221
1773
  if (e.status === 'running') { e.status = 'error'; e.result = { isError: true, content: 'Interrupted', interrupted: true }; }
1222
1774
  const status = e.result?.isError ? 'error' : 'done';
1223
- this.screen.log(toolHeader(e.tool, e.input, e.ctx, { status, width: this.width }), renderToolResult(e, { verbose: this.verbose, width: this.width, cwd: this.cwd }), '');
1775
+ this.remoteToolDone(e);
1776
+ this.logLocal(toolHeader(e.tool, e.input, e.ctx, { status, width: this.width }), renderToolResult(e, { verbose: this.verbose, width: this.width, cwd: this.cwd }), '');
1224
1777
  }
1225
1778
  this.screen.schedule();
1226
1779
  }
@@ -1263,7 +1816,7 @@ export class App {
1263
1816
  takeQueued() {
1264
1817
  if (!this.queue.length) return [];
1265
1818
  const items = this.queue.splice(0);
1266
- for (const q of items) this.screen.log(renderUserMessage(q.display ?? q.text, this.width));
1819
+ for (const q of items) this.echoUser(q.display ?? q.text, q.images?.length);
1267
1820
  this.turnPeers.push(...items.map((q) => q.peer).filter(Boolean));
1268
1821
  this.refreshTopic(items.map((q) => q.display ?? q.text).join('\n\n'));
1269
1822
  // Content blocks for the agent: each queued message's text followed by its images.
@@ -1289,12 +1842,20 @@ export class App {
1289
1842
  else if (ev.type === 'rate_limit_wait' || ev.type === 'rate_limit_resume') this.rateLimitEvent(ev);
1290
1843
  }
1291
1844
 
1292
- compactEnd(summary, agent, err) {
1845
+ compactEnd(summary, agent, err, { part } = {}) {
1293
1846
  if (!this.isMain(agent)) return;
1294
1847
  this.status = null;
1295
1848
  if (err) return;
1296
1849
  this.lastSummary = summary;
1297
- this.screen.log(c.dim('✻ Conversation compacted (the summary is in context; /context to see usage)'), '');
1850
+ const what = part === 'before' ? 'The earlier part of the conversation was summarized' : part === 'after' ? 'The conversation from that message on was summarized' : 'Conversation compacted';
1851
+ this.screen.log(c.dim(`✻ ${what} (the summary is in context; /context to see usage)`), '');
1852
+ }
1853
+
1854
+ // esc esc on an empty prompt: the rewind menu, as in Claude Code (/rewind). Not while a turn runs (esc stops it).
1855
+ openRewind() {
1856
+ if (this.busy || this.dialogs.length || this.exiting) return;
1857
+ if (!this.runtime.checkpoints.list().length && !this.previousSession) { this.flash('Nothing to rewind yet: every prompt you send makes a point to go back to'); return; }
1858
+ Promise.resolve(this.commands.get('rewind').run('')).catch((e) => this.logError(e)).finally(() => this.screen.schedule());
1298
1859
  }
1299
1860
 
1300
1861
  notice(text, kind = 'info') {
@@ -1414,7 +1975,8 @@ export class App {
1414
1975
  const dir = this.agent?.cwd || this.cwd;
1415
1976
  const home = os.homedir();
1416
1977
  const shown = dir.toLowerCase().startsWith(home.toLowerCase()) ? '~' + dir.slice(home.length) : dir;
1417
- return { left: shown.replace(/\\/g, '/'), right: this.peers ? `@${this.peers.name}` : '' };
1978
+ const name = this.peers || this.remote ? `&${this.peerLabel()}` : '';
1979
+ return { left: shown.replace(/\\/g, '/'), right: name && this.remote?.online ? `⇄ ${name}` : name };
1418
1980
  }
1419
1981
 
1420
1982
  footer(w) {
@@ -1428,6 +1990,10 @@ export class App {
1428
1990
  else if (this.editor.mode === 'bash') left = c.magenta(' ! for bash mode');
1429
1991
  else if (this.editor.mode === 'memory') left = c.blue(' # to memorize');
1430
1992
  else left = c.dim(' ? for shortcuts');
1993
+ if (this.remote?.online && this.remote.viewers && !flash && !(this.exitArmed && Date.now() - this.exitArmed < 1500)) {
1994
+ left += c.dim(' · ') + c.green(`⇄ ${this.remote.viewers} watching`);
1995
+ }
1996
+ if (this.transfers.size && !flash) left += c.dim(' · ') + this.transferStatus();
1431
1997
  const cfg = this.config;
1432
1998
  const threshold = cfg.autoCompactThreshold(cfg.mainModel);
1433
1999
  const used = this.agent.contextEstimate;