@yanzc/claude-pro 0.5.6 → 0.5.8

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
@@ -1,2205 +1,2327 @@
1
- // Interactive REPL: owns the terminal, renders the transcript and live region, and
2
- // implements the agent's IO callbacks (streaming, tool display, permission prompts).
3
-
4
- import fs from 'node:fs';
5
- import path from 'node:path';
6
- import os from 'node:os';
7
- import readline from 'node:readline';
8
- import { spawn } from 'node:child_process';
9
- import { c, ESC, stringWidth, truncate, padEnd, wordWrap, colorEnabled } from './ansi.js';
10
- import { Screen, queryCursorRow, mouseFilter } from './screen.js';
11
- import { Editor } from './editor.js';
12
- import { SelectDialog } from './dialogs.js';
13
- import { MarkdownStream, renderMarkdown } from './markdown.js';
14
- import {
15
- DOT, toolHeader, renderToolResult, renderTodos, renderUserMessage, resultLines, formatTokens, formatDuration, toolLabel,
16
- } from './render.js';
17
- import { createRuntime } from '../runtime.js';
18
- import { SessionStore, firstUserText } from '../session.js';
19
- import { defaultEffort, effortSupported, effortToSend, quickQuery } from '../api.js';
20
- import { CONFIG_DIR, VERSION, APP_NAME, INSTALL_DIR } from '../config.js';
21
- import { readGatewayLogin, formatUsd } from '../gateway.js';
22
- import { TOPIC_TITLE_SYSTEM, topicTitlePrompt } from '../prompts.js';
23
- import { buildCommands } from '../commands.js';
24
- import { canRestart, writeRestartState, runSelfCheck, watchSource, restartNote, RESTART_EXIT_CODE, MAX_RESTARTS, RESTART_WINDOW_MS } from '../restart.js';
25
- import { resolvePath, displayPath } from '../utils/paths.js';
26
- import { ripgrepPath } from '../utils/shell.js';
27
- import { describePause, formatClock, humanDuration, resumeText, shouldAnnounce } from '../ratelimit.js';
28
- import { shutdownBackgroundShells, listBackgroundShells } from '../tools/bash.js';
29
- import { estimateTokens } from '../agent.js';
30
- import { readClipboardImage, imageFromFile, watchClipboard, writeClipboard, readClipboardText } 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';
34
- import { checkForUpdate, installUpdate, installedVersion, installedByNpm, isNewer, CHECK_EVERY_MS } from '../updater.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';
38
-
39
- // A model id as people say it: claude-sonnet-5-5 -> sonnet-5-5, claude-haiku-4-5-20251001 -> haiku-4-5.
40
- export const modelTag = (model) => String(model?.id ?? '').replace(/^claude-/, '').replace(/-\d{8}$/, '');
41
-
42
- // The model and effort level that requests use now, for the footer and the terminal title. The effort
43
- // is left out when none is sent (auto) and for a model that does not take one.
44
- export function modelInfo(config) {
45
- const model = config.mainModel;
46
- // What the requests carry: the nearest level the model takes (none goes out as low on gpt-6.1-sol, ultra as max on Claude).
47
- const sent = effortToSend(config, model.id, config.effort);
48
- const effort = sent && effortSupported(model.id) ? sent : null;
49
- return { model: modelTag(model), effort };
50
- }
51
-
52
- // The small model's answer about the topic (see topicTitlePrompt) as a clean title, or null when it is not
53
- // a new topic or the answer cannot be used. `required`: there is no title yet, so any title will do.
54
- export function parseTopicTitle(answer, { required = false } = {}) {
55
- const json = /\{[\s\S]*\}/.exec(String(answer ?? ''))?.[0];
56
- if (!json) return null;
57
- let data;
58
- try { data = JSON.parse(json); } catch { return null; }
59
- if ((!required && data?.isNewTopic !== true) || typeof data?.title !== 'string') return null;
60
- const title = data.title
61
- .replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ')
62
- .replace(/\s+/g, ' ')
63
- .replace(/^["'`“”‘’「」『』《》\s]+|["'`“”‘’「」『』《》\s.。!!??,,::;;]+$/g, '')
64
- .trim();
65
- return title ? truncate(title, 40).replace(/\x1b\[0m$/, '') : null;
66
- }
67
-
68
- const imageBlock = (img) => ({ type: 'image', source: { type: 'base64', media_type: img.media_type, data: img.data } });
69
-
70
- const FOCUS_EVENTS_ON = '\x1b[?1004h';
71
- const FOCUS_EVENTS_OFF = '\x1b[?1004l';
72
-
73
- const SPINNER = ['·', '✢', '✳', '✶', '✻', '✽', '✽', '✻', '✶', '✳', '✢', '·'];
74
- const VERBS = ['Thinking', 'Pondering', 'Crafting', 'Brewing', 'Computing', 'Conjuring', 'Cooking', 'Musing', 'Noodling', 'Percolating', 'Ruminating', 'Synthesizing', 'Working', 'Tinkering', 'Mulling', 'Deliberating', 'Churning', 'Forging', 'Hatching', 'Simmering'];
75
- const HISTORY_FILE = path.join(CONFIG_DIR, 'history.jsonl');
76
-
77
- function loadHistory(cwd) {
78
- try {
79
- return fs.readFileSync(HISTORY_FILE, 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } })
80
- .filter((e) => e && e.project === cwd).map((e) => e.display).slice(-500);
81
- } catch { return []; }
82
- }
83
-
84
- function appendHistory(cwd, text) {
85
- try {
86
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
87
- fs.appendFileSync(HISTORY_FILE, JSON.stringify({ display: text, project: cwd, timestamp: Date.now() }) + '\n');
88
- } catch { /* ignore */ }
89
- }
90
-
91
- class FileIndex {
92
- constructor(cwd, getConfig = () => null) { this.cwd = cwd; this.getConfig = getConfig; this.files = []; this.at = 0; this.loading = false; }
93
- refresh() {
94
- if (this.loading || Date.now() - this.at < 30_000) return;
95
- this.loading = true;
96
- const done = (list) => { this.files = list.slice(0, 50_000); this.at = Date.now(); this.loading = false; };
97
- const rg = ripgrepPath(this.getConfig());
98
- if (rg) {
99
- const child = spawn(rg, ['--files', '--hidden', '--glob', '!.git'], { cwd: this.cwd, windowsHide: true });
100
- let out = '';
101
- child.stdout.on('data', (d) => { if (out.length < 10_000_000) out += d; });
102
- child.on('close', () => done(out.split(/\r?\n/).filter(Boolean).map((f) => f.replace(/\\/g, '/'))));
103
- child.on('error', () => done([]));
104
- } else {
105
- (async () => {
106
- const list = [];
107
- try {
108
- for await (const f of fs.promises.glob('**/*', { cwd: this.cwd, exclude: (p) => /(^|[\\/])(node_modules|\.git)$/.test(p) })) {
109
- list.push(f.replace(/\\/g, '/'));
110
- if (list.length > 50_000) break;
111
- }
112
- } catch { /* ignore */ }
113
- done(list);
114
- })();
115
- }
116
- }
117
- search(q) {
118
- this.refresh();
119
- const query = q.toLowerCase();
120
- const dirs = new Set();
121
- for (const f of this.files) {
122
- const parts = f.split('/');
123
- for (let i = 1; i < parts.length; i++) dirs.add(parts.slice(0, i).join('/') + '/');
124
- if (dirs.size > 5000) break;
125
- }
126
- // Without ripgrep the listing comes from a glob that also returns directories (as plain names): they are already in `dirs`, with their slash.
127
- const all = [...dirs, ...this.files.filter((f) => !dirs.has(f + '/'))];
128
- const scored = [];
129
- for (const f of all) {
130
- const lower = f.toLowerCase();
131
- const base = lower.replace(/\/$/, '').split('/').pop();
132
- let score = -1;
133
- if (!query) score = f.split('/').length;
134
- else if (base.startsWith(query)) score = 0;
135
- else if (lower.startsWith(query)) score = 1;
136
- else if (base.includes(query)) score = 2;
137
- else if (lower.includes(query)) score = 3;
138
- if (score >= 0) scored.push({ f, score: score * 1000 + f.length });
139
- if (scored.length > 2000) break;
140
- }
141
- return scored.sort((a, b) => a.score - b.score).slice(0, 12).map((s) => s.f);
142
- }
143
- }
144
-
145
- export class App {
146
- // `noProcessExit`: the tests, which drive the App themselves; it never ends the process, and it has no peers, no
147
- // gateway connection, no update checks and the default renderer. `selfCheck` (see selfcheck.js): it never ends the
148
- // process either, but otherwise starts as a session does, so that code only a real start runs is checked too.
149
- constructor({ flags, cwd, stdin = process.stdin, stdout = process.stdout, noProcessExit = false, selfCheck = false }) {
150
- this.flags = flags;
151
- this.cwd = cwd;
152
- this.stdin = stdin;
153
- this.stdout = stdout;
154
- this.noProcessExit = noProcessExit || selfCheck;
155
- this.driven = noProcessExit;
156
- this.screen = new Screen(stdout);
157
- this.busy = false;
158
- this.controller = null;
159
- this.queue = [];
160
- this.dialogs = [];
161
- this.active = []; // running tool entries, in call order
162
- this.pendingToolBlocks = new Map(); // streaming tool_use blocks, by index
163
- this.md = null;
164
- this.thinking = null;
165
- this.verbose = !!flags.verbose;
166
- this.showTodos = true;
167
- this.turnStart = 0;
168
- this.turnTokens = 0;
169
- this.verb = 'Thinking';
170
- this.status = null; // override spinner text (e.g. compacting)
171
- this.rateWait = null; // {resumeAt, label, polling} while paused for a rate limit
172
- this.exitArmed = 0;
173
- this.notices = [];
174
- this.topic = null; // what the conversation is about now, named by the small model (see refreshTopic)
175
- this.firstPrompt = null; // the session's first message: the title until there is a topic
176
- this.topicSeq = 0; // topic requests sent ...
177
- this.topicApplied = 0; // ... and the newest one whose answer was used
178
- this.restarts = []; // when this session was restarted lately (carried over by each restart)
179
- this.restartRequest = null; // {reason, then, continueTurn}: restart as soon as nothing is running
180
- this.sourceChanged = null; // the program's own source changed since it was last checked
181
- this.reloading = false;
182
- this.skipAutoReload = false;
183
- this.peers = null; // this session as seen by the other sessions on the machine (see peers.js)
184
- this.turnPeers = []; // tasks from other sessions that the running turn answers: [{msg}]
185
- this.userSentToPeers = new Set(); // ids of messages the user sent to other sessions with @name
186
- this.remote = null; // the browser page that watches and steers this session (see remote.js), when it is on
187
- this.remoteMuted = 0; // > 0 while printing something the page is told about in its own terms
188
- this.remoteDialogs = 0;
189
- this.remoteStarting = null;
190
- this.remoteStopped = false; // /rc stop: offline until /rc, also after a restart
191
- this.sessionName = flags?.restart?.peerName || null; // this session's name when the sessions of this computer do not know it (CLAUDE_PRO_PEERS=0)
192
- this.customTitle = null; // the conversation's name given with /rename: the title, instead of the topic
193
- this.remotePeers = []; // the account's sessions on other computers, as last listed by the gateway (see peers.js)
194
- this.remotePeersAt = 0;
195
- this.remotePeersLoading = null;
196
- this.remotePeersTimer = null;
197
- this.transfers = new Map(); // files on their way to or from other sessions, for the footer
198
- this.transferSeq = 0;
199
- this.cost = 0; // what this conversation's requests have cost so far (USD at list prices): the footer and /cost
200
- this.fileIndex = new FileIndex(cwd, () => this.runtime?.config);
201
- this.store = new SessionStore(cwd);
202
- // What the terminal prints goes to the remote page too, except what it is told about as events.
203
- this.screen.onLog = (items) => {
204
- if (!this.remote || this.remoteMuted) return;
205
- this.remote.output(items.flatMap((i) => (Array.isArray(i) ? i : [i])).flatMap((l) => String(l).split(/\r?\n/)));
206
- };
207
- this.editor = new Editor({
208
- history: loadHistory(cwd),
209
- onSubmit: (value, raw, images) => { try { this.onSubmit(value, raw, images); } catch (e) { this.logError(e); } },
210
- onDoubleEscape: () => this.openRewind(),
211
- imageFromPath: (p) => {
212
- if (/^\s*\/(sendfile|sf)\b/i.test(this.editor?.text || '')) return null; // a file to send, not to show the model
213
- const r = imageFromFile(resolvePath(p, this.cwd));
214
- if (r.error && !/not found|Not a file/.test(r.error)) this.flash(r.error, 'warn');
215
- if (r.image) this.flashMessage = null;
216
- return r.image || null;
217
- },
218
- getSuggestions: (text, cursor) => this.suggest(text, cursor),
219
- onChange: () => this.screen.schedule(),
220
- });
221
- }
222
-
223
- get agent() { return this.runtime.agent; }
224
- get config() { return this.runtime.config; }
225
- get width() { return this.screen.width; }
226
-
227
- // ---------------------------------------------------------------- lifecycle
228
-
229
- async start(initialPrompt) {
230
- const stdin = this.stdin;
231
- stdin.setRawMode(true);
232
- // Where the cursor is, before anything is drawn: the input box is kept on the last rows of the window from
233
- // there (see screen.js). CLAUDE_PRO_PIN_INPUT=0 (here or in the settings) leaves it right below the output.
234
- const pinOff = (v) => /^(0|false|no|off)$/i.test(v || '');
235
- if (pinOff(process.env.CLAUDE_PRO_PIN_INPUT)) this.screen.pin = false;
236
- else if (stdin.isTTY && this.stdout.isTTY) this.screen.pinFrom(await queryCursorRow(stdin, this.stdout));
237
- stdin.setEncoding('utf8');
238
- this.stdout.write(ESC.bracketedPasteOn);
239
- this.watchCopies();
240
- const cleanup = () => this.restoreTerminal();
241
- process.on('exit', cleanup);
242
- process.on('SIGTERM', () => this.exit(143));
243
- process.on('SIGHUP', () => this.exit(129));
244
-
245
- await this.startPeer();
246
- this.runtime = await createRuntime({ cwd: this.cwd, flags: this.flags, io: this });
247
- // The conversation's id, sent to the gateway with each request (see streamMessage); it follows /clear and /resume.
248
- Object.defineProperty(this.runtime.config, 'sessionId', { get: () => this.store.id, configurable: true });
249
- // Every request's cost (the conversation's, its sub-agents', side queries'), kept with the session.
250
- this.runtime.config.onCost = (usd) => {
251
- this.cost += usd;
252
- this.store.setCost(this.cost);
253
- this.screen.schedule();
254
- };
255
- this.commands = buildCommands(this);
256
- if (pinOff(this.config.env.CLAUDE_PRO_PIN_INPUT)) { this.screen.pin = false; this.screen.pinned = null; }
257
- // What the session showed before a restart: scrolled back to in fullscreen, repainted after a resize otherwise.
258
- if (Array.isArray(this.flags.restart?.screenHistory)) this.screen.history = this.flags.restart.screenHistory.filter((l) => typeof l === 'string');
259
- if (this.fullscreenWanted()) this.screen.setFull(true);
260
- // Keys typed while starting up stay buffered in stdin until we listen. Mouse reports (fullscreen: the wheel)
261
- // are taken out before the keys are read.
262
- const keys = mouseFilter(stdin, (m) => this.onMouse(m));
263
- this.keys = keys;
264
- readline.emitKeypressEvents(keys);
265
- keys.on('keypress', (str, key) => {
266
- if (/^\x1b\[\d+;\d+R$/.test(key?.sequence || '')) return; // a late answer to the cursor position query, not a key
267
- try { this.onKey(str, key || {}); } catch (e) { this.logError(e); }
268
- });
269
- stdin.resume();
270
- this.runtime.modes.listeners.add(() => this.screen.schedule());
271
- // Started by a restart of this session: the output of the process before it is still on screen, so no
272
- // welcome box and no transcript, just a line saying so.
273
- const restart = this.flags.restart || null;
274
- const resumable = Boolean(restart?.sessionFile && fs.existsSync(restart.sessionFile));
275
- if (!resumable) this.printWelcome();
276
- // The tab label, as in the official client: what this session is about, then where and with what.
277
- this.screen.setTitle(() => this.terminalTitle(), path.basename(this.cwd) || this.cwd, { inherited: Boolean(restart) });
278
-
279
- if (restart) this.resumeAfterRestart(restart, resumable);
280
- else if (this.flags.resumeFile) this.loadSession(this.flags.resumeFile, this.flags.resumeId);
281
- else if (this.flags.resume) await this.commands.get('resume').run('');
282
- this.autoStartRemote(restart);
283
-
284
- this.screen.setLive((w) => this.renderLive(w));
285
- this.spinnerTimer = setInterval(() => { if (this.busy || this.active.length) this.screen.schedule(); }, 120);
286
- if (restart) this.continueAfterRestart(restart);
287
- else if (initialPrompt?.trim()) this.onSubmit(initialPrompt, initialPrompt);
288
- // Nothing configured yet: the gateway is the default, and its login comes first, as in Claude Code.
289
- else if (this.config.usesGateway && !readGatewayLogin(this.config.gatewayUrl)) {
290
- Promise.resolve(this.commands.get('login').run('')).catch((e) => this.logError(e)).finally(() => this.screen.schedule());
291
- }
292
- this.fileIndex.refresh();
293
- this.startSourceWatch();
294
- if (!this.driven) {
295
- if (!this.flags.restart) this.autoUpdate();
296
- // and every few minutes while it runs, so that a release reaches a session that stays open for days
297
- this.updateTimer = setInterval(() => { if (!this.exiting) this.autoUpdate({ quiet: true }); }, CHECK_EVERY_MS);
298
- this.updateTimer.unref?.();
299
- }
300
- return new Promise((resolve) => { this.resolveExit = resolve; });
301
- }
302
-
303
- // What the session is about now: the topic the small model named last, or else the first thing asked
304
- // (kept when the conversation is compacted afterwards). Cleared by /clear, replaced by /resume.
305
- sessionLabel() {
306
- if (this.customTitle) return this.customTitle;
307
- if (this.topic) return this.topic;
308
- if (!this.firstPrompt) {
309
- const first = this.agent?.messages.find((m) => m.role === 'user' && firstUserText(m));
310
- this.firstPrompt = first ? firstUserText(first) : null;
311
- }
312
- return this.firstPrompt;
313
- }
314
-
315
- // The newest message the user typed in this conversation (not a tool result, a reminder or a summary).
316
- latestPrompt() {
317
- const messages = this.agent?.messages || [];
318
- for (let i = messages.length - 1; i >= 0; i--) {
319
- if (messages[i].role !== 'user') continue;
320
- const text = firstUserText(messages[i]);
321
- if (text && !text.startsWith('This session is being continued')) return text;
322
- }
323
- return null;
324
- }
325
-
326
- // Keep the title on the topic of the conversation: ask the small model, in the background, whether
327
- // `message` starts a new topic and what to call it (as the official client does). Nothing waits for the
328
- // answer, and when there is none (a rate limit, no network) the title just stays as it is.
329
- refreshTopic(message) {
330
- const text = String(message ?? '').trim();
331
- if (!text || this.customTitle || /^(1|true|yes)$/i.test(this.config.env.CLAUDE_PRO_DISABLE_TOPIC_TITLE || '')) return;
332
- const store = this.store;
333
- const seq = ++this.topicSeq;
334
- const current = this.topic;
335
- quickQuery(this.config, {
336
- model: this.config.smallModel, system: TOPIC_TITLE_SYSTEM, prompt: topicTitlePrompt(current, text.slice(0, 2000)), maxTokens: 2048,
337
- }).then((answer) => {
338
- // another session meanwhile, or an answer about a newer message already in use
339
- if (store !== this.store || this.exiting || seq < this.topicApplied) return;
340
- const title = parseTopicTitle(answer, { required: !current });
341
- if (!title) return;
342
- this.topicApplied = seq;
343
- if (title === this.topic) return;
344
- this.topic = title;
345
- store.setTopic(title);
346
- this.screen.schedule();
347
- }, () => { /* only the title: not worth a word */ });
348
- }
349
-
350
- // "<state> <session> | <directory> · <model> · <effort>". The session is cut short so that the model and
351
- // effort, which are what tells tabs apart, are not what a narrow tab loses. While it works the state mark
352
- // blinks, so that a tab that is busy can be told from one that waits for the user.
353
- terminalTitle() {
354
- const dir = path.basename(this.cwd) || this.cwd;
355
- const { model, effort } = modelInfo(this.config);
356
- const state = this.rateWait ? '⏸' : this.busy ? (Math.floor(Date.now() / 1000) % 2 ? '⠐' : '⠂') : '✳';
357
- const session = this.sessionLabel();
358
- const where = [dir, model, effort].filter(Boolean).join(' · ');
359
- return `${state} ${session ? `${truncate(session, 40).replace(/\x1b\[0m$/, '')} | ` : ''}${where}`;
360
- }
361
-
362
- // `handOver`: another process takes the terminal over at once (a restart). It is left the way the program
363
- // had it (raw mode, bracketed paste, the tab's title), so that keys typed in between are not echoed or lost.
364
- restoreTerminal({ handOver = false } = {}) {
365
- if (this.restored) return;
366
- this.restored = true;
367
- try {
368
- this.screen.close({ keepTitle: handOver });
369
- this.clipboardWatch?.close();
370
- if (this.clipboardWatch) this.stdout.write(FOCUS_EVENTS_OFF);
371
- this.stdout.write(handOver ? ESC.showCursor : ESC.bracketedPasteOff + ESC.showCursor);
372
- if (!handOver && this.stdin.isTTY) this.stdin.setRawMode(false);
373
- } catch { /* ignore */ }
374
- }
375
-
376
- exit(code = 0, { restart = false } = {}) {
377
- if (this.exiting) return;
378
- this.exiting = true;
379
- this.controller?.abort();
380
- clearInterval(this.spinnerTimer);
381
- this.sourceWatch?.close();
382
- shutdownBackgroundShells();
383
- this.runtime?.tasks.killAll();
384
- this.runtime?.mcp.close();
385
- this.peers?.close();
386
- clearInterval(this.remotePeersTimer);
387
- const remoteClosed = this.remote?.close({ restarting: restart });
388
- const u = this.agent?.totalUsage();
389
- if (!restart && u && u.requests) {
390
- 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`));
391
- }
392
- if (!restart && this.agent?.messages.length) this.screen.log(c.dim(`Resume this session with: ${APP_NAME} --resume ${this.store.id}`));
393
- this.restoreTerminal({ handOver: restart });
394
- this.stdin.pause();
395
- this.resolveExit?.(code);
396
- if (!this.noProcessExit) {
397
- // with remote control on, the gateway is told first (at most a second and a half; see RemoteControl.close)
398
- if (remoteClosed) remoteClosed.finally(() => process.exit(code));
399
- else setTimeout(() => process.exit(code), 50).unref();
400
- }
401
- }
402
-
403
- // ---------------------------------------------------------------- other sessions (see peers.js)
404
-
405
- // Register this session so that the other sessions on the machine can send it tasks. Not in the tests
406
- // (which drive the App themselves) and not with CLAUDE_PRO_PEERS=0.
407
- async startPeer() {
408
- if (this.driven || !peersEnabled()) return;
409
- try {
410
- this.peers = await new PeerNode({ cwd: this.cwd, name: this.flags.restart?.peerName, id: this.flags.restart?.peerId, onMessage: (msg) => this.onPeerMessage(msg), getStatus: () => ({ busy: this.working() }) }).start();
411
- } catch (e) {
412
- this.peers = null;
413
- this.notice(`Other sessions cannot reach this one: ${e.message}`, 'warn');
414
- }
415
- }
416
-
417
- // This session's name for the others.
418
- peerLabel() { return this.peers?.name || this.sessionName || cleanName(path.basename(this.cwd)) || 'claude-pro'; }
419
-
420
- // /rename: the session's name, for the other sessions (&name, here and at the gateway) and as the conversation's
421
- // title (the tab, /resume, the web page), instead of the topic. Returns the name it got: name-2... when another
422
- // session on this computer has it.
423
- renameSession(name) {
424
- const clean = cleanName(name);
425
- const got = this.peers ? this.peers.setName(clean) : (this.sessionName = clean);
426
- this.customTitle = got;
427
- this.store.setName(got);
428
- this.remote?.register().catch(() => { /* the gateway takes it at the next registration */ });
429
- this.remote?.flush();
430
- this.screen.schedule();
431
- return got;
432
- }
433
-
434
- // `target`: a peer entry, or what names one (see resolvePeer): a session on this computer, or one on another
435
- // computer, reached through the gateway. `byModel`: the MessagePeer tool sent it, so the answer goes to the
436
- // model; otherwise the user typed `@name ...` and the answer is only shown (and noted for the model).
437
- // `files`: paths of files on this computer to send along (see utils/downloads.js); the other session saves
438
- // them in its Downloads folder.
439
- //
440
- // `background`: with files, return as soon as they are checked and the session is found, and send in the
441
- // background (progress in the footer; a finished or failed sending is reported in the transcript).
442
- async sendToPeer(target, text, { byModel = false, files: paths = [], background = false } = {}) {
443
- const files = paths.length ? filesToSend(paths) : [];
444
- let peer = target && typeof target === 'object' ? target : null;
445
- if (!peer) {
446
- const local = this.peers?.peers() || [];
447
- let found = resolvePeer(local, this.remotePeers, target);
448
- if (!found.peer && !found.error && this.remote) found = resolvePeer(local, await this.refreshRemotePeers(), target);
449
- if (!found.peer) {
450
- const offline = this.remote ? '' : ' Sessions on your other computers can be reached once this one is online at the gateway (/login, then /rc).';
451
- throw new Error(found.error || `No running session called "${String(target).replace(/^[@&]/, '')}". /peers lists them.${offline}`);
452
- }
453
- peer = found.peer;
454
- }
455
- 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).`);
456
- if (!peer.remote && !this.peers) throw new Error('Talking to other sessions on this computer is off (CLAUDE_PRO_PEERS=0).');
457
- const send = async () => {
458
- let r;
459
- if (peer.remote) {
460
- const uploaded = [];
461
- if (files.length) {
462
- const job = this.startTransfer('up', files, peer);
463
- try {
464
- for (const f of files) uploaded.push(await this.remote.uploadFile(f, { signal: job.signal, onProgress: (p) => job.progress(f, p) }));
465
- } finally {
466
- job.end();
467
- }
468
- }
469
- r = { ...(await this.remote.sendPeer(peer.id, { text, files: uploaded })), peer };
470
- } else {
471
- r = await this.peers.send(peer, { text, files });
472
- }
473
- r.files = files;
474
- if (!byModel) this.userSentToPeers.add(`${r.peer.id}:${r.id}`);
475
- return r;
476
- };
477
- if (!background || !files.length) return await send();
478
- const label = peer.label || peer.name;
479
- const what = `${files.length} file${files.length === 1 ? '' : 's'} (${formatBytes(files.reduce((n, f) => n + f.size, 0))})`;
480
- send().then(
481
- () => this.screen.log(c.dim(` ⎿ Sent ${what} to &${label}; it saves them in its Downloads folder and confirms`), ''),
482
- (e) => {
483
- const why = e.name === 'AbortError'
484
- ? `Stopped sending ${what} to &${label}. Send the same files again to continue where it stopped.`
485
- : `Could not send ${what} to &${label}: ${e.message}${peer.remote ? ' (sending the same files again continues where it stopped)' : ''}`;
486
- this.notice(why, 'warn');
487
- if (byModel) this.agent.addReminder(`The files you sent with MessagePeer did not go out: ${why}`);
488
- },
489
- );
490
- return { peer, files, background: true };
491
- }
492
-
493
- // A sending or receiving of files, shown in the footer while it runs: {signal, progress(file, p), end()}.
494
- startTransfer(direction, files, peer) {
495
- const id = ++this.transferSeq;
496
- const controller = new AbortController();
497
- const total = files.reduce((n, f) => n + (Number(f.size) || 0), 0);
498
- 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 };
499
- this.transfers.set(id, t);
500
- this.screen.schedule();
501
- return {
502
- signal: controller.signal,
503
- progress: (file, p) => {
504
- t.done.set(file, p.phase === 'hashing' || p.phase === 'checking' ? 0 : p.done);
505
- t.phase = p.phase;
506
- t.file = file.name;
507
- t.retryIn = p.phase === 'waiting' ? Date.now() + (p.retryIn || 0) : 0;
508
- if (p.phase === 'hashing' || p.phase === 'checking') t.hashed = p.total ? p.done / p.total : 0;
509
- if (Date.now() - t.shown > 250) { t.shown = Date.now(); this.screen.schedule(); }
510
- },
511
- end: () => { this.transfers.delete(id); this.screen.schedule(); },
512
- };
513
- }
514
-
515
- // The footer's word on the files on their way.
516
- transferStatus() {
517
- const all = [...this.transfers.values()];
518
- if (!all.length) return '';
519
- const t = all[0];
520
- const done = [...t.done.values()].reduce((n, v) => n + v, 0);
521
- const pct = t.total ? Math.floor((done / t.total) * 100) : 0;
522
- const arrow = t.direction === 'up' ? '⇡' : '⇣';
523
- const name = truncate(t.names.length === 1 ? t.names[0] : `${t.names.length} files`, 24);
524
- const where = t.direction === 'up' ? `→ &${t.label}` : `← &${t.label}`;
525
- let state;
526
- if (t.phase === 'waiting') state = c.yellow(`${pct}% · network down, retrying…`);
527
- else if (t.phase === 'hashing') state = c.dim(`preparing ${Math.floor((t.hashed || 0) * 100)}%`);
528
- else if (t.phase === 'checking') state = c.dim('checking');
529
- else state = c.dim(`${pct}% · ${formatBytes(done)}/${formatBytes(t.total)}`);
530
- return `${c.blue(`${arrow} ${name}`)} ${c.dim(where)} ${state}${all.length > 1 ? c.dim(` +${all.length - 1}`) : ''}`;
531
- }
532
-
533
- // The account's sessions on other computers that are online now, from the gateway (what was known stays when
534
- // it cannot be asked). Sessions of this computer are left out: they are reached directly.
535
- refreshRemotePeers() {
536
- if (!this.remote?.registered) return Promise.resolve(this.remotePeers);
537
- this.remotePeersLoading ??= (async () => {
538
- try {
539
- const near = new Set([this.peers?.id, ...(this.peers?.peers() || []).map((p) => p.id)].filter(Boolean));
540
- const list = await this.remote.sessions();
541
- this.remotePeers = list
542
- .filter((p) => p.online && !p.ended && p.id !== this.remote?.id && !near.has(p.peerId))
543
- .map((p) => {
544
- const machine = p.machine || p.host || 'computer';
545
- const name = p.name || 'session';
546
- return { id: p.id, name, machine, cwd: p.cwd || '', remote: true, label: `${machine}/${name}`, busy: Boolean(p.busy), title: p.title || '' };
547
- });
548
- this.remotePeersAt = Date.now();
549
- } catch { /* the gateway cannot be asked now */ } finally {
550
- this.remotePeersLoading = null;
551
- }
552
- return this.remotePeers;
553
- })();
554
- return this.remotePeersLoading;
555
- }
556
-
557
- // This session and the others, here and on the other computers (for /peers and ListPeers).
558
- async listAllPeers() {
559
- if (this.remote) await this.refreshRemotePeers();
560
- const name = this.peerLabel();
561
- return {
562
- me: { name, cwd: this.agent?.cwd || this.cwd, label: `${machineName(this.config.env)}/${name}` },
563
- local: this.peers?.peers() || [],
564
- remote: this.remotePeers,
565
- online: Boolean(this.remote?.online),
566
- };
567
- }
568
-
569
- onPeerMessage(msg) {
570
- if (this.exiting) return;
571
- if (msg.files?.length && !msg.received) return this.receiveFiles(msg).catch((e) => this.logError(e)); // the gateway keeps the message until this is done
572
- const from = c.bold(`&${msg.from.name}`);
573
- const width = this.width - 1;
574
- if (msg.kind === 'reply' && this.userSentToPeers.delete(`${msg.from.id}:${msg.inReplyTo}`)) {
575
- // the answer to something the user sent: shown, and noted for the model, without a turn of its own
576
- this.screen.log(`${c.blue('✉')} ${from} ${c.dim('answered:')}`, ...renderMarkdown(msg.text, { width: width - 2 }).map((l) => ' ' + l), '');
577
- this.agent.addReminder(`The user sent a message to another claude-pro session, ${msg.from.name} (${msg.from.cwd}), which answered:\n${msg.text}`);
578
- return;
579
- }
580
- const display = `✉ ${msg.kind === 'reply' ? 'answer from' : 'task from'} &${msg.from.name}: ${msg.text}`;
581
- const item = { text: peerMessagePrompt(msg), images: [], display, peer: msg.kind === 'task' ? msg : null };
582
- if (this.busy) { this.queue.push(item); this.screen.schedule(); return; }
583
- this.startTurn(item.text, { display, peers: item.peer ? [item.peer] : [] });
584
- }
585
-
586
- // Files another session sent: saved in the Downloads folder (never over a file that is there) at once, whatever
587
- // this session is doing. Files alone are answered right away with where they are, without a turn of the model;
588
- // with a task, the task runs as usual and is told where they are.
589
- async receiveFiles(msg) {
590
- msg.received = true;
591
- let dir;
592
- try { dir = await downloadsDir(this.config.env); } catch { dir = path.join(os.homedir(), 'Downloads'); }
593
- const saved = [];
594
- const failed = [];
595
- const remote = this.remote;
596
- const job = msg.from.remote ? this.startTransfer('down', msg.files, msg.from) : null;
597
- try {
598
- for (const f of msg.files) {
599
- try {
600
- let file;
601
- if (msg.from.remote) {
602
- if (!remote) throw new Error('this session is not online at the gateway');
603
- file = await remote.downloadFile(f, dir, { signal: job.signal, onProgress: (p) => job.progress(f, p) });
604
- remote.deleteFile(f.id);
605
- } else {
606
- file = await copyUnique(f.path, dir, f.name);
607
- }
608
- saved.push({ path: file, size: fs.statSync(file).size });
609
- } catch (e) {
610
- failed.push(`${f.name}: ${e.message}`);
611
- }
612
- }
613
- } finally {
614
- job?.end();
615
- }
616
- this.screen.log(
617
- `${c.blue('✉')} ${c.bold(`&${msg.from.name}`)} ${c.dim(`sent ${msg.files.length} file${msg.files.length === 1 ? '' : 's'}${saved.length ? `, saved in ${dir}:` : ':'}`)}`,
618
- ...saved.map((s) => ` ${path.basename(s.path)} ${c.dim(formatBytes(s.size))}`),
619
- ...failed.map((f) => c.red(` ✗ ${f}`)),
620
- '',
621
- );
622
- const where = `the Downloads folder of ${machineName(this.config.env)} (${dir})`;
623
- const report = [
624
- saved.length ? `Saved ${saved.length} file${saved.length === 1 ? '' : 's'} in ${where}:\n${saved.map((s) => `- ${s.path}`).join('\n')}` : '',
625
- failed.length ? `Could not save:\n${failed.map((f) => `- ${f}`).join('\n')}` : '',
626
- ].filter(Boolean).join('\n\n');
627
- if (msg.kind === 'task' && !msg.text.trim()) {
628
- this.agent.addReminder(`Another claude-pro session, ${msg.from.name}, sent files to this computer. ${report}`);
629
- this.replyToPeer(msg, report);
630
- return;
631
- }
632
- msg.text = `${msg.text}\n\n[Files that came with this message. ${report}]`;
633
- this.onPeerMessage(msg);
634
- }
635
-
636
- // Send `text` as the answer to `msg`, a message of another session: here, or through the gateway.
637
- replyToPeer(msg, text) {
638
- const done = () => this.screen.log(c.dim(` ⎿ Answer sent to &${msg.from.name}`));
639
- const failed = (e) => this.notice(`Could not send the answer to &${msg.from.name}: ${e.message}`, 'warn');
640
- if (msg.from.remote) { // from another computer: back through the gateway
641
- 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; }
642
- this.remote.sendPeer(msg.from.id, { kind: 'reply', text, inReplyTo: msg.id }).then(done, failed);
643
- return;
644
- }
645
- if (!this.peers) return;
646
- // by id, or by name: a session that was restarted by older code comes back with a new id
647
- const running = this.peers.peers();
648
- const peer = findPeer(running, msg.from.id) || running.find((p) => p.name === msg.from.name && p.cwd === msg.from.cwd);
649
- if (!peer) { this.notice(`&${msg.from.name} is no longer running: its answer was not sent.`, 'warn'); return; }
650
- this.peers.send(peer, { text, kind: 'reply', inReplyTo: msg.id }).then(done, failed);
651
- }
652
-
653
- // /sendfile &session file...: files on this computer to another session (here or on another computer), which
654
- // saves them in its Downloads folder and confirms. Paths may be quoted, @-mentions, or have spaces unquoted.
655
- async sendFiles(args) {
656
- if (/^\s*(stop|cancel)\s*$/i.test(args)) {
657
- const ups = [...this.transfers.values()].filter((t) => t.direction === 'up');
658
- for (const t of ups) t.controller.abort();
659
- this.screen.log(c.dim(ups.length ? ` ⎿ Stopping ${ups.length} sending${ups.length === 1 ? '' : 's'}` : ' ⎿ Nothing is being sent'));
660
- return;
661
- }
662
- const tokens = [...String(args).matchAll(/@?"([^"]+)"|@?'([^']+)'|(\S+)/g)].map((m) => ({ text: m[1] ?? m[2] ?? m[3], quoted: m[3] === undefined }));
663
- const targets = tokens.filter((t) => !t.quoted && /^[&&]./u.test(t.text));
664
- if (targets.length !== 1) {
665
- this.notice(targets.length ? 'Send to one session at a time.' : 'Usage: /sendfile &session file... (type & for the list of sessions)', 'warn');
666
- return;
667
- }
668
- const exists = (p) => { try { return fs.statSync(p).isFile(); } catch { return false; } };
669
- const rest = tokens.filter((t) => t !== targets[0]);
670
- const paths = [];
671
- for (let i = 0; i < rest.length;) {
672
- if (rest[i].quoted) { paths.push(resolvePath(rest[i].text, this.cwd)); i++; continue; }
673
- // the longest run of words that is a file: a path with spaces that was not quoted
674
- let j = i + 1;
675
- for (let k = rest.length; k > i + 1; k--) {
676
- if (rest.slice(i, k).some((t) => t.quoted)) continue;
677
- if (exists(resolvePath(rest.slice(i, k).map((t) => t.text).join(' ').replace(/^@/, ''), this.cwd))) { j = k; break; }
678
- }
679
- paths.push(resolvePath(rest.slice(i, j).map((t) => t.text).join(' ').replace(/^@/, ''), this.cwd));
680
- i = j;
681
- }
682
- let files;
683
- try { files = filesToSend(paths); } catch (e) { this.notice(e.message, 'warn'); return; }
684
- const total = files.reduce((n, f) => n + f.size, 0);
685
- const what = `${files.length} file${files.length === 1 ? '' : 's'} (${formatBytes(total)})`;
686
- try {
687
- const r = await this.sendToPeer(targets[0].text, '', { files: paths, background: true });
688
- const label = r.peer.label || r.peer.name;
689
- 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' : ''})`));
690
- } catch (e) {
691
- this.notice(`Could not send the files: ${e.message}`, 'warn');
692
- }
693
- }
694
-
695
- // The turn that handled tasks of other sessions ended: send each its answer.
696
- answerPeers(result, error) {
697
- const tasks = this.turnPeers.splice(0);
698
- if (!tasks.length) return;
699
- const text = error ? `The session failed before finishing: ${error.message || error}`
700
- : result?.interrupted ? `The user of ${this.peerLabel()} interrupted the work before it was finished.${result.text ? `\n\nWhat it had written:\n${result.text}` : ''}`
701
- : (result?.text || '(no answer text)');
702
- // One answer per session, to its last message: several messages from it that waited for this turn were all
703
- // handled in it, and sending the whole answer once for each would hand that session the same text again and again.
704
- const lastOfEach = new Map();
705
- for (const msg of tasks) lastOfEach.set(`${msg.from.id}|${msg.from.name}|${msg.from.cwd}`, msg);
706
- for (const msg of lastOfEach.values()) this.replyToPeer(msg, text);
707
- }
708
-
709
- // ---------------------------------------------------------------- remote control (see remote.js)
710
-
711
- // Print to the terminal only: the remote page is told about this as an event of its own.
712
- logLocal(...items) {
713
- this.remoteMuted++;
714
- try { this.screen.log(...items); } finally { this.remoteMuted--; }
715
- }
716
-
717
- // A message of the user in the transcript (typed here, on the remote page, or sent by another session).
718
- echoUser(text, images = 0) {
719
- this.logLocal(renderUserMessage(text, this.width));
720
- this.remote?.emit({ t: 'user', text: images ? `${text}\n[${images} image${images === 1 ? '' : 's'}]` : String(text) });
721
- }
722
-
723
- // Online at the gateway (see remote.js) from the start: by itself when this computer is logged in to the gateway
724
- // (CLAUDE_PRO_ONLINE=0 turns that off), with --rc (which prints the link too), and after a restart as before (with
725
- // the same id, so that open pages and messages on their way find it again). It does not hold up the start. Never
726
- // in the tests, which drive the App themselves, nor in the self-check, which sets CLAUDE_PRO_ONLINE=0.
727
- autoStartRemote(restart) {
728
- const carried = restart?.remote;
729
- if (carried?.off) { this.remoteStopped = true; return; }
730
- const explicit = Boolean(this.flags.remoteControl);
731
- if (this.driven && !explicit) return;
732
- if (!explicit && !carried?.id && !(onlineEnabled(this.config.env) && readGatewayLogin(this.config.gatewayUrl))) return;
733
- this.startRemote({ id: carried?.id, quiet: !explicit || Boolean(carried?.id), persistent: !explicit }).catch((e) => {
734
- 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');
735
- });
736
- }
737
-
738
- // Connect this session to the gateway (see remote.js): the web page and the sessions on the account's other
739
- // computers reach it from then on. Needs the gateway login of /login (the provider can be another one); throws
740
- // {code: 'NO_GATEWAY_LOGIN'} without it. `persistent`: a gateway that cannot be reached now is tried again in
741
- // the background instead of failing.
742
- startRemote({ id, quiet = false, persistent = quiet } = {}) {
743
- if (this.remote) return Promise.resolve(this.remote);
744
- this.remoteStarting ??= this.connectRemote({ id, quiet, persistent }).finally(() => { this.remoteStarting = null; });
745
- return this.remoteStarting;
746
- }
747
-
748
- async connectRemote({ id, quiet, persistent }) {
749
- const login = readGatewayLogin(this.config.gatewayUrl);
750
- 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' });
751
- const remote = new RemoteControl({
752
- gateway: { url: login.url || this.config.gatewayUrl, apiKey: login.apiKey },
753
- id,
754
- handlers: {
755
- 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 }),
756
- seed: () => transcriptItems(this.agent.messages, { tools: this.runtime.tools, ctx: this.agent.makeCtx(), cwd: this.cwd }),
757
- status: () => this.remoteStatus(),
758
- send: (text) => this.remoteSend(text),
759
- interrupt: () => this.remoteInterrupt(),
760
- answer: (body) => this.answerDialog(body),
761
- peerMessage: (msg) => this.onPeerMessage(msg),
762
- changed: () => {
763
- this.screen.schedule();
764
- if (remote.online && Date.now() - this.remotePeersAt > 10_000) this.refreshRemotePeers();
765
- },
766
- },
767
- });
768
- remote.account = login.account?.email || login.account?.name || null;
769
- await remote.start({ persistent });
770
- if (this.exiting) { remote.close(); return remote; }
771
- this.remote = remote;
772
- this.remoteStopped = false;
773
- this.remotePeersTimer ??= setInterval(() => { if (this.remote?.online) this.refreshRemotePeers(); }, 60_000);
774
- this.remotePeersTimer.unref?.();
775
- if (!quiet) this.logLocal(this.remoteInfo({ fresh: true }));
776
- this.screen.schedule();
777
- return remote;
778
- }
779
-
780
- stopRemote() {
781
- this.remote?.close();
782
- this.remote = null;
783
- this.remotePeers = [];
784
- this.remoteStopped = true;
785
- this.screen.schedule();
786
- }
787
-
788
- // Where the session is on the gateway (and a QR code of it, for a phone), for the terminal.
789
- remoteInfo({ fresh = false } = {}) {
790
- const r = this.remote;
791
- 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`) : '';
792
- const lines = [`${c.green('⇄')} ${c.bold('This session is online at the gateway')}${r.account ? ` (${r.account})` : ''}${state}. In a browser:`];
793
- lines.push(` ${c.blue(r.url || r.base)}`);
794
- const qr = r.url ? qrLines(r.url, { color: colorEnabled }) : null;
795
- if (qr) lines.push('', c.dim(' Scan with a phone (log in to the gateway there):'), ...qr.map((l) => ' ' + l));
796
- const notes = [
797
- `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.`,
798
- `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.`,
799
- 'Whoever can log in to this gateway account, or holds one of its API keys, can run anything on this computer through it.',
800
- '/rc stop takes this session offline · /rc open opens the page in the browser here',
801
- ];
802
- lines.push('', ...notes.flatMap((n) => wordWrap(n, Math.max(20, this.width - 4))).map((l) => c.dim(` ${l.trimEnd()}`)), '');
803
- return lines;
804
- }
805
-
806
- // The state the page shows below the transcript; sent whenever it changes.
807
- remoteStatus() {
808
- const cfg = this.config;
809
- const { model, effort } = modelInfo(cfg);
810
- const w = this.rateWait;
811
- let rateWait = null;
812
- if (w) {
813
- const label = w.label.charAt(0).toUpperCase() + w.label.slice(1);
814
- rateWait = {
815
- 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)}`}`,
816
- resumeIn: w.resumeAt - Date.now(),
817
- };
818
- }
819
- return {
820
- busy: this.busy,
821
- verb: this.status || (this.thinking ? 'Thinking' : this.verb),
822
- elapsed: this.busy ? Date.now() - this.turnStart : 0,
823
- tokens: this.turnTokens,
824
- aborting: Boolean(this.controller?.signal.aborted),
825
- rateWait,
826
- todos: (this.agent.todos || []).map((t) => ({ content: t.content, status: t.status })),
827
- queue: this.queue.map((q) => q.display ?? q.text),
828
- dialog: this.remoteDialog(),
829
- mode: this.runtime.modes.mode,
830
- model,
831
- effort: effort || defaultEffort(cfg.mainModel.id, cfg),
832
- title: this.sessionLabel() || path.basename(this.cwd),
833
- cwd: (this.agent.cwd || this.cwd).replace(/\\/g, '/'),
834
- name: this.peerLabel(),
835
- tasks: this.runtime.tasks.running().length,
836
- restarting: Boolean(this.restartRequest),
837
- };
838
- }
839
-
840
- // The dialog open in the terminal, for the page to answer (options by index: values need not be strings).
841
- remoteDialog() {
842
- const d = this.dialogs[0];
843
- if (!d) return null;
844
- d.remoteId ??= ++this.remoteDialogs;
845
- const base = { id: d.remoteId, title: d.title || '', question: d.question || '', body: d.body || [], markdown: d.markdown || null };
846
- if (!Array.isArray(d.options)) return base;
847
- return { ...base, multi: Boolean(d.multi), options: d.options.map((o) => ({ label: String(o.label ?? ''), description: o.description || '', other: o.value === '__other__' })) };
848
- }
849
-
850
- answerDialog({ id, index, indices, text, cancel } = {}) {
851
- const d = this.dialogs[0];
852
- if (!d || d.remoteId !== id) throw new Error('That question is no longer open.');
853
- if (cancel) { d.resolve(d.cancelValue); return {}; }
854
- if (!Array.isArray(d.options)) throw new Error('This dialog can only be answered in the terminal.');
855
- const valueAt = (i) => {
856
- const o = d.options[i];
857
- if (!Number.isInteger(i) || !o || o.value === '__other__') throw new Error('No such option.');
858
- return o.value;
859
- };
860
- if (typeof text === 'string') {
861
- if (!text.trim()) throw new Error('Type an answer first.');
862
- d.resolve(d.multi ? [...(indices || []).map(valueAt), text.trim()] : text.trim());
863
- } else if (d.multi) {
864
- if (!indices?.length) throw new Error('Choose at least one option.');
865
- d.resolve(indices.map(valueAt));
866
- } else {
867
- d.resolve(valueAt(index));
868
- }
869
- this.screen.schedule();
870
- return {};
871
- }
872
-
873
- // A message from the page: as if typed in the input box.
874
- remoteSend(text) {
875
- const value = String(text ?? '').replace(/\r\n/g, '\n');
876
- if (!value.trim()) throw new Error('The message is empty.');
877
- if (this.exiting) throw new Error('The session is ending.');
878
- if (this.busy && value.trim().startsWith('/')) throw new Error('Commands wait until the current task is done (or stopped).');
879
- if (this.dialogs.length && !this.busy) throw new Error('A question is open: answer it first.');
880
- const queued = this.busy;
881
- this.onSubmit(value, value);
882
- this.screen.schedule();
883
- return { queued };
884
- }
885
-
886
- remoteInterrupt() {
887
- for (const d of this.dialogs.splice(0)) d.resolve(d.cancelValue);
888
- this.interrupt();
889
- this.screen.schedule();
890
- return {};
891
- }
892
-
893
- // ---------------------------------------------------------------- restarting (see restart.js)
894
-
895
- // What a restart would kill: agents and shells that are still running in the background.
896
- // Something is going on that an update must not cut into: a turn, a question waiting for an answer, messages
897
- // waiting to be sent, background agents and shells, files on their way.
898
- working() {
899
- return Boolean(this.busy || this.dialogs.length || this.queue.length || this.restartBlockers().length);
900
- }
901
-
902
- restartBlockers() {
903
- const found = [];
904
- for (const t of this.transfers.values()) found.push(`files on their way ${t.direction === 'up' ? 'to' : 'from'} &${t.label}`);
905
- for (const t of this.runtime.tasks.running()) found.push(`background agent ${t.id} "${t.description}"`);
906
- for (const s of listBackgroundShells()) if (s.status === 'running') found.push(`background shell ${s.id} (${String(s.command).slice(0, 40)})`);
907
- return found;
908
- }
909
-
910
- // Restart as soon as nothing is running: at once when idle, otherwise when the current turn ends (the
911
- // turn that asked for it still has to be answered and saved). `continueTurn`: the model gets a note in
912
- // the new process and goes on by itself. Returns {ok, message}.
913
- requestRestart({ reason = '', then = '', continueTurn = false, force = false } = {}) {
914
- if (!canRestart()) return { ok: false, message: 'This session cannot restart itself.' };
915
- const now = Date.now();
916
- const recent = this.restarts.filter((t) => now - t < RESTART_WINDOW_MS);
917
- if (recent.length >= MAX_RESTARTS) {
918
- return { ok: false, message: `The program was restarted ${recent.length} times in the last ${Math.round(RESTART_WINDOW_MS / 60_000)} minutes. That looks like a loop: stop restarting and tell the user what is going on.` };
919
- }
920
- const blockers = this.restartBlockers();
921
- if (blockers.length && !force) return { ok: false, message: `A restart would kill: ${blockers.join('; ')}.` };
922
- this.restartRequest = { reason, then, continueTurn };
923
- this.screen.schedule();
924
- if (!this.busy) this.restartNow();
925
- return { ok: true };
926
- }
927
-
928
- // Leave the state of the session for the next process and go; the supervisor starts it again.
929
- restartNow() {
930
- const request = this.restartRequest || {};
931
- this.restartRequest = null;
932
- const cfg = this.config;
933
- const now = Date.now();
934
- const state = {
935
- at: now,
936
- reason: request.reason || '',
937
- continueWith: request.continueTurn ? restartNote(request) : '',
938
- sessionId: this.store.id,
939
- sessionFile: this.store.file,
940
- modelOverride: cfg.modelOverride,
941
- effortOverride: cfg.effortOverride,
942
- effortBoost: cfg.effortBoost,
943
- mode: this.runtime.modes.mode,
944
- todos: this.agent.todos,
945
- draft: this.editor.text,
946
- screenHistory: this.screen.history.slice(-3000),
947
- draftCursor: this.editor.cursor,
948
- queue: this.queue.map((q) => ({ text: q.text, images: q.images || [] })),
949
- checkpoints: this.runtime.checkpoints.snapshot(),
950
- restarts: [...this.restarts.filter((t) => now - t < RESTART_WINDOW_MS), now],
951
- peerName: this.peers?.name || this.sessionName,
952
- peerId: this.peers?.id,
953
- remote: this.remote?.handoff() || (this.remoteStopped ? { off: true } : null),
954
- };
955
- try {
956
- writeRestartState(state);
957
- } catch (e) {
958
- this.notice(`Could not restart: ${e.message}`, 'warn');
959
- return;
960
- }
961
- this.exit(RESTART_EXIT_CODE, { restart: true });
962
- }
963
-
964
- // In the process a restart started: take up the session where the one before it left it.
965
- resumeAfterRestart(state, resumable) {
966
- const cfg = this.config;
967
- // The model and effort the session had, none chosen included: the remembered /model and /effort are shared by
968
- // every window on this machine, and another one may have changed them since (an update restarts them all).
969
- // A restart is not a choice either, so it leaves what is remembered as it is.
970
- if ('modelOverride' in state) cfg.modelOverride = state.modelOverride || null;
971
- if ('effortOverride' in state) cfg.effortOverride = state.effortOverride || null;
972
- // The Effort tool's level holds for the request the restart continues, not for the user's next one.
973
- if (state.effortBoost) { cfg.effortBoost = state.effortBoost; cfg.effortBoostKeepNext = Boolean(state.continueWith); }
974
- if (state.mode === 'plan') this.runtime.modes.setMode('plan');
975
- this.restarts = (Array.isArray(state.restarts) ? state.restarts : []).filter((t) => Number.isFinite(t));
976
- if (resumable) {
977
- this.loadSession(state.sessionFile, state.sessionId, { quiet: true });
978
- if (Array.isArray(state.todos)) this.agent.todos = state.todos;
979
- if (state.checkpoints) this.runtime.checkpoints.adopt(state.checkpoints);
980
- }
981
- if (typeof state.draft === 'string' && state.draft) this.editor.set(state.draft, state.draftCursor);
982
- // Fullscreen starts on an empty screen: without what the process before showed (an older version, or one
983
- // that ran classic), the conversation is drawn again from the session.
984
- if (this.screen.full && resumable && !Array.isArray(state.screenHistory)) this.printTranscript(this.agent.messages.slice(-80));
985
- this.screen.log(c.dim(`↻ Restarted${state.reason ? `: ${state.reason}` : ''}${state.continueWith ? ' · continuing' : ''}`), '');
986
- }
987
-
988
- // The turn that goes on after a restart: the note for the model, and what the user had queued meanwhile.
989
- continueAfterRestart(state) {
990
- const queued = (Array.isArray(state.queue) ? state.queue : []).filter((q) => q && typeof q.text === 'string' && q.text.trim());
991
- if (!state.continueWith && !queued.length) return;
992
- const typed = queued.map((q) => q.text).join('\n\n');
993
- this.startTurn([state.continueWith, typed].filter(Boolean).join('\n\n'), { echo: queued.length > 0, display: typed, topic: typed, images: queued.flatMap((q) => q.images || []) });
994
- }
995
-
996
- // Follow the program's own source: once a turn is over (or at once when idle) a change to it is loaded by
997
- // a restart, if the new code starts.
998
- startSourceWatch() {
999
- if (!canRestart() || /^(0|false|no|off)$/i.test(this.config.env.CLAUDE_PRO_AUTO_RESTART || '')) return;
1000
- this.sourceWatch = watchSource(INSTALL_DIR, (file) => {
1001
- this.sourceChanged = file || true;
1002
- // another window (or npm) installed a new version: say so, and load it once this session is idle. In a
1003
- // source checkout a newer version in package.json is a release being made, not an install.
1004
- const disk = installedVersion();
1005
- if (installedByNpm() && disk !== VERSION && isNewer(disk, VERSION) && this.updateRestart !== disk) {
1006
- this.updateRestart = disk;
1007
- this.pendingUpdate = null;
1008
- this.notice(`${APP_NAME} ${disk} was installed (by another window or npm); this one restarts to load it ${this.working() ? 'once its work is done' : 'now'}.`);
1009
- }
1010
- if (!this.busy) this.maybeAutoReload();
1011
- });
1012
- }
1013
-
1014
- async maybeAutoReload() {
1015
- if (!this.sourceChanged || this.updating || this.reloading || this.busy || this.exiting || this.restartRequest || this.dialogs.length || this.queue.length) return;
1016
- if (this.skipAutoReload) { this.skipAutoReload = false; return; } // the user just interrupted: not now
1017
- if (this.restartBlockers().length) return; // looked at again whenever the program is next idle
1018
- const changed = this.sourceChanged;
1019
- this.sourceChanged = null;
1020
- this.reloading = true;
1021
- this.flash(this.updateRestart ? `Updated to ${this.updateRestart}: checking it starts, then restarting…` : 'Source changed: checking the new code…');
1022
- let check;
1023
- try { check = await runSelfCheck(); } finally { this.reloading = false; }
1024
- if (this.exiting) return;
1025
- if (!check.ok) {
1026
- this.notice(`The program's source changed but the new code does not start, so it was not restarted:\n${check.output.split('\n').slice(0, 12).join('\n')}\nFix it; the restart follows by itself (or run /restart).`, 'warn');
1027
- return;
1028
- }
1029
- if (this.busy || this.dialogs.length) { this.sourceChanged = changed; return; } // something started meanwhile: later
1030
- this.requestRestart({ reason: this.updateRestart ? `updated to ${APP_NAME} ${this.updateRestart}` : 'source changed', force: true });
1031
- }
1032
-
1033
- // A newer version on npm (see updater.js): it is installed once no session of this machine is working, and then
1034
- // loaded by a restart (this session's; the others restart when they see the files change). `force`: the /update
1035
- // command, which also says when there is nothing to do. `quiet`: the periodic check, which says nothing when an
1036
- // install fails (it is tried again in a minute).
1037
- async autoUpdate({ force = false, quiet = false } = {}) {
1038
- if (this.updating) { if (force) this.notice(`${APP_NAME} ${this.updating} is being installed.`); return; }
1039
- let r;
1040
- try {
1041
- r = await checkForUpdate({ force, env: { ...process.env, ...this.config.env } });
1042
- } catch (e) {
1043
- r = { status: 'failed', error: e.message };
1044
- }
1045
- if (this.exiting) return;
1046
- if (r.status === 'available') {
1047
- if (this.pendingUpdate !== r.version) {
1048
- this.pendingUpdate = r.version;
1049
- this.updateQuiet = quiet;
1050
- this.screen.schedule();
1051
- }
1052
- if (force && this.working()) this.notice(`${APP_NAME} ${r.version} is out: it is installed as soon as this session's work is done.`);
1053
- await this.maybeInstallUpdate({ force });
1054
- } else if (r.status === 'installed') {
1055
- if (force || this.updateRestart !== r.version) this.notice(`${APP_NAME} ${r.version} is installed already; this session restarts to load it ${this.working() ? 'once its work is done' : 'now'}.`);
1056
- this.updateRestart = r.version;
1057
- if (canRestart()) { this.sourceChanged ||= true; if (!this.busy) this.maybeAutoReload(); }
1058
- } else if (r.status === 'failed') {
1059
- if (force || (r.version && !quiet)) this.notice(`Could not update ${APP_NAME}${r.version ? ` to ${r.version}` : ''}: ${r.error}`, 'warn');
1060
- } else if (force) {
1061
- this.notice(r.status === 'dev' ? `This copy runs from a source checkout (${INSTALL_DIR}): update it with git.` : `${APP_NAME} ${VERSION} is the latest version.`);
1062
- }
1063
- }
1064
-
1065
- // Install the version found (pendingUpdate) if nothing is going on: in this session, and in the other sessions
1066
- // of this machine, whose files it replaces under them. Called again when this session's turn ends, and every
1067
- // half minute while another session works.
1068
- async maybeInstallUpdate({ force = false } = {}) {
1069
- const version = this.pendingUpdate;
1070
- if (!version || this.updating || this.exiting || this.restartRequest) return;
1071
- if (this.working()) return; // the end of the turn calls this again
1072
- const local = this.peers?.peers() || [];
1073
- const busy = [];
1074
- for (const p of local) if ((await this.peers.status(p)).busy) busy.push(p.name);
1075
- if (this.pendingUpdate !== version || this.updating || this.exiting) return;
1076
- if (busy.length) {
1077
- if (this.updateWaitingFor !== busy.join(',')) {
1078
- this.updateWaitingFor = busy.join(',');
1079
- this.screen.schedule();
1080
- if (force) this.notice(`${APP_NAME} ${version} is installed once &${busy.join(', &')} ${busy.length === 1 ? 'is' : 'are'} done working.`);
1081
- }
1082
- clearTimeout(this.updateRetry);
1083
- this.updateRetry = setTimeout(() => this.maybeInstallUpdate(), 30_000);
1084
- this.updateRetry.unref?.();
1085
- return;
1086
- }
1087
- if (this.working()) return;
1088
- this.updateWaitingFor = null;
1089
- this.updating = version;
1090
- this.screen.schedule();
1091
- let r;
1092
- try {
1093
- r = await installUpdate(version);
1094
- } catch (e) {
1095
- r = { status: 'failed', version, error: e.message };
1096
- } finally {
1097
- this.updating = null;
1098
- this.screen.schedule();
1099
- }
1100
- if (this.exiting) return;
1101
- if (r.status === 'locked') return; // another window is installing it: its files changing tell this one
1102
- if (r.status === 'updated' || r.status === 'installed') {
1103
- this.pendingUpdate = null;
1104
- this.updateRestart = r.version;
1105
- if (canRestart()) {
1106
- this.notice(`${APP_NAME} was updated to ${r.version} (from ${VERSION}); restarting to load it. The conversation carries over.`);
1107
- this.sourceChanged = true;
1108
- this.maybeAutoReload();
1109
- } else {
1110
- this.notice(`${APP_NAME} was updated to ${r.version}: restart the program to use it.`);
1111
- }
1112
- return;
1113
- }
1114
- if (force || !this.updateQuiet) this.notice(`Could not update ${APP_NAME} to ${version}: ${r.error}`, 'warn');
1115
- }
1116
-
1117
- printWelcome() {
1118
- const cfg = this.config;
1119
- const w = Math.min(this.width - 1, 76);
1120
- const inner = w - 4;
1121
- const row = (s) => c.claude('│') + ' ' + padEnd(truncate(s, inner), inner) + ' ' + c.claude('│');
1122
- const model = cfg.mainModel;
1123
- const lines = [
1124
- c.claude('╭' + '─'.repeat(w - 2) + '╮'),
1125
- row(`${c.claude('✻')} Welcome to ${c.bold(APP_NAME)}! ${c.dim(`v${VERSION} · a Claude Code style agent`)}`),
1126
- row(''),
1127
- row(c.dim(' /help for help, /status for your current setup')),
1128
- row(''),
1129
- row(c.dim(` cwd: ${this.cwd}`)),
1130
- row(c.dim(` model: ${model.label}${cfg.effort ? ` · effort ${cfg.effort}` : ''} · ${formatTokens(model.contextWindow)} context`)),
1131
- row(c.dim(` backend: ${cfg.endpointLabel}`)),
1132
- c.claude('╰' + '─'.repeat(w - 2) + '╯'),
1133
- ];
1134
- lines.push('', c.dim(' Full access: every tool runs immediately, nothing is ever asked. File edits are checkpointed;'), c.dim(' /rewind puts files and/or the conversation back (shell-command changes are not tracked).'));
1135
- if (cfg.usesGateway && !readGatewayLogin(cfg.gatewayUrl)) {
1136
- lines.push('', c.yellow(` ⚠ Not logged in to the gateway ${cfg.gatewayUrl}. Run /login.`));
1137
- } else if (cfg.provider === 'api' && !cfg.authToken && !cfg.apiKey) {
1138
- lines.push('', c.yellow(' ⚠ No API credentials found. Set ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY in .env, ~/.claude-pro/settings.json or the environment.'));
1139
- }
1140
- const mcp = this.runtime.mcp.status();
1141
- if (mcp.length) {
1142
- const ok = mcp.filter((m) => m.status === 'connected').length;
1143
- lines.push(c.dim(` MCP: ${ok}/${mcp.length} servers connected${ok < mcp.length ? ' · /mcp for details' : ''}`));
1144
- }
1145
- 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}`), '');
1146
- this.logLocal(lines);
1147
- }
1148
-
1149
- // ---------------------------------------------------------------- input
1150
-
1151
- // Say "copied" when the user copies text selected in this terminal, as Claude Code does. The terminal does the
1152
- // selecting and copying and tells nobody, so the clipboard is watched; focus events (which the terminal sends
1153
- // once asked to) keep copies made in other windows from being announced. Off with CLAUDE_PRO_COPY_NOTICE=0,
1154
- // and when not writing to the real terminal (the tests).
1155
- watchCopies() {
1156
- if (this.stdout !== process.stdout || !this.stdout.isTTY || /^(0|false|no)$/i.test(process.env.CLAUDE_PRO_COPY_NOTICE || '')) return;
1157
- this.focused = true;
1158
- this.clipboardWatch = watchClipboard((chars) => {
1159
- if (!this.focused || this.exiting) return;
1160
- this.flash(chars ? `Copied ${chars.toLocaleString()} characters to the clipboard` : 'Copied to the clipboard');
1161
- });
1162
- this.stdout.write(FOCUS_EVENTS_ON);
1163
- }
1164
-
1165
- // Fullscreen (see screen.js) unless CLAUDE_PRO_TUI=default, as Claude Code's /tui: the input box fixed at the
1166
- // bottom of the window, the conversation scrolled inside the program. Not in the tests that drive the App.
1167
- fullscreenWanted() {
1168
- const mode = String(this.config.env.CLAUDE_PRO_TUI || (this.driven ? 'default' : 'fullscreen')).toLowerCase();
1169
- return mode === 'fullscreen' && this.stdout.isTTY !== false;
1170
- }
1171
-
1172
- // Fullscreen: the wheel scrolls the conversation (CLAUDE_PRO_SCROLL_SPEED rows a notch, 3 by default); a drag,
1173
- // a double or a triple click selects, and what is selected goes to the clipboard (see Screen.mouse).
1174
- onMouse(m) {
1175
- if (!this.screen.full || this.exiting) return;
1176
- if (m.button & 64) {
1177
- if (m.button & 128) return;
1178
- const speed = Math.max(1, Math.min(20, Number(this.config.env.CLAUDE_PRO_SCROLL_SPEED) || 3));
1179
- this.screen.scrollBy(m.button & 1 ? -speed : speed);
1180
- return;
1181
- }
1182
- // A right click, which the terminal no longer gets: what it did there. With a selection it copies it;
1183
- // without one it pastes the clipboard's text, as the terminal's own paste (ctrl+v) arrives, so that it is
1184
- // handled the same way (long text folded, an image path attached, into a dialog's answer box).
1185
- if ((m.button & 3) === 2 && !(m.button & 32) && !m.release) {
1186
- if (this.screen.selection) {
1187
- this.copyText(this.screen.selectedText());
1188
- this.screen.clearSelection();
1189
- return;
1190
- }
1191
- readClipboardText().then((text) => {
1192
- if (!text || this.exiting) return;
1193
- const clean = text.replace(/\x1b\[20[01]~/g, '').replace(/\r\n|\n/g, '\r');
1194
- this.keys.write(`\x1b[200~${clean}\x1b[201~`);
1195
- });
1196
- return;
1197
- }
1198
- const r = this.screen.mouse(m);
1199
- if (r?.copy) this.copyText(r.copy);
1200
- }
1201
-
1202
- copyText(text) {
1203
- writeClipboard(text).then((ok) => {
1204
- if (ok) this.flash(`Copied ${text.length.toLocaleString()} characters to the clipboard`);
1205
- else this.flash('Could not copy: no clipboard tool found (hold Shift and drag to select with the terminal)', 'warn');
1206
- });
1207
- }
1208
-
1209
- onKey(str, key) {
1210
- if (this.exiting) return;
1211
- if (key.sequence === '\x1b[I') { this.focused = true; return; }
1212
- if (key.sequence === '\x1b[O') { this.focused = false; return; }
1213
- if (this.screen.full) {
1214
- const half = Math.max(1, Math.floor((this.screen.lastArea || this.screen.height) / 2));
1215
- if (key.name === 'pageup') { this.screen.scrollBy(half); return; }
1216
- if (key.name === 'pagedown') { this.screen.scrollBy(-half); return; }
1217
- if (key.ctrl && key.name === 'home') { this.screen.scrollBy('top'); return; }
1218
- if (key.ctrl && key.name === 'end') { this.screen.scrollBy('bottom'); return; }
1219
- // A selection: ctrl+c copies it (and does not interrupt), esc only clears it, any other key clears it and
1220
- // then does what it does.
1221
- if (this.screen.selection) {
1222
- if (key.ctrl && key.name === 'c') { this.copyText(this.screen.selectedText()); this.screen.clearSelection(); return; }
1223
- this.screen.clearSelection();
1224
- if (key.name === 'escape') return;
1225
- }
1226
- }
1227
- if (key.ctrl && key.name === 'c') { this.onCtrlC(); return; }
1228
- const dialog = this.dialogs[0];
1229
- if (dialog) {
1230
- dialog.handleKey(str, key);
1231
- this.screen.schedule();
1232
- return;
1233
- }
1234
- if (key.name === 'tab' && key.shift) { this.runtime.modes.toggle(); this.screen.schedule(); return; }
1235
- // alt+v like Claude Code on Windows (ctrl+v is the terminal's own text paste there).
1236
- if ((key.meta || key.ctrl) && key.name === 'v') { this.pasteImage(); return; }
1237
- if (key.ctrl && key.name === 'o') {
1238
- this.verbose = !this.verbose;
1239
- this.notice(this.verbose ? 'Verbose mode on: showing thinking and full tool output' : 'Verbose mode off', 'info');
1240
- return;
1241
- }
1242
- if (key.ctrl && key.name === 't') { this.showTodos = !this.showTodos; this.screen.schedule(); return; }
1243
- if (key.ctrl && key.name === 'l') { if (this.screen.full) this.screen.repaint(); else this.screen.clearScreen(); return; }
1244
- if (key.ctrl && key.name === 'd' && !this.editor.text) { this.exit(0); return; }
1245
- if (key.name === 'escape' && this.busy) { this.interrupt(); return; }
1246
- this.exitArmed = 0;
1247
- this.editor.handleKey(str, key);
1248
- this.screen.schedule();
1249
- }
1250
-
1251
- onCtrlC() {
1252
- if (this.dialogs.length) {
1253
- // Cancel the dialog and interrupt the run.
1254
- for (const d of this.dialogs.splice(0)) d.resolve(d.cancelValue);
1255
- this.interrupt();
1256
- return;
1257
- }
1258
- if (this.busy) { this.interrupt(); return; }
1259
- if (this.editor.text) { this.editor.discard(); this.screen.schedule(); return; }
1260
- if (Date.now() - this.exitArmed < 1500) { this.exit(0); return; }
1261
- this.exitArmed = Date.now();
1262
- this.screen.schedule();
1263
- setTimeout(() => this.screen.schedule(), 1600);
1264
- }
1265
-
1266
- interrupt() {
1267
- if (!this.controller || this.controller.signal.aborted) return;
1268
- this.controller.abort();
1269
- this.screen.schedule();
1270
- }
1271
-
1272
- suggest(text, cursor) {
1273
- if (text.startsWith('/') && !/\s/.test(text) && cursor === text.length) {
1274
- const q = text.slice(1).toLowerCase();
1275
- const all = [...this.commands.values()].filter((cmd) => !cmd.hidden);
1276
- const pref = all.filter((cmd) => cmd.name.startsWith(q) || cmd.aliases?.some((a) => a.startsWith(q)));
1277
- const incl = q ? all.filter((cmd) => !pref.includes(cmd) && cmd.name.includes(q)) : [];
1278
- const items = [...pref, ...incl].map((cmd) => ({
1279
- value: '/' + cmd.name,
1280
- label: '/' + cmd.name + (cmd.argHint ? ' ' + cmd.argHint : ''),
1281
- description: cmd.description,
1282
- submitOnEnter: !cmd.argRequired,
1283
- }));
1284
- if (items.length === 1 && items[0].value === text && !items[0].submitOnEnter) return null;
1285
- return { items, from: 0, to: text.length };
1286
- }
1287
- // The mention being typed: `@name`, or `@"name with spaces` once a quote has been opened.
1288
- const before = text.slice(0, cursor);
1289
- const m = /(^|\s)@(?:"([^"@]*)|([^\s@"]*))$/.exec(before);
1290
- if (m) {
1291
- const quoted = m[2] !== undefined;
1292
- const q = quoted ? m[2] : m[3];
1293
- const files = this.fileIndex.search(q);
1294
- if (!files.length) return null;
1295
- // A name with spaces has to be quoted for the mention to be read back as one file. A directory
1296
- // leaves the quote open, so that completion goes on inside it; a file closes it.
1297
- const value = (f) => (quoted || /\s/.test(f) ? (f.endsWith('/') ? `@"${f}` : `@"${f}"`) : '@' + f);
1298
- return { items: files.map((f) => ({ value: value(f), label: f, noSpace: f.endsWith('/') })), from: cursor - q.length - (quoted ? 2 : 1), to: cursor };
1299
- }
1300
- // `&name`: the other sessions, on this computer and (through the gateway) on the account's others. At the start
1301
- // it sends them the message; elsewhere it names them (/sendfile, or for the model).
1302
- const amp = /(^|\s)[&&]([^\s&&]*)$/u.exec(before);
1303
- if (amp) return this.peerSuggestions(amp[2], cursor, amp.index === 0 && !amp[1]);
1304
- return null;
1305
- }
1306
-
1307
- peerSuggestions(q, cursor, atStart = true) {
1308
- if (this.remote?.online && Date.now() - this.remotePeersAt > 10_000) {
1309
- this.refreshRemotePeers().then(() => {
1310
- if (!/[&&][^\s&&]*$/u.test(this.editor.text.slice(0, this.editor.cursor))) return;
1311
- this.editor.refreshSuggestions();
1312
- this.screen.schedule();
1313
- });
1314
- }
1315
- const lq = q.toLowerCase();
1316
- const items = [
1317
- ...(this.peers?.peers() || []).filter((p) => p.name.toLowerCase().startsWith(lq))
1318
- .map((p) => ({ value: '&' + p.name, label: `&${p.name}`, description: `this computer · ${displayPath(p.cwd, this.cwd)}` })),
1319
- ...this.remotePeers.filter((p) => [p.label, p.name, p.machine].some((v) => v.toLowerCase().startsWith(lq)))
1320
- .map((p) => ({ value: '&' + p.label, label: `&${p.label}`, description: `${p.machine} · ${p.cwd}${p.busy ? ' · busy' : ''}` })),
1321
- ];
1322
- if (!items.length) {
1323
- 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)');
1324
- return null;
1325
- }
1326
- return { items, from: cursor - q.length - 1, to: cursor };
1327
- }
1328
-
1329
- onSubmit(value, raw, images = []) {
1330
- const text = value.trim();
1331
- if (!text) { this.screen.schedule(); return; }
1332
- if (this.screen.scrolledUp) this.screen.scrollBy('bottom'); // what was sent, and what comes of it, is in view
1333
- appendHistory(this.cwd, raw);
1334
- if (this.sendTypedToPeer(text)) return;
1335
- if (this.busy) {
1336
- if (text.startsWith('/')) { this.notice('Wait for the current task to finish (or press Esc) before running commands.', 'warn'); return; }
1337
- this.queue.push({ text: value, images });
1338
- this.screen.schedule();
1339
- return;
1340
- }
1341
- if (images.length) { this.startTurn(value, { images }); return; }
1342
- if (text === '?') { this.screen.log(this.shortcutsHelp()); return; }
1343
- if (raw.startsWith('!')) { this.runBashMode(value.slice(1).trim()); return; }
1344
- if (raw.startsWith('#')) { this.addMemory(value.slice(1).trim()); return; }
1345
- if (text.startsWith('/')) {
1346
- const [name, ...rest] = text.slice(1).split(/\s+/);
1347
- const args = text.slice(1 + name.length).trim();
1348
- const cmd = this.commands.get(name.toLowerCase()) || [...this.commands.values()].find((x) => x.aliases?.includes(name.toLowerCase()));
1349
- if (cmd) {
1350
- this.echoUser(text);
1351
- Promise.resolve(cmd.run(args, rest)).catch((e) => this.logError(e)).finally(() => this.screen.schedule());
1352
- return;
1353
- }
1354
- if (!/^\/[\w:-]+$/.test(text.split(/\s/)[0])) { this.startTurn(value); return; } // e.g. a path like /usr/bin
1355
- this.notice(`Unknown command: /${name}. Type /help for the list of commands.`, 'warn');
1356
- return;
1357
- }
1358
- this.startTurn(value);
1359
- }
1360
-
1361
- // `&name message`: the message goes to that session, as a task for it. A session on another computer of the
1362
- // gateway account: `&machine/name message`, or `&machine message` when that computer runs one session.
1363
- sendTypedToPeer(text) {
1364
- if (!/^[&&]/u.test(text)) return false;
1365
- const m = /^[&&](\S+)(?:\s+([\s\S]*))?$/u.exec(text);
1366
- const task = m?.[2]?.trim();
1367
- if (!m || !task) { this.notice('Type the task after the name: &name task (/peers lists the sessions).', 'warn'); return true; }
1368
- this.echoUser(text);
1369
- const found = resolvePeer(this.peers?.peers() || [], this.remotePeers, m[1], { ids: false });
1370
- if (found.ambiguous) { this.notice(found.error, 'warn'); return true; }
1371
- this.sendToPeer(found.peer || m[1], task).then(
1372
- ({ peer }) => {
1373
- const where = peer.remote ? `on ${peer.machine}: ${peer.cwd}` : displayPath(peer.cwd, this.cwd);
1374
- this.screen.log(c.dim(` ⎿ Sent to &${peer.label || peer.name} (${where}); its answer will be shown here`), '');
1375
- },
1376
- (e) => this.notice(e.message, 'warn'),
1377
- );
1378
- return true;
1379
- }
1380
-
1381
- shortcutsHelp() {
1382
- const rows = [
1383
- ['! for bash mode', 'double tap esc to clear input'],
1384
- ['/ for commands', 'shift + tab to cycle permission modes'],
1385
- ['@ for file paths, & for other sessions', 'ctrl + o to toggle verbose output'],
1386
- ['PgUp/PgDn, wheel to scroll', 'ctrl + home/end to the top/bottom'],
1387
- ['# to memorize', 'ctrl + t to toggle todos'],
1388
- ['\\⏎ or alt+⏎ for newline', 'ctrl + l to redraw, ctrl + d to exit'],
1389
- ['alt + v to paste an image', ''],
1390
- ];
1391
- return rows.map(([a, b]) => c.dim(' ' + padEnd(a, 30) + b));
1392
- }
1393
-
1394
- // ---------------------------------------------------------------- turns
1395
-
1396
- // Where a mentioned path is (null when there is no such file): relative to the project root, which is
1397
- // where the autocomplete lists files from, or else relative to where the shell is now (a `cd` in a
1398
- // Bash tool call moves that). Nothing at all is not a path: it would mean the directory itself.
1399
- resolveMention(ref) {
1400
- if (!ref.trim()) return null;
1401
- for (const base of [this.cwd, this.agent.cwd]) {
1402
- const file = resolvePath(ref, base);
1403
- if (fs.existsSync(file)) return file;
1404
- }
1405
- return null;
1406
- }
1407
-
1408
- // The file a mention `@...` stands for. A quoted name is exactly what is inside the quotes. A plain name
1409
- // that exists as written is that file; otherwise the punctuation prose puts behind a mention ("see
1410
- // @a.js, then", "(@a.js)") is not part of it.
1411
- mentionedFile(token) {
1412
- if (token.startsWith('"')) return this.resolveMention(token.slice(1, -1));
1413
- return this.resolveMention(token) || this.resolveMention(token.replace(/[,.;:!?)]+$/, ''));
1414
- }
1415
-
1416
- // Expand @file mentions into attached file contents, like Claude Code: what the model would have
1417
- // seen had it called Read itself. Going through the Read tool is what keeps this honest: binary files
1418
- // are refused, images become images, and the file is recorded as read, as only partly read when it
1419
- // was cut (so Write will not overwrite lines the model was never shown).
1420
- async attachments(text) {
1421
- const blocks = [];
1422
- const seen = new Set();
1423
- const read = this.runtime.tools.find((t) => t.name === 'Read');
1424
- const skipped = (file, why) => this.screen.log(c.dim(` ⎿ Skipped ${displayPath(file, this.cwd)}: ${why}`));
1425
- for (const m of text.matchAll(/(?:^|\s)@("[^"]+"|[^\s"]+)/g)) {
1426
- const file = this.mentionedFile(m[1]);
1427
- if (!file || seen.has(file)) continue; // @someone, @..., a file that is not there: not a mention
1428
- seen.add(file);
1429
- let st;
1430
- try { st = fs.statSync(file); } catch { continue; }
1431
- if (st.isDirectory()) {
1432
- let entries;
1433
- try {
1434
- const all = fs.readdirSync(file, { withFileTypes: true });
1435
- entries = all.slice(0, 200).map((e) => (e.isDirectory() ? e.name + '/' : e.name));
1436
- if (all.length > 200) entries.push(`(${all.length - 200} more entries not shown)`);
1437
- } catch (e) { skipped(file, e.message); continue; }
1438
- blocks.push({ type: 'text', text: `<system-reminder>Called the Bash tool with the following input: {"command":"ls ${file}"}\nResult:\n${entries.join('\n')}</system-reminder>` });
1439
- this.screen.log(c.dim(` ⎿ Listed directory ${displayPath(file, this.cwd)}/`));
1440
- continue;
1441
- }
1442
- if (!read || !st.isFile()) continue;
1443
- const isImage = /\.(png|jpe?g|gif|webp)$/i.test(file);
1444
- if (!isImage && st.size >= 512 * 1024) { skipped(file, `${Math.round(st.size / 1024)}KB is too large to attach; ask for the part you need`); continue; }
1445
- const input = { file_path: file, limit: 2000 };
1446
- let result;
1447
- try { result = await read.call(input, this.agent.makeCtx()); } catch (e) { skipped(file, e.message); continue; }
1448
- if (result.isError) { skipped(file, String(result.content).split('\n')[0]); continue; }
1449
- blocks.push(typeof result.content === 'string'
1450
- ? { type: 'text', text: `<system-reminder>Called the Read tool with the following input: ${JSON.stringify(input)}\nResult of calling the Read tool:\n${result.content}</system-reminder>` }
1451
- : { type: 'text', text: `<system-reminder>The user attached ${file}:</system-reminder>` });
1452
- if (typeof result.content !== 'string') blocks.push(...result.content);
1453
- this.screen.log(c.dim(` ⎿ ${result.display?.summary || 'Read'}: ${displayPath(file, this.cwd)}`));
1454
- }
1455
- return blocks;
1456
- }
1457
-
1458
- // `topic`: what to name the conversation's topic after (default: the message as shown).
1459
- // `peers`: tasks of other sessions that this turn answers.
1460
- async startTurn(prompt, { echo = true, display, images = [], topic, peers = [] } = {}) {
1461
- if (echo) this.echoUser(display ?? prompt, images.length);
1462
- this.refreshTopic(topic ?? display ?? prompt);
1463
- let content;
1464
- try {
1465
- const sources = images.map((i) => i.source).filter(Boolean);
1466
- content = [
1467
- { type: 'text', text: prompt },
1468
- ...images.map(imageBlock),
1469
- ...(sources.length ? [{ type: 'text', text: `(The attached image${sources.length === 1 ? ' is the file' : 's are the files'} ${sources.join(', ')})` }] : []),
1470
- ...(await this.attachments(prompt)),
1471
- ...(await this.peerMentions(prompt)),
1472
- ];
1473
- await fitImagesIn(content); // a dropped or attached image file at full size would be refused later
1474
- } catch (e) {
1475
- this.logError(e); // nobody awaits this method: an error left to escape would end the program
1476
- return;
1477
- }
1478
- await this.runAgent(content, { peers });
1479
- }
1480
-
1481
- // What `&name` in a message the user wrote stands for, told to the model: which session, and how to reach it.
1482
- async peerMentions(prompt) {
1483
- const refs = [...new Set([...String(prompt).matchAll(/(?:^|\s)[&&]([^\s&&,,。;;::!!??、))]+)/gu)].map((m) => m[1]))];
1484
- if (!refs.length) return [];
1485
- const local = this.peers?.peers() || [];
1486
- const notes = [];
1487
- for (const ref of refs) {
1488
- let found = resolvePeer(local, this.remotePeers, ref, { ids: false });
1489
- if (!found.peer && !found.ambiguous && this.remote) found = resolvePeer(local, await this.refreshRemotePeers(), ref, { ids: false });
1490
- const p = found.peer;
1491
- 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}`);
1492
- else if (found.ambiguous) notes.push(found.error);
1493
- }
1494
- if (!notes.length) return [];
1495
- 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>` }];
1496
- }
1497
-
1498
- // alt+v (ctrl+v where the terminal passes it through): attach an image from the clipboard.
1499
- async pasteImage() {
1500
- if (this.readingClipboard) return;
1501
- this.readingClipboard = true;
1502
- this.flash('Reading image from clipboard…');
1503
- try {
1504
- const r = await readClipboardImage();
1505
- if (r.image) {
1506
- const label = this.editor.insertImage(r.image);
1507
- const size = r.image.width ? `${r.image.width}×${r.image.height}, ` : '';
1508
- this.flash(`${label} attached (${size}${formatTokens(r.image.bytes).replace(/k$/, 'KB')})`);
1509
- } else {
1510
- this.flash(r.error || 'No image found in clipboard. Copy an image (e.g. Win+Shift+S) and press alt+v again.', 'warn');
1511
- }
1512
- } catch (e) {
1513
- this.flash(`Could not read the clipboard: ${e.message}`, 'warn');
1514
- } finally {
1515
- this.readingClipboard = false;
1516
- this.screen.schedule();
1517
- }
1518
- }
1519
-
1520
- // A short-lived message in the footer (not written to the transcript).
1521
- flash(text, kind = 'info') {
1522
- this.flashMessage = { text, kind, until: Date.now() + 4000 };
1523
- this.screen.schedule();
1524
- clearTimeout(this.flashTimer);
1525
- this.flashTimer = setTimeout(() => this.screen.schedule(), 4100);
1526
- }
1527
-
1528
- async runAgent(content, { peers = [] } = {}) {
1529
- this.busy = true;
1530
- this.turnPeers.push(...peers);
1531
- let result = null;
1532
- let error = null;
1533
- this.controller = new AbortController();
1534
- this.turnStart = Date.now();
1535
- this.turnTokens = 0;
1536
- this.verb = VERBS[Math.floor(Math.random() * VERBS.length)];
1537
- this.screen.schedule();
1538
- try {
1539
- result = await this.agent.run(content, { signal: this.controller.signal });
1540
- if (result.interrupted) {
1541
- this.flushStreaming();
1542
- this.flushActive(true);
1543
- this.screen.log(resultLines([c.red('Interrupted by user')]), '');
1544
- if (this.restartRequest) {
1545
- this.restartRequest = null;
1546
- this.notice('The restart was cancelled with the turn. Run /restart to load the new code.');
1547
- }
1548
- if (this.sourceChanged) this.skipAutoReload = true;
1549
- }
1550
- } catch (e) {
1551
- error = e;
1552
- this.flushStreaming();
1553
- this.logError(e);
1554
- } finally {
1555
- this.flushStreaming();
1556
- this.flushActive(true);
1557
- this.answerPeers(result, error);
1558
- this.busy = false;
1559
- this.controller = null;
1560
- this.status = null;
1561
- this.rateWait = null;
1562
- this.screen.schedule();
1563
- }
1564
- this.sendWhatWaited();
1565
- }
1566
-
1567
- // The app has stopped being busy (a turn, a compaction, a command): send what was typed in the
1568
- // meantime as the next turn, or else tell the model about background agents that finished after the
1569
- // last check. Everything that makes the app busy has to end with this, or what waited stays queued.
1570
- sendWhatWaited() {
1571
- if (this.busy || this.exiting) return;
1572
- // A restart that was asked for goes first; what waited is carried over and sent by the new process.
1573
- if (this.restartRequest) { this.restartNow(); return; }
1574
- if (this.queue.length) {
1575
- const next = this.queue.splice(0);
1576
- this.startTurn(next.map((q) => q.text).join('\n\n'), {
1577
- images: next.flatMap((q) => q.images),
1578
- display: next.map((q) => q.display ?? q.text).join('\n\n'),
1579
- peers: next.map((q) => q.peer).filter(Boolean),
1580
- });
1581
- } else if (this.agent.notifications.length) {
1582
- this.runAgent(this.agent.drainNotifications());
1583
- } else {
1584
- this.maybeAutoReload();
1585
- this.maybeInstallUpdate();
1586
- }
1587
- }
1588
-
1589
- // A background agent ended. If nothing is running, start a turn so the model sees its
1590
- // report right away; otherwise the running turn picks it up at its next step.
1591
- backgroundTaskDone(task) {
1592
- const secs = Math.round((task.ended - task.started) / 1000);
1593
- const color = task.status === 'completed' ? c.green : c.red;
1594
- this.screen.log(`${color(DOT)} ${c.bold('Background agent')} ${task.id} ${c.dim(`"${task.description}"`)} ${task.status} ${c.dim(`(${formatDuration(secs * 1000)})`)}`, '');
1595
- if (!this.busy && !this.exiting && this.agent.notifications.length) this.runAgent(this.agent.drainNotifications());
1596
- else this.screen.schedule();
1597
- }
1598
-
1599
- async runBashMode(command) {
1600
- if (!command) return;
1601
- this.screen.log(c.magenta('! ') + command);
1602
- const tool = this.runtime.tools.find((t) => t.name === 'Bash');
1603
- this.busy = true;
1604
- this.controller = new AbortController();
1605
- this.status = 'Running';
1606
- this.turnStart = Date.now();
1607
- this.turnTokens = 0;
1608
- const ctx = this.agent.makeCtx(this.controller.signal);
1609
- const entry = { tool, input: { command }, status: 'running', progress: '', sub: [], subCount: 0, ctx, started: Date.now() };
1610
- this.active.push(entry);
1611
- let result;
1612
- try {
1613
- ctx.onProgress = (p) => { entry.progress = p; };
1614
- result = await tool.call({ command }, ctx);
1615
- } catch {
1616
- const partial = (entry.progress || '').replace(/\r\n/g, '\n').trimEnd();
1617
- result = { isError: true, content: `${partial ? partial + '\n' : ''}Interrupted by user`, interrupted: true };
1618
- }
1619
- this.active = this.active.filter((e) => e !== entry);
1620
- const out = typeof result.content === 'string' ? result.content : '';
1621
- let lines = out.split('\n');
1622
- if (lines.length > 40) lines = [...lines.slice(0, 20), c.dim(`… +${lines.length - 40} lines`), ...lines.slice(-20)];
1623
- this.screen.log(resultLines(lines.map((l) => (result.isError && l === lines[lines.length - 1] ? c.red(l) : c.dim(l)))), '');
1624
- this.agent.pushMessage({
1625
- role: 'user',
1626
- content: [{ type: 'text', text: `<bash-input>${command}</bash-input>\n<bash-stdout>${out}</bash-stdout>\n<system-reminder>The user ran this command directly in bash mode. Take its output into account in later answers.</system-reminder>` }],
1627
- });
1628
- this.busy = false;
1629
- this.controller = null;
1630
- this.status = null;
1631
- this.screen.schedule();
1632
- this.sendWhatWaited();
1633
- }
1634
-
1635
- addMemory(text) {
1636
- if (!text) return;
1637
- const files = {
1638
- project: path.join(this.cwd, 'CLAUDE.md'),
1639
- local: path.join(this.cwd, 'CLAUDE.local.md'),
1640
- user: path.join(CONFIG_DIR, 'CLAUDE.md'),
1641
- };
1642
- this.showDialog(new SelectDialog({
1643
- title: 'Save memory',
1644
- body: [c.dim(truncate(text, this.width - 10))],
1645
- question: 'Where should this memory be saved?',
1646
- color: c.blue,
1647
- options: [
1648
- { label: `Project memory ${c.dim(displayPath(files.project, this.cwd))}`, value: 'project', description: 'Checked in and shared with your team' },
1649
- { label: `Project memory (local) ${c.dim(displayPath(files.local, this.cwd))}`, value: 'local', description: 'Just for you in this project' },
1650
- { label: `User memory ${c.dim(displayPath(files.user, this.cwd))}`, value: 'user', description: 'Your instructions for all projects' },
1651
- ],
1652
- })).then((choice) => {
1653
- if (!choice) return;
1654
- const file = files[choice];
1655
- // This runs outside any handler: an error here (a read-only directory) would end the program.
1656
- try {
1657
- fs.mkdirSync(path.dirname(file), { recursive: true });
1658
- let existing = '';
1659
- try { existing = fs.readFileSync(file, 'utf8'); } catch { /* new */ }
1660
- fs.appendFileSync(file, `${existing && !existing.endsWith('\n') ? '\n' : ''}- ${text}\n`);
1661
- // The system prompt stays as it is until the conversation starts over (/clear or a compaction): a prompt
1662
- // that changes in the middle of it cannot be read from the prompt cache.
1663
- this.runtime.deferSystemPromptRefresh();
1664
- this.screen.log(resultLines([c.dim(`Saved to ${displayPath(file, this.cwd)} · the model sees it from the next /clear or /compact`)]));
1665
- } catch (e) {
1666
- this.notice(`Could not save the memory to ${displayPath(file, this.cwd)}: ${e.message}`, 'error');
1667
- }
1668
- });
1669
- }
1670
-
1671
- // `quiet`: do not print the transcript (a restart: it is still on screen).
1672
- loadSession(file, id, { quiet = false } = {}) {
1673
- const loaded = SessionStore.load(file);
1674
- this.previousSession = null; // the conversation before a /clear: resuming another one ends the offer
1675
- this.store = new SessionStore(this.cwd, id);
1676
- this.topic = loaded.topic;
1677
- this.customTitle = loaded.name;
1678
- // a conversation that was given a name brings it back: the other sessions reach this one by it again
1679
- if (loaded.name && loaded.name !== this.peerLabel()) {
1680
- if (this.peers) this.peers.setName(loaded.name);
1681
- else this.sessionName = loaded.name;
1682
- this.remote?.register().catch(() => {});
1683
- }
1684
- this.firstPrompt = loaded.title;
1685
- this.cost = loaded.cost;
1686
- this.agent.messages = loaded.messages;
1687
- this.agent.restoreFileState(loaded.messages, loaded.times);
1688
- this.agent.lastActivityAt = loaded.lastAt; // a long pause before this turn clears old tool results, as it would have
1689
- this.agent.lastContextTokens = estimateTokens(loaded.messages);
1690
- // Continue behind the system prompt the conversation had (and its cache), not a freshly built one.
1691
- this.store.systemPrompt = loaded.systemPrompt;
1692
- this.runtime.resumeSystemPrompt(loaded.systemPrompt);
1693
- if (!this.topic) this.refreshTopic(this.latestPrompt()); // a session from before topics were kept
1694
- this.remote?.resync();
1695
- if (quiet) return;
1696
- this.logLocal(c.dim(`Resumed session ${id} (${loaded.messages.length} messages)`), '');
1697
- this.remoteMuted++;
1698
- try { this.printTranscript(loaded.messages.slice(-30)); } finally { this.remoteMuted--; }
1699
- }
1700
-
1701
- printTranscript(messages) {
1702
- const toolMap = new Map(this.runtime.tools.map((t) => [t.name, t]));
1703
- const ctx = this.agent.makeCtx();
1704
- for (const m of messages) {
1705
- const blocks = typeof m.content === 'string' ? [{ type: 'text', text: m.content }] : m.content;
1706
- for (const b of blocks) {
1707
- if (m.role === 'user' && b.type === 'text' && !b.text.startsWith('<system-reminder>')) this.screen.log(renderUserMessage(b.text.slice(0, 2000), this.width));
1708
- if (m.role === 'assistant' && b.type === 'text') this.screen.log(renderMarkdown(b.text, { width: this.width - 1, firstPrefix: `${DOT} `, indent: ' ' }), '');
1709
- if (m.role === 'assistant' && b.type === 'tool_use') {
1710
- const tool = toolMap.get(b.name) || { name: b.name };
1711
- this.screen.log(toolHeader(tool, b.input, ctx, { width: this.width }));
1712
- }
1713
- }
1714
- }
1715
- this.screen.log('');
1716
- }
1717
-
1718
- newSession() {
1719
- this.store = new SessionStore(this.cwd);
1720
- this.topic = null;
1721
- this.customTitle = null;
1722
- this.firstPrompt = null;
1723
- this.cost = 0;
1724
- this.remote?.resync();
1725
- }
1726
-
1727
- // ---------------------------------------------------------------- dialogs
1728
-
1729
- showDialog(dialog) {
1730
- return new Promise((resolve) => {
1731
- const prev = dialog.onResolve;
1732
- dialog.onResolve = (v) => {
1733
- this.dialogs = this.dialogs.filter((d) => d !== dialog);
1734
- prev?.(v);
1735
- resolve(v);
1736
- this.screen.schedule();
1737
- };
1738
- this.dialogs.push(dialog);
1739
- this.screen.schedule();
1740
- });
1741
- }
1742
-
1743
- async approvePlan(plan) {
1744
- this.flushStreaming();
1745
- const md = renderMarkdown(plan, { width: this.width - 8 });
1746
- const max = Math.max(8, this.screen.height - 14);
1747
- const body = md.length > max ? [...md.slice(0, max), c.dim(`… +${md.length - max} lines (the full plan is printed above)`)] : md;
1748
- if (md.length > max) this.logLocal(c.plan(`${DOT} Plan:`), ...md.map((l) => ' ' + l), '');
1749
- const dialog = new SelectDialog({
1750
- title: 'Ready to code?',
1751
- body: ["Here is the plan:", '', ...body],
1752
- question: 'Would you like to proceed?',
1753
- color: c.plan,
1754
- cancelValue: 'reject',
1755
- options: [
1756
- { label: 'Yes, start coding', value: 'full' },
1757
- { label: 'No, keep planning', value: 'reject' },
1758
- ],
1759
- });
1760
- dialog.markdown = plan; // the remote page shows the whole plan
1761
- return await this.showDialog(dialog);
1762
- }
1763
-
1764
- async askQuestion(q) {
1765
- const answer = await this.showDialog(new SelectDialog({
1766
- title: q.header || 'Question',
1767
- question: c.bold(q.question),
1768
- color: c.blue,
1769
- multi: !!q.multiSelect,
1770
- otherOption: true,
1771
- options: (q.options || []).map((o) => ({ label: o.label, value: o.label, description: o.description })),
1772
- }));
1773
- if (answer === null || answer === undefined) return null;
1774
- return Array.isArray(answer) ? answer.join(', ') : answer;
1775
- }
1776
-
1777
- // ---------------------------------------------------------------- agent IO
1778
-
1779
- isMain(agent) { return !agent || agent === this.runtime?.agent; }
1780
-
1781
- streamEvent(ev, agent) {
1782
- if (!this.isMain(agent)) return;
1783
- const w = this.width - 1;
1784
- switch (ev.type) {
1785
- case 'block_start':
1786
- if (ev.block.type === 'text') {
1787
- this.flushThinking();
1788
- this.md = new MarkdownStream({ width: w, firstPrefix: `${DOT} `, indent: ' ' });
1789
- this.remote?.emit({ t: 'start', kind: 'text' });
1790
- } else if (ev.block.type === 'thinking') {
1791
- this.remote?.emit({ t: 'start', kind: 'thinking' });
1792
- 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 };
1793
- } else if (ev.block.type === 'tool_use') {
1794
- this.flushThinking();
1795
- this.pendingToolBlocks.set(ev.index, { name: ev.block.name, partial: '' });
1796
- }
1797
- break;
1798
- case 'text':
1799
- this.turnTokens += estimateTokens(ev.text);
1800
- if (this.md) {
1801
- const lines = this.md.write(ev.text);
1802
- if (lines.length) this.logLocal(lines);
1803
- this.remote?.emit({ t: 'delta', kind: 'text', text: ev.text });
1804
- }
1805
- break;
1806
- case 'thinking':
1807
- this.turnTokens += estimateTokens(ev.text);
1808
- if (this.thinking) {
1809
- this.thinking.text += ev.text;
1810
- this.remote?.emit({ t: 'delta', kind: 'thinking', text: ev.text });
1811
- if (this.thinking.md) {
1812
- const lines = this.thinking.md.write(ev.text);
1813
- if (lines.length) this.logLocal(lines);
1814
- }
1815
- }
1816
- break;
1817
- case 'tool_input': {
1818
- this.turnTokens += 1;
1819
- const p = this.pendingToolBlocks.get(ev.index);
1820
- if (p) p.partial = ev.partial;
1821
- break;
1822
- }
1823
- case 'block_stop':
1824
- if (ev.block.type === 'text' && this.md) {
1825
- const lines = this.md.end();
1826
- if (lines.length) this.logLocal(lines);
1827
- this.logLocal('');
1828
- this.md = null;
1829
- this.remote?.emit({ t: 'stop' });
1830
- } else if (ev.block.type === 'thinking') {
1831
- this.flushThinking();
1832
- } else if (ev.block.type === 'tool_use') {
1833
- this.pendingToolBlocks.delete(ev.index);
1834
- }
1835
- break;
1836
- case 'retry':
1837
- this.notice(`API error: ${truncate(ev.error.message, 200)} · retrying in ${Math.round(ev.delayMs / 1000)}s (attempt ${ev.attempt}${ev.maxRetries ? `/${ev.maxRetries}` : ''})`, 'warn');
1838
- break;
1839
- case 'rate_limit_wait':
1840
- case 'rate_limit_resume':
1841
- this.rateLimitEvent(ev);
1842
- break;
1843
- case 'reset':
1844
- if (this.md || this.thinking) this.remote?.emit({ t: 'stop' });
1845
- this.md = null;
1846
- this.thinking = null;
1847
- this.pendingToolBlocks.clear();
1848
- break;
1849
- default:
1850
- break;
1851
- }
1852
- this.screen.schedule();
1853
- }
1854
-
1855
- // A rate limit pause or its end, from a model request of the main agent (a turn or a compaction).
1856
- rateLimitEvent(ev) {
1857
- if (ev.type === 'rate_limit_resume') {
1858
- this.rateWait = null;
1859
- this.notice(resumeText(ev));
1860
- return;
1861
- }
1862
- // Say it once; later waits of the same pause only move the countdown (unless the reset moved a lot).
1863
- const prev = this.rateWait;
1864
- this.rateWait = { resumeAt: ev.resumeAt, label: ev.label, polling: ev.polling, waitingForLogin: Boolean(ev.waitingForLogin), overloaded: Boolean(ev.overloaded) };
1865
- if (shouldAnnounce(prev, ev)) this.notice(`${describePause(ev)} Esc cancels.`, 'warn');
1866
- this.screen.schedule();
1867
- }
1868
-
1869
- flushThinking() {
1870
- const t = this.thinking;
1871
- if (!t) return;
1872
- this.thinking = null;
1873
- this.remote?.emit({ t: 'stop' });
1874
- if (t.md) {
1875
- const lines = t.md.end();
1876
- if (lines.length) this.logLocal(lines);
1877
- this.logLocal('');
1878
- } else if (t.text.trim()) {
1879
- const secs = Math.max(1, Math.round((Date.now() - t.start) / 1000));
1880
- this.logLocal(c.dim(c.italic(`✻ Thought for ${secs}s (ctrl+o to show thinking)`)), '');
1881
- }
1882
- }
1883
-
1884
- flushStreaming() {
1885
- this.flushThinking();
1886
- if (this.md) {
1887
- const lines = this.md.end();
1888
- if (lines.length) this.logLocal(lines, '');
1889
- this.md = null;
1890
- this.remote?.emit({ t: 'stop' });
1891
- }
1892
- this.pendingToolBlocks.clear();
1893
- }
1894
-
1895
- toolBatchStart() {
1896
- this.flushStreaming();
1897
- }
1898
-
1899
- toolStart(tu, tool, input, agent) {
1900
- if (!this.isMain(agent)) return {};
1901
- const entry = { tu, tool, input, status: 'running', progress: '', sub: [], subCount: 0, ctx: agent.makeCtx(), started: Date.now() };
1902
- this.active.push(entry);
1903
- this.remote?.emit({ t: 'tool', id: tu.id, head: toolHeader(tool, input, entry.ctx, { width: REMOTE_WIDTH, dot: false }) });
1904
- this.screen.schedule();
1905
- return {
1906
- progress: (p) => {
1907
- if (typeof p === 'string') { entry.progress = p; this.remote?.toolProgress(tu.id, entry); }
1908
- else if (p?.type === 'subtool') { entry.sub.push(p.header); entry.subCount++; this.remote?.emit({ t: 'sub', id: tu.id, head: p.header }); }
1909
- this.screen.schedule();
1910
- },
1911
- done: (result) => {
1912
- entry.result = result;
1913
- entry.status = result.isError ? 'error' : 'done';
1914
- this.remoteToolDone(entry);
1915
- this.flushActive();
1916
- },
1917
- };
1918
- }
1919
-
1920
- toolRejected(tu, tool, message, agent) {
1921
- if (!this.isMain(agent)) return;
1922
- const ctx = agent.makeCtx();
1923
- const entry = { tu, tool, input: tu.input, status: 'rejected', rejected: true, rejectMessage: message, ctx };
1924
- if (this.remote) {
1925
- this.remote.emit({ t: 'tool', id: tu.id, head: toolHeader(tool, tu.input, ctx, { width: REMOTE_WIDTH, dot: false }) });
1926
- this.remoteToolDone(entry);
1927
- }
1928
- this.logLocal(toolHeader(tool, tu.input, ctx, { status: 'rejected', width: this.width }), renderToolResult(entry, { width: this.width, cwd: this.cwd }), '');
1929
- }
1930
-
1931
- // A tool call finished (or was refused, or interrupted): its result on the remote page, once.
1932
- remoteToolDone(entry) {
1933
- if (!this.remote || !entry.tu?.id || entry.remoteDone) return;
1934
- entry.remoteDone = true;
1935
- let lines = [];
1936
- try { lines = renderToolResult(entry, { width: REMOTE_WIDTH, cwd: this.cwd }); } catch { /* shown without */ }
1937
- this.remote.emit({ t: 'done', id: entry.tu.id, status: entry.result?.isError || entry.rejected ? 'error' : 'done', lines });
1938
- }
1939
-
1940
- // Move finished tool entries (in call order) from the live region to the transcript.
1941
- flushActive(all = false) {
1942
- while (this.active.length && (all || this.active[0].status !== 'running')) {
1943
- const e = this.active.shift();
1944
- if (e.status === 'running') { e.status = 'error'; e.result = { isError: true, content: 'Interrupted', interrupted: true }; }
1945
- const status = e.result?.isError ? 'error' : 'done';
1946
- this.remoteToolDone(e);
1947
- this.logLocal(toolHeader(e.tool, e.input, e.ctx, { status, width: this.width }), renderToolResult(e, { verbose: this.verbose, width: this.width, cwd: this.cwd }), '');
1948
- }
1949
- this.screen.schedule();
1950
- }
1951
-
1952
- // IO object for a sub-agent: reports its tool calls into the parent's Task entry.
1953
- subagentIo({ def, parentCtx }) {
1954
- const app = this;
1955
- let paused = null; // the rate limit wait last announced for this agent
1956
- // The spinner belongs to the main agent (a background agent can be paused while it keeps working),
1957
- // so a sub-agent's pause is only announced, once, and its end too. This covers the agent's turns
1958
- // and the compaction of its own conversation alike.
1959
- const rateLimit = (ev) => {
1960
- if (ev.type === 'rate_limit_wait') {
1961
- if (shouldAnnounce(paused, ev)) app.notice(`[${def.name}] ${describePause(ev)}`, 'warn');
1962
- paused = { resumeAt: ev.resumeAt, waitingForLogin: Boolean(ev.waitingForLogin) };
1963
- }
1964
- if (ev.type === 'rate_limit_resume') {
1965
- paused = null;
1966
- app.notice(`[${def.name}] ${resumeText(ev)}`);
1967
- }
1968
- };
1969
- return {
1970
- approvePlan: async () => 'reject',
1971
- askQuestion: async () => null,
1972
- notice: (text, kind) => app.notice(`[${def.name}] ${text}`, kind),
1973
- toolStart(tu, tool, input, agent) {
1974
- parentCtx.onProgress?.({ type: 'subtool', header: toolHeader(tool, input, agent.makeCtx(), { status: 'done', width: app.width - 8, dot: false }) });
1975
- return {};
1976
- },
1977
- streamEvent(ev) {
1978
- if (ev.type === 'text' || ev.type === 'thinking') app.turnTokens += estimateTokens(ev.text);
1979
- if (ev.type === 'retry') app.notice(`[${def.name}] API error, retrying: ${truncate(ev.error.message, 120)}`, 'warn');
1980
- rateLimit(ev);
1981
- },
1982
- compactProgress: rateLimit,
1983
- takeQueued: () => [],
1984
- };
1985
- }
1986
-
1987
- takeQueued() {
1988
- if (!this.queue.length) return [];
1989
- const items = this.queue.splice(0);
1990
- for (const q of items) this.echoUser(q.display ?? q.text, q.images?.length);
1991
- this.turnPeers.push(...items.map((q) => q.peer).filter(Boolean));
1992
- this.refreshTopic(items.map((q) => q.display ?? q.text).join('\n\n'));
1993
- // Content blocks for the agent: each queued message's text followed by its images.
1994
- return items.flatMap((q) => [{ type: 'text', text: q.text }, ...q.images.map(imageBlock)]);
1995
- }
1996
-
1997
- messageAdded(msg, agent) { if (this.isMain(agent)) this.store.message(msg); }
1998
- systemPrompt(text, agent) { if (this.isMain(agent)) this.store.setSystemPrompt(text); }
1999
- messagesReplaced(msgs, agent) { if (this.isMain(agent)) this.store.reset(msgs); }
2000
- usage() { this.screen.schedule(); }
2001
-
2002
- compactStart(auto, agent) {
2003
- if (!this.isMain(agent)) return;
2004
- this.flushStreaming();
2005
- this.status = auto ? 'Auto-compacting conversation' : 'Compacting conversation';
2006
- this.compactTokens = 0;
2007
- this.screen.schedule();
2008
- }
2009
-
2010
- compactProgress(ev) {
2011
- if (ev.type === 'text' || ev.type === 'thinking') { this.turnTokens += estimateTokens(ev.text); this.screen.schedule(); }
2012
- // A compaction can hit the limit too: say so instead of sitting on "Compacting conversation" for hours.
2013
- else if (ev.type === 'rate_limit_wait' || ev.type === 'rate_limit_resume') this.rateLimitEvent(ev);
2014
- }
2015
-
2016
- compactEnd(summary, agent, err, { part } = {}) {
2017
- if (!this.isMain(agent)) return;
2018
- this.status = null;
2019
- if (err) return;
2020
- this.lastSummary = summary;
2021
- const what = part === 'before' ? 'The earlier part of the conversation was summarized' : part === 'after' ? 'The conversation from that message on was summarized' : 'Conversation compacted';
2022
- this.screen.log(c.dim(`✻ ${what} (the summary is in context; /context to see usage)`), '');
2023
- }
2024
-
2025
- // esc esc on an empty prompt: the rewind menu, as in Claude Code (/rewind). Not while a turn runs (esc stops it).
2026
- openRewind() {
2027
- if (this.busy || this.dialogs.length || this.exiting) return;
2028
- 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; }
2029
- Promise.resolve(this.commands.get('rewind').run('')).catch((e) => this.logError(e)).finally(() => this.screen.schedule());
2030
- }
2031
-
2032
- notice(text, kind = 'info') {
2033
- const color = kind === 'warn' ? c.yellow : kind === 'error' ? c.red : c.dim;
2034
- // Long notices (the rate limit ones are) break between words, not wherever the terminal width falls.
2035
- // A notice of several lines (a hook's warning, say) keeps its lines, each one indented and wrapped.
2036
- const width = Math.max(20, this.width - 4);
2037
- this.screen.log(String(text).split('\n').flatMap((line) => wordWrap(line, width)).map((line) => color(` ${line.trimEnd()}`)));
2038
- }
2039
-
2040
- logError(e) {
2041
- const msg = e?.message || String(e);
2042
- this.screen.log(c.red(`${DOT} API Error: ${msg}`), '');
2043
- if (process.env.CLAUDE_PRO_DEBUG && e?.stack) this.screen.log(c.dim(e.stack));
2044
- }
2045
-
2046
- // ---------------------------------------------------------------- live region
2047
-
2048
- spinnerLine() {
2049
- const frame = SPINNER[Math.floor(Date.now() / 120) % SPINNER.length];
2050
- if (this.rateWait) {
2051
- const w = this.rateWait;
2052
- const left = humanDuration(w.resumeAt - Date.now()); // the same wording as the notice above it
2053
- const esc = c.dim(`(${this.controller?.signal.aborted ? 'cancelling…' : 'esc to cancel'})`);
2054
- if (w.waitingForLogin) return c.yellow(`${frame} Waiting for the Claude Code login to be renewed · checking again in ${left}`) + ' ' + esc;
2055
- const label = w.label.charAt(0).toUpperCase() + w.label.slice(1);
2056
- const when = w.polling ? `checking again in ${left}` : `resumes at ${formatClock(w.resumeAt)} (in ${left})`;
2057
- return c.yellow(`${frame} ${w.overloaded ? label : `${label} reached`} · ${when}`) + ' ' + esc;
2058
- }
2059
- const secs = formatDuration(Date.now() - this.turnStart);
2060
- const verb = this.status || (this.thinking ? 'Thinking' : this.verb);
2061
- const aborted = this.controller?.signal.aborted;
2062
- const parts = [secs];
2063
- if (this.turnTokens) parts.push(`↓ ${formatTokens(this.turnTokens)} tokens`);
2064
- parts.push(aborted ? 'interrupting…' : 'esc to interrupt');
2065
- return c.claude(`${frame} ${verb}…`) + ' ' + c.dim(`(${parts.join(' · ')})`);
2066
- }
2067
-
2068
- renderActiveEntry(e, w) {
2069
- const lines = [toolHeader(e.tool, e.input, e.ctx, { status: 'running', width: w })];
2070
- if (e.tool.name === 'Task' || e.sub.length) {
2071
- const shown = e.sub.slice(-3);
2072
- const more = e.subCount - shown.length;
2073
- const sub = shown.map((h) => c.dim(truncate(h, w - 8)));
2074
- if (more > 0) sub.push(c.dim(`+${more} more tool use${more === 1 ? '' : 's'}`));
2075
- if (!sub.length) sub.push(c.dim('Initializing…'));
2076
- lines.push(...resultLines(sub));
2077
- } else if (e.progress) {
2078
- const out = e.progress.replace(/\r\n/g, '\n').trimEnd().split('\n');
2079
- const tail = out.slice(-5).map((l) => c.dim(truncate(l.replace(/\r/g, ''), w - 8)));
2080
- if (out.length > 5) tail.unshift(c.dim(`… +${out.length - 5} lines`));
2081
- lines.push(...resultLines(tail));
2082
- } else {
2083
- const secs = Math.floor((Date.now() - e.started) / 1000);
2084
- if (secs >= 2) lines.push(...resultLines([c.dim(`Running… (${secs}s)`)]));
2085
- }
2086
- return lines;
2087
- }
2088
-
2089
- renderLive(width) {
2090
- const w = width - 1;
2091
- const lines = [];
2092
- let cursor = null;
2093
- if (this.busy) {
2094
- // In-progress assistant text.
2095
- if (this.md) {
2096
- const partial = this.md.partial();
2097
- lines.push(...partial.slice(-Math.floor(this.screen.height / 2)));
2098
- }
2099
- if (this.thinking?.md) lines.push(...this.thinking.md.partial().slice(-3));
2100
- else if (this.thinking && this.thinking.text) {
2101
- const last = this.thinking.text.trimEnd().split('\n').pop() || '';
2102
- lines.push(c.dim(c.italic(truncate(`✻ ${last}`, w))));
2103
- }
2104
- for (const [, p] of this.pendingToolBlocks) {
2105
- const m = /"(?:file_path|command|pattern|url|description|query)"\s*:\s*"((?:\\.|[^"\\])*)/.exec(p.partial);
2106
- const tool = this.runtime.tools.find((t) => t.name === p.name) || { name: p.name };
2107
- const arg = m ? m[1].replace(/\\n/g, ' ').replace(/\\(.)/g, '$1') : '';
2108
- lines.push(`${c.gray(DOT)} ${c.bold(toolLabel(tool, {}))}${arg ? `(${truncate(arg, w - 20)})` : ''} ${c.dim(p.partial.length > 200 ? `(${formatTokens(Math.round(p.partial.length / 4))} tokens…)` : '…')}`);
2109
- }
2110
- for (const e of this.active) lines.push(...this.renderActiveEntry(e, w));
2111
- if (!this.dialogs.length) {
2112
- if (lines.length) lines.push('');
2113
- lines.push(this.spinnerLine());
2114
- const todos = this.agent.todos;
2115
- if (this.showTodos && todos.length) lines.push(...renderTodos(todos).map((l) => truncate(l, w)));
2116
- }
2117
- } else if (this.active.length) {
2118
- for (const e of this.active) lines.push(...this.renderActiveEntry(e, w));
2119
- }
2120
- if (this.dialogs.length) {
2121
- if (lines.length) lines.push('');
2122
- const r = this.dialogs[0].render(width, this.screen.height - lines.length - 1);
2123
- const top = lines.length;
2124
- lines.push(...r.lines);
2125
- if (r.cursor) cursor = { row: top + r.cursor.row, col: r.cursor.col };
2126
- return { lines, cursor };
2127
- }
2128
- if (this.queue.length) {
2129
- for (const q of this.queue) lines.push(c.dim(' ⎿ queued: ') + c.gray(truncate((q.display ?? q.text).replace(/\s+/g, ' '), w - 14)));
2130
- }
2131
- if (lines.length) lines.push('');
2132
- const placeholder = this.busy ? 'Type a message to queue it for the agent…' : (this.agent.messages.length ? '' : 'Try "explain this codebase" or "fix the failing test"');
2133
- const r = this.editor.render(width, { placeholder, title: this.boxTitle() });
2134
- const top = lines.length;
2135
- lines.push(...r.lines);
2136
- if (r.cursor) cursor = { row: top + r.cursor.row, col: r.cursor.col };
2137
- if (this.editor.suggestions) lines.push(...this.editor.renderSuggestions(w));
2138
- else if (this.editor.text === '?') lines.push(...this.shortcutsHelp());
2139
- else lines.push(this.footer(w));
2140
- return { lines, cursor };
2141
- }
2142
-
2143
- // In the input box's top border: the directory the agent is in now (a `cd` moves it), and this
2144
- // session's name for the other sessions.
2145
- boxTitle() {
2146
- const dir = this.agent?.cwd || this.cwd;
2147
- const home = os.homedir();
2148
- const shown = dir.toLowerCase().startsWith(home.toLowerCase()) ? '~' + dir.slice(home.length) : dir;
2149
- const name = this.peers || this.remote ? `&${this.peerLabel()}` : '';
2150
- return { left: shown.replace(/\\/g, '/'), right: name && this.remote?.online ? `⇄ ${name}` : name };
2151
- }
2152
-
2153
- footer(w) {
2154
- const mode = this.runtime.modes.mode;
2155
- let left;
2156
- const flash = this.flashMessage && Date.now() < this.flashMessage.until ? this.flashMessage : null;
2157
- if (this.exitArmed && Date.now() - this.exitArmed < 1500) left = c.dim(' Press Ctrl-C again to exit');
2158
- else if (flash) left = (flash.kind === 'warn' ? c.yellow : c.dim)(' ' + truncate(flash.text, w - 4));
2159
- else if (this.restartRequest) left = c.dim(' ↻ restarting when this turn ends');
2160
- else if (mode === 'plan') left = ' ' + c.plan('⏸ plan mode on (read-only)') + c.dim(' (shift+tab to leave)');
2161
- else if (this.editor.mode === 'bash') left = c.magenta(' ! for bash mode');
2162
- else if (this.editor.mode === 'memory') left = c.blue(' # to memorize');
2163
- else left = c.dim(' ? for shortcuts');
2164
- if (this.remote?.online && this.remote.viewers && !flash && !(this.exitArmed && Date.now() - this.exitArmed < 1500)) {
2165
- left += c.dim(' · ') + c.green(`⇄ ${this.remote.viewers} watching`);
2166
- }
2167
- if (this.transfers.size && !flash) left += c.dim(' · ') + this.transferStatus();
2168
- if (!flash) {
2169
- if (this.updating) left += c.dim(' · ') + c.blue(`⇣ updating ${APP_NAME} to ${this.updating}…`);
2170
- else if (this.updateRestart && this.sourceChanged && !this.restartRequest) left += c.dim(' · ') + c.blue(`↻ ${this.updateRestart} installed: restarts ${this.working() ? 'when the work is done' : 'now'}`);
2171
- else if (this.pendingUpdate) left += c.dim(' · ') + c.dim(`update ${this.pendingUpdate} ready · ${this.updateWaitingFor ? `installs when &${this.updateWaitingFor.split(',')[0]} is idle` : 'installs when idle'}`);
2172
- }
2173
- const cfg = this.config;
2174
- const threshold = cfg.autoCompactThreshold(cfg.mainModel);
2175
- const used = this.agent.contextEstimate;
2176
- let notice = '';
2177
- const pctLeft = Math.max(0, Math.round((1 - used / threshold) * 100));
2178
- if (pctLeft <= 30 && cfg.autoCompactEnabled) notice = c.yellow(`Context left until auto-compact: ${pctLeft}%`);
2179
- else if (this.runtime.tasks.running().length) notice = c.dim(`${this.runtime.tasks.running().length} background agent${this.runtime.tasks.running().length === 1 ? '' : 's'} running · /tasks`);
2180
- else if (this.verbose) notice = c.dim('verbose');
2181
- // The model and effort in use always have a place at the right: with the notice after it when both fit,
2182
- // and the notice alone when they do not (a context warning matters more than the model's name).
2183
- const { model, effort } = modelInfo(cfg);
2184
- // An effort that is not set is not sent: the API then uses the model's default, which is shown instead.
2185
- const level = effort || defaultEffort(cfg.mainModel.id, cfg);
2186
- const info = c.dim(level ? `${model} · effort ${level}` : model);
2187
- // What the conversation has cost so far goes before the model, and is the first to go when room is short.
2188
- const spent = this.cost > 0 ? c.dim(formatUsd(this.cost)) : '';
2189
- const join = (...parts) => parts.filter(Boolean).join(c.dim(' · '));
2190
- const room = w - stringWidth(left) - 1;
2191
- const right = [join(notice, spent, info), join(notice, info), notice, info].find((r) => r && stringWidth(r) < room) || '';
2192
- const gap = room - stringWidth(right);
2193
- return right ? left + ' '.repeat(gap) + right : left;
2194
- }
2195
- }
2196
-
2197
- export async function runRepl({ flags, cwd, initialPrompt }) {
2198
- const app = new App({ flags, cwd });
2199
- process.on('uncaughtException', (e) => {
2200
- app.restoreTerminal();
2201
- process.stderr.write(`\nUnexpected error: ${e?.stack || e}\n`);
2202
- process.exit(1);
2203
- });
2204
- return await app.start(initialPrompt);
2205
- }
1
+ // Interactive REPL: owns the terminal, renders the transcript and live region, and
2
+ // implements the agent's IO callbacks (streaming, tool display, permission prompts).
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+ import readline from 'node:readline';
8
+ import { spawn } from 'node:child_process';
9
+ import { c, ESC, stringWidth, truncate, padEnd, wordWrap, colorEnabled } from './ansi.js';
10
+ import { Screen, queryCursorRow, mouseFilter } from './screen.js';
11
+ import { Editor, topBorder } from './editor.js';
12
+ import { SelectDialog } from './dialogs.js';
13
+ import { MarkdownStream, renderMarkdown } from './markdown.js';
14
+ import {
15
+ DOT, toolHeader, renderToolResult, renderTodos, renderUserMessage, resultLines, formatTokens, formatDuration, toolLabel,
16
+ } from './render.js';
17
+ import { createRuntime } from '../runtime.js';
18
+ import { SessionStore, firstUserText } from '../session.js';
19
+ import { defaultEffort, effortSupported, effortToSend, quickQuery } from '../api.js';
20
+ import { CONFIG_DIR, VERSION, APP_NAME, INSTALL_DIR } from '../config.js';
21
+ import { readGatewayLogin, formatUsd } from '../gateway.js';
22
+ import { TOPIC_TITLE_SYSTEM, topicTitlePrompt } from '../prompts.js';
23
+ import { buildCommands } from '../commands.js';
24
+ import { canRestart, writeRestartState, runSelfCheck, watchSource, restartNote, RESTART_EXIT_CODE, MAX_RESTARTS, RESTART_WINDOW_MS } from '../restart.js';
25
+ import { resolvePath, displayPath } from '../utils/paths.js';
26
+ import { ripgrepPath } from '../utils/shell.js';
27
+ import { describePause, formatClock, humanDuration, resumeText, shouldAnnounce } from '../ratelimit.js';
28
+ import { shutdownBackgroundShells, listBackgroundShells } from '../tools/bash.js';
29
+ import { estimateTokens } from '../agent.js';
30
+ import { readClipboardImage, imageFromFile, watchClipboard, writeClipboard, readClipboardText } 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';
34
+ import { checkForUpdate, installUpdate, installedVersion, installedByNpm, isNewer, CHECK_EVERY_MS } from '../updater.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';
38
+
39
+ // A model id as people say it: claude-sonnet-5-5 -> sonnet-5-5, claude-haiku-4-5-20251001 -> haiku-4-5.
40
+ export const modelTag = (model) => String(model?.id ?? '').replace(/^claude-/, '').replace(/-\d{8}$/, '');
41
+
42
+ // The model and effort level that requests use now, for the footer and the terminal title. The effort
43
+ // is left out when none is sent (auto) and for a model that does not take one.
44
+ export function modelInfo(config) {
45
+ const model = config.mainModel;
46
+ // What the requests carry: the nearest level the model takes (none goes out as low on gpt-6.1-sol, ultra as max on Claude).
47
+ const sent = effortToSend(config, model.id, config.effort);
48
+ const effort = sent && effortSupported(model.id) ? sent : null;
49
+ return { model: modelTag(model), effort };
50
+ }
51
+
52
+ // The small model's answer about the topic (see topicTitlePrompt) as a clean title, or null when it is not
53
+ // a new topic or the answer cannot be used. `required`: there is no title yet, so any title will do.
54
+ export function parseTopicTitle(answer, { required = false } = {}) {
55
+ const json = /\{[\s\S]*\}/.exec(String(answer ?? ''))?.[0];
56
+ if (!json) return null;
57
+ let data;
58
+ try { data = JSON.parse(json); } catch { return null; }
59
+ if ((!required && data?.isNewTopic !== true) || typeof data?.title !== 'string') return null;
60
+ const title = data.title
61
+ .replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ')
62
+ .replace(/\s+/g, ' ')
63
+ .replace(/^["'`“”‘’「」『』《》\s]+|["'`“”‘’「」『』《》\s.。!!??,,::;;]+$/g, '')
64
+ .trim();
65
+ return title ? truncate(title, 40).replace(/\x1b\[0m$/, '') : null;
66
+ }
67
+
68
+ const imageBlock = (img) => ({ type: 'image', source: { type: 'base64', media_type: img.media_type, data: img.data } });
69
+
70
+ const FOCUS_EVENTS_ON = '\x1b[?1004h';
71
+ const FOCUS_EVENTS_OFF = '\x1b[?1004l';
72
+
73
+ const SPINNER = ['·', '✢', '✳', '✶', '✻', '✽', '✽', '✻', '✶', '✳', '✢', '·'];
74
+ const VERBS = ['Thinking', 'Pondering', 'Crafting', 'Brewing', 'Computing', 'Conjuring', 'Cooking', 'Musing', 'Noodling', 'Percolating', 'Ruminating', 'Synthesizing', 'Working', 'Tinkering', 'Mulling', 'Deliberating', 'Churning', 'Forging', 'Hatching', 'Simmering'];
75
+ const HISTORY_FILE = path.join(CONFIG_DIR, 'history.jsonl');
76
+
77
+ function loadHistory(cwd) {
78
+ try {
79
+ return fs.readFileSync(HISTORY_FILE, 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } })
80
+ .filter((e) => e && e.project === cwd).map((e) => e.display).slice(-500);
81
+ } catch { return []; }
82
+ }
83
+
84
+ function appendHistory(cwd, text) {
85
+ try {
86
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
87
+ fs.appendFileSync(HISTORY_FILE, JSON.stringify({ display: text, project: cwd, timestamp: Date.now() }) + '\n');
88
+ } catch { /* ignore */ }
89
+ }
90
+
91
+ class FileIndex {
92
+ constructor(cwd, getConfig = () => null) { this.cwd = cwd; this.getConfig = getConfig; this.files = []; this.at = 0; this.loading = false; }
93
+ refresh() {
94
+ if (this.loading || Date.now() - this.at < 30_000) return;
95
+ this.loading = true;
96
+ const done = (list) => { this.files = list.slice(0, 50_000); this.at = Date.now(); this.loading = false; };
97
+ const rg = ripgrepPath(this.getConfig());
98
+ if (rg) {
99
+ const child = spawn(rg, ['--files', '--hidden', '--glob', '!.git'], { cwd: this.cwd, windowsHide: true });
100
+ let out = '';
101
+ child.stdout.on('data', (d) => { if (out.length < 10_000_000) out += d; });
102
+ child.on('close', () => done(out.split(/\r?\n/).filter(Boolean).map((f) => f.replace(/\\/g, '/'))));
103
+ child.on('error', () => done([]));
104
+ } else {
105
+ (async () => {
106
+ const list = [];
107
+ try {
108
+ for await (const f of fs.promises.glob('**/*', { cwd: this.cwd, exclude: (p) => /(^|[\\/])(node_modules|\.git)$/.test(p) })) {
109
+ list.push(f.replace(/\\/g, '/'));
110
+ if (list.length > 50_000) break;
111
+ }
112
+ } catch { /* ignore */ }
113
+ done(list);
114
+ })();
115
+ }
116
+ }
117
+ search(q) {
118
+ this.refresh();
119
+ const query = q.toLowerCase();
120
+ const dirs = new Set();
121
+ for (const f of this.files) {
122
+ const parts = f.split('/');
123
+ for (let i = 1; i < parts.length; i++) dirs.add(parts.slice(0, i).join('/') + '/');
124
+ if (dirs.size > 5000) break;
125
+ }
126
+ // Without ripgrep the listing comes from a glob that also returns directories (as plain names): they are already in `dirs`, with their slash.
127
+ const all = [...dirs, ...this.files.filter((f) => !dirs.has(f + '/'))];
128
+ const scored = [];
129
+ for (const f of all) {
130
+ const lower = f.toLowerCase();
131
+ const base = lower.replace(/\/$/, '').split('/').pop();
132
+ let score = -1;
133
+ if (!query) score = f.split('/').length;
134
+ else if (base.startsWith(query)) score = 0;
135
+ else if (lower.startsWith(query)) score = 1;
136
+ else if (base.includes(query)) score = 2;
137
+ else if (lower.includes(query)) score = 3;
138
+ if (score >= 0) scored.push({ f, score: score * 1000 + f.length });
139
+ if (scored.length > 2000) break;
140
+ }
141
+ return scored.sort((a, b) => a.score - b.score).slice(0, 12).map((s) => s.f);
142
+ }
143
+ }
144
+
145
+ export class App {
146
+ // `noProcessExit`: the tests, which drive the App themselves; it never ends the process, and it has no peers, no
147
+ // gateway connection, no update checks and the default renderer. `selfCheck` (see selfcheck.js): it never ends the
148
+ // process either, but otherwise starts as a session does, so that code only a real start runs is checked too.
149
+ constructor({ flags, cwd, stdin = process.stdin, stdout = process.stdout, noProcessExit = false, selfCheck = false }) {
150
+ this.flags = flags;
151
+ this.cwd = cwd;
152
+ this.stdin = stdin;
153
+ this.stdout = stdout;
154
+ this.noProcessExit = noProcessExit || selfCheck;
155
+ this.driven = noProcessExit;
156
+ this.screen = new Screen(stdout);
157
+ this.busy = false;
158
+ this.controller = null;
159
+ this.queue = [];
160
+ this.dialogs = [];
161
+ this.active = []; // running tool entries, in call order
162
+ this.pendingToolBlocks = new Map(); // streaming tool_use blocks, by index
163
+ this.md = null;
164
+ this.thinking = null;
165
+ this.verbose = !!flags.verbose;
166
+ this.showTodos = true;
167
+ this.turnStart = 0;
168
+ this.turnTokens = 0;
169
+ this.verb = 'Thinking';
170
+ this.status = null; // override spinner text (e.g. compacting)
171
+ this.rateWait = null; // {resumeAt, label, polling} while paused for a rate limit
172
+ this.exitArmed = 0;
173
+ this.notices = [];
174
+ this.topic = null; // what the conversation is about now, named by the small model (see refreshTopic)
175
+ this.firstPrompt = null; // the session's first message: the title until there is a topic
176
+ this.topicSeq = 0; // topic requests sent ...
177
+ this.topicApplied = 0; // ... and the newest one whose answer was used
178
+ this.restarts = []; // when this session was restarted lately (carried over by each restart)
179
+ this.restartRequest = null; // {reason, then, continueTurn}: restart as soon as nothing is running
180
+ this.sourceChanged = null; // the program's own source changed since it was last checked
181
+ this.reloading = false;
182
+ this.skipAutoReload = false;
183
+ this.peers = null; // this session as seen by the other sessions on the machine (see peers.js)
184
+ this.turnPeers = []; // tasks from other sessions that the running turn answers: [{msg}]
185
+ this.userSentToPeers = new Set(); // ids of messages the user sent to other sessions with @name
186
+ this.remote = null; // the browser page that watches and steers this session (see remote.js), when it is on
187
+ this.remoteMuted = 0; // > 0 while printing something the page is told about in its own terms
188
+ this.remoteDialogs = 0;
189
+ this.remoteStarting = null;
190
+ this.remoteStopped = false; // /rc stop: offline until /rc, also after a restart
191
+ this.sessionName = flags?.restart?.peerName || null; // this session's name when the sessions of this computer do not know it (CLAUDE_PRO_PEERS=0)
192
+ this.customTitle = null; // the conversation's name given with /rename: the title, instead of the topic
193
+ this.remotePeers = []; // the account's sessions on other computers, as last listed by the gateway (see peers.js)
194
+ this.remotePeersAt = 0;
195
+ this.remotePeersLoading = null;
196
+ this.remotePeersTimer = null;
197
+ this.transfers = new Map(); // files on their way to or from other sessions, for the footer
198
+ this.transferSeq = 0;
199
+ this.cost = 0; // what this conversation's requests have cost so far (USD at list prices): the footer and /cost
200
+ this.fileIndex = new FileIndex(cwd, () => this.runtime?.config);
201
+ this.store = new SessionStore(cwd);
202
+ // What the terminal prints goes to the remote page too, except what it is told about as events.
203
+ this.screen.onLog = (items) => {
204
+ if (!this.remote || this.remoteMuted) return;
205
+ this.remote.output(items.flatMap((i) => (Array.isArray(i) ? i : [i])).flatMap((l) => String(l).split(/\r?\n/)));
206
+ };
207
+ this.editor = new Editor({
208
+ history: loadHistory(cwd),
209
+ onSubmit: (value, raw, images) => { try { this.onSubmit(value, raw, images); } catch (e) { this.logError(e); } },
210
+ onDoubleEscape: () => this.openRewind(),
211
+ imageFromPath: (p) => {
212
+ if (/^\s*\/(sendfile|sf)\b/i.test(this.editor?.text || '')) return null; // a file to send, not to show the model
213
+ const r = imageFromFile(resolvePath(p, this.cwd));
214
+ if (r.error && !/not found|Not a file/.test(r.error)) this.flash(r.error, 'warn');
215
+ if (r.image) this.flashMessage = null;
216
+ return r.image || null;
217
+ },
218
+ getSuggestions: (text, cursor) => this.suggest(text, cursor),
219
+ onChange: () => this.screen.schedule(),
220
+ });
221
+ }
222
+
223
+ get agent() { return this.runtime.agent; }
224
+ get config() { return this.runtime.config; }
225
+ get width() { return this.screen.width; }
226
+
227
+ // ---------------------------------------------------------------- lifecycle
228
+
229
+ async start(initialPrompt) {
230
+ const stdin = this.stdin;
231
+ stdin.setRawMode(true);
232
+ // Where the cursor is, before anything is drawn: the input box is kept on the last rows of the window from
233
+ // there (see screen.js). CLAUDE_PRO_PIN_INPUT=0 (here or in the settings) leaves it right below the output.
234
+ const pinOff = (v) => /^(0|false|no|off)$/i.test(v || '');
235
+ if (pinOff(process.env.CLAUDE_PRO_PIN_INPUT)) this.screen.pin = false;
236
+ else if (stdin.isTTY && this.stdout.isTTY) this.screen.pinFrom(await queryCursorRow(stdin, this.stdout));
237
+ stdin.setEncoding('utf8');
238
+ this.stdout.write(ESC.bracketedPasteOn);
239
+ this.watchCopies();
240
+ const cleanup = () => this.restoreTerminal();
241
+ process.on('exit', cleanup);
242
+ process.on('SIGTERM', () => this.exit(143));
243
+ process.on('SIGHUP', () => this.exit(129));
244
+
245
+ await this.startPeer();
246
+ this.runtime = await createRuntime({ cwd: this.cwd, flags: this.flags, io: this });
247
+ // The conversation's id, sent to the gateway with each request (see streamMessage); it follows /clear and /resume.
248
+ Object.defineProperty(this.runtime.config, 'sessionId', { get: () => this.store.id, configurable: true });
249
+ // Every request's cost (the conversation's, its sub-agents', side queries'), kept with the session.
250
+ this.runtime.config.onCost = (usd) => {
251
+ this.cost += usd;
252
+ this.store.setCost(this.cost);
253
+ this.screen.schedule();
254
+ };
255
+ this.commands = buildCommands(this);
256
+ if (pinOff(this.config.env.CLAUDE_PRO_PIN_INPUT)) { this.screen.pin = false; this.screen.pinned = null; }
257
+ // What the session showed before a restart: scrolled back to in fullscreen, repainted after a resize otherwise.
258
+ if (Array.isArray(this.flags.restart?.screenHistory)) this.screen.history = this.flags.restart.screenHistory.filter((l) => typeof l === 'string');
259
+ if (this.fullscreenWanted()) this.screen.setFull(true);
260
+ // Keys typed while starting up stay buffered in stdin until we listen. Mouse reports (fullscreen: the wheel)
261
+ // are taken out before the keys are read.
262
+ const keys = mouseFilter(stdin, (m) => this.onMouse(m));
263
+ this.keys = keys;
264
+ readline.emitKeypressEvents(keys);
265
+ keys.on('keypress', (str, key) => {
266
+ if (/^\x1b\[\d+;\d+R$/.test(key?.sequence || '')) return; // a late answer to the cursor position query, not a key
267
+ try { this.onKey(str, key || {}); } catch (e) { this.logError(e); }
268
+ });
269
+ stdin.resume();
270
+ this.runtime.modes.listeners.add(() => this.screen.schedule());
271
+ // Started by a restart of this session: the output of the process before it is still on screen, so no
272
+ // welcome box and no transcript, just a line saying so.
273
+ const restart = this.flags.restart || null;
274
+ const resumable = Boolean(restart?.sessionFile && fs.existsSync(restart.sessionFile));
275
+ if (!resumable) this.printWelcome();
276
+ // The tab label, as in the official client: what this session is about, then where and with what.
277
+ this.screen.setTitle(() => this.terminalTitle(), path.basename(this.cwd) || this.cwd, { inherited: Boolean(restart) });
278
+
279
+ if (restart) this.resumeAfterRestart(restart, resumable);
280
+ else if (this.flags.resumeFile) this.loadSession(this.flags.resumeFile, this.flags.resumeId);
281
+ else if (this.flags.resume) await this.commands.get('resume').run('');
282
+ this.autoStartRemote(restart);
283
+
284
+ this.screen.setLive((w) => this.renderLive(w));
285
+ this.spinnerTimer = setInterval(() => {
286
+ const clock = this.cacheClock(); // the footer's clock counts down while nothing else is drawn
287
+ if (this.busy || this.active.length || clock !== this.shownClock) this.screen.schedule();
288
+ this.shownClock = clock;
289
+ }, 120);
290
+ if (restart) this.continueAfterRestart(restart);
291
+ else if (initialPrompt?.trim()) this.onSubmit(initialPrompt, initialPrompt);
292
+ // Nothing configured yet: the gateway is the default, and its login comes first, as in Claude Code.
293
+ else if (this.config.usesGateway && !readGatewayLogin(this.config.gatewayUrl)) {
294
+ Promise.resolve(this.commands.get('login').run('')).catch((e) => this.logError(e)).finally(() => this.screen.schedule());
295
+ }
296
+ this.fileIndex.refresh();
297
+ this.startSourceWatch();
298
+ if (!this.driven) {
299
+ if (!this.flags.restart) this.autoUpdate();
300
+ // and every few minutes while it runs, so that a release reaches a session that stays open for days
301
+ this.updateTimer = setInterval(() => { if (!this.exiting) this.autoUpdate({ quiet: true }); }, CHECK_EVERY_MS);
302
+ this.updateTimer.unref?.();
303
+ }
304
+ return new Promise((resolve) => { this.resolveExit = resolve; });
305
+ }
306
+
307
+ // What the session is about now: the topic the small model named last, or else the first thing asked
308
+ // (kept when the conversation is compacted afterwards). Cleared by /clear, replaced by /resume.
309
+ sessionLabel() {
310
+ if (this.customTitle) return this.customTitle;
311
+ if (this.topic) return this.topic;
312
+ if (!this.firstPrompt) {
313
+ const first = this.agent?.messages.find((m) => m.role === 'user' && firstUserText(m));
314
+ this.firstPrompt = first ? firstUserText(first) : null;
315
+ }
316
+ return this.firstPrompt;
317
+ }
318
+
319
+ // The newest message the user typed in this conversation (not a tool result, a reminder or a summary).
320
+ latestPrompt() {
321
+ const messages = this.agent?.messages || [];
322
+ for (let i = messages.length - 1; i >= 0; i--) {
323
+ if (messages[i].role !== 'user') continue;
324
+ const text = firstUserText(messages[i]);
325
+ if (text && !text.startsWith('This session is being continued')) return text;
326
+ }
327
+ return null;
328
+ }
329
+
330
+ // Keep the title on the topic of the conversation: ask the small model, in the background, whether
331
+ // `message` starts a new topic and what to call it (as the official client does). Nothing waits for the
332
+ // answer, and when there is none (a rate limit, no network) the title just stays as it is.
333
+ refreshTopic(message) {
334
+ const text = String(message ?? '').trim();
335
+ if (!text || this.customTitle || /^(1|true|yes)$/i.test(this.config.env.CLAUDE_PRO_DISABLE_TOPIC_TITLE || '')) return;
336
+ const store = this.store;
337
+ const seq = ++this.topicSeq;
338
+ const current = this.topic;
339
+ quickQuery(this.config, {
340
+ model: this.config.smallModel, system: TOPIC_TITLE_SYSTEM, prompt: topicTitlePrompt(current, text.slice(0, 2000)), maxTokens: 2048,
341
+ }).then((answer) => {
342
+ // another session meanwhile, or an answer about a newer message already in use
343
+ if (store !== this.store || this.exiting || seq < this.topicApplied) return;
344
+ const title = parseTopicTitle(answer, { required: !current });
345
+ if (!title) return;
346
+ this.topicApplied = seq;
347
+ if (title === this.topic) return;
348
+ this.topic = title;
349
+ store.setTopic(title);
350
+ this.screen.schedule();
351
+ }, () => { /* only the title: not worth a word */ });
352
+ }
353
+
354
+ // "<state> <session> | <directory> · <model> · <effort>". The session is cut short so that the model and
355
+ // effort, which are what tells tabs apart, are not what a narrow tab loses. While it works the state mark
356
+ // blinks, so that a tab that is busy can be told from one that waits for the user.
357
+ terminalTitle() {
358
+ const dir = path.basename(this.cwd) || this.cwd;
359
+ const { model, effort } = modelInfo(this.config);
360
+ const state = this.rateWait ? '⏸' : this.busy ? (Math.floor(Date.now() / 1000) % 2 ? '⠐' : '⠂') : '✳';
361
+ const session = this.sessionLabel();
362
+ const where = [dir, model, effort].filter(Boolean).join(' · ');
363
+ return `${state} ${session ? `${truncate(session, 40).replace(/\x1b\[0m$/, '')} | ` : ''}${where}`;
364
+ }
365
+
366
+ // `handOver`: another process takes the terminal over at once (a restart). It is left the way the program
367
+ // had it (raw mode, bracketed paste, the tab's title), so that keys typed in between are not echoed or lost.
368
+ restoreTerminal({ handOver = false } = {}) {
369
+ if (this.restored) return;
370
+ this.restored = true;
371
+ try {
372
+ this.screen.close({ keepTitle: handOver });
373
+ this.clipboardWatch?.close();
374
+ if (this.clipboardWatch) this.stdout.write(FOCUS_EVENTS_OFF);
375
+ this.stdout.write(handOver ? ESC.showCursor : ESC.bracketedPasteOff + ESC.showCursor);
376
+ if (!handOver && this.stdin.isTTY) this.stdin.setRawMode(false);
377
+ } catch { /* ignore */ }
378
+ }
379
+
380
+ exit(code = 0, { restart = false } = {}) {
381
+ if (this.exiting) return;
382
+ this.exiting = true;
383
+ this.controller?.abort();
384
+ clearInterval(this.spinnerTimer);
385
+ this.sourceWatch?.close();
386
+ shutdownBackgroundShells();
387
+ this.runtime?.tasks.killAll();
388
+ this.runtime?.mcp.close();
389
+ this.peers?.close();
390
+ clearInterval(this.remotePeersTimer);
391
+ const remoteClosed = this.remote?.close({ restarting: restart });
392
+ const u = this.agent?.totalUsage();
393
+ if (!restart && u && u.requests) {
394
+ 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`));
395
+ }
396
+ if (!restart && this.agent?.messages.length) this.screen.log(c.dim(`Resume this session with: ${APP_NAME} --resume ${this.store.id}`));
397
+ this.restoreTerminal({ handOver: restart });
398
+ this.stdin.pause();
399
+ this.resolveExit?.(code);
400
+ if (!this.noProcessExit) {
401
+ // with remote control on, the gateway is told first (at most a second and a half; see RemoteControl.close)
402
+ if (remoteClosed) remoteClosed.finally(() => process.exit(code));
403
+ else setTimeout(() => process.exit(code), 50).unref();
404
+ }
405
+ }
406
+
407
+ // ---------------------------------------------------------------- other sessions (see peers.js)
408
+
409
+ // Register this session so that the other sessions on the machine can send it tasks. Not in the tests
410
+ // (which drive the App themselves) and not with CLAUDE_PRO_PEERS=0.
411
+ async startPeer() {
412
+ if (this.driven || !peersEnabled()) return;
413
+ try {
414
+ this.peers = await new PeerNode({ cwd: this.cwd, name: this.flags.restart?.peerName, id: this.flags.restart?.peerId, onMessage: (msg) => this.onPeerMessage(msg), getStatus: () => ({ busy: this.working() }) }).start();
415
+ } catch (e) {
416
+ this.peers = null;
417
+ this.notice(`Other sessions cannot reach this one: ${e.message}`, 'warn');
418
+ }
419
+ }
420
+
421
+ // This session's name for the others.
422
+ peerLabel() { return this.peers?.name || this.sessionName || cleanName(path.basename(this.cwd)) || 'claude-pro'; }
423
+
424
+ // /rename: the session's name, for the other sessions (&name, here and at the gateway) and as the conversation's
425
+ // title (the tab, /resume, the web page), instead of the topic. Returns the name it got: name-2... when another
426
+ // session on this computer has it.
427
+ renameSession(name) {
428
+ const clean = cleanName(name);
429
+ const got = this.peers ? this.peers.setName(clean) : (this.sessionName = clean);
430
+ this.customTitle = got;
431
+ this.store.setName(got);
432
+ this.remote?.register().catch(() => { /* the gateway takes it at the next registration */ });
433
+ this.remote?.flush();
434
+ this.screen.schedule();
435
+ return got;
436
+ }
437
+
438
+ // `target`: a peer entry, or what names one (see resolvePeer): a session on this computer, or one on another
439
+ // computer, reached through the gateway. `byModel`: the MessagePeer tool sent it, so the answer goes to the
440
+ // model; otherwise the user typed `@name ...` and the answer is only shown (and noted for the model).
441
+ // `files`: paths of files on this computer to send along (see utils/downloads.js); the other session saves
442
+ // them in its Downloads folder.
443
+ //
444
+ // `background`: with files, return as soon as they are checked and the session is found, and send in the
445
+ // background (progress in the footer; a finished or failed sending is reported in the transcript).
446
+ async sendToPeer(target, text, { byModel = false, files: paths = [], background = false } = {}) {
447
+ const files = paths.length ? filesToSend(paths) : [];
448
+ let peer = target && typeof target === 'object' ? target : null;
449
+ if (!peer) {
450
+ const local = this.peers?.peers() || [];
451
+ let found = resolvePeer(local, this.remotePeers, target);
452
+ if (!found.peer && !found.error && this.remote) found = resolvePeer(local, await this.refreshRemotePeers(), target);
453
+ if (!found.peer) {
454
+ const offline = this.remote ? '' : ' Sessions on your other computers can be reached once this one is online at the gateway (/login, then /rc).';
455
+ throw new Error(found.error || `No running session called "${String(target).replace(/^[@&]/, '')}". /peers lists them.${offline}`);
456
+ }
457
+ peer = found.peer;
458
+ }
459
+ 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).`);
460
+ if (!peer.remote && !this.peers) throw new Error('Talking to other sessions on this computer is off (CLAUDE_PRO_PEERS=0).');
461
+ const send = async () => {
462
+ let r;
463
+ if (peer.remote) {
464
+ const uploaded = [];
465
+ if (files.length) {
466
+ const job = this.startTransfer('up', files, peer);
467
+ try {
468
+ for (const f of files) uploaded.push(await this.remote.uploadFile(f, { signal: job.signal, onProgress: (p) => job.progress(f, p) }));
469
+ } finally {
470
+ job.end();
471
+ }
472
+ }
473
+ r = { ...(await this.remote.sendPeer(peer.id, { text, files: uploaded })), peer };
474
+ } else {
475
+ r = await this.peers.send(peer, { text, files });
476
+ }
477
+ r.files = files;
478
+ if (!byModel) this.userSentToPeers.add(`${r.peer.id}:${r.id}`);
479
+ return r;
480
+ };
481
+ if (!background || !files.length) return await send();
482
+ const label = peer.label || peer.name;
483
+ const what = `${files.length} file${files.length === 1 ? '' : 's'} (${formatBytes(files.reduce((n, f) => n + f.size, 0))})`;
484
+ send().then(
485
+ () => this.screen.log(c.dim(` ⎿ Sent ${what} to &${label}; it saves them in its Downloads folder and confirms`), ''),
486
+ (e) => {
487
+ const why = e.name === 'AbortError'
488
+ ? `Stopped sending ${what} to &${label}. Send the same files again to continue where it stopped.`
489
+ : `Could not send ${what} to &${label}: ${e.message}${peer.remote ? ' (sending the same files again continues where it stopped)' : ''}`;
490
+ this.notice(why, 'warn');
491
+ if (byModel) this.agent.addReminder(`The files you sent with MessagePeer did not go out: ${why}`);
492
+ },
493
+ );
494
+ return { peer, files, background: true };
495
+ }
496
+
497
+ // A sending or receiving of files, shown in the footer while it runs: {signal, progress(file, p), end()}.
498
+ startTransfer(direction, files, peer) {
499
+ const id = ++this.transferSeq;
500
+ const controller = new AbortController();
501
+ const total = files.reduce((n, f) => n + (Number(f.size) || 0), 0);
502
+ 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 };
503
+ this.transfers.set(id, t);
504
+ this.screen.schedule();
505
+ return {
506
+ signal: controller.signal,
507
+ progress: (file, p) => {
508
+ t.done.set(file, p.phase === 'hashing' || p.phase === 'checking' ? 0 : p.done);
509
+ t.phase = p.phase;
510
+ t.file = file.name;
511
+ t.retryIn = p.phase === 'waiting' ? Date.now() + (p.retryIn || 0) : 0;
512
+ if (p.phase === 'hashing' || p.phase === 'checking') t.hashed = p.total ? p.done / p.total : 0;
513
+ if (Date.now() - t.shown > 250) { t.shown = Date.now(); this.screen.schedule(); }
514
+ },
515
+ end: () => { this.transfers.delete(id); this.screen.schedule(); },
516
+ };
517
+ }
518
+
519
+ // The footer's word on the files on their way.
520
+ transferStatus() {
521
+ const all = [...this.transfers.values()];
522
+ if (!all.length) return '';
523
+ const t = all[0];
524
+ const done = [...t.done.values()].reduce((n, v) => n + v, 0);
525
+ const pct = t.total ? Math.floor((done / t.total) * 100) : 0;
526
+ const arrow = t.direction === 'up' ? '⇡' : '⇣';
527
+ const name = truncate(t.names.length === 1 ? t.names[0] : `${t.names.length} files`, 24);
528
+ const where = t.direction === 'up' ? `→ &${t.label}` : `← &${t.label}`;
529
+ let state;
530
+ if (t.phase === 'waiting') state = c.yellow(`${pct}% · network down, retrying…`);
531
+ else if (t.phase === 'hashing') state = c.dim(`preparing ${Math.floor((t.hashed || 0) * 100)}%`);
532
+ else if (t.phase === 'checking') state = c.dim('checking');
533
+ else state = c.dim(`${pct}% · ${formatBytes(done)}/${formatBytes(t.total)}`);
534
+ return `${c.blue(`${arrow} ${name}`)} ${c.dim(where)} ${state}${all.length > 1 ? c.dim(` +${all.length - 1}`) : ''}`;
535
+ }
536
+
537
+ // The account's sessions on other computers that are online now, from the gateway (what was known stays when
538
+ // it cannot be asked). Sessions of this computer are left out: they are reached directly.
539
+ refreshRemotePeers() {
540
+ if (!this.remote?.registered) return Promise.resolve(this.remotePeers);
541
+ this.remotePeersLoading ??= (async () => {
542
+ try {
543
+ const near = new Set([this.peers?.id, ...(this.peers?.peers() || []).map((p) => p.id)].filter(Boolean));
544
+ const list = await this.remote.sessions();
545
+ this.remotePeers = list
546
+ .filter((p) => p.online && !p.ended && p.id !== this.remote?.id && !near.has(p.peerId))
547
+ .map((p) => {
548
+ const machine = p.machine || p.host || 'computer';
549
+ const name = p.name || 'session';
550
+ return { id: p.id, name, machine, cwd: p.cwd || '', remote: true, label: `${machine}/${name}`, busy: Boolean(p.busy), title: p.title || '' };
551
+ });
552
+ this.remotePeersAt = Date.now();
553
+ } catch { /* the gateway cannot be asked now */ } finally {
554
+ this.remotePeersLoading = null;
555
+ }
556
+ return this.remotePeers;
557
+ })();
558
+ return this.remotePeersLoading;
559
+ }
560
+
561
+ // This session and the others, here and on the other computers (for /peers and ListPeers).
562
+ async listAllPeers() {
563
+ if (this.remote) await this.refreshRemotePeers();
564
+ const name = this.peerLabel();
565
+ return {
566
+ me: { name, cwd: this.agent?.cwd || this.cwd, label: `${machineName(this.config.env)}/${name}` },
567
+ local: this.peers?.peers() || [],
568
+ remote: this.remotePeers,
569
+ online: Boolean(this.remote?.online),
570
+ };
571
+ }
572
+
573
+ onPeerMessage(msg) {
574
+ if (this.exiting) return;
575
+ if (msg.files?.length && !msg.received) return this.receiveFiles(msg).catch((e) => this.logError(e)); // the gateway keeps the message until this is done
576
+ const from = c.bold(`&${msg.from.name}`);
577
+ const width = this.width - 1;
578
+ if (msg.kind === 'reply' && this.userSentToPeers.delete(`${msg.from.id}:${msg.inReplyTo}`)) {
579
+ // the answer to something the user sent: shown, and noted for the model, without a turn of its own
580
+ this.screen.log(`${c.blue('✉')} ${from} ${c.dim('answered:')}`, ...renderMarkdown(msg.text, { width: width - 2 }).map((l) => ' ' + l), '');
581
+ this.agent.addReminder(`The user sent a message to another claude-pro session, ${msg.from.name} (${msg.from.cwd}), which answered:\n${msg.text}`);
582
+ return;
583
+ }
584
+ const display = `✉ ${msg.kind === 'reply' ? 'answer from' : 'task from'} &${msg.from.name}: ${msg.text}`;
585
+ const item = { text: peerMessagePrompt(msg), images: [], display, peer: msg.kind === 'task' ? msg : null };
586
+ if (this.busy) { this.queue.push(item); this.screen.schedule(); return; }
587
+ this.startTurn(item.text, { display, peers: item.peer ? [item.peer] : [] });
588
+ }
589
+
590
+ // Files another session sent: saved in the Downloads folder (never over a file that is there) at once, whatever
591
+ // this session is doing. Files alone are answered right away with where they are, without a turn of the model;
592
+ // with a task, the task runs as usual and is told where they are.
593
+ async receiveFiles(msg) {
594
+ msg.received = true;
595
+ let dir;
596
+ try { dir = await downloadsDir(this.config.env); } catch { dir = path.join(os.homedir(), 'Downloads'); }
597
+ const saved = [];
598
+ const failed = [];
599
+ const remote = this.remote;
600
+ const job = msg.from.remote ? this.startTransfer('down', msg.files, msg.from) : null;
601
+ try {
602
+ for (const f of msg.files) {
603
+ try {
604
+ let file;
605
+ if (msg.from.remote) {
606
+ if (!remote) throw new Error('this session is not online at the gateway');
607
+ file = await remote.downloadFile(f, dir, { signal: job.signal, onProgress: (p) => job.progress(f, p) });
608
+ remote.deleteFile(f.id);
609
+ } else {
610
+ file = await copyUnique(f.path, dir, f.name);
611
+ }
612
+ saved.push({ path: file, size: fs.statSync(file).size });
613
+ } catch (e) {
614
+ failed.push(`${f.name}: ${e.message}`);
615
+ }
616
+ }
617
+ } finally {
618
+ job?.end();
619
+ }
620
+ this.screen.log(
621
+ `${c.blue('✉')} ${c.bold(`&${msg.from.name}`)} ${c.dim(`sent ${msg.files.length} file${msg.files.length === 1 ? '' : 's'}${saved.length ? `, saved in ${dir}:` : ':'}`)}`,
622
+ ...saved.map((s) => ` ${path.basename(s.path)} ${c.dim(formatBytes(s.size))}`),
623
+ ...failed.map((f) => c.red(` ✗ ${f}`)),
624
+ '',
625
+ );
626
+ const where = `the Downloads folder of ${machineName(this.config.env)} (${dir})`;
627
+ const report = [
628
+ saved.length ? `Saved ${saved.length} file${saved.length === 1 ? '' : 's'} in ${where}:\n${saved.map((s) => `- ${s.path}`).join('\n')}` : '',
629
+ failed.length ? `Could not save:\n${failed.map((f) => `- ${f}`).join('\n')}` : '',
630
+ ].filter(Boolean).join('\n\n');
631
+ if (msg.kind === 'task' && !msg.text.trim()) {
632
+ this.agent.addReminder(`Another claude-pro session, ${msg.from.name}, sent files to this computer. ${report}`);
633
+ this.replyToPeer(msg, report);
634
+ return;
635
+ }
636
+ msg.text = `${msg.text}\n\n[Files that came with this message. ${report}]`;
637
+ this.onPeerMessage(msg);
638
+ }
639
+
640
+ // Send `text` as the answer to `msg`, a message of another session: here, or through the gateway.
641
+ replyToPeer(msg, text) {
642
+ const done = () => this.screen.log(c.dim(` ⎿ Answer sent to &${msg.from.name}`));
643
+ const failed = (e) => this.notice(`Could not send the answer to &${msg.from.name}: ${e.message}`, 'warn');
644
+ if (msg.from.remote) { // from another computer: back through the gateway
645
+ 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; }
646
+ this.remote.sendPeer(msg.from.id, { kind: 'reply', text, inReplyTo: msg.id }).then(done, failed);
647
+ return;
648
+ }
649
+ if (!this.peers) return;
650
+ // by id, or by name: a session that was restarted by older code comes back with a new id
651
+ const running = this.peers.peers();
652
+ const peer = findPeer(running, msg.from.id) || running.find((p) => p.name === msg.from.name && p.cwd === msg.from.cwd);
653
+ if (!peer) { this.notice(`&${msg.from.name} is no longer running: its answer was not sent.`, 'warn'); return; }
654
+ this.peers.send(peer, { text, kind: 'reply', inReplyTo: msg.id }).then(done, failed);
655
+ }
656
+
657
+ // /sendfile &session file...: files on this computer to another session (here or on another computer), which
658
+ // saves them in its Downloads folder and confirms. Paths may be quoted, @-mentions, or have spaces unquoted.
659
+ async sendFiles(args) {
660
+ if (/^\s*(stop|cancel)\s*$/i.test(args)) {
661
+ const ups = [...this.transfers.values()].filter((t) => t.direction === 'up');
662
+ for (const t of ups) t.controller.abort();
663
+ this.screen.log(c.dim(ups.length ? ` ⎿ Stopping ${ups.length} sending${ups.length === 1 ? '' : 's'}` : ' ⎿ Nothing is being sent'));
664
+ return;
665
+ }
666
+ const tokens = [...String(args).matchAll(/@?"([^"]+)"|@?'([^']+)'|(\S+)/g)].map((m) => ({ text: m[1] ?? m[2] ?? m[3], quoted: m[3] === undefined }));
667
+ const targets = tokens.filter((t) => !t.quoted && /^[&&]./u.test(t.text));
668
+ if (targets.length !== 1) {
669
+ this.notice(targets.length ? 'Send to one session at a time.' : 'Usage: /sendfile &session file... (type & for the list of sessions)', 'warn');
670
+ return;
671
+ }
672
+ const exists = (p) => { try { return fs.statSync(p).isFile(); } catch { return false; } };
673
+ const rest = tokens.filter((t) => t !== targets[0]);
674
+ const paths = [];
675
+ for (let i = 0; i < rest.length;) {
676
+ if (rest[i].quoted) { paths.push(resolvePath(rest[i].text, this.cwd)); i++; continue; }
677
+ // the longest run of words that is a file: a path with spaces that was not quoted
678
+ let j = i + 1;
679
+ for (let k = rest.length; k > i + 1; k--) {
680
+ if (rest.slice(i, k).some((t) => t.quoted)) continue;
681
+ if (exists(resolvePath(rest.slice(i, k).map((t) => t.text).join(' ').replace(/^@/, ''), this.cwd))) { j = k; break; }
682
+ }
683
+ paths.push(resolvePath(rest.slice(i, j).map((t) => t.text).join(' ').replace(/^@/, ''), this.cwd));
684
+ i = j;
685
+ }
686
+ let files;
687
+ try { files = filesToSend(paths); } catch (e) { this.notice(e.message, 'warn'); return; }
688
+ const total = files.reduce((n, f) => n + f.size, 0);
689
+ const what = `${files.length} file${files.length === 1 ? '' : 's'} (${formatBytes(total)})`;
690
+ try {
691
+ const r = await this.sendToPeer(targets[0].text, '', { files: paths, background: true });
692
+ const label = r.peer.label || r.peer.name;
693
+ 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' : ''})`));
694
+ } catch (e) {
695
+ this.notice(`Could not send the files: ${e.message}`, 'warn');
696
+ }
697
+ }
698
+
699
+ // The turn that handled tasks of other sessions ended: send each its answer.
700
+ answerPeers(result, error) {
701
+ const tasks = this.turnPeers.splice(0);
702
+ if (!tasks.length) return;
703
+ const text = error ? `The session failed before finishing: ${error.message || error}`
704
+ : result?.interrupted ? `The user of ${this.peerLabel()} interrupted the work before it was finished.${result.text ? `\n\nWhat it had written:\n${result.text}` : ''}`
705
+ : (result?.text || '(no answer text)');
706
+ // One answer per session, to its last message: several messages from it that waited for this turn were all
707
+ // handled in it, and sending the whole answer once for each would hand that session the same text again and again.
708
+ const lastOfEach = new Map();
709
+ for (const msg of tasks) lastOfEach.set(`${msg.from.id}|${msg.from.name}|${msg.from.cwd}`, msg);
710
+ for (const msg of lastOfEach.values()) this.replyToPeer(msg, text);
711
+ }
712
+
713
+ // ---------------------------------------------------------------- remote control (see remote.js)
714
+
715
+ // Print to the terminal only: the remote page is told about this as an event of its own.
716
+ logLocal(...items) {
717
+ this.remoteMuted++;
718
+ try { this.screen.log(...items); } finally { this.remoteMuted--; }
719
+ }
720
+
721
+ // A message of the user in the transcript (typed here, on the remote page, or sent by another session).
722
+ echoUser(text, images = 0) {
723
+ this.logLocal(renderUserMessage(text, this.width));
724
+ this.remote?.emit({ t: 'user', text: images ? `${text}\n[${images} image${images === 1 ? '' : 's'}]` : String(text) });
725
+ }
726
+
727
+ // Online at the gateway (see remote.js) from the start: by itself when this computer is logged in to the gateway
728
+ // (CLAUDE_PRO_ONLINE=0 turns that off), with --rc (which prints the link too), and after a restart as before (with
729
+ // the same id, so that open pages and messages on their way find it again). It does not hold up the start. Never
730
+ // in the tests, which drive the App themselves, nor in the self-check, which sets CLAUDE_PRO_ONLINE=0.
731
+ autoStartRemote(restart) {
732
+ const carried = restart?.remote;
733
+ if (carried?.off) { this.remoteStopped = true; return; }
734
+ const explicit = Boolean(this.flags.remoteControl);
735
+ if (this.driven && !explicit) return;
736
+ if (!explicit && !carried?.id && !(onlineEnabled(this.config.env) && readGatewayLogin(this.config.gatewayUrl))) return;
737
+ this.startRemote({ id: carried?.id, quiet: !explicit || Boolean(carried?.id), persistent: !explicit }).catch((e) => {
738
+ 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');
739
+ });
740
+ }
741
+
742
+ // Connect this session to the gateway (see remote.js): the web page and the sessions on the account's other
743
+ // computers reach it from then on. Needs the gateway login of /login (the provider can be another one); throws
744
+ // {code: 'NO_GATEWAY_LOGIN'} without it. `persistent`: a gateway that cannot be reached now is tried again in
745
+ // the background instead of failing.
746
+ startRemote({ id, quiet = false, persistent = quiet } = {}) {
747
+ if (this.remote) return Promise.resolve(this.remote);
748
+ this.remoteStarting ??= this.connectRemote({ id, quiet, persistent }).finally(() => { this.remoteStarting = null; });
749
+ return this.remoteStarting;
750
+ }
751
+
752
+ async connectRemote({ id, quiet, persistent }) {
753
+ const login = readGatewayLogin(this.config.gatewayUrl);
754
+ 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' });
755
+ const remote = new RemoteControl({
756
+ gateway: { url: login.url || this.config.gatewayUrl, apiKey: login.apiKey },
757
+ id,
758
+ handlers: {
759
+ 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 }),
760
+ seed: () => transcriptItems(this.agent.messages, { tools: this.runtime.tools, ctx: this.agent.makeCtx(), cwd: this.cwd }),
761
+ status: () => this.remoteStatus(),
762
+ send: (text) => this.remoteSend(text),
763
+ interrupt: () => this.remoteInterrupt(),
764
+ answer: (body) => this.answerDialog(body),
765
+ peerMessage: (msg) => this.onPeerMessage(msg),
766
+ changed: () => {
767
+ this.screen.schedule();
768
+ if (remote.online && Date.now() - this.remotePeersAt > 10_000) this.refreshRemotePeers();
769
+ },
770
+ },
771
+ });
772
+ remote.account = login.account?.email || login.account?.name || null;
773
+ await remote.start({ persistent });
774
+ if (this.exiting) { remote.close(); return remote; }
775
+ this.remote = remote;
776
+ this.remoteStopped = false;
777
+ this.remotePeersTimer ??= setInterval(() => { if (this.remote?.online) this.refreshRemotePeers(); }, 60_000);
778
+ this.remotePeersTimer.unref?.();
779
+ if (!quiet) this.logLocal(this.remoteInfo({ fresh: true }));
780
+ this.screen.schedule();
781
+ return remote;
782
+ }
783
+
784
+ stopRemote() {
785
+ this.remote?.close();
786
+ this.remote = null;
787
+ this.remotePeers = [];
788
+ this.remoteStopped = true;
789
+ this.screen.schedule();
790
+ }
791
+
792
+ // Where the session is on the gateway (and a QR code of it, for a phone), for the terminal.
793
+ remoteInfo({ fresh = false } = {}) {
794
+ const r = this.remote;
795
+ 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`) : '';
796
+ const lines = [`${c.green('⇄')} ${c.bold('This session is online at the gateway')}${r.account ? ` (${r.account})` : ''}${state}. In a browser:`];
797
+ lines.push(` ${c.blue(r.url || r.base)}`);
798
+ const qr = r.url ? qrLines(r.url, { color: colorEnabled }) : null;
799
+ if (qr) lines.push('', c.dim(' Scan with a phone (log in to the gateway there):'), ...qr.map((l) => ' ' + l));
800
+ const notes = [
801
+ `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.`,
802
+ `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.`,
803
+ 'Whoever can log in to this gateway account, or holds one of its API keys, can run anything on this computer through it.',
804
+ '/rc stop takes this session offline · /rc open opens the page in the browser here',
805
+ ];
806
+ lines.push('', ...notes.flatMap((n) => wordWrap(n, Math.max(20, this.width - 4))).map((l) => c.dim(` ${l.trimEnd()}`)), '');
807
+ return lines;
808
+ }
809
+
810
+ // The state the page shows below the transcript; sent whenever it changes.
811
+ remoteStatus() {
812
+ const cfg = this.config;
813
+ const { model, effort } = modelInfo(cfg);
814
+ const w = this.rateWait;
815
+ let rateWait = null;
816
+ if (w) {
817
+ const label = w.label.charAt(0).toUpperCase() + w.label.slice(1);
818
+ rateWait = {
819
+ 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)}`}`,
820
+ resumeIn: w.resumeAt - Date.now(),
821
+ };
822
+ }
823
+ return {
824
+ busy: this.busy,
825
+ verb: this.status || (this.thinking ? 'Thinking' : this.verb),
826
+ elapsed: this.busy ? Date.now() - this.turnStart : 0,
827
+ tokens: this.turnTokens,
828
+ aborting: Boolean(this.controller?.signal.aborted),
829
+ rateWait,
830
+ todos: (this.agent.todos || []).map((t) => ({ content: t.content, status: t.status })),
831
+ queue: this.queue.map((q) => q.display ?? q.text),
832
+ dialog: this.remoteDialog(),
833
+ mode: this.runtime.modes.mode,
834
+ model,
835
+ effort: effort || defaultEffort(cfg.mainModel.id, cfg),
836
+ title: this.sessionLabel() || path.basename(this.cwd),
837
+ cwd: (this.agent.cwd || this.cwd).replace(/\\/g, '/'),
838
+ name: this.peerLabel(),
839
+ tasks: this.runtime.tasks.running().length,
840
+ restarting: Boolean(this.restartRequest),
841
+ };
842
+ }
843
+
844
+ // The dialog open in the terminal, for the page to answer (options by index: values need not be strings).
845
+ remoteDialog() {
846
+ const d = this.dialogs[0];
847
+ if (!d) return null;
848
+ d.remoteId ??= ++this.remoteDialogs;
849
+ const base = { id: d.remoteId, title: d.title || '', question: d.question || '', body: d.body || [], markdown: d.markdown || null };
850
+ if (!Array.isArray(d.options)) return base;
851
+ return { ...base, multi: Boolean(d.multi), options: d.options.map((o) => ({ label: String(o.label ?? ''), description: o.description || '', other: o.value === '__other__' })) };
852
+ }
853
+
854
+ answerDialog({ id, index, indices, text, cancel } = {}) {
855
+ const d = this.dialogs[0];
856
+ if (!d || d.remoteId !== id) throw new Error('That question is no longer open.');
857
+ if (cancel) { d.resolve(d.cancelValue); return {}; }
858
+ if (!Array.isArray(d.options)) throw new Error('This dialog can only be answered in the terminal.');
859
+ const valueAt = (i) => {
860
+ const o = d.options[i];
861
+ if (!Number.isInteger(i) || !o || o.value === '__other__') throw new Error('No such option.');
862
+ return o.value;
863
+ };
864
+ if (typeof text === 'string') {
865
+ if (!text.trim()) throw new Error('Type an answer first.');
866
+ d.resolve(d.multi ? [...(indices || []).map(valueAt), text.trim()] : text.trim());
867
+ } else if (d.multi) {
868
+ if (!indices?.length) throw new Error('Choose at least one option.');
869
+ d.resolve(indices.map(valueAt));
870
+ } else {
871
+ d.resolve(valueAt(index));
872
+ }
873
+ this.screen.schedule();
874
+ return {};
875
+ }
876
+
877
+ // A message from the page: as if typed in the input box.
878
+ remoteSend(text) {
879
+ const value = String(text ?? '').replace(/\r\n/g, '\n');
880
+ if (!value.trim()) throw new Error('The message is empty.');
881
+ if (this.exiting) throw new Error('The session is ending.');
882
+ if (this.busy && value.trim().startsWith('/')) throw new Error('Commands wait until the current task is done (or stopped).');
883
+ if (this.dialogs.length && !this.busy) throw new Error('A question is open: answer it first.');
884
+ const queued = this.busy;
885
+ this.onSubmit(value, value);
886
+ this.screen.schedule();
887
+ return { queued };
888
+ }
889
+
890
+ remoteInterrupt() {
891
+ for (const d of this.dialogs.splice(0)) d.resolve(d.cancelValue);
892
+ this.interrupt();
893
+ this.screen.schedule();
894
+ return {};
895
+ }
896
+
897
+ // ---------------------------------------------------------------- restarting (see restart.js)
898
+
899
+ // What a restart would kill: agents and shells that are still running in the background.
900
+ // Something is going on that an update must not cut into: a turn, a question waiting for an answer, messages
901
+ // waiting to be sent, background agents and shells, files on their way.
902
+ working() {
903
+ return Boolean(this.busy || this.dialogs.length || this.queue.length || this.restartBlockers().length);
904
+ }
905
+
906
+ restartBlockers() {
907
+ const found = [];
908
+ for (const t of this.transfers.values()) found.push(`files on their way ${t.direction === 'up' ? 'to' : 'from'} &${t.label}`);
909
+ for (const t of this.runtime.tasks.running()) found.push(`background agent ${t.id} "${t.description}"`);
910
+ for (const s of listBackgroundShells()) if (s.status === 'running') found.push(`background shell ${s.id} (${String(s.command).slice(0, 40)})`);
911
+ return found;
912
+ }
913
+
914
+ // Restart as soon as nothing is running: at once when idle, otherwise when the current turn ends (the
915
+ // turn that asked for it still has to be answered and saved). `continueTurn`: the model gets a note in
916
+ // the new process and goes on by itself. Returns {ok, message}.
917
+ requestRestart({ reason = '', then = '', continueTurn = false, force = false } = {}) {
918
+ if (!canRestart()) return { ok: false, message: 'This session cannot restart itself.' };
919
+ const now = Date.now();
920
+ const recent = this.restarts.filter((t) => now - t < RESTART_WINDOW_MS);
921
+ if (recent.length >= MAX_RESTARTS) {
922
+ return { ok: false, message: `The program was restarted ${recent.length} times in the last ${Math.round(RESTART_WINDOW_MS / 60_000)} minutes. That looks like a loop: stop restarting and tell the user what is going on.` };
923
+ }
924
+ const blockers = this.restartBlockers();
925
+ if (blockers.length && !force) return { ok: false, message: `A restart would kill: ${blockers.join('; ')}.` };
926
+ this.restartRequest = { reason, then, continueTurn };
927
+ this.screen.schedule();
928
+ if (!this.busy) this.restartNow();
929
+ return { ok: true };
930
+ }
931
+
932
+ // Leave the state of the session for the next process and go; the supervisor starts it again.
933
+ restartNow() {
934
+ const request = this.restartRequest || {};
935
+ this.restartRequest = null;
936
+ const cfg = this.config;
937
+ const now = Date.now();
938
+ const state = {
939
+ at: now,
940
+ reason: request.reason || '',
941
+ continueWith: request.continueTurn ? restartNote(request) : '',
942
+ sessionId: this.store.id,
943
+ sessionFile: this.store.file,
944
+ modelOverride: cfg.modelOverride,
945
+ effortOverride: cfg.effortOverride,
946
+ effortBoost: cfg.effortBoost,
947
+ mode: this.runtime.modes.mode,
948
+ todos: this.agent.todos,
949
+ draft: this.editor.text,
950
+ screenHistory: this.screen.history.slice(-3000),
951
+ draftCursor: this.editor.cursor,
952
+ queue: this.queue.map((q) => ({ text: q.text, images: q.images || [] })),
953
+ checkpoints: this.runtime.checkpoints.snapshot(),
954
+ restarts: [...this.restarts.filter((t) => now - t < RESTART_WINDOW_MS), now],
955
+ peerName: this.peers?.name || this.sessionName,
956
+ peerId: this.peers?.id,
957
+ remote: this.remote?.handoff() || (this.remoteStopped ? { off: true } : null),
958
+ };
959
+ try {
960
+ writeRestartState(state);
961
+ } catch (e) {
962
+ this.notice(`Could not restart: ${e.message}`, 'warn');
963
+ return;
964
+ }
965
+ this.exit(RESTART_EXIT_CODE, { restart: true });
966
+ }
967
+
968
+ // In the process a restart started: take up the session where the one before it left it.
969
+ resumeAfterRestart(state, resumable) {
970
+ const cfg = this.config;
971
+ // The model and effort the session had, none chosen included: the remembered /model and /effort are shared by
972
+ // every window on this machine, and another one may have changed them since (an update restarts them all).
973
+ // A restart is not a choice either, so it leaves what is remembered as it is.
974
+ if ('modelOverride' in state) cfg.modelOverride = state.modelOverride || null;
975
+ if ('effortOverride' in state) cfg.effortOverride = state.effortOverride || null;
976
+ // The Effort tool's level holds for the request the restart continues, not for the user's next one.
977
+ if (state.effortBoost) { cfg.effortBoost = state.effortBoost; cfg.effortBoostKeepNext = Boolean(state.continueWith); }
978
+ if (state.mode === 'plan') this.runtime.modes.setMode('plan');
979
+ this.restarts = (Array.isArray(state.restarts) ? state.restarts : []).filter((t) => Number.isFinite(t));
980
+ if (resumable) {
981
+ this.loadSession(state.sessionFile, state.sessionId, { quiet: true });
982
+ if (Array.isArray(state.todos)) this.agent.todos = state.todos;
983
+ if (state.checkpoints) this.runtime.checkpoints.adopt(state.checkpoints);
984
+ }
985
+ if (typeof state.draft === 'string' && state.draft) this.editor.set(state.draft, state.draftCursor);
986
+ // Fullscreen starts on an empty screen: without what the process before showed (an older version, or one
987
+ // that ran classic), the conversation is drawn again from the session.
988
+ if (this.screen.full && resumable && !Array.isArray(state.screenHistory)) this.printTranscript(this.agent.messages.slice(-80));
989
+ this.screen.log(c.dim(`↻ Restarted${state.reason ? `: ${state.reason}` : ''}${state.continueWith ? ' · continuing' : ''}`), '');
990
+ }
991
+
992
+ // The turn that goes on after a restart: the note for the model, and what the user had queued meanwhile.
993
+ continueAfterRestart(state) {
994
+ const queued = (Array.isArray(state.queue) ? state.queue : []).filter((q) => q && typeof q.text === 'string' && q.text.trim());
995
+ if (!state.continueWith && !queued.length) return;
996
+ const typed = queued.map((q) => q.text).join('\n\n');
997
+ this.startTurn([state.continueWith, typed].filter(Boolean).join('\n\n'), { echo: queued.length > 0, display: typed, topic: typed, images: queued.flatMap((q) => q.images || []) });
998
+ }
999
+
1000
+ // Follow the program's own source: once a turn is over (or at once when idle) a change to it is loaded by
1001
+ // a restart, if the new code starts.
1002
+ startSourceWatch() {
1003
+ if (!canRestart() || /^(0|false|no|off)$/i.test(this.config.env.CLAUDE_PRO_AUTO_RESTART || '')) return;
1004
+ this.sourceWatch = watchSource(INSTALL_DIR, (file) => {
1005
+ this.sourceChanged = file || true;
1006
+ // another window (or npm) installed a new version: say so, and load it once this session is idle. In a
1007
+ // source checkout a newer version in package.json is a release being made, not an install.
1008
+ const disk = installedVersion();
1009
+ if (installedByNpm() && disk !== VERSION && isNewer(disk, VERSION) && this.updateRestart !== disk) {
1010
+ this.updateRestart = disk;
1011
+ this.pendingUpdate = null;
1012
+ this.notice(`${APP_NAME} ${disk} was installed (by another window or npm); this one restarts to load it ${this.working() ? 'once its work is done' : 'now'}.`);
1013
+ }
1014
+ if (!this.busy) this.maybeAutoReload();
1015
+ });
1016
+ }
1017
+
1018
+ async maybeAutoReload() {
1019
+ if (!this.sourceChanged || this.updating || this.reloading || this.busy || this.exiting || this.restartRequest || this.dialogs.length || this.queue.length) return;
1020
+ if (this.skipAutoReload) { this.skipAutoReload = false; return; } // the user just interrupted: not now
1021
+ if (this.restartBlockers().length) return; // looked at again whenever the program is next idle
1022
+ const changed = this.sourceChanged;
1023
+ this.sourceChanged = null;
1024
+ this.reloading = true;
1025
+ this.flash(this.updateRestart ? `Updated to ${this.updateRestart}: checking it starts, then restarting…` : 'Source changed: checking the new code…');
1026
+ let check;
1027
+ try { check = await runSelfCheck(); } finally { this.reloading = false; }
1028
+ if (this.exiting) return;
1029
+ if (!check.ok) {
1030
+ this.notice(`The program's source changed but the new code does not start, so it was not restarted:\n${check.output.split('\n').slice(0, 12).join('\n')}\nFix it; the restart follows by itself (or run /restart).`, 'warn');
1031
+ return;
1032
+ }
1033
+ if (this.busy || this.dialogs.length) { this.sourceChanged = changed; return; } // something started meanwhile: later
1034
+ this.requestRestart({ reason: this.updateRestart ? `updated to ${APP_NAME} ${this.updateRestart}` : 'source changed', force: true });
1035
+ }
1036
+
1037
+ // A newer version on npm (see updater.js): it is installed once no session of this machine is working, and then
1038
+ // loaded by a restart (this session's; the others restart when they see the files change). `force`: the /update
1039
+ // command, which also says when there is nothing to do. `quiet`: the periodic check, which says nothing when an
1040
+ // install fails (it is tried again in a minute).
1041
+ async autoUpdate({ force = false, quiet = false } = {}) {
1042
+ if (this.updating) { if (force) this.notice(`${APP_NAME} ${this.updating} is being installed.`); return; }
1043
+ let r;
1044
+ try {
1045
+ r = await checkForUpdate({ force, env: { ...process.env, ...this.config.env } });
1046
+ } catch (e) {
1047
+ r = { status: 'failed', error: e.message };
1048
+ }
1049
+ if (this.exiting) return;
1050
+ if (r.status === 'available') {
1051
+ if (this.pendingUpdate !== r.version) {
1052
+ this.pendingUpdate = r.version;
1053
+ this.updateQuiet = quiet;
1054
+ this.screen.schedule();
1055
+ }
1056
+ if (force && this.working()) this.notice(`${APP_NAME} ${r.version} is out: it is installed as soon as this session's work is done.`);
1057
+ await this.maybeInstallUpdate({ force });
1058
+ } else if (r.status === 'installed') {
1059
+ if (force || this.updateRestart !== r.version) this.notice(`${APP_NAME} ${r.version} is installed already; this session restarts to load it ${this.working() ? 'once its work is done' : 'now'}.`);
1060
+ this.updateRestart = r.version;
1061
+ if (canRestart()) { this.sourceChanged ||= true; if (!this.busy) this.maybeAutoReload(); }
1062
+ } else if (r.status === 'failed') {
1063
+ if (force || (r.version && !quiet)) this.notice(`Could not update ${APP_NAME}${r.version ? ` to ${r.version}` : ''}: ${r.error}`, 'warn');
1064
+ } else if (force) {
1065
+ this.notice(r.status === 'dev' ? `This copy runs from a source checkout (${INSTALL_DIR}): update it with git.` : `${APP_NAME} ${VERSION} is the latest version.`);
1066
+ }
1067
+ }
1068
+
1069
+ // Install the version found (pendingUpdate) if nothing is going on: in this session, and in the other sessions
1070
+ // of this machine, whose files it replaces under them. Called again when this session's turn ends, and every
1071
+ // half minute while another session works.
1072
+ async maybeInstallUpdate({ force = false } = {}) {
1073
+ const version = this.pendingUpdate;
1074
+ if (!version || this.updating || this.exiting || this.restartRequest) return;
1075
+ if (this.working()) return; // the end of the turn calls this again
1076
+ const local = this.peers?.peers() || [];
1077
+ const busy = [];
1078
+ for (const p of local) if ((await this.peers.status(p)).busy) busy.push(p.name);
1079
+ if (this.pendingUpdate !== version || this.updating || this.exiting) return;
1080
+ if (busy.length) {
1081
+ if (this.updateWaitingFor !== busy.join(',')) {
1082
+ this.updateWaitingFor = busy.join(',');
1083
+ this.screen.schedule();
1084
+ if (force) this.notice(`${APP_NAME} ${version} is installed once &${busy.join(', &')} ${busy.length === 1 ? 'is' : 'are'} done working.`);
1085
+ }
1086
+ clearTimeout(this.updateRetry);
1087
+ this.updateRetry = setTimeout(() => this.maybeInstallUpdate(), 30_000);
1088
+ this.updateRetry.unref?.();
1089
+ return;
1090
+ }
1091
+ if (this.working()) return;
1092
+ this.updateWaitingFor = null;
1093
+ this.updating = version;
1094
+ this.screen.schedule();
1095
+ let r;
1096
+ try {
1097
+ r = await installUpdate(version);
1098
+ } catch (e) {
1099
+ r = { status: 'failed', version, error: e.message };
1100
+ } finally {
1101
+ this.updating = null;
1102
+ this.screen.schedule();
1103
+ }
1104
+ if (this.exiting) return;
1105
+ if (r.status === 'locked') return; // another window is installing it: its files changing tell this one
1106
+ if (r.status === 'updated' || r.status === 'installed') {
1107
+ this.pendingUpdate = null;
1108
+ this.updateRestart = r.version;
1109
+ if (canRestart()) {
1110
+ this.notice(`${APP_NAME} was updated to ${r.version} (from ${VERSION}); restarting to load it. The conversation carries over.`);
1111
+ this.sourceChanged = true;
1112
+ this.maybeAutoReload();
1113
+ } else {
1114
+ this.notice(`${APP_NAME} was updated to ${r.version}: restart the program to use it.`);
1115
+ }
1116
+ return;
1117
+ }
1118
+ if (force || !this.updateQuiet) this.notice(`Could not update ${APP_NAME} to ${version}: ${r.error}`, 'warn');
1119
+ }
1120
+
1121
+ printWelcome() {
1122
+ const cfg = this.config;
1123
+ const w = Math.min(this.width - 1, 76);
1124
+ const inner = w - 4;
1125
+ const row = (s) => c.claude('│') + ' ' + padEnd(truncate(s, inner), inner) + ' ' + c.claude('│');
1126
+ const model = cfg.mainModel;
1127
+ const lines = [
1128
+ c.claude('╭' + '─'.repeat(w - 2) + '╮'),
1129
+ row(`${c.claude('✻')} Welcome to ${c.bold(APP_NAME)}! ${c.dim(`v${VERSION} · a Claude Code style agent`)}`),
1130
+ row(''),
1131
+ row(c.dim(' /help for help, /status for your current setup')),
1132
+ row(''),
1133
+ row(c.dim(` cwd: ${this.cwd}`)),
1134
+ row(c.dim(` model: ${model.label}${cfg.effort ? ` · effort ${cfg.effort}` : ''} · ${formatTokens(model.contextWindow)} context`)),
1135
+ row(c.dim(` backend: ${cfg.endpointLabel}`)),
1136
+ c.claude('╰' + '─'.repeat(w - 2) + '╯'),
1137
+ ];
1138
+ lines.push('', c.dim(' Full access: every tool runs immediately, nothing is ever asked. File edits are checkpointed;'), c.dim(' /rewind puts files and/or the conversation back (shell-command changes are not tracked).'));
1139
+ if (cfg.usesGateway && !readGatewayLogin(cfg.gatewayUrl)) {
1140
+ lines.push('', c.yellow(` ⚠ Not logged in to the gateway ${cfg.gatewayUrl}. Run /login.`));
1141
+ } else if (cfg.provider === 'api' && !cfg.authToken && !cfg.apiKey) {
1142
+ lines.push('', c.yellow(' ⚠ No API credentials found. Set ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY in .env, ~/.claude-pro/settings.json or the environment.'));
1143
+ }
1144
+ const mcp = this.runtime.mcp.status();
1145
+ if (mcp.length) {
1146
+ const ok = mcp.filter((m) => m.status === 'connected').length;
1147
+ lines.push(c.dim(` MCP: ${ok}/${mcp.length} servers connected${ok < mcp.length ? ' · /mcp for details' : ''}`));
1148
+ }
1149
+ 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}`), '');
1150
+ this.logLocal(lines);
1151
+ }
1152
+
1153
+ // ---------------------------------------------------------------- input
1154
+
1155
+ // Say "copied" when the user copies text selected in this terminal, as Claude Code does. The terminal does the
1156
+ // selecting and copying and tells nobody, so the clipboard is watched; focus events (which the terminal sends
1157
+ // once asked to) keep copies made in other windows from being announced. Off with CLAUDE_PRO_COPY_NOTICE=0,
1158
+ // and when not writing to the real terminal (the tests).
1159
+ watchCopies() {
1160
+ if (this.stdout !== process.stdout || !this.stdout.isTTY || /^(0|false|no)$/i.test(process.env.CLAUDE_PRO_COPY_NOTICE || '')) return;
1161
+ this.focused = true;
1162
+ this.clipboardWatch = watchClipboard((chars) => {
1163
+ if (!this.focused || this.exiting) return;
1164
+ this.flash(chars ? `Copied ${chars.toLocaleString()} characters to the clipboard` : 'Copied to the clipboard');
1165
+ });
1166
+ this.stdout.write(FOCUS_EVENTS_ON);
1167
+ }
1168
+
1169
+ // Fullscreen (see screen.js) unless CLAUDE_PRO_TUI=default, as Claude Code's /tui: the input box fixed at the
1170
+ // bottom of the window, the conversation scrolled inside the program. Not in the tests that drive the App.
1171
+ fullscreenWanted() {
1172
+ const mode = String(this.config.env.CLAUDE_PRO_TUI || (this.driven ? 'default' : 'fullscreen')).toLowerCase();
1173
+ return mode === 'fullscreen' && this.stdout.isTTY !== false;
1174
+ }
1175
+
1176
+ // Fullscreen: the wheel scrolls the conversation (CLAUDE_PRO_SCROLL_SPEED rows a notch, 3 by default); a drag,
1177
+ // a double or a triple click selects, and what is selected goes to the clipboard (see Screen.mouse).
1178
+ onMouse(m) {
1179
+ if (!this.screen.full || this.exiting) return;
1180
+ if (m.button & 64) {
1181
+ if (m.button & 128) return;
1182
+ const speed = Math.max(1, Math.min(20, Number(this.config.env.CLAUDE_PRO_SCROLL_SPEED) || 3));
1183
+ this.screen.scrollBy(m.button & 1 ? -speed : speed);
1184
+ return;
1185
+ }
1186
+ // A right click, which the terminal no longer gets: what it did there. With a selection it copies it;
1187
+ // without one it pastes the clipboard's text, as the terminal's own paste (ctrl+v) arrives, so that it is
1188
+ // handled the same way (long text folded, an image path attached, into a dialog's answer box).
1189
+ if ((m.button & 3) === 2 && !(m.button & 32) && !m.release) {
1190
+ if (this.screen.selection) {
1191
+ this.copyText(this.screen.selectedText());
1192
+ this.screen.clearSelection();
1193
+ return;
1194
+ }
1195
+ readClipboardText().then((text) => {
1196
+ if (!text || this.exiting) return;
1197
+ const clean = text.replace(/\x1b\[20[01]~/g, '').replace(/\r\n|\n/g, '\r');
1198
+ this.keys.write(`\x1b[200~${clean}\x1b[201~`);
1199
+ });
1200
+ return;
1201
+ }
1202
+ const r = this.screen.mouse(m);
1203
+ if (r?.copy) this.copyText(r.copy);
1204
+ }
1205
+
1206
+ copyText(text) {
1207
+ writeClipboard(text).then((ok) => {
1208
+ if (ok) this.flash(`Copied ${text.length.toLocaleString()} characters to the clipboard`);
1209
+ else this.flash('Could not copy: no clipboard tool found (hold Shift and drag to select with the terminal)', 'warn');
1210
+ });
1211
+ }
1212
+
1213
+ onKey(str, key) {
1214
+ if (this.exiting) return;
1215
+ if (key.sequence === '\x1b[I') { this.focused = true; return; }
1216
+ if (key.sequence === '\x1b[O') { this.focused = false; return; }
1217
+ if (this.screen.full) {
1218
+ const half = Math.max(1, Math.floor((this.screen.lastArea || this.screen.height) / 2));
1219
+ if (key.name === 'pageup') { this.screen.scrollBy(half); return; }
1220
+ if (key.name === 'pagedown') { this.screen.scrollBy(-half); return; }
1221
+ if (key.ctrl && key.name === 'home') { this.screen.scrollBy('top'); return; }
1222
+ if (key.ctrl && key.name === 'end') { this.screen.scrollBy('bottom'); return; }
1223
+ // ctrl+f looks for what is selected, if anything
1224
+ if (key.ctrl && key.name === 'f' && !this.dialogs.length) {
1225
+ const seed = this.screen.selection ? this.screen.selectedText() : '';
1226
+ this.screen.clearSelection();
1227
+ this.openFind(seed);
1228
+ return;
1229
+ }
1230
+ // A selection: ctrl+c copies it (and does not interrupt), esc only clears it, any other key clears it and
1231
+ // then does what it does.
1232
+ if (this.screen.selection) {
1233
+ if (key.ctrl && key.name === 'c') { this.copyText(this.screen.selectedText()); this.screen.clearSelection(); return; }
1234
+ this.screen.clearSelection();
1235
+ if (key.name === 'escape') return;
1236
+ }
1237
+ }
1238
+ if (key.ctrl && key.name === 'c' && this.finder && !this.dialogs.length) { this.closeFind(); return; } // not the hidden input's text
1239
+ if (key.ctrl && key.name === 'c') { this.onCtrlC(); return; }
1240
+ const dialog = this.dialogs[0];
1241
+ if (dialog) {
1242
+ dialog.handleKey(str, key);
1243
+ this.screen.schedule();
1244
+ return;
1245
+ }
1246
+ if (key.ctrl && key.name === 'f') { this.openFind(); return; }
1247
+ if (this.finder) { this.findKey(str, key); this.screen.schedule(); return; }
1248
+ if (key.name === 'tab' && key.shift) { this.runtime.modes.toggle(); this.screen.schedule(); return; }
1249
+ // alt+v like Claude Code on Windows (ctrl+v is the terminal's own text paste there).
1250
+ if ((key.meta || key.ctrl) && key.name === 'v') { this.pasteImage(); return; }
1251
+ if (key.ctrl && key.name === 'o') {
1252
+ this.verbose = !this.verbose;
1253
+ this.notice(this.verbose ? 'Verbose mode on: showing thinking and full tool output' : 'Verbose mode off', 'info');
1254
+ return;
1255
+ }
1256
+ if (key.ctrl && key.name === 't') { this.showTodos = !this.showTodos; this.screen.schedule(); return; }
1257
+ if (key.ctrl && key.name === 'l') { if (this.screen.full) this.screen.repaint(); else this.screen.clearScreen(); return; }
1258
+ if (key.ctrl && key.name === 'd' && !this.editor.text) { this.exit(0); return; }
1259
+ if (key.name === 'escape' && this.busy) { this.interrupt(); return; }
1260
+ this.exitArmed = 0;
1261
+ this.editor.handleKey(str, key);
1262
+ this.screen.schedule();
1263
+ }
1264
+
1265
+ onCtrlC() {
1266
+ if (this.dialogs.length) {
1267
+ // Cancel the dialog and interrupt the run.
1268
+ for (const d of this.dialogs.splice(0)) d.resolve(d.cancelValue);
1269
+ this.interrupt();
1270
+ return;
1271
+ }
1272
+ if (this.busy) { this.interrupt(); return; }
1273
+ if (this.editor.text) { this.editor.discard(); this.screen.schedule(); return; }
1274
+ if (Date.now() - this.exitArmed < 1500) { this.exit(0); return; }
1275
+ this.exitArmed = Date.now();
1276
+ this.screen.schedule();
1277
+ setTimeout(() => this.screen.schedule(), 1600);
1278
+ }
1279
+
1280
+ interrupt() {
1281
+ if (!this.controller || this.controller.signal.aborted) return;
1282
+ this.controller.abort();
1283
+ this.screen.schedule();
1284
+ }
1285
+
1286
+ suggest(text, cursor) {
1287
+ if (text.startsWith('/') && !/\s/.test(text) && cursor === text.length) {
1288
+ const q = text.slice(1).toLowerCase();
1289
+ const all = [...this.commands.values()].filter((cmd) => !cmd.hidden);
1290
+ const pref = all.filter((cmd) => cmd.name.startsWith(q) || cmd.aliases?.some((a) => a.startsWith(q)));
1291
+ const incl = q ? all.filter((cmd) => !pref.includes(cmd) && cmd.name.includes(q)) : [];
1292
+ const items = [...pref, ...incl].map((cmd) => ({
1293
+ value: '/' + cmd.name,
1294
+ label: '/' + cmd.name + (cmd.argHint ? ' ' + cmd.argHint : ''),
1295
+ description: cmd.description,
1296
+ submitOnEnter: !cmd.argRequired,
1297
+ }));
1298
+ if (items.length === 1 && items[0].value === text && !items[0].submitOnEnter) return null;
1299
+ return { items, from: 0, to: text.length };
1300
+ }
1301
+ // The mention being typed: `@name`, or `@"name with spaces` once a quote has been opened.
1302
+ const before = text.slice(0, cursor);
1303
+ const m = /(^|\s)@(?:"([^"@]*)|([^\s@"]*))$/.exec(before);
1304
+ if (m) {
1305
+ const quoted = m[2] !== undefined;
1306
+ const q = quoted ? m[2] : m[3];
1307
+ const files = this.fileIndex.search(q);
1308
+ if (!files.length) return null;
1309
+ // A name with spaces has to be quoted for the mention to be read back as one file. A directory
1310
+ // leaves the quote open, so that completion goes on inside it; a file closes it.
1311
+ const value = (f) => (quoted || /\s/.test(f) ? (f.endsWith('/') ? `@"${f}` : `@"${f}"`) : '@' + f);
1312
+ return { items: files.map((f) => ({ value: value(f), label: f, noSpace: f.endsWith('/') })), from: cursor - q.length - (quoted ? 2 : 1), to: cursor };
1313
+ }
1314
+ // `&name`: the other sessions, on this computer and (through the gateway) on the account's others. At the start
1315
+ // it sends them the message; elsewhere it names them (/sendfile, or for the model).
1316
+ const amp = /(^|\s)[&&]([^\s&&]*)$/u.exec(before);
1317
+ if (amp) return this.peerSuggestions(amp[2], cursor, amp.index === 0 && !amp[1]);
1318
+ return null;
1319
+ }
1320
+
1321
+ peerSuggestions(q, cursor, atStart = true) {
1322
+ if (this.remote?.online && Date.now() - this.remotePeersAt > 10_000) {
1323
+ this.refreshRemotePeers().then(() => {
1324
+ if (!/[&&][^\s&&]*$/u.test(this.editor.text.slice(0, this.editor.cursor))) return;
1325
+ this.editor.refreshSuggestions();
1326
+ this.screen.schedule();
1327
+ });
1328
+ }
1329
+ const lq = q.toLowerCase();
1330
+ const items = [
1331
+ ...(this.peers?.peers() || []).filter((p) => p.name.toLowerCase().startsWith(lq))
1332
+ .map((p) => ({ value: '&' + p.name, label: `&${p.name}`, description: `this computer · ${displayPath(p.cwd, this.cwd)}` })),
1333
+ ...this.remotePeers.filter((p) => [p.label, p.name, p.machine].some((v) => v.toLowerCase().startsWith(lq)))
1334
+ .map((p) => ({ value: '&' + p.label, label: `&${p.label}`, description: `${p.machine} · ${p.cwd}${p.busy ? ' · busy' : ''}` })),
1335
+ ];
1336
+ if (!items.length) {
1337
+ 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)');
1338
+ return null;
1339
+ }
1340
+ return { items, from: cursor - q.length - 1, to: cursor };
1341
+ }
1342
+
1343
+ onSubmit(value, raw, images = []) {
1344
+ const text = value.trim();
1345
+ if (!text) { this.screen.schedule(); return; }
1346
+ if (this.screen.scrolledUp) this.screen.scrollBy('bottom'); // what was sent, and what comes of it, is in view
1347
+ appendHistory(this.cwd, raw);
1348
+ if (this.sendTypedToPeer(text)) return;
1349
+ if (this.busy) {
1350
+ if (text.startsWith('/')) { this.notice('Wait for the current task to finish (or press Esc) before running commands.', 'warn'); return; }
1351
+ this.queue.push({ text: value, images });
1352
+ this.screen.schedule();
1353
+ return;
1354
+ }
1355
+ if (images.length) { this.startTurn(value, { images }); return; }
1356
+ if (text === '?') { this.screen.log(this.shortcutsHelp()); return; }
1357
+ if (raw.startsWith('!')) { this.runBashMode(value.slice(1).trim()); return; }
1358
+ if (raw.startsWith('#')) { this.addMemory(value.slice(1).trim()); return; }
1359
+ if (text.startsWith('/')) {
1360
+ const [name, ...rest] = text.slice(1).split(/\s+/);
1361
+ const args = text.slice(1 + name.length).trim();
1362
+ const cmd = this.commands.get(name.toLowerCase()) || [...this.commands.values()].find((x) => x.aliases?.includes(name.toLowerCase()));
1363
+ if (cmd) {
1364
+ this.echoUser(text);
1365
+ Promise.resolve(cmd.run(args, rest)).catch((e) => this.logError(e)).finally(() => this.screen.schedule());
1366
+ return;
1367
+ }
1368
+ if (!/^\/[\w:-]+$/.test(text.split(/\s/)[0])) { this.startTurn(value); return; } // e.g. a path like /usr/bin
1369
+ this.notice(`Unknown command: /${name}. Type /help for the list of commands.`, 'warn');
1370
+ return;
1371
+ }
1372
+ this.startTurn(value);
1373
+ }
1374
+
1375
+ // `&name message`: the message goes to that session, as a task for it. A session on another computer of the
1376
+ // gateway account: `&machine/name message`, or `&machine message` when that computer runs one session.
1377
+ sendTypedToPeer(text) {
1378
+ if (!/^[&&]/u.test(text)) return false;
1379
+ const m = /^[&&](\S+)(?:\s+([\s\S]*))?$/u.exec(text);
1380
+ const task = m?.[2]?.trim();
1381
+ if (!m || !task) { this.notice('Type the task after the name: &name task (/peers lists the sessions).', 'warn'); return true; }
1382
+ this.echoUser(text);
1383
+ const found = resolvePeer(this.peers?.peers() || [], this.remotePeers, m[1], { ids: false });
1384
+ if (found.ambiguous) { this.notice(found.error, 'warn'); return true; }
1385
+ this.sendToPeer(found.peer || m[1], task).then(
1386
+ ({ peer }) => {
1387
+ const where = peer.remote ? `on ${peer.machine}: ${peer.cwd}` : displayPath(peer.cwd, this.cwd);
1388
+ this.screen.log(c.dim(` ⎿ Sent to &${peer.label || peer.name} (${where}); its answer will be shown here`), '');
1389
+ },
1390
+ (e) => this.notice(e.message, 'warn'),
1391
+ );
1392
+ return true;
1393
+ }
1394
+
1395
+ shortcutsHelp() {
1396
+ const rows = [
1397
+ ['! for bash mode', 'double tap esc to clear input'],
1398
+ ['/ for commands', 'shift + tab to cycle permission modes'],
1399
+ ['@ for file paths, & for other sessions', 'ctrl + o to toggle verbose output'],
1400
+ ['PgUp/PgDn, wheel to scroll', 'ctrl + home/end to the top/bottom'],
1401
+ ['# to memorize', 'ctrl + t to toggle todos'],
1402
+ ['\\⏎ or alt+⏎ for newline', 'ctrl + l to redraw, ctrl + d to exit'],
1403
+ ['alt + v to paste an image', 'ctrl + f to find in the conversation'],
1404
+ ];
1405
+ return rows.map(([a, b]) => c.dim(' ' + padEnd(a, 30) + b));
1406
+ }
1407
+
1408
+ // ---------------------------------------------------------------- finding in the conversation (ctrl+f)
1409
+ //
1410
+ // As in VS Code: a box takes the input box's place, the conversation shows the matches as they are typed, the
1411
+ // current one apart, and goes to it. In a conversation the newest matter most: the first one shown is the
1412
+ // nearest above the bottom of the view, enter (or ↑) goes on to older ones, shift+enter (or ↓) back to newer
1413
+ // ones, round from either end. Esc closes the box and leaves the view where it is. The text typed last comes
1414
+ // back selected (typing replaces it), or what is selected with the mouse. Fullscreen only: in the classic
1415
+ // renderer the conversation is in the terminal's own scrollback.
1416
+
1417
+ openFind(seed = '') {
1418
+ if (!this.screen.full) { this.flash('ctrl+f finds in the conversation in fullscreen (/tui); here use the terminal\'s own find', 'warn'); return; }
1419
+ if (this.finder && !seed.trim()) { this.finder.selected = Boolean(this.finder.text); this.screen.schedule(); return; } // open: select what is typed
1420
+ const text = (seed.split('\n').find((l) => l.trim()) || '').trim() || this.lastFind || '';
1421
+ if (!this.finder) this.screen.openFind();
1422
+ this.finder = { text, cursor: text.length, selected: Boolean(text), pasting: null };
1423
+ this.screen.setFind(text);
1424
+ this.screen.schedule();
1425
+ }
1426
+
1427
+ closeFind() {
1428
+ if (!this.finder) return;
1429
+ this.lastFind = this.finder.text;
1430
+ this.finder = null;
1431
+ this.screen.closeFind();
1432
+ this.screen.schedule();
1433
+ }
1434
+
1435
+ findKey(str, key) {
1436
+ const f = this.finder;
1437
+ const name = key.name;
1438
+ const edit = (text, cursor) => {
1439
+ Object.assign(f, { text, cursor, selected: false });
1440
+ this.screen.setFind(text);
1441
+ };
1442
+ const insert = (s) => {
1443
+ const t = s.replace(/[\r\n\t]+/g, ' ');
1444
+ if (!t) return;
1445
+ if (f.selected) edit(t, t.length);
1446
+ else edit(f.text.slice(0, f.cursor) + t + f.text.slice(f.cursor), f.cursor + t.length);
1447
+ };
1448
+ if (name === 'paste-start') { f.pasting = ''; return; }
1449
+ if (f.pasting !== null) {
1450
+ if (name === 'paste-end') { const t = f.pasting.split(/\r?\n/).find((l) => l.trim()) || ''; f.pasting = null; insert(t.trim()); }
1451
+ else if (name === 'return' || name === 'enter') f.pasting += '\n';
1452
+ else if (str) f.pasting += str;
1453
+ return;
1454
+ }
1455
+ if (name === 'escape') { this.closeFind(); return; }
1456
+ const back = (name === 'return' && (key.meta || key.shift)) || name === 'enter' || key.sequence === '\x1b[13;2u' || (name === 'f3' && key.shift);
1457
+ if (back || name === 'down' || (key.ctrl && name === 'n')) { f.selected = false; this.screen.findStep(-1); return; }
1458
+ if (name === 'return' || name === 'f3' || name === 'up' || (key.ctrl && name === 'p')) { f.selected = false; this.screen.findStep(1); return; }
1459
+ const chars = [...f.text.slice(0, f.cursor)];
1460
+ if (name === 'backspace') {
1461
+ if (f.selected) edit('', 0);
1462
+ else if (chars.length) { const keep = chars.slice(0, -1).join(''); edit(keep + f.text.slice(f.cursor), keep.length); }
1463
+ return;
1464
+ }
1465
+ if (name === 'delete') {
1466
+ if (f.selected) edit('', 0);
1467
+ else if (f.cursor < f.text.length) { const next = [...f.text.slice(f.cursor)].slice(1).join(''); edit(f.text.slice(0, f.cursor) + next, f.cursor); }
1468
+ return;
1469
+ }
1470
+ if (key.ctrl && name === 'u') { edit('', 0); return; }
1471
+ if (name === 'left') { f.selected = false; f.cursor = chars.slice(0, -1).join('').length; return; }
1472
+ if (name === 'right') { f.selected = false; f.cursor = Math.min(f.text.length, f.cursor + ([...f.text.slice(f.cursor)][0]?.length || 0)); return; }
1473
+ if (name === 'home' || (key.ctrl && name === 'a')) { f.selected = false; f.cursor = 0; return; }
1474
+ if (name === 'end' || (key.ctrl && name === 'e')) { f.selected = false; f.cursor = f.text.length; return; }
1475
+ if (str && !key.ctrl && !key.meta && !/[\x00-\x1f\x7f]/.test(str)) insert(str);
1476
+ }
1477
+
1478
+ // The find box, in the input box's place: what is typed (selected: in reverse video), and how many matches.
1479
+ renderFind(width) {
1480
+ const f = this.finder;
1481
+ const boxW = Math.max(20, width - 1);
1482
+ const textW = boxW - 6;
1483
+ const s = this.screen.findStatus();
1484
+ const count = !f.text ? '' : s?.total ? `${s.index + 1}/${s.total}` : c.red('No results');
1485
+ const title = { left: 'Find in the conversation', right: count };
1486
+ // the part of the text that fits, the cursor in it
1487
+ let before = f.text.slice(0, f.cursor);
1488
+ while (before && stringWidth(before) > textW - 1) before = [...before].slice(1).join('');
1489
+ const shown = truncate(before + f.text.slice(f.cursor), textW, '');
1490
+ const content = f.selected && shown ? c.inverse(shown) : shown;
1491
+ const lines = [
1492
+ topBorder(boxW, c.yellow, title),
1493
+ c.yellow('│') + ' ' + c.yellow('⌕') + ' ' + padEnd(content, textW) + ' ' + c.yellow('│'),
1494
+ c.yellow('╰' + '─'.repeat(boxW - 2) + '╯'),
1495
+ ];
1496
+ return { lines, cursor: { row: 1, col: 4 + stringWidth(before) } };
1497
+ }
1498
+
1499
+ // ---------------------------------------------------------------- turns
1500
+
1501
+ // Where a mentioned path is (null when there is no such file): relative to the project root, which is
1502
+ // where the autocomplete lists files from, or else relative to where the shell is now (a `cd` in a
1503
+ // Bash tool call moves that). Nothing at all is not a path: it would mean the directory itself.
1504
+ resolveMention(ref) {
1505
+ if (!ref.trim()) return null;
1506
+ for (const base of [this.cwd, this.agent.cwd]) {
1507
+ const file = resolvePath(ref, base);
1508
+ if (fs.existsSync(file)) return file;
1509
+ }
1510
+ return null;
1511
+ }
1512
+
1513
+ // The file a mention `@...` stands for. A quoted name is exactly what is inside the quotes. A plain name
1514
+ // that exists as written is that file; otherwise the punctuation prose puts behind a mention ("see
1515
+ // @a.js, then", "(@a.js)") is not part of it.
1516
+ mentionedFile(token) {
1517
+ if (token.startsWith('"')) return this.resolveMention(token.slice(1, -1));
1518
+ return this.resolveMention(token) || this.resolveMention(token.replace(/[,.;:!?)]+$/, ''));
1519
+ }
1520
+
1521
+ // Expand @file mentions into attached file contents, like Claude Code: what the model would have
1522
+ // seen had it called Read itself. Going through the Read tool is what keeps this honest: binary files
1523
+ // are refused, images become images, and the file is recorded as read, as only partly read when it
1524
+ // was cut (so Write will not overwrite lines the model was never shown).
1525
+ async attachments(text) {
1526
+ const blocks = [];
1527
+ const seen = new Set();
1528
+ const read = this.runtime.tools.find((t) => t.name === 'Read');
1529
+ const skipped = (file, why) => this.screen.log(c.dim(` ⎿ Skipped ${displayPath(file, this.cwd)}: ${why}`));
1530
+ for (const m of text.matchAll(/(?:^|\s)@("[^"]+"|[^\s"]+)/g)) {
1531
+ const file = this.mentionedFile(m[1]);
1532
+ if (!file || seen.has(file)) continue; // @someone, @..., a file that is not there: not a mention
1533
+ seen.add(file);
1534
+ let st;
1535
+ try { st = fs.statSync(file); } catch { continue; }
1536
+ if (st.isDirectory()) {
1537
+ let entries;
1538
+ try {
1539
+ const all = fs.readdirSync(file, { withFileTypes: true });
1540
+ entries = all.slice(0, 200).map((e) => (e.isDirectory() ? e.name + '/' : e.name));
1541
+ if (all.length > 200) entries.push(`(${all.length - 200} more entries not shown)`);
1542
+ } catch (e) { skipped(file, e.message); continue; }
1543
+ blocks.push({ type: 'text', text: `<system-reminder>Called the Bash tool with the following input: {"command":"ls ${file}"}\nResult:\n${entries.join('\n')}</system-reminder>` });
1544
+ this.screen.log(c.dim(` ⎿ Listed directory ${displayPath(file, this.cwd)}/`));
1545
+ continue;
1546
+ }
1547
+ if (!read || !st.isFile()) continue;
1548
+ const isImage = /\.(png|jpe?g|gif|webp)$/i.test(file);
1549
+ if (!isImage && st.size >= 512 * 1024) { skipped(file, `${Math.round(st.size / 1024)}KB is too large to attach; ask for the part you need`); continue; }
1550
+ const input = { file_path: file, limit: 2000 };
1551
+ let result;
1552
+ try { result = await read.call(input, this.agent.makeCtx()); } catch (e) { skipped(file, e.message); continue; }
1553
+ if (result.isError) { skipped(file, String(result.content).split('\n')[0]); continue; }
1554
+ blocks.push(typeof result.content === 'string'
1555
+ ? { type: 'text', text: `<system-reminder>Called the Read tool with the following input: ${JSON.stringify(input)}\nResult of calling the Read tool:\n${result.content}</system-reminder>` }
1556
+ : { type: 'text', text: `<system-reminder>The user attached ${file}:</system-reminder>` });
1557
+ if (typeof result.content !== 'string') blocks.push(...result.content);
1558
+ this.screen.log(c.dim(` ⎿ ${result.display?.summary || 'Read'}: ${displayPath(file, this.cwd)}`));
1559
+ }
1560
+ return blocks;
1561
+ }
1562
+
1563
+ // `topic`: what to name the conversation's topic after (default: the message as shown).
1564
+ // `peers`: tasks of other sessions that this turn answers.
1565
+ async startTurn(prompt, { echo = true, display, images = [], topic, peers = [] } = {}) {
1566
+ if (echo) this.echoUser(display ?? prompt, images.length);
1567
+ this.refreshTopic(topic ?? display ?? prompt);
1568
+ let content;
1569
+ try {
1570
+ const sources = images.map((i) => i.source).filter(Boolean);
1571
+ content = [
1572
+ { type: 'text', text: prompt },
1573
+ ...images.map(imageBlock),
1574
+ ...(sources.length ? [{ type: 'text', text: `(The attached image${sources.length === 1 ? ' is the file' : 's are the files'} ${sources.join(', ')})` }] : []),
1575
+ ...(await this.attachments(prompt)),
1576
+ ...(await this.peerMentions(prompt)),
1577
+ ];
1578
+ await fitImagesIn(content); // a dropped or attached image file at full size would be refused later
1579
+ } catch (e) {
1580
+ this.logError(e); // nobody awaits this method: an error left to escape would end the program
1581
+ return;
1582
+ }
1583
+ await this.runAgent(content, { peers });
1584
+ }
1585
+
1586
+ // What `&name` in a message the user wrote stands for, told to the model: which session, and how to reach it.
1587
+ async peerMentions(prompt) {
1588
+ const refs = [...new Set([...String(prompt).matchAll(/(?:^|\s)[&&]([^\s&&,,。;;::!!??、))]+)/gu)].map((m) => m[1]))];
1589
+ if (!refs.length) return [];
1590
+ const local = this.peers?.peers() || [];
1591
+ const notes = [];
1592
+ for (const ref of refs) {
1593
+ let found = resolvePeer(local, this.remotePeers, ref, { ids: false });
1594
+ if (!found.peer && !found.ambiguous && this.remote) found = resolvePeer(local, await this.refreshRemotePeers(), ref, { ids: false });
1595
+ const p = found.peer;
1596
+ 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}`);
1597
+ else if (found.ambiguous) notes.push(found.error);
1598
+ }
1599
+ if (!notes.length) return [];
1600
+ 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>` }];
1601
+ }
1602
+
1603
+ // alt+v (ctrl+v where the terminal passes it through): attach an image from the clipboard.
1604
+ async pasteImage() {
1605
+ if (this.readingClipboard) return;
1606
+ this.readingClipboard = true;
1607
+ this.flash('Reading image from clipboard…');
1608
+ try {
1609
+ const r = await readClipboardImage();
1610
+ if (r.image) {
1611
+ const label = this.editor.insertImage(r.image);
1612
+ const size = r.image.width ? `${r.image.width}×${r.image.height}, ` : '';
1613
+ this.flash(`${label} attached (${size}${formatTokens(r.image.bytes).replace(/k$/, 'KB')})`);
1614
+ } else {
1615
+ this.flash(r.error || 'No image found in clipboard. Copy an image (e.g. Win+Shift+S) and press alt+v again.', 'warn');
1616
+ }
1617
+ } catch (e) {
1618
+ this.flash(`Could not read the clipboard: ${e.message}`, 'warn');
1619
+ } finally {
1620
+ this.readingClipboard = false;
1621
+ this.screen.schedule();
1622
+ }
1623
+ }
1624
+
1625
+ // A short-lived message in the footer (not written to the transcript).
1626
+ flash(text, kind = 'info') {
1627
+ this.flashMessage = { text, kind, until: Date.now() + 4000 };
1628
+ this.screen.schedule();
1629
+ clearTimeout(this.flashTimer);
1630
+ this.flashTimer = setTimeout(() => this.screen.schedule(), 4100);
1631
+ }
1632
+
1633
+ async runAgent(content, { peers = [] } = {}) {
1634
+ this.busy = true;
1635
+ this.turnPeers.push(...peers);
1636
+ let result = null;
1637
+ let error = null;
1638
+ this.controller = new AbortController();
1639
+ this.turnStart = Date.now();
1640
+ this.turnTokens = 0;
1641
+ this.verb = VERBS[Math.floor(Math.random() * VERBS.length)];
1642
+ this.screen.schedule();
1643
+ try {
1644
+ result = await this.agent.run(content, { signal: this.controller.signal });
1645
+ if (result.interrupted) {
1646
+ this.flushStreaming();
1647
+ this.flushActive(true);
1648
+ this.screen.log(resultLines([c.red('Interrupted by user')]), '');
1649
+ if (this.restartRequest) {
1650
+ this.restartRequest = null;
1651
+ this.notice('The restart was cancelled with the turn. Run /restart to load the new code.');
1652
+ }
1653
+ if (this.sourceChanged) this.skipAutoReload = true;
1654
+ }
1655
+ } catch (e) {
1656
+ error = e;
1657
+ this.flushStreaming();
1658
+ this.logError(e);
1659
+ } finally {
1660
+ this.flushStreaming();
1661
+ this.flushActive(true);
1662
+ this.answerPeers(result, error);
1663
+ this.busy = false;
1664
+ this.controller = null;
1665
+ this.status = null;
1666
+ this.rateWait = null;
1667
+ this.screen.schedule();
1668
+ }
1669
+ this.sendWhatWaited();
1670
+ }
1671
+
1672
+ // The app has stopped being busy (a turn, a compaction, a command): send what was typed in the
1673
+ // meantime as the next turn, or else tell the model about background agents that finished after the
1674
+ // last check. Everything that makes the app busy has to end with this, or what waited stays queued.
1675
+ sendWhatWaited() {
1676
+ if (this.busy || this.exiting) return;
1677
+ // A restart that was asked for goes first; what waited is carried over and sent by the new process.
1678
+ if (this.restartRequest) { this.restartNow(); return; }
1679
+ if (this.queue.length) {
1680
+ const next = this.queue.splice(0);
1681
+ this.startTurn(next.map((q) => q.text).join('\n\n'), {
1682
+ images: next.flatMap((q) => q.images),
1683
+ display: next.map((q) => q.display ?? q.text).join('\n\n'),
1684
+ peers: next.map((q) => q.peer).filter(Boolean),
1685
+ });
1686
+ } else if (this.agent.notifications.length) {
1687
+ this.runAgent(this.agent.drainNotifications());
1688
+ } else {
1689
+ this.maybeAutoReload();
1690
+ this.maybeInstallUpdate();
1691
+ }
1692
+ }
1693
+
1694
+ // A background agent ended. If nothing is running, start a turn so the model sees its
1695
+ // report right away; otherwise the running turn picks it up at its next step.
1696
+ backgroundTaskDone(task) {
1697
+ const secs = Math.round((task.ended - task.started) / 1000);
1698
+ const color = task.status === 'completed' ? c.green : c.red;
1699
+ this.screen.log(`${color(DOT)} ${c.bold('Background agent')} ${task.id} ${c.dim(`"${task.description}"`)} ${task.status} ${c.dim(`(${formatDuration(secs * 1000)})`)}`, '');
1700
+ if (!this.busy && !this.exiting && this.agent.notifications.length) this.runAgent(this.agent.drainNotifications());
1701
+ else this.screen.schedule();
1702
+ }
1703
+
1704
+ async runBashMode(command) {
1705
+ if (!command) return;
1706
+ this.screen.log(c.magenta('! ') + command);
1707
+ const tool = this.runtime.tools.find((t) => t.name === 'Bash');
1708
+ this.busy = true;
1709
+ this.controller = new AbortController();
1710
+ this.status = 'Running';
1711
+ this.turnStart = Date.now();
1712
+ this.turnTokens = 0;
1713
+ const ctx = this.agent.makeCtx(this.controller.signal);
1714
+ const entry = { tool, input: { command }, status: 'running', progress: '', sub: [], subCount: 0, ctx, started: Date.now() };
1715
+ this.active.push(entry);
1716
+ let result;
1717
+ try {
1718
+ ctx.onProgress = (p) => { entry.progress = p; };
1719
+ result = await tool.call({ command }, ctx);
1720
+ } catch {
1721
+ const partial = (entry.progress || '').replace(/\r\n/g, '\n').trimEnd();
1722
+ result = { isError: true, content: `${partial ? partial + '\n' : ''}Interrupted by user`, interrupted: true };
1723
+ }
1724
+ this.active = this.active.filter((e) => e !== entry);
1725
+ const out = typeof result.content === 'string' ? result.content : '';
1726
+ let lines = out.split('\n');
1727
+ if (lines.length > 40) lines = [...lines.slice(0, 20), c.dim(`… +${lines.length - 40} lines`), ...lines.slice(-20)];
1728
+ this.screen.log(resultLines(lines.map((l) => (result.isError && l === lines[lines.length - 1] ? c.red(l) : c.dim(l)))), '');
1729
+ this.agent.pushMessage({
1730
+ role: 'user',
1731
+ content: [{ type: 'text', text: `<bash-input>${command}</bash-input>\n<bash-stdout>${out}</bash-stdout>\n<system-reminder>The user ran this command directly in bash mode. Take its output into account in later answers.</system-reminder>` }],
1732
+ });
1733
+ this.busy = false;
1734
+ this.controller = null;
1735
+ this.status = null;
1736
+ this.screen.schedule();
1737
+ this.sendWhatWaited();
1738
+ }
1739
+
1740
+ addMemory(text) {
1741
+ if (!text) return;
1742
+ const files = {
1743
+ project: path.join(this.cwd, 'CLAUDE.md'),
1744
+ local: path.join(this.cwd, 'CLAUDE.local.md'),
1745
+ user: path.join(CONFIG_DIR, 'CLAUDE.md'),
1746
+ };
1747
+ this.showDialog(new SelectDialog({
1748
+ title: 'Save memory',
1749
+ body: [c.dim(truncate(text, this.width - 10))],
1750
+ question: 'Where should this memory be saved?',
1751
+ color: c.blue,
1752
+ options: [
1753
+ { label: `Project memory ${c.dim(displayPath(files.project, this.cwd))}`, value: 'project', description: 'Checked in and shared with your team' },
1754
+ { label: `Project memory (local) ${c.dim(displayPath(files.local, this.cwd))}`, value: 'local', description: 'Just for you in this project' },
1755
+ { label: `User memory ${c.dim(displayPath(files.user, this.cwd))}`, value: 'user', description: 'Your instructions for all projects' },
1756
+ ],
1757
+ })).then((choice) => {
1758
+ if (!choice) return;
1759
+ const file = files[choice];
1760
+ // This runs outside any handler: an error here (a read-only directory) would end the program.
1761
+ try {
1762
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1763
+ let existing = '';
1764
+ try { existing = fs.readFileSync(file, 'utf8'); } catch { /* new */ }
1765
+ fs.appendFileSync(file, `${existing && !existing.endsWith('\n') ? '\n' : ''}- ${text}\n`);
1766
+ // The system prompt stays as it is until the conversation starts over (/clear or a compaction): a prompt
1767
+ // that changes in the middle of it cannot be read from the prompt cache.
1768
+ this.runtime.deferSystemPromptRefresh();
1769
+ this.screen.log(resultLines([c.dim(`Saved to ${displayPath(file, this.cwd)} · the model sees it from the next /clear or /compact`)]));
1770
+ } catch (e) {
1771
+ this.notice(`Could not save the memory to ${displayPath(file, this.cwd)}: ${e.message}`, 'error');
1772
+ }
1773
+ });
1774
+ }
1775
+
1776
+ // `quiet`: do not print the transcript (a restart: it is still on screen).
1777
+ loadSession(file, id, { quiet = false } = {}) {
1778
+ const loaded = SessionStore.load(file);
1779
+ this.previousSession = null; // the conversation before a /clear: resuming another one ends the offer
1780
+ this.store = new SessionStore(this.cwd, id);
1781
+ this.topic = loaded.topic;
1782
+ this.customTitle = loaded.name;
1783
+ // a conversation that was given a name brings it back: the other sessions reach this one by it again
1784
+ if (loaded.name && loaded.name !== this.peerLabel()) {
1785
+ if (this.peers) this.peers.setName(loaded.name);
1786
+ else this.sessionName = loaded.name;
1787
+ this.remote?.register().catch(() => {});
1788
+ }
1789
+ this.firstPrompt = loaded.title;
1790
+ this.cost = loaded.cost;
1791
+ this.agent.cache.clockUntil = this.store.cacheUntil = loaded.cacheUntil;
1792
+ this.agent.messages = loaded.messages;
1793
+ this.agent.restoreFileState(loaded.messages, loaded.times);
1794
+ this.agent.lastActivityAt = loaded.lastAt; // a long pause before this turn clears old tool results, as it would have
1795
+ this.agent.lastContextTokens = estimateTokens(loaded.messages);
1796
+ // Continue behind the system prompt the conversation had (and its cache), not a freshly built one.
1797
+ this.store.systemPrompt = loaded.systemPrompt;
1798
+ this.runtime.resumeSystemPrompt(loaded.systemPrompt);
1799
+ if (!this.topic) this.refreshTopic(this.latestPrompt()); // a session from before topics were kept
1800
+ this.remote?.resync();
1801
+ if (quiet) return;
1802
+ this.logLocal(c.dim(`Resumed session ${id} (${loaded.messages.length} messages)`), '');
1803
+ this.remoteMuted++;
1804
+ try { this.printTranscript(loaded.messages.slice(-30)); } finally { this.remoteMuted--; }
1805
+ }
1806
+
1807
+ printTranscript(messages) {
1808
+ const toolMap = new Map(this.runtime.tools.map((t) => [t.name, t]));
1809
+ const ctx = this.agent.makeCtx();
1810
+ for (const m of messages) {
1811
+ const blocks = typeof m.content === 'string' ? [{ type: 'text', text: m.content }] : m.content;
1812
+ for (const b of blocks) {
1813
+ if (m.role === 'user' && b.type === 'text' && !b.text.startsWith('<system-reminder>')) this.screen.log(renderUserMessage(b.text.slice(0, 2000), this.width));
1814
+ if (m.role === 'assistant' && b.type === 'text') this.screen.log(renderMarkdown(b.text, { width: this.width - 1, firstPrefix: `${DOT} `, indent: ' ' }), '');
1815
+ if (m.role === 'assistant' && b.type === 'tool_use') {
1816
+ const tool = toolMap.get(b.name) || { name: b.name };
1817
+ this.screen.log(toolHeader(tool, b.input, ctx, { width: this.width }));
1818
+ }
1819
+ }
1820
+ }
1821
+ this.screen.log('');
1822
+ }
1823
+
1824
+ newSession() {
1825
+ this.store = new SessionStore(this.cwd);
1826
+ this.topic = null;
1827
+ this.customTitle = null;
1828
+ this.firstPrompt = null;
1829
+ this.cost = 0;
1830
+ this.remote?.resync();
1831
+ }
1832
+
1833
+ // ---------------------------------------------------------------- dialogs
1834
+
1835
+ showDialog(dialog) {
1836
+ return new Promise((resolve) => {
1837
+ const prev = dialog.onResolve;
1838
+ dialog.onResolve = (v) => {
1839
+ this.dialogs = this.dialogs.filter((d) => d !== dialog);
1840
+ prev?.(v);
1841
+ resolve(v);
1842
+ this.screen.schedule();
1843
+ };
1844
+ this.dialogs.push(dialog);
1845
+ this.screen.schedule();
1846
+ });
1847
+ }
1848
+
1849
+ async approvePlan(plan) {
1850
+ this.flushStreaming();
1851
+ const md = renderMarkdown(plan, { width: this.width - 8 });
1852
+ const max = Math.max(8, this.screen.height - 14);
1853
+ const body = md.length > max ? [...md.slice(0, max), c.dim(`… +${md.length - max} lines (the full plan is printed above)`)] : md;
1854
+ if (md.length > max) this.logLocal(c.plan(`${DOT} Plan:`), ...md.map((l) => ' ' + l), '');
1855
+ const dialog = new SelectDialog({
1856
+ title: 'Ready to code?',
1857
+ body: ["Here is the plan:", '', ...body],
1858
+ question: 'Would you like to proceed?',
1859
+ color: c.plan,
1860
+ cancelValue: 'reject',
1861
+ options: [
1862
+ { label: 'Yes, start coding', value: 'full' },
1863
+ { label: 'No, keep planning', value: 'reject' },
1864
+ ],
1865
+ });
1866
+ dialog.markdown = plan; // the remote page shows the whole plan
1867
+ return await this.showDialog(dialog);
1868
+ }
1869
+
1870
+ async askQuestion(q) {
1871
+ const answer = await this.showDialog(new SelectDialog({
1872
+ title: q.header || 'Question',
1873
+ question: c.bold(q.question),
1874
+ color: c.blue,
1875
+ multi: !!q.multiSelect,
1876
+ otherOption: true,
1877
+ options: (q.options || []).map((o) => ({ label: o.label, value: o.label, description: o.description })),
1878
+ }));
1879
+ if (answer === null || answer === undefined) return null;
1880
+ return Array.isArray(answer) ? answer.join(', ') : answer;
1881
+ }
1882
+
1883
+ // ---------------------------------------------------------------- agent IO
1884
+
1885
+ isMain(agent) { return !agent || agent === this.runtime?.agent; }
1886
+
1887
+ streamEvent(ev, agent) {
1888
+ if (!this.isMain(agent)) return;
1889
+ const w = this.width - 1;
1890
+ switch (ev.type) {
1891
+ case 'block_start':
1892
+ if (ev.block.type === 'text') {
1893
+ this.flushThinking();
1894
+ this.md = new MarkdownStream({ width: w, firstPrefix: `${DOT} `, indent: ' ' });
1895
+ this.remote?.emit({ t: 'start', kind: 'text' });
1896
+ } else if (ev.block.type === 'thinking') {
1897
+ this.remote?.emit({ t: 'start', kind: 'thinking' });
1898
+ 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 };
1899
+ } else if (ev.block.type === 'tool_use') {
1900
+ this.flushThinking();
1901
+ this.pendingToolBlocks.set(ev.index, { name: ev.block.name, partial: '' });
1902
+ }
1903
+ break;
1904
+ case 'text':
1905
+ this.turnTokens += estimateTokens(ev.text);
1906
+ if (this.md) {
1907
+ const lines = this.md.write(ev.text);
1908
+ if (lines.length) this.logLocal(lines);
1909
+ this.remote?.emit({ t: 'delta', kind: 'text', text: ev.text });
1910
+ }
1911
+ break;
1912
+ case 'thinking':
1913
+ this.turnTokens += estimateTokens(ev.text);
1914
+ if (this.thinking) {
1915
+ this.thinking.text += ev.text;
1916
+ this.remote?.emit({ t: 'delta', kind: 'thinking', text: ev.text });
1917
+ if (this.thinking.md) {
1918
+ const lines = this.thinking.md.write(ev.text);
1919
+ if (lines.length) this.logLocal(lines);
1920
+ }
1921
+ }
1922
+ break;
1923
+ case 'tool_input': {
1924
+ this.turnTokens += 1;
1925
+ const p = this.pendingToolBlocks.get(ev.index);
1926
+ if (p) p.partial = ev.partial;
1927
+ break;
1928
+ }
1929
+ case 'block_stop':
1930
+ if (ev.block.type === 'text' && this.md) {
1931
+ const lines = this.md.end();
1932
+ if (lines.length) this.logLocal(lines);
1933
+ this.logLocal('');
1934
+ this.md = null;
1935
+ this.remote?.emit({ t: 'stop' });
1936
+ } else if (ev.block.type === 'thinking') {
1937
+ this.flushThinking();
1938
+ } else if (ev.block.type === 'tool_use') {
1939
+ this.pendingToolBlocks.delete(ev.index);
1940
+ }
1941
+ break;
1942
+ case 'retry':
1943
+ this.notice(`API error: ${truncate(ev.error.message, 200)} · retrying in ${Math.round(ev.delayMs / 1000)}s (attempt ${ev.attempt}${ev.maxRetries ? `/${ev.maxRetries}` : ''})`, 'warn');
1944
+ break;
1945
+ case 'rate_limit_wait':
1946
+ case 'rate_limit_resume':
1947
+ this.rateLimitEvent(ev);
1948
+ break;
1949
+ case 'reset':
1950
+ if (this.md || this.thinking) this.remote?.emit({ t: 'stop' });
1951
+ this.md = null;
1952
+ this.thinking = null;
1953
+ this.pendingToolBlocks.clear();
1954
+ break;
1955
+ default:
1956
+ break;
1957
+ }
1958
+ this.screen.schedule();
1959
+ }
1960
+
1961
+ // A rate limit pause or its end, from a model request of the main agent (a turn or a compaction).
1962
+ rateLimitEvent(ev) {
1963
+ if (ev.type === 'rate_limit_resume') {
1964
+ this.rateWait = null;
1965
+ this.notice(resumeText(ev));
1966
+ return;
1967
+ }
1968
+ // Say it once; later waits of the same pause only move the countdown (unless the reset moved a lot).
1969
+ const prev = this.rateWait;
1970
+ this.rateWait = { resumeAt: ev.resumeAt, label: ev.label, polling: ev.polling, waitingForLogin: Boolean(ev.waitingForLogin), overloaded: Boolean(ev.overloaded) };
1971
+ if (shouldAnnounce(prev, ev)) this.notice(`${describePause(ev)} Esc cancels.`, 'warn');
1972
+ this.screen.schedule();
1973
+ }
1974
+
1975
+ flushThinking() {
1976
+ const t = this.thinking;
1977
+ if (!t) return;
1978
+ this.thinking = null;
1979
+ this.remote?.emit({ t: 'stop' });
1980
+ if (t.md) {
1981
+ const lines = t.md.end();
1982
+ if (lines.length) this.logLocal(lines);
1983
+ this.logLocal('');
1984
+ } else if (t.text.trim()) {
1985
+ const secs = Math.max(1, Math.round((Date.now() - t.start) / 1000));
1986
+ this.logLocal(c.dim(c.italic(`✻ Thought for ${secs}s (ctrl+o to show thinking)`)), '');
1987
+ }
1988
+ }
1989
+
1990
+ flushStreaming() {
1991
+ this.flushThinking();
1992
+ if (this.md) {
1993
+ const lines = this.md.end();
1994
+ if (lines.length) this.logLocal(lines, '');
1995
+ this.md = null;
1996
+ this.remote?.emit({ t: 'stop' });
1997
+ }
1998
+ this.pendingToolBlocks.clear();
1999
+ }
2000
+
2001
+ toolBatchStart() {
2002
+ this.flushStreaming();
2003
+ }
2004
+
2005
+ toolStart(tu, tool, input, agent) {
2006
+ if (!this.isMain(agent)) return {};
2007
+ const entry = { tu, tool, input, status: 'running', progress: '', sub: [], subCount: 0, ctx: agent.makeCtx(), started: Date.now() };
2008
+ this.active.push(entry);
2009
+ this.remote?.emit({ t: 'tool', id: tu.id, head: toolHeader(tool, input, entry.ctx, { width: REMOTE_WIDTH, dot: false }) });
2010
+ this.screen.schedule();
2011
+ return {
2012
+ progress: (p) => {
2013
+ if (typeof p === 'string') { entry.progress = p; this.remote?.toolProgress(tu.id, entry); }
2014
+ else if (p?.type === 'subtool') { entry.sub.push(p.header); entry.subCount++; this.remote?.emit({ t: 'sub', id: tu.id, head: p.header }); }
2015
+ this.screen.schedule();
2016
+ },
2017
+ done: (result) => {
2018
+ entry.result = result;
2019
+ entry.status = result.isError ? 'error' : 'done';
2020
+ this.remoteToolDone(entry);
2021
+ this.flushActive();
2022
+ },
2023
+ };
2024
+ }
2025
+
2026
+ toolRejected(tu, tool, message, agent) {
2027
+ if (!this.isMain(agent)) return;
2028
+ const ctx = agent.makeCtx();
2029
+ const entry = { tu, tool, input: tu.input, status: 'rejected', rejected: true, rejectMessage: message, ctx };
2030
+ if (this.remote) {
2031
+ this.remote.emit({ t: 'tool', id: tu.id, head: toolHeader(tool, tu.input, ctx, { width: REMOTE_WIDTH, dot: false }) });
2032
+ this.remoteToolDone(entry);
2033
+ }
2034
+ this.logLocal(toolHeader(tool, tu.input, ctx, { status: 'rejected', width: this.width }), renderToolResult(entry, { width: this.width, cwd: this.cwd }), '');
2035
+ }
2036
+
2037
+ // A tool call finished (or was refused, or interrupted): its result on the remote page, once.
2038
+ remoteToolDone(entry) {
2039
+ if (!this.remote || !entry.tu?.id || entry.remoteDone) return;
2040
+ entry.remoteDone = true;
2041
+ let lines = [];
2042
+ try { lines = renderToolResult(entry, { width: REMOTE_WIDTH, cwd: this.cwd }); } catch { /* shown without */ }
2043
+ this.remote.emit({ t: 'done', id: entry.tu.id, status: entry.result?.isError || entry.rejected ? 'error' : 'done', lines });
2044
+ }
2045
+
2046
+ // Move finished tool entries (in call order) from the live region to the transcript.
2047
+ flushActive(all = false) {
2048
+ while (this.active.length && (all || this.active[0].status !== 'running')) {
2049
+ const e = this.active.shift();
2050
+ if (e.status === 'running') { e.status = 'error'; e.result = { isError: true, content: 'Interrupted', interrupted: true }; }
2051
+ const status = e.result?.isError ? 'error' : 'done';
2052
+ this.remoteToolDone(e);
2053
+ this.logLocal(toolHeader(e.tool, e.input, e.ctx, { status, width: this.width }), renderToolResult(e, { verbose: this.verbose, width: this.width, cwd: this.cwd }), '');
2054
+ }
2055
+ this.screen.schedule();
2056
+ }
2057
+
2058
+ // IO object for a sub-agent: reports its tool calls into the parent's Task entry.
2059
+ subagentIo({ def, parentCtx }) {
2060
+ const app = this;
2061
+ let paused = null; // the rate limit wait last announced for this agent
2062
+ // The spinner belongs to the main agent (a background agent can be paused while it keeps working),
2063
+ // so a sub-agent's pause is only announced, once, and its end too. This covers the agent's turns
2064
+ // and the compaction of its own conversation alike.
2065
+ const rateLimit = (ev) => {
2066
+ if (ev.type === 'rate_limit_wait') {
2067
+ if (shouldAnnounce(paused, ev)) app.notice(`[${def.name}] ${describePause(ev)}`, 'warn');
2068
+ paused = { resumeAt: ev.resumeAt, waitingForLogin: Boolean(ev.waitingForLogin) };
2069
+ }
2070
+ if (ev.type === 'rate_limit_resume') {
2071
+ paused = null;
2072
+ app.notice(`[${def.name}] ${resumeText(ev)}`);
2073
+ }
2074
+ };
2075
+ return {
2076
+ approvePlan: async () => 'reject',
2077
+ askQuestion: async () => null,
2078
+ notice: (text, kind) => app.notice(`[${def.name}] ${text}`, kind),
2079
+ toolStart(tu, tool, input, agent) {
2080
+ parentCtx.onProgress?.({ type: 'subtool', header: toolHeader(tool, input, agent.makeCtx(), { status: 'done', width: app.width - 8, dot: false }) });
2081
+ return {};
2082
+ },
2083
+ streamEvent(ev) {
2084
+ if (ev.type === 'text' || ev.type === 'thinking') app.turnTokens += estimateTokens(ev.text);
2085
+ if (ev.type === 'retry') app.notice(`[${def.name}] API error, retrying: ${truncate(ev.error.message, 120)}`, 'warn');
2086
+ rateLimit(ev);
2087
+ },
2088
+ compactProgress: rateLimit,
2089
+ takeQueued: () => [],
2090
+ };
2091
+ }
2092
+
2093
+ takeQueued() {
2094
+ if (!this.queue.length) return [];
2095
+ const items = this.queue.splice(0);
2096
+ for (const q of items) this.echoUser(q.display ?? q.text, q.images?.length);
2097
+ this.turnPeers.push(...items.map((q) => q.peer).filter(Boolean));
2098
+ this.refreshTopic(items.map((q) => q.display ?? q.text).join('\n\n'));
2099
+ // Content blocks for the agent: each queued message's text followed by its images.
2100
+ return items.flatMap((q) => [{ type: 'text', text: q.text }, ...q.images.map(imageBlock)]);
2101
+ }
2102
+
2103
+ messageAdded(msg, agent) { if (this.isMain(agent)) this.store.message(msg); }
2104
+ systemPrompt(text, agent) { if (this.isMain(agent)) this.store.setSystemPrompt(text); }
2105
+ messagesReplaced(msgs, agent) { if (this.isMain(agent)) this.store.reset(msgs); }
2106
+ usage(_u, agent) {
2107
+ if (this.isMain(agent)) this.store.setCacheUntil(agent.cache.clockUntil); // the clock of a resumed conversation
2108
+ this.screen.schedule();
2109
+ }
2110
+
2111
+ compactStart(auto, agent) {
2112
+ if (!this.isMain(agent)) return;
2113
+ this.flushStreaming();
2114
+ this.status = auto ? 'Auto-compacting conversation' : 'Compacting conversation';
2115
+ this.compactTokens = 0;
2116
+ this.screen.schedule();
2117
+ }
2118
+
2119
+ compactProgress(ev) {
2120
+ if (ev.type === 'text' || ev.type === 'thinking') { this.turnTokens += estimateTokens(ev.text); this.screen.schedule(); }
2121
+ // A compaction can hit the limit too: say so instead of sitting on "Compacting conversation" for hours.
2122
+ else if (ev.type === 'rate_limit_wait' || ev.type === 'rate_limit_resume') this.rateLimitEvent(ev);
2123
+ }
2124
+
2125
+ compactEnd(summary, agent, err, { part } = {}) {
2126
+ if (!this.isMain(agent)) return;
2127
+ this.status = null;
2128
+ if (err) return;
2129
+ this.lastSummary = summary;
2130
+ this.store.setCacheUntil(agent.cache.clockUntil);
2131
+ const what = part === 'before' ? 'The earlier part of the conversation was summarized' : part === 'after' ? 'The conversation from that message on was summarized' : 'Conversation compacted';
2132
+ this.screen.log(c.dim(`✻ ${what} (the summary is in context; /context to see usage)`), '');
2133
+ }
2134
+
2135
+ // esc esc on an empty prompt: the rewind menu, as in Claude Code (/rewind). Not while a turn runs (esc stops it).
2136
+ openRewind() {
2137
+ if (this.busy || this.dialogs.length || this.exiting) return;
2138
+ 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; }
2139
+ Promise.resolve(this.commands.get('rewind').run('')).catch((e) => this.logError(e)).finally(() => this.screen.schedule());
2140
+ }
2141
+
2142
+ notice(text, kind = 'info') {
2143
+ const color = kind === 'warn' ? c.yellow : kind === 'error' ? c.red : c.dim;
2144
+ // Long notices (the rate limit ones are) break between words, not wherever the terminal width falls.
2145
+ // A notice of several lines (a hook's warning, say) keeps its lines, each one indented and wrapped.
2146
+ const width = Math.max(20, this.width - 4);
2147
+ this.screen.log(String(text).split('\n').flatMap((line) => wordWrap(line, width)).map((line) => color(` ${line.trimEnd()}`)));
2148
+ }
2149
+
2150
+ logError(e) {
2151
+ const msg = e?.message || String(e);
2152
+ this.screen.log(c.red(`${DOT} API Error: ${msg}`), '');
2153
+ if (process.env.CLAUDE_PRO_DEBUG && e?.stack) this.screen.log(c.dim(e.stack));
2154
+ }
2155
+
2156
+ // ---------------------------------------------------------------- live region
2157
+
2158
+ spinnerLine() {
2159
+ const frame = SPINNER[Math.floor(Date.now() / 120) % SPINNER.length];
2160
+ if (this.rateWait) {
2161
+ const w = this.rateWait;
2162
+ const left = humanDuration(w.resumeAt - Date.now()); // the same wording as the notice above it
2163
+ const esc = c.dim(`(${this.controller?.signal.aborted ? 'cancelling…' : 'esc to cancel'})`);
2164
+ if (w.waitingForLogin) return c.yellow(`${frame} Waiting for the Claude Code login to be renewed · checking again in ${left}`) + ' ' + esc;
2165
+ const label = w.label.charAt(0).toUpperCase() + w.label.slice(1);
2166
+ const when = w.polling ? `checking again in ${left}` : `resumes at ${formatClock(w.resumeAt)} (in ${left})`;
2167
+ return c.yellow(`${frame} ${w.overloaded ? label : `${label} reached`} · ${when}`) + ' ' + esc;
2168
+ }
2169
+ const secs = formatDuration(Date.now() - this.turnStart);
2170
+ const verb = this.status || (this.thinking ? 'Thinking' : this.verb);
2171
+ const aborted = this.controller?.signal.aborted;
2172
+ const parts = [secs];
2173
+ if (this.turnTokens) parts.push(`↓ ${formatTokens(this.turnTokens)} tokens`);
2174
+ parts.push(aborted ? 'interrupting…' : 'esc to interrupt');
2175
+ return c.claude(`${frame} ${verb}…`) + ' ' + c.dim(`(${parts.join(' · ')})`);
2176
+ }
2177
+
2178
+ renderActiveEntry(e, w) {
2179
+ const lines = [toolHeader(e.tool, e.input, e.ctx, { status: 'running', width: w })];
2180
+ if (e.tool.name === 'Task' || e.sub.length) {
2181
+ const shown = e.sub.slice(-3);
2182
+ const more = e.subCount - shown.length;
2183
+ const sub = shown.map((h) => c.dim(truncate(h, w - 8)));
2184
+ if (more > 0) sub.push(c.dim(`+${more} more tool use${more === 1 ? '' : 's'}`));
2185
+ if (!sub.length) sub.push(c.dim('Initializing…'));
2186
+ lines.push(...resultLines(sub));
2187
+ } else if (e.progress) {
2188
+ const out = e.progress.replace(/\r\n/g, '\n').trimEnd().split('\n');
2189
+ const tail = out.slice(-5).map((l) => c.dim(truncate(l.replace(/\r/g, ''), w - 8)));
2190
+ if (out.length > 5) tail.unshift(c.dim(`… +${out.length - 5} lines`));
2191
+ lines.push(...resultLines(tail));
2192
+ } else {
2193
+ const secs = Math.floor((Date.now() - e.started) / 1000);
2194
+ if (secs >= 2) lines.push(...resultLines([c.dim(`Running… (${secs}s)`)]));
2195
+ }
2196
+ return lines;
2197
+ }
2198
+
2199
+ renderLive(width) {
2200
+ const w = width - 1;
2201
+ const lines = [];
2202
+ let cursor = null;
2203
+ if (this.busy) {
2204
+ // In-progress assistant text.
2205
+ if (this.md) {
2206
+ const partial = this.md.partial();
2207
+ lines.push(...partial.slice(-Math.floor(this.screen.height / 2)));
2208
+ }
2209
+ if (this.thinking?.md) lines.push(...this.thinking.md.partial().slice(-3));
2210
+ else if (this.thinking && this.thinking.text) {
2211
+ const last = this.thinking.text.trimEnd().split('\n').pop() || '';
2212
+ lines.push(c.dim(c.italic(truncate(`✻ ${last}`, w))));
2213
+ }
2214
+ for (const [, p] of this.pendingToolBlocks) {
2215
+ const m = /"(?:file_path|command|pattern|url|description|query)"\s*:\s*"((?:\\.|[^"\\])*)/.exec(p.partial);
2216
+ const tool = this.runtime.tools.find((t) => t.name === p.name) || { name: p.name };
2217
+ const arg = m ? m[1].replace(/\\n/g, ' ').replace(/\\(.)/g, '$1') : '';
2218
+ lines.push(`${c.gray(DOT)} ${c.bold(toolLabel(tool, {}))}${arg ? `(${truncate(arg, w - 20)})` : ''} ${c.dim(p.partial.length > 200 ? `(${formatTokens(Math.round(p.partial.length / 4))} tokens…)` : '…')}`);
2219
+ }
2220
+ for (const e of this.active) lines.push(...this.renderActiveEntry(e, w));
2221
+ if (!this.dialogs.length) {
2222
+ if (lines.length) lines.push('');
2223
+ lines.push(this.spinnerLine());
2224
+ const todos = this.agent.todos;
2225
+ if (this.showTodos && todos.length) lines.push(...renderTodos(todos).map((l) => truncate(l, w)));
2226
+ }
2227
+ } else if (this.active.length) {
2228
+ for (const e of this.active) lines.push(...this.renderActiveEntry(e, w));
2229
+ }
2230
+ if (this.dialogs.length) {
2231
+ if (lines.length) lines.push('');
2232
+ const r = this.dialogs[0].render(width, this.screen.height - lines.length - 1);
2233
+ const top = lines.length;
2234
+ lines.push(...r.lines);
2235
+ if (r.cursor) cursor = { row: top + r.cursor.row, col: r.cursor.col };
2236
+ return { lines, cursor };
2237
+ }
2238
+ if (this.queue.length) {
2239
+ for (const q of this.queue) lines.push(c.dim(' ⎿ queued: ') + c.gray(truncate((q.display ?? q.text).replace(/\s+/g, ' '), w - 14)));
2240
+ }
2241
+ if (lines.length) lines.push('');
2242
+ const placeholder = this.busy ? 'Type a message to queue it for the agent…' : (this.agent.messages.length ? '' : 'Try "explain this codebase" or "fix the failing test"');
2243
+ const r = this.finder ? this.renderFind(width) : this.editor.render(width, { placeholder, title: this.boxTitle() });
2244
+ const top = lines.length;
2245
+ lines.push(...r.lines);
2246
+ if (r.cursor) cursor = { row: top + r.cursor.row, col: r.cursor.col };
2247
+ if (this.finder) lines.push(c.dim(truncate(' ⏎ or ↑ older · shift+⏎ or ↓ newer · PgUp/PgDn scroll · esc to close', w)));
2248
+ else if (this.editor.suggestions) lines.push(...this.editor.renderSuggestions(w));
2249
+ else if (this.editor.text === '?') lines.push(...this.shortcutsHelp());
2250
+ else lines.push(this.footer(w));
2251
+ return { lines, cursor };
2252
+ }
2253
+
2254
+ // In the input box's top border: the directory the agent is in now (a `cd` moves it), and this
2255
+ // session's name for the other sessions.
2256
+ boxTitle() {
2257
+ const dir = this.agent?.cwd || this.cwd;
2258
+ const home = os.homedir();
2259
+ const shown = dir.toLowerCase().startsWith(home.toLowerCase()) ? '~' + dir.slice(home.length) : dir;
2260
+ const name = this.peers || this.remote ? `&${this.peerLabel()}` : '';
2261
+ return { left: shown.replace(/\\/g, '/'), right: name && this.remote?.online ? `⇄ ${name}` : name };
2262
+ }
2263
+
2264
+ // The prompt cache clock, as in Claude Code's VS Code extension: the minutes the conversation's cache has left
2265
+ // ("◷ 12m"), or the icon alone in red once it has run out or right after a compaction, until the next response:
2266
+ // the next message then writes the whole conversation to the cache again. Nothing before a response reported
2267
+ // cache use.
2268
+ cacheClock() {
2269
+ const clock = this.agent?.cache.clock();
2270
+ return !clock ? '' : clock.cold ? c.red('◷') : c.dim(`◷ ${clock.minutes}m`);
2271
+ }
2272
+
2273
+ footer(w) {
2274
+ const mode = this.runtime.modes.mode;
2275
+ let left;
2276
+ const flash = this.flashMessage && Date.now() < this.flashMessage.until ? this.flashMessage : null;
2277
+ if (this.exitArmed && Date.now() - this.exitArmed < 1500) left = c.dim(' Press Ctrl-C again to exit');
2278
+ else if (flash) left = (flash.kind === 'warn' ? c.yellow : c.dim)(' ' + truncate(flash.text, w - 4));
2279
+ else if (this.restartRequest) left = c.dim(' ↻ restarting when this turn ends');
2280
+ else if (mode === 'plan') left = ' ' + c.plan('⏸ plan mode on (read-only)') + c.dim(' (shift+tab to leave)');
2281
+ else if (this.editor.mode === 'bash') left = c.magenta(' ! for bash mode');
2282
+ else if (this.editor.mode === 'memory') left = c.blue(' # to memorize');
2283
+ else left = c.dim(' ? for shortcuts');
2284
+ if (this.remote?.online && this.remote.viewers && !flash && !(this.exitArmed && Date.now() - this.exitArmed < 1500)) {
2285
+ left += c.dim(' · ') + c.green(`⇄ ${this.remote.viewers} watching`);
2286
+ }
2287
+ if (this.transfers.size && !flash) left += c.dim(' · ') + this.transferStatus();
2288
+ if (!flash) {
2289
+ if (this.updating) left += c.dim(' · ') + c.blue(`⇣ updating ${APP_NAME} to ${this.updating}…`);
2290
+ else if (this.updateRestart && this.sourceChanged && !this.restartRequest) left += c.dim(' · ') + c.blue(`↻ ${this.updateRestart} installed: restarts ${this.working() ? 'when the work is done' : 'now'}`);
2291
+ else if (this.pendingUpdate) left += c.dim(' · ') + c.dim(`update ${this.pendingUpdate} ready · ${this.updateWaitingFor ? `installs when &${this.updateWaitingFor.split(',')[0]} is idle` : 'installs when idle'}`);
2292
+ }
2293
+ const cfg = this.config;
2294
+ const threshold = cfg.autoCompactThreshold(cfg.mainModel);
2295
+ const used = this.agent.contextEstimate;
2296
+ let notice = '';
2297
+ const pctLeft = Math.max(0, Math.round((1 - used / threshold) * 100));
2298
+ if (pctLeft <= 30 && cfg.autoCompactEnabled) notice = c.yellow(`Context left until auto-compact: ${pctLeft}%`);
2299
+ else if (this.runtime.tasks.running().length) notice = c.dim(`${this.runtime.tasks.running().length} background agent${this.runtime.tasks.running().length === 1 ? '' : 's'} running · /tasks`);
2300
+ else if (this.verbose) notice = c.dim('verbose');
2301
+ // The model and effort in use always have a place at the right: with the notice after it when both fit,
2302
+ // and the notice alone when they do not (a context warning matters more than the model's name).
2303
+ const { model, effort } = modelInfo(cfg);
2304
+ // An effort that is not set is not sent: the API then uses the model's default, which is shown instead.
2305
+ const level = effort || defaultEffort(cfg.mainModel.id, cfg);
2306
+ const info = c.dim(level ? `${model} · effort ${level}` : model);
2307
+ // Before the model: the prompt cache clock, then what the conversation has cost so far; when room is short
2308
+ // the cost goes first, then the clock.
2309
+ const spent = this.cost > 0 ? c.dim(formatUsd(this.cost)) : '';
2310
+ const clock = this.cacheClock();
2311
+ const join = (...parts) => parts.filter(Boolean).join(c.dim(' · '));
2312
+ const room = w - stringWidth(left) - 1;
2313
+ const right = [join(notice, clock, spent, info), join(notice, clock, info), join(notice, info), notice, info].find((r) => r && stringWidth(r) < room) || '';
2314
+ const gap = room - stringWidth(right);
2315
+ return right ? left + ' '.repeat(gap) + right : left;
2316
+ }
2317
+ }
2318
+
2319
+ export async function runRepl({ flags, cwd, initialPrompt }) {
2320
+ const app = new App({ flags, cwd });
2321
+ process.on('uncaughtException', (e) => {
2322
+ app.restoreTerminal();
2323
+ process.stderr.write(`\nUnexpected error: ${e?.stack || e}\n`);
2324
+ process.exit(1);
2325
+ });
2326
+ return await app.start(initialPrompt);
2327
+ }