@yanzc/claude-pro 0.4.5 → 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/README.md +36 -4
- package/package.json +8 -2
- package/src/agent.js +38 -1
- package/src/api.js +12 -0
- package/src/checkpoints.js +20 -1
- package/src/commands.js +148 -35
- package/src/config.js +3 -0
- package/src/main.js +2 -0
- package/src/peers.js +40 -7
- package/src/prompts.js +17 -0
- package/src/qr.js +206 -0
- package/src/remote.js +540 -0
- package/src/session.js +4 -1
- package/src/tools/files.js +13 -4
- package/src/tools/peers.js +26 -19
- package/src/transfer.js +260 -0
- package/src/ui/dialogs.js +3 -2
- package/src/ui/editor.js +72 -41
- package/src/ui/repl.js +637 -72
- package/src/ui/screen.js +4 -1
- package/src/utils/downloads.js +83 -0
- package/src/utils/images.js +175 -0
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';
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
import { createRuntime } from '../runtime.js';
|
|
18
18
|
import { SessionStore, firstUserText } from '../session.js';
|
|
19
19
|
import { defaultEffort, effortSupported, effortToSend, quickQuery } from '../api.js';
|
|
20
|
-
import { CONFIG_DIR, VERSION, APP_NAME, INSTALL_DIR
|
|
20
|
+
import { CONFIG_DIR, VERSION, APP_NAME, INSTALL_DIR } from '../config.js';
|
|
21
21
|
import { readGatewayLogin } from '../gateway.js';
|
|
22
22
|
import { TOPIC_TITLE_SYSTEM, topicTitlePrompt } from '../prompts.js';
|
|
23
23
|
import { buildCommands } from '../commands.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)
|
|
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
|
-
//
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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'}
|
|
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
|
|
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.
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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);
|
|
@@ -463,14 +927,13 @@ export class App {
|
|
|
463
927
|
// In the process a restart started: take up the session where the one before it left it.
|
|
464
928
|
resumeAfterRestart(state, resumable) {
|
|
465
929
|
const cfg = this.config;
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
930
|
+
// The model and effort the session had, none chosen included: the remembered /model and /effort are shared by
|
|
931
|
+
// every window on this machine, and another one may have changed them since (an update restarts them all).
|
|
932
|
+
// A restart is not a choice either, so it leaves what is remembered as it is.
|
|
933
|
+
if ('modelOverride' in state) cfg.modelOverride = state.modelOverride || null;
|
|
934
|
+
if ('effortOverride' in state) cfg.effortOverride = state.effortOverride || null;
|
|
469
935
|
// The Effort tool's level holds for the request the restart continues, not for the user's next one.
|
|
470
936
|
if (state.effortBoost) { cfg.effortBoost = state.effortBoost; cfg.effortBoostKeepNext = Boolean(state.continueWith); }
|
|
471
|
-
// These were chosen with /model and /effort, which are kept for later sessions. One made by a version of
|
|
472
|
-
// the program that did not keep them yet is saved now.
|
|
473
|
-
try { rememberChoice(cfg.provider, kept); } catch { /* it still holds for this session */ }
|
|
474
937
|
if (state.mode === 'plan') this.runtime.modes.setMode('plan');
|
|
475
938
|
this.restarts = (Array.isArray(state.restarts) ? state.restarts : []).filter((t) => Number.isFinite(t));
|
|
476
939
|
if (resumable) {
|
|
@@ -572,7 +1035,7 @@ export class App {
|
|
|
572
1035
|
lines.push(c.dim(` MCP: ${ok}/${mcp.length} servers connected${ok < mcp.length ? ' · /mcp for details' : ''}`));
|
|
573
1036
|
}
|
|
574
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}`), '');
|
|
575
|
-
this.
|
|
1038
|
+
this.logLocal(lines);
|
|
576
1039
|
}
|
|
577
1040
|
|
|
578
1041
|
// ---------------------------------------------------------------- input
|
|
@@ -662,20 +1125,41 @@ export class App {
|
|
|
662
1125
|
const quoted = m[2] !== undefined;
|
|
663
1126
|
const q = quoted ? m[2] : m[3];
|
|
664
1127
|
const files = this.fileIndex.search(q);
|
|
665
|
-
|
|
666
|
-
const peers = !quoted && m.index === 0 && this.peers
|
|
667
|
-
? this.peers.peers().filter((p) => p.name.toLowerCase().startsWith(q.toLowerCase()))
|
|
668
|
-
.map((p) => ({ value: '@' + p.name, label: `@${p.name}`, description: `session · ${displayPath(p.cwd, this.cwd)}` }))
|
|
669
|
-
: [];
|
|
670
|
-
if (!files.length && !peers.length) return null;
|
|
1128
|
+
if (!files.length) return null;
|
|
671
1129
|
// A name with spaces has to be quoted for the mention to be read back as one file. A directory
|
|
672
1130
|
// leaves the quote open, so that completion goes on inside it; a file closes it.
|
|
673
1131
|
const value = (f) => (quoted || /\s/.test(f) ? (f.endsWith('/') ? `@"${f}` : `@"${f}"`) : '@' + f);
|
|
674
|
-
return { items:
|
|
1132
|
+
return { items: files.map((f) => ({ value: value(f), label: f, noSpace: f.endsWith('/') })), from: cursor - q.length - (quoted ? 2 : 1), to: cursor };
|
|
675
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]);
|
|
676
1138
|
return null;
|
|
677
1139
|
}
|
|
678
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
|
+
|
|
679
1163
|
onSubmit(value, raw, images = []) {
|
|
680
1164
|
const text = value.trim();
|
|
681
1165
|
if (!text) { this.screen.schedule(); return; }
|
|
@@ -696,7 +1180,7 @@ export class App {
|
|
|
696
1180
|
const args = text.slice(1 + name.length).trim();
|
|
697
1181
|
const cmd = this.commands.get(name.toLowerCase()) || [...this.commands.values()].find((x) => x.aliases?.includes(name.toLowerCase()));
|
|
698
1182
|
if (cmd) {
|
|
699
|
-
this.
|
|
1183
|
+
this.echoUser(text);
|
|
700
1184
|
Promise.resolve(cmd.run(args, rest)).catch((e) => this.logError(e)).finally(() => this.screen.schedule());
|
|
701
1185
|
return;
|
|
702
1186
|
}
|
|
@@ -707,15 +1191,21 @@ export class App {
|
|
|
707
1191
|
this.startTurn(value);
|
|
708
1192
|
}
|
|
709
1193
|
|
|
710
|
-
//
|
|
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.
|
|
711
1196
|
sendTypedToPeer(text) {
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
const
|
|
715
|
-
if (!
|
|
716
|
-
this.
|
|
717
|
-
this.
|
|
718
|
-
|
|
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
|
+
},
|
|
719
1209
|
(e) => this.notice(e.message, 'warn'),
|
|
720
1210
|
);
|
|
721
1211
|
return true;
|
|
@@ -725,7 +1215,7 @@ export class App {
|
|
|
725
1215
|
const rows = [
|
|
726
1216
|
['! for bash mode', 'double tap esc to clear input'],
|
|
727
1217
|
['/ for commands', 'shift + tab to cycle permission modes'],
|
|
728
|
-
['@ for file paths
|
|
1218
|
+
['@ for file paths, & for other sessions', 'ctrl + o to toggle verbose output'],
|
|
729
1219
|
['# to memorize', 'ctrl + t to toggle todos'],
|
|
730
1220
|
['\\⏎ or alt+⏎ for newline', 'ctrl + l to redraw, ctrl + d to exit'],
|
|
731
1221
|
['alt + v to paste an image', ''],
|
|
@@ -800,11 +1290,19 @@ export class App {
|
|
|
800
1290
|
// `topic`: what to name the conversation's topic after (default: the message as shown).
|
|
801
1291
|
// `peers`: tasks of other sessions that this turn answers.
|
|
802
1292
|
async startTurn(prompt, { echo = true, display, images = [], topic, peers = [] } = {}) {
|
|
803
|
-
if (echo) this.
|
|
1293
|
+
if (echo) this.echoUser(display ?? prompt, images.length);
|
|
804
1294
|
this.refreshTopic(topic ?? display ?? prompt);
|
|
805
1295
|
let content;
|
|
806
1296
|
try {
|
|
807
|
-
|
|
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
|
|
808
1306
|
} catch (e) {
|
|
809
1307
|
this.logError(e); // nobody awaits this method: an error left to escape would end the program
|
|
810
1308
|
return;
|
|
@@ -812,6 +1310,23 @@ export class App {
|
|
|
812
1310
|
await this.runAgent(content, { peers });
|
|
813
1311
|
}
|
|
814
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
|
+
|
|
815
1330
|
// alt+v (ctrl+v where the terminal passes it through): attach an image from the clipboard.
|
|
816
1331
|
async pasteImage() {
|
|
817
1332
|
if (this.readingClipboard) return;
|
|
@@ -987,8 +1502,16 @@ export class App {
|
|
|
987
1502
|
// `quiet`: do not print the transcript (a restart: it is still on screen).
|
|
988
1503
|
loadSession(file, id, { quiet = false } = {}) {
|
|
989
1504
|
const loaded = SessionStore.load(file);
|
|
1505
|
+
this.previousSession = null; // the conversation before a /clear: resuming another one ends the offer
|
|
990
1506
|
this.store = new SessionStore(this.cwd, id);
|
|
991
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
|
+
}
|
|
992
1515
|
this.firstPrompt = loaded.title;
|
|
993
1516
|
this.agent.messages = loaded.messages;
|
|
994
1517
|
this.agent.restoreFileState(loaded.messages, loaded.times);
|
|
@@ -998,9 +1521,11 @@ export class App {
|
|
|
998
1521
|
this.store.systemPrompt = loaded.systemPrompt;
|
|
999
1522
|
this.runtime.resumeSystemPrompt(loaded.systemPrompt);
|
|
1000
1523
|
if (!this.topic) this.refreshTopic(this.latestPrompt()); // a session from before topics were kept
|
|
1524
|
+
this.remote?.resync();
|
|
1001
1525
|
if (quiet) return;
|
|
1002
|
-
this.
|
|
1003
|
-
this.
|
|
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--; }
|
|
1004
1529
|
}
|
|
1005
1530
|
|
|
1006
1531
|
printTranscript(messages) {
|
|
@@ -1023,7 +1548,9 @@ export class App {
|
|
|
1023
1548
|
newSession() {
|
|
1024
1549
|
this.store = new SessionStore(this.cwd);
|
|
1025
1550
|
this.topic = null;
|
|
1551
|
+
this.customTitle = null;
|
|
1026
1552
|
this.firstPrompt = null;
|
|
1553
|
+
this.remote?.resync();
|
|
1027
1554
|
}
|
|
1028
1555
|
|
|
1029
1556
|
// ---------------------------------------------------------------- dialogs
|
|
@@ -1047,8 +1574,8 @@ export class App {
|
|
|
1047
1574
|
const md = renderMarkdown(plan, { width: this.width - 8 });
|
|
1048
1575
|
const max = Math.max(8, this.screen.height - 14);
|
|
1049
1576
|
const body = md.length > max ? [...md.slice(0, max), c.dim(`… +${md.length - max} lines (the full plan is printed above)`)] : md;
|
|
1050
|
-
if (md.length > max) this.
|
|
1051
|
-
const
|
|
1577
|
+
if (md.length > max) this.logLocal(c.plan(`${DOT} Plan:`), ...md.map((l) => ' ' + l), '');
|
|
1578
|
+
const dialog = new SelectDialog({
|
|
1052
1579
|
title: 'Ready to code?',
|
|
1053
1580
|
body: ["Here is the plan:", '', ...body],
|
|
1054
1581
|
question: 'Would you like to proceed?',
|
|
@@ -1058,8 +1585,9 @@ export class App {
|
|
|
1058
1585
|
{ label: 'Yes, start coding', value: 'full' },
|
|
1059
1586
|
{ label: 'No, keep planning', value: 'reject' },
|
|
1060
1587
|
],
|
|
1061
|
-
})
|
|
1062
|
-
|
|
1588
|
+
});
|
|
1589
|
+
dialog.markdown = plan; // the remote page shows the whole plan
|
|
1590
|
+
return await this.showDialog(dialog);
|
|
1063
1591
|
}
|
|
1064
1592
|
|
|
1065
1593
|
async askQuestion(q) {
|
|
@@ -1087,7 +1615,9 @@ export class App {
|
|
|
1087
1615
|
if (ev.block.type === 'text') {
|
|
1088
1616
|
this.flushThinking();
|
|
1089
1617
|
this.md = new MarkdownStream({ width: w, firstPrefix: `${DOT} `, indent: ' ' });
|
|
1618
|
+
this.remote?.emit({ t: 'start', kind: 'text' });
|
|
1090
1619
|
} else if (ev.block.type === 'thinking') {
|
|
1620
|
+
this.remote?.emit({ t: 'start', kind: 'thinking' });
|
|
1091
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 };
|
|
1092
1622
|
} else if (ev.block.type === 'tool_use') {
|
|
1093
1623
|
this.flushThinking();
|
|
@@ -1098,16 +1628,18 @@ export class App {
|
|
|
1098
1628
|
this.turnTokens += estimateTokens(ev.text);
|
|
1099
1629
|
if (this.md) {
|
|
1100
1630
|
const lines = this.md.write(ev.text);
|
|
1101
|
-
if (lines.length) this.
|
|
1631
|
+
if (lines.length) this.logLocal(lines);
|
|
1632
|
+
this.remote?.emit({ t: 'delta', kind: 'text', text: ev.text });
|
|
1102
1633
|
}
|
|
1103
1634
|
break;
|
|
1104
1635
|
case 'thinking':
|
|
1105
1636
|
this.turnTokens += estimateTokens(ev.text);
|
|
1106
1637
|
if (this.thinking) {
|
|
1107
1638
|
this.thinking.text += ev.text;
|
|
1639
|
+
this.remote?.emit({ t: 'delta', kind: 'thinking', text: ev.text });
|
|
1108
1640
|
if (this.thinking.md) {
|
|
1109
1641
|
const lines = this.thinking.md.write(ev.text);
|
|
1110
|
-
if (lines.length) this.
|
|
1642
|
+
if (lines.length) this.logLocal(lines);
|
|
1111
1643
|
}
|
|
1112
1644
|
}
|
|
1113
1645
|
break;
|
|
@@ -1120,9 +1652,10 @@ export class App {
|
|
|
1120
1652
|
case 'block_stop':
|
|
1121
1653
|
if (ev.block.type === 'text' && this.md) {
|
|
1122
1654
|
const lines = this.md.end();
|
|
1123
|
-
if (lines.length) this.
|
|
1124
|
-
this.
|
|
1655
|
+
if (lines.length) this.logLocal(lines);
|
|
1656
|
+
this.logLocal('');
|
|
1125
1657
|
this.md = null;
|
|
1658
|
+
this.remote?.emit({ t: 'stop' });
|
|
1126
1659
|
} else if (ev.block.type === 'thinking') {
|
|
1127
1660
|
this.flushThinking();
|
|
1128
1661
|
} else if (ev.block.type === 'tool_use') {
|
|
@@ -1137,6 +1670,7 @@ export class App {
|
|
|
1137
1670
|
this.rateLimitEvent(ev);
|
|
1138
1671
|
break;
|
|
1139
1672
|
case 'reset':
|
|
1673
|
+
if (this.md || this.thinking) this.remote?.emit({ t: 'stop' });
|
|
1140
1674
|
this.md = null;
|
|
1141
1675
|
this.thinking = null;
|
|
1142
1676
|
this.pendingToolBlocks.clear();
|
|
@@ -1165,13 +1699,14 @@ export class App {
|
|
|
1165
1699
|
const t = this.thinking;
|
|
1166
1700
|
if (!t) return;
|
|
1167
1701
|
this.thinking = null;
|
|
1702
|
+
this.remote?.emit({ t: 'stop' });
|
|
1168
1703
|
if (t.md) {
|
|
1169
1704
|
const lines = t.md.end();
|
|
1170
|
-
if (lines.length) this.
|
|
1171
|
-
this.
|
|
1705
|
+
if (lines.length) this.logLocal(lines);
|
|
1706
|
+
this.logLocal('');
|
|
1172
1707
|
} else if (t.text.trim()) {
|
|
1173
1708
|
const secs = Math.max(1, Math.round((Date.now() - t.start) / 1000));
|
|
1174
|
-
this.
|
|
1709
|
+
this.logLocal(c.dim(c.italic(`✻ Thought for ${secs}s (ctrl+o to show thinking)`)), '');
|
|
1175
1710
|
}
|
|
1176
1711
|
}
|
|
1177
1712
|
|
|
@@ -1179,8 +1714,9 @@ export class App {
|
|
|
1179
1714
|
this.flushThinking();
|
|
1180
1715
|
if (this.md) {
|
|
1181
1716
|
const lines = this.md.end();
|
|
1182
|
-
if (lines.length) this.
|
|
1717
|
+
if (lines.length) this.logLocal(lines, '');
|
|
1183
1718
|
this.md = null;
|
|
1719
|
+
this.remote?.emit({ t: 'stop' });
|
|
1184
1720
|
}
|
|
1185
1721
|
this.pendingToolBlocks.clear();
|
|
1186
1722
|
}
|
|
@@ -1193,16 +1729,18 @@ export class App {
|
|
|
1193
1729
|
if (!this.isMain(agent)) return {};
|
|
1194
1730
|
const entry = { tu, tool, input, status: 'running', progress: '', sub: [], subCount: 0, ctx: agent.makeCtx(), started: Date.now() };
|
|
1195
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 }) });
|
|
1196
1733
|
this.screen.schedule();
|
|
1197
1734
|
return {
|
|
1198
1735
|
progress: (p) => {
|
|
1199
|
-
if (typeof p === 'string') entry.progress = p;
|
|
1200
|
-
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 }); }
|
|
1201
1738
|
this.screen.schedule();
|
|
1202
1739
|
},
|
|
1203
1740
|
done: (result) => {
|
|
1204
1741
|
entry.result = result;
|
|
1205
1742
|
entry.status = result.isError ? 'error' : 'done';
|
|
1743
|
+
this.remoteToolDone(entry);
|
|
1206
1744
|
this.flushActive();
|
|
1207
1745
|
},
|
|
1208
1746
|
};
|
|
@@ -1212,7 +1750,20 @@ export class App {
|
|
|
1212
1750
|
if (!this.isMain(agent)) return;
|
|
1213
1751
|
const ctx = agent.makeCtx();
|
|
1214
1752
|
const entry = { tu, tool, input: tu.input, status: 'rejected', rejected: true, rejectMessage: message, ctx };
|
|
1215
|
-
|
|
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 });
|
|
1216
1767
|
}
|
|
1217
1768
|
|
|
1218
1769
|
// Move finished tool entries (in call order) from the live region to the transcript.
|
|
@@ -1221,7 +1772,8 @@ export class App {
|
|
|
1221
1772
|
const e = this.active.shift();
|
|
1222
1773
|
if (e.status === 'running') { e.status = 'error'; e.result = { isError: true, content: 'Interrupted', interrupted: true }; }
|
|
1223
1774
|
const status = e.result?.isError ? 'error' : 'done';
|
|
1224
|
-
this.
|
|
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 }), '');
|
|
1225
1777
|
}
|
|
1226
1778
|
this.screen.schedule();
|
|
1227
1779
|
}
|
|
@@ -1264,7 +1816,7 @@ export class App {
|
|
|
1264
1816
|
takeQueued() {
|
|
1265
1817
|
if (!this.queue.length) return [];
|
|
1266
1818
|
const items = this.queue.splice(0);
|
|
1267
|
-
for (const q of items) this.
|
|
1819
|
+
for (const q of items) this.echoUser(q.display ?? q.text, q.images?.length);
|
|
1268
1820
|
this.turnPeers.push(...items.map((q) => q.peer).filter(Boolean));
|
|
1269
1821
|
this.refreshTopic(items.map((q) => q.display ?? q.text).join('\n\n'));
|
|
1270
1822
|
// Content blocks for the agent: each queued message's text followed by its images.
|
|
@@ -1290,12 +1842,20 @@ export class App {
|
|
|
1290
1842
|
else if (ev.type === 'rate_limit_wait' || ev.type === 'rate_limit_resume') this.rateLimitEvent(ev);
|
|
1291
1843
|
}
|
|
1292
1844
|
|
|
1293
|
-
compactEnd(summary, agent, err) {
|
|
1845
|
+
compactEnd(summary, agent, err, { part } = {}) {
|
|
1294
1846
|
if (!this.isMain(agent)) return;
|
|
1295
1847
|
this.status = null;
|
|
1296
1848
|
if (err) return;
|
|
1297
1849
|
this.lastSummary = summary;
|
|
1298
|
-
|
|
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());
|
|
1299
1859
|
}
|
|
1300
1860
|
|
|
1301
1861
|
notice(text, kind = 'info') {
|
|
@@ -1415,7 +1975,8 @@ export class App {
|
|
|
1415
1975
|
const dir = this.agent?.cwd || this.cwd;
|
|
1416
1976
|
const home = os.homedir();
|
|
1417
1977
|
const shown = dir.toLowerCase().startsWith(home.toLowerCase()) ? '~' + dir.slice(home.length) : dir;
|
|
1418
|
-
|
|
1978
|
+
const name = this.peers || this.remote ? `&${this.peerLabel()}` : '';
|
|
1979
|
+
return { left: shown.replace(/\\/g, '/'), right: name && this.remote?.online ? `⇄ ${name}` : name };
|
|
1419
1980
|
}
|
|
1420
1981
|
|
|
1421
1982
|
footer(w) {
|
|
@@ -1429,6 +1990,10 @@ export class App {
|
|
|
1429
1990
|
else if (this.editor.mode === 'bash') left = c.magenta(' ! for bash mode');
|
|
1430
1991
|
else if (this.editor.mode === 'memory') left = c.blue(' # to memorize');
|
|
1431
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();
|
|
1432
1997
|
const cfg = this.config;
|
|
1433
1998
|
const threshold = cfg.autoCompactThreshold(cfg.mainModel);
|
|
1434
1999
|
const used = this.agent.contextEstimate;
|