heartcode 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1572 @@
1
+ #!/usr/bin/env node
2
+ import path from 'node:path'
3
+ import { parseArgs } from 'node:util'
4
+ import { Client } from '../src/client.js'
5
+ import { loadConfig, saveConfig, readJson, writeJson, providerName, CONFIG_FILE, HOME, DEFAULT_MODEL, VERSION } from '../src/config.js'
6
+ import { probe, loadProfile, strategy, describe, profilePath, profileFrom } from '../src/probe.js'
7
+ import { learnFromUsage, RATE_CAP_MS } from '../src/context.js'
8
+ import { Session, transcript } from '../src/agent.js'
9
+ import { treeItems, codePlan, move, leaving, leftText, leftFiles } from '../src/tree.js'
10
+ import { UI, c, PERMISSIONS, rank, setTheme, themeCodes, renderDiff, diffLines, DEFAULT_KEYS, loadKeys } from '../src/ui.js'
11
+ import { listFiles, runCommand, clip } from '../src/tools.js'
12
+ import { log, LOG_FILE, LOG_DIR, requestLog, useRequestLog, useLogs, pruneLogs, logDays } from '../src/log.js'
13
+ import { prompt, usePromptsFrom, reloadPrompts } from '../src/prompts.js'
14
+ import { newChat, saveChat, listChats, loadChat, restoreFiles, ago, forkChat, exportChat } from '../src/sessions.js'
15
+ import { loadPrices, priceOf, addUsage, money, limitsOf } from '../src/usage.js'
16
+ import { Mcp } from '../src/mcp.js'
17
+ import { forget } from '../src/oauth.js'
18
+ import { loadSkills, fillArgs, extraSkillDirs } from '../src/skills.js'
19
+ import { HeadlessUI, runHeadless, toolNames, PERMISSION_MODES } from '../src/headless.js'
20
+ import { findImports } from '../src/importer.js'
21
+ import { spawnSync } from 'node:child_process'
22
+ import fs from 'node:fs'
23
+ import { Memory } from '../src/memory.js'
24
+ import { Director } from '../src/director.js'
25
+ import { createWorktree, worktreeState, removeWorktree } from '../src/worktree.js'
26
+ import { runAcp } from '../src/acp.js'
27
+ import { Tabs } from '../src/tabs.js'
28
+ import { startSettings } from '../src/settings.js'
29
+ import { RESETS, reset, resetPaths, resetCode } from '../src/reset.js'
30
+
31
+ const LOCAL_SERVERS = ['http://localhost:11434/v1', 'http://localhost:1234/v1', 'http://localhost:8080/v1']
32
+
33
+ const HELP = `${c.magenta('♥')} ${c.bold('HeartCode')} — a coding CLI for any local or OpenAI-compatible model
34
+
35
+ ${c.bold('usage')}
36
+ heart interactive session in this folder (or: heart .)
37
+ heart ~/code/my-app in that folder (a folder, not a file)
38
+ heart "fix the failing test" run one task and exit
39
+ heart providers list added providers
40
+ heart models [filter] "default" (your list), then your models: #1 (in use), then its fallbacks
41
+ heart models --all [filter] every provider's models, yours marked
42
+ heart models --json as JSON (with --all too), for other programs
43
+ heart probe test the current model for real (otherwise its abilities come from models.dev)
44
+ heart config show the config file
45
+ heart settings change providers, models and settings in your browser
46
+
47
+ ${c.bold('options')}
48
+ -u, --base-url <url> use this server for this run (add --save to keep it)
49
+ -m, --model <name> use this model alone for this run (no fallbacks); "default": your list, with its fallbacks
50
+ --api-key <key> API key for --base-url
51
+ -y, --yes do everything without asking (the default)
52
+ --ask ask before every edit and command, this run
53
+ --hide-thinking collapse the model's thinking to one line
54
+ -C, --cwd <dir> project folder (default: current folder)
55
+ --temp a throwaway settings folder for this run (your providers, model tests
56
+ and prices copied in; deleted after); ~/.heart isn't touched
57
+ --context <n> context window size in tokens
58
+ --force re-run the probe
59
+ --save keep the given -u/-m/--api-key/... in ${CONFIG_FILE}
60
+ -w, --worktree work in a new git worktree and branch (--worktree=name to name it,
61
+ or to continue in it); removed on exit if nothing changed
62
+ -c, --continue continue the last session in this folder
63
+ -r, --resume [id] pick an earlier session to continue (or the one with this id)
64
+ --provider <name> use this provider for this run (or -m "provider model")
65
+
66
+ ${c.bold('headless')} (for other programs; flags and output as in Claude Code's -p)
67
+ -p, --print run without the screen, print the answer, exit
68
+ --output-format <f> text (default), json, or stream-json (one JSON event per line)
69
+ --input-format <f> text (default; the prompt, or stdin) or stream-json (messages on stdin)
70
+ --append-system-prompt <text> added to HeartCode's system prompt
71
+ --mcp-config <file|json> more MCP servers (repeatable); --strict-mcp-config: only those
72
+ --skills-dir <dir> more skills (repeatable)
73
+ --allowedTools <list> only these tools (HeartCode's or Claude Code's names: Read, Edit, Bash…)
74
+ --disallowedTools <list> never these tools
75
+ --permission-mode <m> all, commands, ask, plan (or bypassPermissions, acceptEdits, default);
76
+ --dangerously-skip-permissions = all. Nothing is asked: the rest is declined
77
+ --session-id <id> start (or continue) the session with this id
78
+ --effort <level> low, medium or high, for thinking models
79
+ --no-memory don't read or write memory notes
80
+ --max-retries <n> give up on a failing request after n retries (headless default 10;
81
+ interactive: until Esc)
82
+ --max-turns <n> stop after n model steps in one message (default: no limit)
83
+ --verbose warnings and retries on stderr
84
+
85
+ ${c.bold('editors')} heartcode --acp the Agent Client Protocol on stdin/stdout (Zed, JetBrains, Neovim)
86
+
87
+ ${c.bold('env')} HEART_BASE_URL, HEART_MODEL, HEART_API_KEY (or OPENAI_BASE_URL / OPENAI_API_KEY)
88
+ HEART_LOG=<file> where heart logs everything it does (default: ~/.heart/logs/, one per run; off: none)
89
+
90
+ ${c.bold('in a session')} attach an image with @path/to/shot.png · /help for commands`
91
+
92
+ // [name, description, takes an argument], by section. /help and the "/" menu are built from it.
93
+ const SECTIONS = [
94
+ ['providers and models', [
95
+ ['/select_model', 'switch to another of your models (the list, and every provider\'s models: /settings)', true],
96
+ ]],
97
+ ['settings', [
98
+ ['/toggle_thinking', "show or collapse the model's thinking"],
99
+ ['/voice', 'say your message (or press Ctrl+R in the input): it goes in the input box'],
100
+ ]],
101
+ ['session', [
102
+ ['/resume', 'continue an earlier session in this folder'],
103
+ ['/rewind', 'go back to an earlier message: conversation, code or both (Esc Esc); what came after stays in /tree'],
104
+ ['/tree', "every message on every branch of this session: go to any, the files follow, a summary of what you leave comes along"],
105
+ ['/rename', 'name this session (shown in /resume)', true],
106
+ ['/fork', 'go on in a copy of this session (the original stays in /resume)'],
107
+ ['/export', 'save the conversation as Markdown, HTML or JSON', true],
108
+ ['/copy', "copy the model's last answer to the clipboard"],
109
+ ['/diff', 'every change HeartCode made to files in this session (add a file name to see one)', true],
110
+ ['/init', 'look at the project and write AGENTS.md: its commands, conventions and layout'],
111
+ ['/btw', 'ask something on the side, also while it works; the answer stays out of the conversation', true],
112
+ ['/cost', 'tokens used and what they cost, this session'],
113
+ ['/model_info', 'what the model can do and how HeartCode uses it'],
114
+ ['/test_model', 'test the model for real (otherwise what it can do comes from models.dev, or your settings)'],
115
+ ['/tidy_memory', 'merge, update and link the memory notes now'],
116
+ ['/import', 'bring commands, skills, MCP servers and memory from other CLIs'],
117
+ ['/login_mcp', 'sign in (again) to a remote MCP server', true],
118
+ ['/compact', 'summarize the conversation to free up context (add what to keep)', true],
119
+ ['/clear_chat', 'forget the conversation'],
120
+ ['/reset', "HeartCode's settings (or also providers, or everything) back to the start; asks for a code first"],
121
+ ['/settings', 'change providers, models, settings, MCP servers, hooks, keys and skills in your browser'],
122
+ ['/help', 'this list'],
123
+ ['/tab', 'a new tab (Ctrl+T): another conversation beside this one · /tab worktree [name]: in its own git worktree · /tab 2: go to tab 2 (Ctrl+N/P) · Ctrl+Q closes one', true],
124
+ ['/exit', 'quit HeartCode (Ctrl+Q closes just this tab)'],
125
+ ]],
126
+ ]
127
+ const COMMANDS = SECTIONS.flatMap(([, list]) => list)
128
+ const helpText = (sections) => sections.map(([title, list]) => ` ${c.bold(title)}\n` + list.map(([n, d]) => ` ${c.cyan(n.padEnd(18))} ${d}`).join('\n')).join('\n\n')
129
+ const SLASH = helpText(SECTIONS)
130
+
131
+ const HOWTO = `${c.yellow('No model set up yet.')} Add a provider and choose its models in your browser:
132
+
133
+ ${c.cyan('/settings')} (or ${c.cyan('heart settings')} in another terminal)
134
+
135
+ ${c.dim('Ollama, LM Studio or llama.cpp running on this machine is found on its own.')}`
136
+ const NO_MODELS = `${c.yellow('No model chosen yet.')} Choose one on ${c.cyan('/settings')}: a provider's models, click one.`
137
+
138
+ // Choices for commands that take one (also offered in the "/" menu).
139
+ const ARGS = {
140
+ export: [['md', 'Markdown'], ['html', 'a web page'], ['json', 'the whole session file']],
141
+ set_effort: [['default', "the model's own setting"], ['low', 'quick, less thinking'], ['medium', 'balanced'], ['high', 'thinks the longest']],
142
+ }
143
+
144
+
145
+ // Terminal.app can't report Shift+Enter; Option+Enter works there ("Use Option as Meta key").
146
+ const NEWLINE_KEY = process.env.TERM_PROGRAM === 'Apple_Terminal' ? 'Option+Enter' : 'Shift+Enter'
147
+
148
+ const kTokens = (n) => (n >= 1e6 ? `${Math.round(n / 1e5) / 10}M` : n >= 1000 ? `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k` : String(n))
149
+
150
+ const mask = (k) => (k ? (k.length > 12 ? `${k.slice(0, 4)}…${k.slice(-4)}` : '…') : c.dim('no key'))
151
+
152
+ class App {
153
+ constructor(cfg, ui, root) {
154
+ Object.assign(this, { cfg, ui, root })
155
+ this.s = null // the Session, built on first use
156
+ this.lists = {} // provider → model ids, cached for this run
157
+ this.chat = newChat(root) // the conversation, saved after every message (sessions.js)
158
+ this.mcp = this.newMcp() // MCP servers (each starts when first used); cfg.mcp: --mcp-config
159
+ this.skills = loadSkills(root)
160
+ this.memory = new Memory(root) // .heartcode/memory: notes linked with [[name]]
161
+ this.director = new Director(this) // the manage tool: skills, MCP servers and settings by asking
162
+ this.later = [] // setting changes that wait until the current message is done
163
+ }
164
+
165
+ newMcp() {
166
+ const mcp = new Mcp(this.root, this.cfg.mcp)
167
+ // a remote server asks to sign in: its page opens in the browser (and the link is shown)
168
+ if (!this.ui.noUser)
169
+ mcp.onSignIn = (name, url) => {
170
+ this.ui.print(`${c.info('↗')} ${c.bold(name)} ${c.muted('needs you to sign in; opening your browser…')}\n ${c.faint(url)}`)
171
+ const [cmd, ...args] = process.platform === 'darwin' ? ['open', url] : process.platform === 'win32' ? ['cmd', '/c', 'start', '', url] : ['xdg-open', url]
172
+ spawnSync(cmd, args, { stdio: 'ignore' })
173
+ this.ui.spinner(`waiting for you to sign in to ${name} in the browser · Esc to stop`)
174
+ }
175
+ return mcp
176
+ }
177
+
178
+ // After a skill or MCP server was saved or deleted (by the director or /import).
179
+ reload(kind) {
180
+ if (kind === 'skill') this.skills = loadSkills(this.root)
181
+ if (kind === 'mcp') {
182
+ this.mcp.dispose()
183
+ this.mcp = this.newMcp()
184
+ }
185
+ if (this.s) Object.assign(this.s, { skills: this.skills, mcp: this.mcp })
186
+ }
187
+
188
+ afterLine() {
189
+ for (const fn of this.later.splice(0)) fn()
190
+ }
191
+
192
+ // The settings the director can change (manage, kind setting).
193
+ settings() {
194
+ const { cfg, ui } = this
195
+ return [
196
+ `permissions: ${ui.permission}`,
197
+ `effort: ${cfg.effort ?? 'default'}`,
198
+ `auto_compact: ${cfg.autoCompact === false ? 'off' : 'on'}`,
199
+ `director: ${cfg.director === false ? 'off' : 'on'}`,
200
+ `model: ${cfg.provider ?? '(none)'} ${cfg.model ?? ''}`,
201
+ `providers: ${Object.keys(cfg.providers).join(', ') || '(none)'}`,
202
+ ].join('\n')
203
+ }
204
+
205
+ applySetting(name, value) {
206
+ const { cfg, ui } = this
207
+ const v = value.trim()
208
+ const bool = /^(on|true|yes)$/i.test(v) ? true : /^(off|false|no)$/i.test(v) ? false : null
209
+ const bad = (choices) => `error: ${name} must be ${choices}`
210
+ const save = (msg) => (saveConfig(cfg), log('director', `setting ${name} = ${v}`), msg)
211
+ switch (name) {
212
+ case 'permissions':
213
+ if (!PERMISSIONS.some(([p]) => p === v)) return bad('all, commands, ask or plan')
214
+ ui.setPermission(v)
215
+ return `permissions: ${v}`
216
+ case 'effort':
217
+ if (!ARGS.set_effort.some(([o]) => o === v)) return bad('default, low, medium or high')
218
+ cfg.effort = v === 'default' ? undefined : v
219
+ if (this.s?.strat.thinking) this.s.client.effort = cfg.effort
220
+ return save(`effort: ${v}`)
221
+ case 'auto_compact':
222
+ case 'director':
223
+ if (bool === null) return bad('on or off')
224
+ cfg[name === 'director' ? 'director' : 'autoCompact'] = bool
225
+ if (name === 'auto_compact' && this.s) this.s.autoCompact = bool
226
+ return save(`${name}: ${bool ? 'on' : 'off'}${name === 'director' ? ' (from the next message)' : ''}`)
227
+ case 'model': {
228
+ let [p, ...m] = v.split(/\s+/)
229
+ if (!cfg.providers[p]) [p, m] = [cfg.provider, [v]]
230
+ if (!cfg.providers[p] || !m.join(' ')) return `error: give "provider model" or a model of the current provider; providers: ${Object.keys(cfg.providers).join(', ')}`
231
+ this.later.push(() => this.use(p, m.join(' ')).catch((e) => ui.warn(e.message)))
232
+ return `switching to ${p} · ${m.join(' ')} after this reply`
233
+ }
234
+ default:
235
+ return `error: no setting "${name}"; manage list kind setting shows them`
236
+ }
237
+ }
238
+
239
+ // The model, its probe and strategy for a provider and model (probed once, then cached).
240
+ async engine(provider, model, ui, signal) {
241
+ const { cfg } = this
242
+ const client = this.client(provider, model)
243
+ // tokens and cost, into the chat (also a helper's, the probe's, compaction's)
244
+ let price = priceOf(provider, client.baseUrl, model)
245
+ loadPrices().then(() => ((price = priceOf(provider, client.baseUrl, model)), ui.render?.()))
246
+ client.price = () => price
247
+ client.onSpend = (u) => addUsage((this.chat.usage ??= {}), u, price)
248
+ client.onRetry = (err, ms, attempt, next) => ui.retrying(err, ms, attempt, next) // keep trying until Esc (also while probing)
249
+ // the model list (settings page): another model answered; its price from now on
250
+ client.onSwitch = (t) => {
251
+ price = priceOf(t.provider, t.baseUrl, t.model)
252
+ const i = client.rotation.list.indexOf(t)
253
+ ui.print(c.dim(`↻ ${t.provider} · ${t.model} answered${i ? ` (#${i + 1} in your model list)` : ' (#1 again)'}`))
254
+ }
255
+ if (cfg.maxRetries !== undefined) client.retryIf = (e, attempt) => attempt < cfg.maxRetries // headless: then give up
256
+ const profile = await getProfile(client, ui, cfg.force, signal)
257
+ cfg.force = false
258
+ if (strategy(profile, cfg).thinking) client.effort = cfg.effort
259
+ const strat = strategy(profile, cfg)
260
+ // What the window is, learned while working (context.js); kept in the profile for next time.
261
+ const learned = () => {
262
+ Object.assign(strat, (({ context, contextSure, ratio, reply }) => ({ context, contextSure, ratio, reply }))(strategy(profile, cfg)))
263
+ Object.assign(client, { tokenRatio: profile.tokenRatio ?? 1, knownGood: profile.largestAccepted ?? 0 })
264
+ writeJson(profilePath(client), profile)
265
+ }
266
+ Object.assign(client, { tokenRatio: profile.tokenRatio ?? 1, knownGood: profile.largestAccepted ?? 0 })
267
+ client.onUsage = (used, estimate) => {
268
+ const was = profile.largestAccepted
269
+ const change = learnFromUsage(profile, used, estimate)
270
+ if (change === 'cut') ui.warn(`${model} silently cut a request to ~${profile.inputLimit} tokens: that's its window here; keeping the conversation within it`)
271
+ if (change) log('context', `${change}: ${used} tokens counted, ~${estimate} estimated; window now ${strategy(profile, cfg).context}`, { was, largestAccepted: profile.largestAccepted, inputLimit: profile.inputLimit, tokenRatio: profile.tokenRatio })
272
+ if (change) learned()
273
+ }
274
+ // The server said "too large" with its limit: remembered for this model (also next time). A
275
+ // per-minute cap is your account's at this provider: kept apart, for a day.
276
+ client.onInputLimit = (n, { rate = false } = {}) => {
277
+ if (rate) profile.rateCap = { tokens: n, until: Date.now() + RATE_CAP_MS }
278
+ else Object.assign(profile, { inputLimit: Math.min(profile.inputLimit ?? Infinity, n), inputLimitAt: Date.now() })
279
+ learned()
280
+ ui.warn(rate ? `${provider} takes at most ~${n} tokens a minute on your account; shortening the conversation to fit (for today)` : `${model} takes at most ~${n} tokens per request here; shortening the conversation to fit`)
281
+ }
282
+ // models.dev's published size for the model (a day old at most): used when the server doesn't say
283
+ loadPrices().then(() => {
284
+ const known = limitsOf(provider, client.baseUrl, model) ?? undefined
285
+ if (JSON.stringify(known) === JSON.stringify(profile.known)) return
286
+ profile.known = known
287
+ if (known && profile.source === 'assumed') profile.source = 'models.dev' // it's listed now
288
+ learned()
289
+ ui.render?.()
290
+ })
291
+ return { client, profile, strat }
292
+ }
293
+
294
+ setChat(chat) {
295
+ this.chat = chat
296
+ if (this.s) this.s.chat = chat
297
+ }
298
+
299
+ save() {
300
+ this.chat.log = this.ui.log
301
+ saveChat(this.chat)
302
+ }
303
+
304
+ // /btw: a side question, answered from the conversation so far and never added to it. While a
305
+ // task works it's typed into the busy input and runs at once (working: true), beside the task.
306
+ async btw(question, working = false) {
307
+ const { ui } = this
308
+ if (!question.trim()) return ui.print(`${c.cyan('/btw')} ${c.muted('<question>')} ${c.faint('asks something on the side, also while HeartCode works; the answer stays out of the conversation')}`)
309
+ const s = await this.session()
310
+ if (!s) return
311
+ if (!working) ui.spinner('answering on the side')
312
+ const bar = c.info('┃')
313
+ try {
314
+ const answer = await s.side(question, working ? undefined : this.signal)
315
+ if (!working) ui.stopSpin()
316
+ ui.print(`${bar} ${c.info(c.bold('btw'))} ${c.muted(clip(question.split('\n')[0], 100))}\n${(answer || c.faint('(no answer)')).split('\n').map((l) => `${bar} ${l}`).join('\n')}`)
317
+ } catch (e) {
318
+ if (!working) ui.stopSpin()
319
+ ui.print(`${bar} ${c.info(c.bold('btw'))} ${c.red(e.name === 'AbortError' ? 'stopped' : `failed: ${e.message}`)}`)
320
+ }
321
+ }
322
+
323
+ // /resume: pick one of this folder's saved sessions and continue it.
324
+ async resume(chat) {
325
+ const { ui } = this
326
+ if (!chat) {
327
+ const chats = listChats(this.root).filter((x) => x.id !== this.chat.id)
328
+ if (!chats.length) return ui.print('No earlier sessions in this folder yet.')
329
+ const it = await ui.pick('Resume which session?', chats.map((x) => ({ label: x.title, desc: `${ago(x.updated)} · ${x.turns.length} message${x.turns.length > 1 ? 's' : ''}`, chat: x })))
330
+ if (!it) return
331
+ chat = it.chat
332
+ }
333
+ this.setChat(chat)
334
+ ui.setLog(chat.log)
335
+ ui.print(c.dim(`resumed "${chat.title}" · ${chat.turns.length} message${chat.turns.length > 1 ? 's' : ''} · ${ago(chat.updated)}`))
336
+ }
337
+
338
+ // /rewind: back to before one of your messages; the conversation, the files HeartCode changed, or both.
339
+ async rewind() {
340
+ const { ui, chat } = this
341
+ if (!chat.turns.length) return ui.print('Nothing to rewind yet.')
342
+ const turns = chat.turns.map((t, i) => ({ t, i })).reverse()
343
+ const pickTurn = await ui.pick(
344
+ 'Go back to before which message?',
345
+ turns.map(({ t, i }) => {
346
+ const n = Object.keys(chat.turns.slice(i).reduce((all, x) => Object.assign(all, x.files), {})).length
347
+ return { label: t.task.split('\n')[0].slice(0, 70), desc: `${ago(t.at)}${n ? ` · ${n} file${n > 1 ? 's' : ''} changed since` : ''}`, i }
348
+ }),
349
+ )
350
+ if (!pickTurn) return
351
+ const i = pickTurn.i
352
+ const turn = chat.turns[i]
353
+ const changed = Object.keys(chat.turns.slice(i).reduce((all, x) => Object.assign(all, x.files), {}))
354
+ const options = [
355
+ ...(changed.length ? [{ label: 'Restore conversation and code', conversation: true, code: true }] : []),
356
+ { label: 'Restore conversation', desc: changed.length ? 'keep the files as they are now' : '', conversation: true },
357
+ ...(changed.length ? [{ label: 'Restore code', desc: `put back ${changed.join(', ')}; keep the conversation`, code: true }] : []),
358
+ { label: 'Cancel' },
359
+ ]
360
+ const how = await ui.pick('Restore what?', options)
361
+ if (!how || (!how.conversation && !how.code)) return
362
+ if (how.code && !how.conversation) {
363
+ const files = restoreFiles(chat, i)
364
+ log('rewind', `files restored to before "${turn.task}":`, files)
365
+ for (const t of chat.turns.slice(i)) t.files = {}
366
+ chat.messages?.push({ role: 'user', content: prompt('agent/files-restored', { task: turn.task, files: files.join(', ') }) })
367
+ ui.print(c.dim(`restored ${files.join(', ')} to before "${turn.task.split('\n')[0].slice(0, 60)}"`))
368
+ }
369
+ if (how.conversation) {
370
+ // what came after is kept as a branch (/tree), never lost: with its files as they are now,
371
+ // so the move puts the files back (not restoreFiles before it)
372
+ chat.log = ui.log
373
+ const { files } = move(chat, null, i, { code: Boolean(how.code) })
374
+ log('rewind', `files restored to before "${turn.task}":`, files)
375
+ ui.setLog(chat.log)
376
+ if (how.code) ui.print(c.dim(`restored ${files.join(', ') || 'nothing (no file differed)'}`))
377
+ ui.print(c.dim('the messages after it are kept as a branch: /tree goes back to them'))
378
+ this.nextInput = turn.task // back in the input, to edit and send again
379
+ }
380
+ log('rewind', `to before message ${i + 1}`, { conversation: Boolean(how.conversation), code: Boolean(how.code) })
381
+ if (this.s) this.s.client.lastTokens = 0
382
+ this.save()
383
+ }
384
+
385
+ // /tree: every message of this session, on every branch; go to any of them. The branch you
386
+ // leave is kept, the files can follow, and a summary of what you leave can come along.
387
+ async tree() {
388
+ const { ui, chat } = this
389
+ if (!chat.turns.length && !chat.branches?.length) return ui.print('Nothing here yet: the tree grows as you send messages and go back (/rewind, /tree).')
390
+ chat.log = ui.log
391
+ const n = (chat.branches?.length ?? 0) + 1
392
+ const it = await ui.pick(`This session${n > 1 ? `, ${n} branches` : ''} · pick a message`, treeItems(chat, ago))
393
+ if (!it) return
394
+ const atEnd = !it.branch && it.keep + 1 === chat.turns.length // the last message where you are
395
+ const go = await ui.pick('Go there how?', [
396
+ ...(atEnd ? [] : [{ label: 'Continue after this message', desc: it.end ? 'where that branch ended' : 'its answer and what came before it', keep: it.keep + 1 }]),
397
+ { label: 'Edit this message and send it again', desc: 'a new branch from just before it', keep: it.keep, edit: true },
398
+ { label: 'Cancel' },
399
+ ])
400
+ if (go?.keep === undefined) return
401
+ const { plan, unknown } = codePlan(chat, it.branch, go.keep)
402
+ let code = true
403
+ if (plan.length) {
404
+ const names = plan.map(([rel]) => rel)
405
+ const how = await ui.pick('And the files?', [
406
+ { label: 'Switch them too', desc: `${names.length} file${names.length > 1 ? 's' : ''} as they were there: ${clip(names.join(', '), 80)}`, code: true },
407
+ { label: 'Keep them as they are now', code: false },
408
+ ])
409
+ if (!how) return
410
+ code = how.code
411
+ }
412
+ let summarize = false
413
+ if (leaving(chat, it.branch, go.keep) && (this.s || this.cfg.providers[this.cfg.provider])) {
414
+ const a = await ui.pick('Bring a summary of the messages you leave?', [
415
+ { label: 'Yes', desc: 'one request to the model, so this branch knows what was tried there', yes: true },
416
+ { label: 'No', desc: 'they stay in the tree, the model just won\'t know about them' },
417
+ ])
418
+ if (!a) return
419
+ summarize = Boolean(a.yes)
420
+ }
421
+ const { left, files } = move(chat, it.branch, go.keep, { code })
422
+ ui.setLog(chat.log)
423
+ if (files.length) ui.print(c.dim(`files now as on this branch: ${files.join(', ')}`))
424
+ if (code && unknown.length) ui.warn(`not known how ${unknown.join(', ')} looked there; left as they are`)
425
+ if (summarize) {
426
+ const s = await this.session()
427
+ if (s) {
428
+ ui.spinner('summarizing the branch you left')
429
+ try {
430
+ const changedThere = leftFiles(left)
431
+ const summary = await s.summarizeBranch(leftText(left, transcript), changedThere, this.signal)
432
+ chat.branchNote = prompt('compact/branch-note', { summary, files: changedThere.join(', '), kept: changedThere.length && !code, restored: changedThere.length && code })
433
+ ui.stopSpin()
434
+ ui.print(`${c.faint('✻')} ${c.muted(`summary of the branch you left (goes with your next message): ${clip(summary.split('\n').find((l) => l.trim()) ?? '', 120)}`)}`)
435
+ } catch (e) {
436
+ ui.stopSpin()
437
+ if (e.name === 'AbortError') throw e
438
+ ui.warn(`no summary: ${e.message}`)
439
+ }
440
+ }
441
+ }
442
+ ui.print(c.dim(`${go.edit ? 'edit the message and send it: a new branch' : 'here now'} · the branch you left stays in /tree`))
443
+ if (go.edit) this.nextInput = it.task
444
+ if (this.s) this.s.client.lastTokens = 0
445
+ this.save()
446
+ }
447
+
448
+ // Built-in commands, then the skills you can type (a built-in wins on the same name).
449
+ commands() {
450
+ const builtIn = new Set(COMMANDS.map(([n]) => n))
451
+ const mine = [...this.skills.values()].filter((k) => k.use !== 'model' && !builtIn.has(`/${k.name}`)).map((k) => [`/${k.name}`, k.description, true])
452
+ return [...COMMANDS, ...mine]
453
+ }
454
+
455
+ help() {
456
+ const mine = this.commands().slice(COMMANDS.length)
457
+ return helpText(mine.length ? [...SECTIONS, ['your skills', mine]] : SECTIONS)
458
+ }
459
+
460
+ // "/review focus on tests": the skill's text, arguments filled in, `!command` output added.
461
+ async expandSkill(k, args) {
462
+ let text = fillArgs(k.body, args).replace(/(^|\s)@([\w./-]+)/g, '$1$2') // @file: HeartCode attaches files a message names
463
+ for (const m of [...text.matchAll(/!`([^`]+)`|```!\n([\s\S]*?)```/g)]) {
464
+ const cmd = (m[1] ?? m[2]).trim()
465
+ const ok = await this.ui.confirm(`Run ${c.bold(cmd)} (from /${k.name})?`, 'command')
466
+ const out = ok === true ? (await runCommand(this.root, cmd, { signal: this.signal })).output.trim() : '(not run)'
467
+ log('skill', `/${k.name} ran ${cmd}`)
468
+ text = text.replace(m[0], () => `\n\`\`\`\n$ ${cmd}\n${clip(out, 8000)}\n\`\`\`\n`)
469
+ }
470
+ return text
471
+ }
472
+
473
+ // What the menu under the prompt offers for the text before the cursor.
474
+ suggest(line, cursor) {
475
+ const before = line.slice(0, cursor)
476
+ const end = cursor + (/^\S*/.exec(line.slice(cursor))?.[0].length ?? 0)
477
+ let m = /^\/(\S*)$/.exec(before)
478
+ if (m) {
479
+ const items = this.commands().map(([value, desc, args]) => ({ value, desc, space: args, start: 0, end }))
480
+ const hits = rank(items, '/' + m[1])
481
+ return hits.length === 1 && hits[0].value === before ? [] : hits
482
+ }
483
+ m = /^\/(\w+)\s+(?:(\S+)\s+)?(\S*)$/.exec(before)
484
+ if (m) {
485
+ const [, cmd, first, typed] = m
486
+ const start = cursor - typed.length
487
+ const opts = (list) => rank(list.map(([value, desc]) => ({ value, desc, start, end })), typed)
488
+ if (ARGS[cmd] && !first) return opts(ARGS[cmd])
489
+ if (cmd === 'select_model' && !first) return this.modelChoices(typed, start, end)
490
+ return []
491
+ }
492
+ m = /(?:^|\s)@(\S*)$/.exec(before)
493
+ if (m) {
494
+ if (!this.files || Date.now() - this.filesAt > 5000) [this.files, this.filesAt] = [listFiles(this.root), Date.now()]
495
+ const start = cursor - m[1].length
496
+ return rank(this.files.map((f) => ({ value: f, start, end, insertOnly: true })), m[1]).slice(0, 50)
497
+ }
498
+ return []
499
+ }
500
+
501
+ // Your models (the list on /settings: #1 and the fallbacks), for the "/select_model " menu.
502
+ modelChoices(typed, start, end) {
503
+ const mine = this.myModels()
504
+ const twice = (m) => mine.filter((t) => t.model === m).length > 1
505
+ const items = mine.map((t, i) => ({ value: twice(t.model) ? `${t.provider} ${t.model}` : t.model, desc: `${t.provider} · #${i + 1}${i === 0 ? ' · in use' : ''}`, start, end }))
506
+ return rank(items, typed).slice(0, 200)
507
+ }
508
+
509
+ // The status bar: what's in use, its abilities, and how full the context is.
510
+ status() {
511
+ const { cfg, ui } = this
512
+ const sep = c.ghost(' │ ')
513
+ if (!cfg.providers[cfg.provider] || !cfg.model) return c.dim('no model · /settings to choose one')
514
+ const rot = this.s?.client.rotation
515
+ const now = rot?.list[rot.at] ?? { provider: cfg.provider, model: cfg.model } // the model list: the one in use
516
+ const parts = [`${c.brand('●')} ${c.muted(now.provider)} ${c.ghost('·')} ${c.info(now.model)}${rot ? c.faint(` ↻ ${rot.at + 1}/${rot.list.length}`) : ''}`]
517
+ const key = `${cfg.provider}/${cfg.model}`
518
+ if (this.profileKey !== key) [this.profileKey, this.profile] = [key, this.s?.profile ?? loadProfile(this.client(cfg.provider, cfg.model))]
519
+ const profile = this.s?.profile ?? this.profile
520
+ if (profile) {
521
+ const strat = this.s?.strat ?? strategy(profile, cfg)
522
+ parts.push(c.muted(strat.actions === 'tools' ? 'tools' : 'guided'))
523
+ if (strat.thinking) parts.push(c.think(`thinking${cfg.effort ? ` · ${cfg.effort}` : ''}`) + (ui.showThinking ? '' : c.faint(' (collapsed)')))
524
+ if (strat.vision) parts.push(c.muted('images'))
525
+ const used = this.s?.client.lastTokens ?? 0
526
+ const pct = Math.round((used / strat.context) * 100)
527
+ const paint = pct >= 80 ? c.red : pct >= 50 ? c.yellow : c.green
528
+ const guess = strat.contextSure ? '' : '~' // not said by the server: assumed, and growing as bigger requests work
529
+ parts.push(`${c.faint('ctx')} ${paint(`${kTokens(used)}/${guess}${kTokens(strat.context)}`)} ${c.faint(`${pct}%`)}`)
530
+ const tps = this.s?.client.tps
531
+ if (tps) parts.push(`${c.muted(String(tps))} ${c.faint('tok/s')}`)
532
+ if (this.chat.usage?.priced) parts.push(c.muted(money(this.chat.usage.cost)))
533
+ } else {
534
+ parts.push(c.dim('not probed yet'))
535
+ }
536
+ if (cfg.autoCompact === false) parts.push(c.faint('auto-compact off'))
537
+ if (this.worktree) parts.push(c.info(`⎇ ${this.worktree.branch}`))
538
+
539
+ const perm = { all: c.green('⏵⏵ all allowed'), commands: c.yellow('ask before commands'), ask: c.yellow('ask before changes'), plan: c.info('⏸ plan mode') }[ui.permission]
540
+ parts.push(perm) // Shift+Tab changes it (see the header)
541
+ return parts.join(sep)
542
+ }
543
+
544
+ client(name, model) {
545
+ const p = this.cfg.providers[name]
546
+ return new Client({ baseUrl: p.baseUrl, apiKey: p.apiKey, model, provider: name })
547
+ }
548
+
549
+
550
+ // With nothing configured, use a local server if one is running.
551
+ async detectLocal() {
552
+ for (const url of LOCAL_SERVERS) {
553
+ try {
554
+ const models = await new Client({ baseUrl: url }).listModels(800)
555
+ const name = providerName(url)
556
+ this.cfg.providers[name] = { baseUrl: url }
557
+ this.cfg.adhoc = name
558
+ this.cfg.provider = name
559
+ this.cfg.model ??= models[0]
560
+ this.lists[name] = models
561
+ return true
562
+ } catch {}
563
+ }
564
+ return false
565
+ }
566
+
567
+ // The Session for the current provider and model, or null (after saying how to set one up).
568
+ async session() {
569
+ if (this.s) return this.s
570
+ const { ui, cfg } = this
571
+ if (!Object.keys(cfg.providers).length) await this.detectLocal()
572
+ if (!cfg.providers[cfg.provider]) {
573
+ if (!Object.keys(cfg.providers).length) ui.print(HOWTO)
574
+ else ui.print(NO_MODELS)
575
+ return null
576
+ }
577
+ if (!cfg.model) {
578
+ ui.print(NO_MODELS)
579
+ return null
580
+ }
581
+ const { client, profile, strat } = await this.engine(cfg.provider, cfg.model, ui, this.signal)
582
+ this.s = new Session({ client, profile, strat, root: this.root, ui, chat: this.chat }) // details: the status bar
583
+ this.s.autoCompact = cfg.autoCompact !== false
584
+ this.s.onTurn = () => this.save()
585
+ this.useRotation()
586
+ Object.assign(this.s, { noFormat: cfg.format === false, noLsp: cfg.lsp === false })
587
+ Object.assign(this.s, { mcp: this.mcp, memory: this.memory, skills: this.skills, director: this.director })
588
+ if (this.allowTools?.length) this.s.allowTools = toolNames(this.allowTools) // headless --allowedTools
589
+ if (this.denyTools?.length) this.s.denyTools = toolNames(this.denyTools)
590
+ if (this.cfg.maxTurns) this.s.maxTurns = this.cfg.maxTurns
591
+ log('session', `${cfg.provider} · ${cfg.model} at ${client.baseUrl}`, { strategy: this.s.strat, effort: client.effort })
592
+ return this.s
593
+ }
594
+
595
+ // The model list (settings page): this model first, then the others in order; set on the session.
596
+ // A model given for this run (-m, not "default") is used alone: no fallbacks (cfg.fixed).
597
+ useRotation() {
598
+ const { cfg } = this
599
+ const seen = new Set()
600
+ const list = [{ provider: cfg.provider, model: cfg.model }, ...(cfg.fixed ? [] : (cfg.rotation?.then ?? []))]
601
+ .filter((t) => cfg.providers[t.provider] && t.model && !seen.has(`${t.provider}/${t.model}`) && seen.add(`${t.provider}/${t.model}`))
602
+ .map((t) => ({ ...t, baseUrl: cfg.providers[t.provider].baseUrl, apiKey: cfg.providers[t.provider].apiKey }))
603
+ this.s?.client.setRotation(list, cfg.rotation ?? {})
604
+ }
605
+
606
+ async use(name, model) {
607
+ Object.assign(this.cfg, { provider: name, model })
608
+ this.s = null
609
+ if (name !== this.cfg.adhoc) saveConfig(this.cfg)
610
+ this.ui.print(`${c.green('using')} ${name} · ${c.cyan(model)}`)
611
+ await this.session()
612
+ }
613
+
614
+ // Your models, in order: this one (#1), then the fallbacks (chosen on /settings, where every
615
+ // provider's whole list is).
616
+ myModels() {
617
+ const { cfg } = this
618
+ const seen = new Set()
619
+ return [{ provider: cfg.provider, model: cfg.model }, ...(cfg.rotation?.then ?? [])].filter((t) => cfg.providers[t.provider] && t.model && !seen.has(`${t.provider}/${t.model}`) && seen.add(`${t.provider}/${t.model}`))
620
+ }
621
+
622
+ // Makes one of your models #1 (the rest keep their order); saved for next time.
623
+ async useMine(t) {
624
+ const { cfg } = this
625
+ delete cfg.fixed // one of your models: your list again, with its fallbacks
626
+ const rest = this.myModels().filter((x) => x.provider !== t.provider || x.model !== t.model)
627
+ cfg.rotation = { ...cfg.rotation, then: rest }
628
+ await this.use(t.provider, t.model)
629
+ }
630
+
631
+ // The picker: your models only, #1 first; Enter makes it #1.
632
+ async chooseModel(filter = '') {
633
+ const { ui, cfg } = this
634
+ const mine = this.myModels()
635
+ if (!mine.length) {
636
+ if (!Object.keys(cfg.providers).length && (await this.detectLocal())) return this.session()
637
+ return ui.print(NO_MODELS)
638
+ }
639
+ const items = mine.map((t, i) => ({ label: t.model, group: t.provider, t, current: i === 0, desc: [i === 0 ? '#1 · in use' : `#${i + 1} · fallback`, loadProfile(this.client(t.provider, t.model)) && '✓ tested'].filter(Boolean).join(' · ') }))
640
+ const it = await ui.pick('Choose a model (more on /settings)', items, { filter })
641
+ if (it && !it.current) await this.useMine(it.t)
642
+ }
643
+
644
+ // "/select_model gpt-oss-120b" or "/select_model groq openai/gpt-oss-120b": one of your models;
645
+ // no argument, or one that's not in your list: the picker.
646
+ async pickModel(args) {
647
+ if (!args.length) return this.chooseModel()
648
+ const [name, model] = args.length > 1 ? [args[0], args.slice(1).join(' ')] : [null, args[0]]
649
+ const found = this.myModels().filter((t) => t.model === model && (!name || t.provider === name))
650
+ if (found.length === 1) return found[0].provider === this.cfg.provider && found[0].model === this.cfg.model ? this.ui.print(c.dim(`${model} is in use already`)) : this.useMine(found[0])
651
+ if (!found.length) this.ui.print(c.dim(`${model} isn't one of your models: add it on /settings`))
652
+ return this.chooseModel(found.length ? model : '')
653
+ }
654
+
655
+ listProviders() {
656
+ const { ui, cfg } = this
657
+ const names = Object.keys(cfg.providers)
658
+ if (!names.length) return ui.print(`No providers yet: add one with ${c.cyan('heart settings')}.`)
659
+ for (const n of names) {
660
+ const p = cfg.providers[n]
661
+ const notes = [n === cfg.provider && c.magenta('● current'), n === cfg.adhoc && c.dim('this run only')].filter(Boolean).join(' ')
662
+ ui.print(`${c.bold(n.padEnd(14))} ${p.baseUrl} ${c.dim(mask(p.apiKey))} ${notes}`)
663
+ }
664
+ }
665
+
666
+ // `heart models`: your models, in order (every provider's whole list is on the settings page).
667
+ // all: every provider's models too (fetched from each; a provider whose list is switched off: the
668
+ // ones added by name), yours marked. json: one JSON object on stdout, for other programs:
669
+ // { "models": [{ "place": null, "provider": null, "model": "default", "role": "default", "rotates": true, "about": "…" },
670
+ // { "place": 1, "provider", "model", "role": "in use" | "fallback", "rotates": false }, …],
671
+ // "providers": [{ "name", "baseUrl", "list": true, "models": ["…"], "error"? }] } (--all)
672
+ // "default" is the name for your list: -m default (or no -m) uses #1 and moves on to the next when
673
+ // one fails; -m with one of the models is that model alone.
674
+ async listModels(filter = '', { all = false, json = false } = {}) {
675
+ const { ui, cfg } = this
676
+ const match = (id) => id.toLowerCase().includes(filter.toLowerCase())
677
+ const mine = this.myModels().map((t, i) => ({ place: i + 1, provider: t.provider, model: t.model, role: i === 0 ? 'in use' : 'fallback', rotates: false }))
678
+ const placeOf = (provider, model) => mine.find((t) => t.provider === provider && t.model === model)?.place
679
+ const providers = !all ? [] : await Promise.all(Object.entries(cfg.providers).map(async ([name, p]) => {
680
+ const out = { name, baseUrl: p.baseUrl, list: p.list !== false, models: [] }
681
+ if (out.list) {
682
+ try {
683
+ out.models = await new Client({ baseUrl: p.baseUrl, apiKey: p.apiKey }).listModels()
684
+ } catch (e) {
685
+ out.error = e.message
686
+ }
687
+ }
688
+ // and the ones added by name, and any of yours it doesn't list (as on the settings page)
689
+ out.models = [...new Set([...out.models, ...(p.models ?? []), ...mine.filter((t) => t.provider === name).map((t) => t.model)])].sort((a, b) => a.localeCompare(b)).filter(match)
690
+ return out
691
+ }))
692
+ // "default" first, in the same list: your models, rotating (when there's any)
693
+ const about = mine.length > 1 ? `your models in order: #1, then the next when one fails (-m ${DEFAULT_MODEL}, or no -m)` : `your one model, #1 (-m ${DEFAULT_MODEL}, or no -m)`
694
+ const first = mine.length ? [{ place: null, provider: null, model: DEFAULT_MODEL, role: 'default', rotates: mine.length > 1, about }] : []
695
+ const shown = [...first, ...mine].filter((t) => match(t.model))
696
+ if (json) {
697
+ process.stdout.write(JSON.stringify({ models: shown, ...(all && { providers }) }, null, 2) + '\n')
698
+ return
699
+ }
700
+ if (!all) {
701
+ if (!shown.length) return ui.print(filter ? `None of your models matches "${filter}".` : NO_MODELS)
702
+ for (const t of shown) ui.print(t.place === null ? `${c.magenta('●')} ${c.bold(t.model)} ${c.dim(t.about)}` : ` ${c.bold(`#${t.place}`)} ${t.model} ${c.dim(`${t.provider} · ${t.role}; alone with -m "${t.provider} ${t.model}"`)}`)
703
+ return
704
+ }
705
+ if (!providers.length) return ui.print(NO_MODELS)
706
+ for (const p of providers) {
707
+ ui.print(`${c.bold(p.name)} ${c.dim(p.baseUrl)}${p.list ? '' : c.dim(' (its list is off: the models added by name)')}`)
708
+ if (p.error) ui.print(` ${c.red("can't list its models:")} ${c.dim(p.error)}`)
709
+ if (!p.models.length && !p.error) ui.print(c.dim(filter ? ` none matches "${filter}"` : ' no models'))
710
+ for (const id of p.models) {
711
+ const at = placeOf(p.name, id)
712
+ ui.print(` ${at === 1 ? c.magenta('●') : ' '} ${id}${at ? ` ${c.bold(`#${at}`)} ${c.dim(at === 1 ? 'in use' : 'fallback')}` : ''}`)
713
+ }
714
+ }
715
+ }
716
+ }
717
+
718
+ // Leaving a --worktree session: nothing changed → it's removed; else you choose (it's kept by default).
719
+ async function leaveWorktree(app) {
720
+ const wt = app.worktree
721
+ if (!wt) return
722
+ const st = worktreeState(wt)
723
+ wt.done = true
724
+ if (st.empty) {
725
+ removeWorktree(wt)
726
+ return console.log(c.dim(`worktree ${wt.name}: nothing changed, removed`))
727
+ }
728
+ const what = [st.changes && `${st.changes} uncommitted change${st.changes > 1 ? 's' : ''}`, st.commits && `${st.commits} commit${st.commits > 1 ? 's' : ''}`].filter(Boolean).join(' and ')
729
+ const rl = (await import('node:readline/promises')).createInterface({ input: process.stdin, output: process.stdout })
730
+ const a = process.stdin.isTTY ? await rl.question(`worktree ${wt.name} has ${what}. Keep it? [Y/n, or "remove"] `) : ''
731
+ rl.close()
732
+ if (/^(n|no|remove)$/i.test(a.trim())) {
733
+ removeWorktree(wt)
734
+ return console.log(c.dim(`removed ${wt.dir} and the branch ${wt.branch}`))
735
+ }
736
+ console.log(`kept ${c.info(wt.dir)} on ${c.info(wt.branch)}\n${c.dim(`continue: heartcode --worktree=${wt.name} · merge: git merge ${wt.branch} (commit in the worktree first) · remove: git worktree remove ${wt.dir}`)}`)
737
+ }
738
+
739
+ // Which conversations are open in tabs, per folder (~/.heart/tabs.json), to offer them again.
740
+ const TABS_FILE = path.join(HOME, 'tabs.json')
741
+ function saveTabs(tabs) {
742
+ const all = readJson(TABS_FILE, {})
743
+ all[tabs.root] = { at: tabs.list.indexOf(tabs.at), tabs: tabs.list.map((t) => ({ chat: t.app.chat.id, worktree: t.app.worktree?.name ?? null, provider: t.app.cfg.provider ?? null, model: t.app.cfg.model ?? null })) }
744
+ writeJson(TABS_FILE, all)
745
+ }
746
+
747
+ // Starting in a folder with an earlier session: continue it? With every tab it had open that has a
748
+ // message sent (answered or not; a worktree tab if its worktree is still there). A last run with
749
+ // nothing sent: nothing to continue. No record of one (sessions from before tabs, or -p): the
750
+ // last session here.
751
+ async function continueTabs(app, saved) {
752
+ const { ui, tabs } = app
753
+ let open = (saved?.tabs ?? []).map((t, i) => ({ ...t, i, chat: loadChat(t.chat) })).filter((t) => t.chat?.turns?.length && (!t.worktree || fs.existsSync(t.chat.root)))
754
+ if (!saved) open = listChats(app.root).filter((c) => c.turns?.length).slice(0, 1).map((chat) => ({ chat, worktree: null }))
755
+ if (!open.length) return
756
+ const many = open.length > 1
757
+ const newest = open.map((t) => t.chat.updated).sort().at(-1)
758
+ const go = await ui.pick(many ? `Continue where you left off? ${open.length} tabs` : 'Continue your last session here?', [
759
+ { label: many ? `Yes, with its ${open.length} tabs` : 'Yes', desc: `${open.map((t) => `"${t.chat.title}"`).join(', ')} · ${ago(newest)}`, yes: true },
760
+ { label: 'No, start a new session', desc: '/resume finds it later' },
761
+ ], { quiet: true })
762
+ if (!go?.yes) return
763
+ // this tab takes the first one in this folder; the rest (and worktree ones) open as tabs
764
+ const here = open.find((t) => !t.worktree) ?? null
765
+ if (here) {
766
+ await app.resume(here.chat)
767
+ useModel(app, here) // after: resuming redraws the screen
768
+ }
769
+ const tabOf = new Map(here ? [[here, tabs.at]] : [])
770
+ for (const t of open.filter((x) => x !== here)) {
771
+ try {
772
+ const wt = t.worktree ? createWorktree(app.root, t.worktree) : null
773
+ tabOf.set(t, tabs.add({ root: wt?.cwd ?? app.root, wt, chat: t.chat, model: t }))
774
+ } catch (e) {
775
+ ui.warn(`"${t.chat.title}": ${e.message}`)
776
+ }
777
+ }
778
+ tabs.show(tabOf.get(open.find((t) => t.i === saved?.at)) ?? tabs.list[0])
779
+ }
780
+
781
+ // A continued tab's own model, as it was (unless its provider is gone: then the current one).
782
+ function useModel(app, { provider, model }) {
783
+ if (!provider || !model || (provider === app.cfg.provider && model === app.cfg.model)) return
784
+ if (!app.cfg.providers[provider]) return app.ui.warn(`this tab used ${provider} · ${model}, but there's no provider "${provider}" now: using ${app.cfg.provider} · ${app.cfg.model}`)
785
+ Object.assign(app.cfg, { provider, model })
786
+ app.s = null
787
+ }
788
+
789
+ // The settings page (/settings): on while its dialog is (one at a time).
790
+ let settingsPage = null
791
+
792
+ function openBrowser(url) {
793
+ if (process.env.HEART_NO_BROWSER) return
794
+ const [cmd, ...args] = process.platform === 'darwin' ? ['open', url] : process.platform === 'win32' ? ['cmd', '/c', 'start', '', url] : ['xdg-open', url]
795
+ spawnSync(cmd, args, { stdio: 'ignore' })
796
+ }
797
+
798
+ // A change on the settings page (in cfg, saved), brought into every open tab: right away, or the
799
+ // model and MCP servers after the reply in progress.
800
+ function applySettings(apps, cfg, what) {
801
+ const before = themeCodes()
802
+ if (what === 'theme' || what === 'setting:theme') setTheme(cfg.theme)
803
+ if (what === 'prompts') reloadPrompts() // from each tab's next message
804
+ if (['setting:log', 'setting:logRequests', 'setting:logDetail'].includes(what)) useRequestLog(cfg) // the run's log and the requests', on or off now
805
+ if (what === 'setting:logDays') pruneLogs(logDays(cfg))
806
+ settingsSeen(what) // the watcher (watchSettings) mustn't take it in a second time
807
+ for (const app of apps) {
808
+ const { ui } = app
809
+ const busy = Boolean(ui.busySince)
810
+ const now = (fn) => (busy ? app.later.push(fn) : fn())
811
+ if (app.cfg !== cfg) for (const k of ['providers', 'provider', 'model', 'effort', 'theme', 'context', 'autoCompact', 'director', 'format', 'lsp', 'voice', 'rotation']) app.cfg[k] = cfg[k]
812
+ if (what === 'model') {
813
+ app.lists = {}
814
+ delete app.cfg.fixed // a model chosen on the page: your list again
815
+ const want = cfg.providers[cfg.provider] && app.client(cfg.provider, cfg.model)
816
+ if (app.s && !(want && ['model', 'baseUrl', 'apiKey'].every((k) => want[k] === app.s.client[k])))
817
+ now(() => {
818
+ app.s = null
819
+ ui.print(c.dim(cfg.model ? `settings: ${cfg.provider} · ${cfg.model} from the next message` : 'settings: no model chosen'))
820
+ })
821
+ }
822
+ if (what === 'provider') app.lists = {} // its list switched on or off, a model added by name
823
+ if (what === 'setting:permissions') ui.setPermission(cfg.permissions)
824
+ if (what === 'setting:effort' && app.s?.strat.thinking) app.s.client.effort = cfg.effort
825
+ if (what === 'setting:autoCompact' && app.s) app.s.autoCompact = cfg.autoCompact !== false
826
+ if (what === 'setting:format' && app.s) app.s.noFormat = cfg.format === false
827
+ if (what === 'setting:lsp' && app.s) app.s.noLsp = cfg.lsp === false
828
+ if (what === 'setting:context') now(() => (app.s = null))
829
+ if (what === 'model' || what === 'setting:rotation') now(() => app.useRotation())
830
+ if (what === 'mcp' || what === 'skill') now(() => app.reload(what))
831
+ if (what === 'keys') ui.keys = { ...DEFAULT_KEYS, ...loadKeys() }
832
+ // a model set up or measured on the page: into the session's profile now (it saves its profile
833
+ // as it learns, so it mustn't keep the old one); its mode from the next message
834
+ if (what === 'profile' && app.s?.profile) {
835
+ const disk = loadProfile({ baseUrl: app.s.profile.baseUrl, model: app.s.profile.model })
836
+ if (disk) {
837
+ for (const k of ['source', 'set', 'known', 'measured', 'measuredAt']) disk[k] === undefined ? delete app.s.profile[k] : (app.s.profile[k] = disk[k])
838
+ const st = strategy(app.s.profile, app.cfg)
839
+ Object.assign(app.s.strat, (({ context, contextSure, reply }) => ({ context, contextSure, reply }))(st))
840
+ if (st.actions !== app.s.strat.actions || st.thinking !== app.s.strat.thinking || st.vision !== app.s.strat.vision || st.stream !== app.s.strat.stream) now(() => (app.s = null))
841
+ }
842
+ }
843
+ if (what === 'theme' || what === 'setting:theme') ui.recolor(before, themeCodes())
844
+ else ui.render()
845
+ }
846
+ apps[0]?.tabs?.onChange?.() // each tab's model, for next time
847
+ }
848
+
849
+ // Changes saved by another HeartCode, or \`heart settings\` in another terminal, while this one runs:
850
+ // taken in as the page's own are (applySettings). config.json is compared key by key with what it
851
+ // was (so a model given for this run with -m stays unless the list's #1 changes); the other files
852
+ // by their size and time, and only when this run didn't just apply them itself.
853
+ const WATCHED = { 'mcp.json': 'mcp', 'keybindings.json': 'keys', 'theme.json': 'theme', models: 'profile', prompts: 'prompts', skills: 'skill' }
854
+ const seenStamp = {}
855
+ const stampOf = (p) => {
856
+ try {
857
+ const st = fs.statSync(p)
858
+ if (!st.isDirectory()) return `${st.mtimeMs}:${st.size}`
859
+ return fs.readdirSync(p, { recursive: true }).sort().map((f) => `${f}=${stampOf(path.join(p, f))}`).join('|')
860
+ } catch {
861
+ return ''
862
+ }
863
+ }
864
+ function settingsSeen(what) {
865
+ for (const [name, w] of Object.entries(WATCHED)) if (w === what || (what === 'setting:theme' && w === 'theme')) seenStamp[name] = stampOf(path.join(HOME, name))
866
+ }
867
+ function watchSettings(apps, cfg) {
868
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
869
+ let last = readJson(CONFIG_FILE, {})
870
+ for (const name of Object.keys(WATCHED)) seenStamp[name] = stampOf(path.join(HOME, name))
871
+ const configChanged = () => {
872
+ const now = readJson(CONFIG_FILE, null)
873
+ if (!now) return
874
+ const keys = [...new Set([...Object.keys(now), ...Object.keys(last)])].filter((k) => !same(now[k], last[k]))
875
+ last = now
876
+ const whats = new Set()
877
+ for (const k of keys) {
878
+ const value = k === 'providers' ? { ...now.providers, ...(cfg.adhoc && { [cfg.adhoc]: cfg.providers[cfg.adhoc] }) } : now[k]
879
+ if (same(value, cfg[k])) continue
880
+ value === undefined ? delete cfg[k] : (cfg[k] = value)
881
+ whats.add(['provider', 'model', 'providers'].includes(k) ? 'model' : k === 'rotation' ? 'setting:rotation' : `setting:${k}`)
882
+ }
883
+ for (const what of whats) applySettings(apps(), cfg, what)
884
+ if (whats.size) log('settings', `taken in from config.json: ${[...whats].join(', ')}`)
885
+ }
886
+ let timer = null
887
+ const check = () => {
888
+ timer = null
889
+ configChanged()
890
+ for (const [name, what] of Object.entries(WATCHED)) {
891
+ const now = stampOf(path.join(HOME, name))
892
+ if (now === seenStamp[name]) continue
893
+ seenStamp[name] = now
894
+ log('settings', `taken in: ${name} changed`)
895
+ applySettings(apps(), cfg, what)
896
+ }
897
+ }
898
+ try {
899
+ const watcher = fs.watch(HOME, { recursive: true }, (_, file) => {
900
+ const top = String(file ?? '').split(path.sep)[0]
901
+ if (top !== 'config.json' && !WATCHED[top]) return // sessions, logs…: not settings
902
+ clearTimeout(timer)
903
+ timer = setTimeout(check, 150) // a file written in parts, several files at once: one look
904
+ })
905
+ watcher.on('error', () => {})
906
+ watcher.unref()
907
+ } catch (e) {
908
+ log('settings', `can't watch ${HOME}: ${e.message}`)
909
+ }
910
+ }
911
+
912
+ // After /reset: every tab goes on with what's left (the files as they are now), from the next
913
+ // message; the screen in the theme there is now.
914
+ function afterReset(apps, level) {
915
+ const fresh = loadConfig()
916
+ useRequestLog(fresh)
917
+ const before = themeCodes()
918
+ setTheme(fresh.theme)
919
+ reloadPrompts()
920
+ for (const app of apps) {
921
+ for (const k of Object.keys(app.cfg)) if (!['force', 'maxRetries', 'maxTurns', 'mcp', 'adhoc', 'fixed'].includes(k)) delete app.cfg[k]
922
+ Object.assign(app.cfg, fresh)
923
+ const { ui } = app
924
+ ui.permission = fresh.permissions ?? 'all' // not setPermission: that saves it again
925
+ ui.keys = { ...DEFAULT_KEYS, ...loadKeys() }
926
+ app.lists = {}
927
+ const now = (fn) => (ui.busySince ? app.later.push(fn) : fn())
928
+ now(() => {
929
+ app.s = null
930
+ app.reload('mcp')
931
+ app.reload('skill')
932
+ if (level === 'everything') app.memory = new Memory(app.root)
933
+ })
934
+ ui.recolor(before, themeCodes())
935
+ }
936
+ apps[0]?.tabs?.onChange?.()
937
+ }
938
+
939
+ // A tab in a worktree closed while other tabs stay open: removed if nothing changed, else kept.
940
+ function leaveTabWorktree(app) {
941
+ const wt = app.worktree
942
+ if (!wt) return
943
+ wt.done = true
944
+ const ui = app.tabs.at.ui
945
+ if (worktreeState(wt).empty) return removeWorktree(wt), ui.print(c.dim(`worktree ${wt.name}: nothing changed, removed`))
946
+ ui.print(`${c.dim('kept worktree')} ${c.info(wt.dir)} ${c.dim('on')} ${c.info(wt.branch)} ${c.faint(`· merge: git merge ${wt.branch} (commit in the worktree first)`)}`)
947
+ }
948
+
949
+ // The system clipboard (pbcopy, clip, wl-copy, xclip, xsel); else the terminal's (OSC 52, works over SSH too).
950
+ function copyText(text) {
951
+ const tools = process.platform === 'darwin' ? [['pbcopy']] : process.platform === 'win32' ? [['clip']] : [['wl-copy'], ['xclip', '-selection', 'clipboard'], ['xsel', '-b', '-i']]
952
+ for (const [cmd, ...args] of tools) if (spawnSync(cmd, args, { input: text, stdio: ['pipe', 'ignore', 'ignore'] }).status === 0) return 'system'
953
+ process.stdout.write(`\x1b]52;c;${Buffer.from(text).toString('base64')}\x07`)
954
+ return 'osc'
955
+ }
956
+
957
+ // A model's profile: the saved one; else what models.dev says (nothing is sent to the model to
958
+ // find out); a model it doesn't know: plain assumptions, until you set it up on the settings page.
959
+ // force (/test_model, heart probe): test it now instead, with real requests.
960
+ async function getProfile(client, ui, force, signal) {
961
+ let p = !force && loadProfile(client)
962
+ if (!p && !force) {
963
+ await loadPrices()
964
+ p = profileFrom(client, limitsOf(client.provider, client.baseUrl, client.model))
965
+ writeJson(profilePath(client), p)
966
+ }
967
+ // one models.dev didn't know when it was saved: looked up again (it may have been added, or the list
968
+ // couldn't be had then)
969
+ if (p?.source === 'assumed') {
970
+ await loadPrices()
971
+ const known = limitsOf(client.provider, client.baseUrl, client.model)
972
+ if (known) writeJson(profilePath(client), (p = { ...p, known, source: 'models.dev' }))
973
+ }
974
+ if (p) {
975
+ if (p.source === 'assumed') ui.warn(`${client.model} isn't in models.dev: assuming it calls tools and streams, with a ~128k window. Set what it can do on /settings`)
976
+ return p
977
+ }
978
+ ui.print(c.dim(`Testing ${client.model}…`))
979
+ const t0 = Date.now()
980
+ const tested = await probe(client, (label) => ui.spinner(`testing: ${label}`), signal)
981
+ ui.stopSpin()
982
+ ui.print(c.dim(`done in ${((Date.now() - t0) / 1000).toFixed(1)}s · saved to ${profilePath(client)}`))
983
+ return tested
984
+ }
985
+
986
+ // A setting from its choices: the argument if valid, else a picker. Returns the choice or null.
987
+ async function choose(ui, cmd, arg, title, current) {
988
+ if (ARGS[cmd].some(([o]) => o === arg)) return arg
989
+ return (await ui.pick(title, ARGS[cmd].map(([label, desc]) => ({ label, desc, current: label === current }))))?.label ?? null
990
+ }
991
+
992
+ async function slash(app, line) {
993
+ const [cmd, ...args] = line.slice(1).trim().split(/\s+/)
994
+ const { ui, cfg } = app
995
+ switch (cmd) {
996
+ case 'exit':
997
+ case 'quit': {
998
+ const n = app.tabs?.list.length ?? 1
999
+ if (n > 1) {
1000
+ const go = await ui.pick(`Quit HeartCode? ${n} tabs are open`, [
1001
+ { label: 'Yes, quit', desc: 'they come back next time you start HeartCode here', yes: true },
1002
+ { label: 'No', desc: 'Ctrl+Q closes just this tab' },
1003
+ ])
1004
+ if (!go?.yes) return
1005
+ }
1006
+ return 'exit'
1007
+ }
1008
+ case 'reset': {
1009
+ const it = await ui.pick('Reset what?', RESETS.map((r) => ({ label: r.label, desc: r.desc, level: r.level })))
1010
+ if (!it) return ui.print(c.dim('nothing reset'))
1011
+ const home = process.env.HOME
1012
+ const tilde = (f) => (home && f.startsWith(home) ? '~' + f.slice(home.length) : f)
1013
+ const files = resetPaths(it.level)
1014
+ const what = [...(it.level === 'settings' ? [`the settings in ${tilde(CONFIG_FILE)} (not your providers, keys or model)`] : []), ...files.map(tilde)]
1015
+ ui.print(`${c.warn('!')} ${c.bold(`Reset: ${it.label.toLowerCase()}.`)} ${c.muted("This can't be undone. It removes:")}\n${what.length ? what.map((f) => ` ${c.faint('·')} ${f}`).join('\n') : c.faint(' (nothing there yet)')}`)
1016
+ const code = resetCode()
1017
+ const typed = await ui.ask(` Type ${c.bold(code)} to reset ${c.dim('(anything else: no)')}: `)
1018
+ if (typed?.trim().toUpperCase() !== code) return ui.print(c.dim('not reset'))
1019
+ reset(it.level)
1020
+ log('reset', it.level, what)
1021
+ afterReset(app.tabs ? app.tabs.list.map((t) => t.app) : [app], it.level)
1022
+ return ui.print(`${c.ok('✓')} ${c.muted(`reset: ${it.label.toLowerCase()}`)}${it.level === 'settings' ? '' : c.dim(' · add a provider on /settings')}`)
1023
+ }
1024
+ case 'settings': {
1025
+ settingsPage ??= startSettings({ cfg, apply: (what) => applySettings(app.tabs ? app.tabs.list.map((t) => t.app) : [app], cfg, what) })
1026
+ const { url, close } = await settingsPage
1027
+ openBrowser(url)
1028
+ ui.print(`${c.info('↗')} ${c.bold('settings')} ${c.muted('opened in your browser:')}\n ${c.faint(url)}`)
1029
+ // a dialog until it's turned off: nothing else meanwhile, in any tab (Esc turns it off too).
1030
+ // Not busy meanwhile: what's changed on the page applies at once, here too.
1031
+ ui.idle()
1032
+ if (app.tabs) app.tabs.locked = true
1033
+ try {
1034
+ await ui.pick(`${c.info('↗')} ${c.bold('The settings page is on')}`, [{ label: 'Turn off', desc: 'stops the page and gives HeartCode back' }], { fixed: true })
1035
+ } finally {
1036
+ if (app.tabs) app.tabs.locked = false
1037
+ close()
1038
+ settingsPage = null
1039
+ }
1040
+ return ui.print(c.dim('settings page turned off'))
1041
+ }
1042
+ case 'tab': {
1043
+ const { tabs } = app
1044
+ if (!tabs) return ui.print(c.dim('tabs need the full screen'))
1045
+ if (!args[0] || args[0] === 'new') return void tabs.add({ root: app.root })
1046
+ if (args[0] === 'worktree') {
1047
+ const wt = createWorktree(app.root, args[1] ?? '')
1048
+ return void tabs.add({ root: wt.cwd, wt })
1049
+ }
1050
+ if (/^\d+$/.test(args[0]) && tabs.list[args[0] - 1]) return tabs.show(tabs.list[args[0] - 1])
1051
+ return ui.print(`${c.cyan('/tab')} ${c.faint('a new tab ·')} ${c.cyan('/tab worktree')} ${c.muted('[name]')} ${c.faint('a new tab in its own git worktree ·')} ${c.cyan('/tab')} ${c.muted('<1–' + tabs.list.length + '>')} ${c.faint('go to that tab')}`)
1052
+ }
1053
+ case 'help':
1054
+ return ui.print(app.help())
1055
+ case 'select_model':
1056
+ return app.pickModel(args)
1057
+ case 'rename': {
1058
+ const name = args.join(' ').trim() || (await ui.ask(` ${c.bold('Name for this session')}: `, { initial: app.chat.name ?? '' }))?.trim()
1059
+ if (!name) return ui.print(c.dim('cancelled'))
1060
+ app.chat.name = name
1061
+ if (app.chat.turns.length) app.save()
1062
+ return ui.print(c.dim(`named "${name}"${app.chat.turns.length ? '' : ' (saved with the first message)'}`))
1063
+ }
1064
+ case 'fork': {
1065
+ if (!app.chat.turns.length) return ui.print('Nothing to fork yet.')
1066
+ app.save()
1067
+ const was = app.chat.id
1068
+ app.setChat(forkChat(app.chat))
1069
+ app.save()
1070
+ return ui.print(c.dim(`forked: you're in "${app.chat.name}" now; the original (${was}) stays in /resume`))
1071
+ }
1072
+ case 'export': {
1073
+ if (!app.chat.turns.length) return ui.print('Nothing to export yet.')
1074
+ let file = args.join(' ').trim()
1075
+ if (!file) file = (await choose(ui, cmd, '', 'Export as?')) ?? ''
1076
+ if (!file) return
1077
+ if (ARGS.export.some(([f]) => f === file)) file = `heartcode-${app.chat.id}.${file}`
1078
+ const format = /\.html?$/i.test(file) ? 'html' : /\.json$/i.test(file) ? 'json' : 'md'
1079
+ const abs = path.resolve(app.root, file)
1080
+ fs.mkdirSync(path.dirname(abs), { recursive: true })
1081
+ fs.writeFileSync(abs, exportChat(app.chat, format, cfg.model ? `${cfg.provider} · ${cfg.model}` : ''))
1082
+ return ui.print(`${c.green('saved')} ${path.relative(app.root, abs) || abs}`)
1083
+ }
1084
+ case 'diff': {
1085
+ // each file from before HeartCode first changed it in this session, to now
1086
+ const first = new Map()
1087
+ for (const t of app.chat.turns) for (const [rel, before] of Object.entries(t.files)) if (!first.has(rel)) first.set(rel, before)
1088
+ const want = args.join(' ').trim()
1089
+ const files = [...first.keys()].filter((f) => !want || f.includes(want)).sort()
1090
+ if (!files.length) return ui.print(want ? `No changes to ${want} in this session.` : 'No changes in this session yet.')
1091
+ let shown = 0
1092
+ for (const rel of files) {
1093
+ const abs = path.join(app.root, rel)
1094
+ const now = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null
1095
+ const was = first.get(rel)
1096
+ if (was === now) continue
1097
+ shown++
1098
+ if (was === null || now === null) ui.print(`${c.bold(rel)} ${was === null ? c.green('(new)') : c.red('(deleted)')}`)
1099
+ if (now !== null) ui.print(renderDiff(diffLines(was ?? '', now), rel, undefined, 3, 400))
1100
+ }
1101
+ return ui.print(c.dim(shown ? `${shown} file${shown > 1 ? 's' : ''} changed in this session${files.length > shown ? ` (${files.length - shown} back as they were)` : ''}` : 'every file HeartCode changed is back as it was'))
1102
+ }
1103
+ case 'init': {
1104
+ const has = (f) => fs.existsSync(path.join(app.root, f))
1105
+ const file = has('HEARTCODE.md') ? 'HEARTCODE.md' : 'AGENTS.md'
1106
+ const others = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md', '.github/copilot-instructions.md', '.cursorrules'].filter((f) => f !== file && has(f))
1107
+ return runTask(app, prompt('agent/init', { file, existing: has(file), others: others.join(', ') }), { signal: app.signal })
1108
+ }
1109
+ case 'copy': {
1110
+ const answer = app.chat.history.at(-1)?.answer?.trim()
1111
+ if (!answer) return ui.print('No answer to copy yet.')
1112
+ const how = copyText(answer)
1113
+ return ui.print(c.dim(`copied the last answer (${answer.split('\n').length} lines)${how === 'osc' ? ' through the terminal' : ''}`))
1114
+ }
1115
+ case 'cost': {
1116
+ const u = app.chat.usage
1117
+ if (!u?.requests) return ui.print('Nothing used yet in this session.')
1118
+ const price = app.s?.client.price?.()
1119
+ ui.print(`${c.bold('tokens')} ${kTokens(u.input)} in${u.cached ? ` (${kTokens(u.cached)} from the cache)` : ''} · ${kTokens(u.output)} out · ${u.requests} request${u.requests > 1 ? 's' : ''}`)
1120
+ return ui.print(
1121
+ u.priced
1122
+ ? `${c.bold('cost')} ${money(u.cost)}${price ? c.faint(` (${cfg.model}: $${price.input ?? 0} in / $${price.output ?? 0} out per million tokens, from models.dev)`) : ''}`
1123
+ : c.faint(`cost: not known for ${cfg.model ?? 'this model'} (models.dev doesn't list it; local and subscription models have none)`),
1124
+ )
1125
+ }
1126
+ case 'clear_chat': // a new session; the old one stays in /resume
1127
+ ui.clear()
1128
+ app.setChat(newChat(app.root))
1129
+ if (app.s) app.s.client.lastTokens = 0
1130
+ return
1131
+ case 'resume':
1132
+ return app.resume()
1133
+ case 'rewind':
1134
+ return app.rewind()
1135
+ case 'tree':
1136
+ return app.tree()
1137
+ case 'tidy_memory': {
1138
+ const s = await app.session()
1139
+ if (!s) return
1140
+ return s.tidyMemory(app.signal)
1141
+ }
1142
+ case 'login_mcp': {
1143
+ const remote = app.mcp.names.filter((n) => app.mcp.get(n).url)
1144
+ const name = args[0] ?? (await ui.pick('Sign in to which server?', remote.map((n) => ({ label: n, desc: app.mcp.get(n).url }))))?.label
1145
+ if (!name) return remote.length ? undefined : ui.print('No remote MCP servers (ones with a "url") to sign in to.')
1146
+ const sv = app.mcp.get(name)
1147
+ if (!sv?.url) return ui.print(`No remote MCP server "${name}".`)
1148
+ forget(sv.url) // a fresh sign-in
1149
+ sv.stop()
1150
+ Object.assign(sv, { token: null, tools: null })
1151
+ ui.spinner(`connecting to ${name}`)
1152
+ const tools = await sv.listTools(app.signal)
1153
+ ui.stopSpin()
1154
+ return ui.print(`${c.green('connected')} ${c.bold(name)} · ${tools.length} tools`)
1155
+ }
1156
+ case 'import': {
1157
+ const items = findImports(app.root)
1158
+ if (!items.length) return ui.print('Nothing to import: no commands, skills, MCP servers, instructions or memory from Claude Code, Codex, Gemini CLI, OpenCode or Cursor found.')
1159
+ const chosen = await ui.pick(`Import from other CLIs (${items.length} found)`, items, { multi: true })
1160
+ if (!chosen) return
1161
+ for (const it of chosen) {
1162
+ try {
1163
+ ui.print(`${c.green('✓')} ${it.run()}`)
1164
+ } catch (e) {
1165
+ ui.print(`${c.red('✗')} ${it.label}: ${e.message}`)
1166
+ }
1167
+ }
1168
+ app.reload('skill')
1169
+ app.reload('mcp')
1170
+ log('import', chosen.map((it) => `${it.group}: ${it.label}`))
1171
+ return
1172
+ }
1173
+ case 'btw':
1174
+ return app.btw(line.trim().replace(/^\/btw\b/, '').trim())
1175
+ case 'voice': {
1176
+ const text = await ui.dictate()
1177
+ if (text) app.nextInput = text // in the box, to read over and send
1178
+ return
1179
+ }
1180
+ case 'toggle_thinking':
1181
+ ui.showThinking = !ui.showThinking
1182
+ return ui.print(c.dim(`thinking ${ui.showThinking ? 'shown' : 'collapsed'}`))
1183
+ }
1184
+ if (!['model_info', 'test_model', 'compact'].includes(cmd)) {
1185
+ app.skills = loadSkills(app.root) // edits to skill files apply right away
1186
+ if (app.s) app.s.skills = app.skills
1187
+ const k = app.skills.get(cmd)
1188
+ if (k && k.use !== 'model') return runTask(app, await app.expandSkill(k, args.join(' ')), { signal: app.signal })
1189
+ return ui.print(`unknown command /${cmd} ${c.dim('· /help lists them')}`)
1190
+ }
1191
+ const s = await app.session()
1192
+ if (!s) return
1193
+ const restrat = () => (s.strat = strategy(s.profile, cfg))
1194
+ switch (cmd) {
1195
+ case 'compact': {
1196
+ const done = await s.compact({ signal: app.signal, focus: args.join(' ') })
1197
+ if (!done) return ui.print('Nothing to compact yet.')
1198
+ s.client.lastTokens = 0
1199
+ return app.save()
1200
+ }
1201
+ case 'model_info':
1202
+ return ui.print(`${c.cyan(s.client.model)}\n${describe(s.profile, s.strat)}`)
1203
+ case 'test_model':
1204
+ s.profile = await getProfile(s.client, ui, true, app.signal)
1205
+ restrat()
1206
+ s.reset()
1207
+ return ui.print(describe(s.profile, s.strat))
1208
+ }
1209
+ }
1210
+
1211
+ async function runTask(app, task, ctl) {
1212
+ const { ui } = app
1213
+ try {
1214
+ const s = await app.session()
1215
+ if (!s) return false
1216
+ try {
1217
+ await s.run(task, ctl?.signal)
1218
+ } finally {
1219
+ app.save()
1220
+ }
1221
+ } catch (e) {
1222
+ ui.stopSpin()
1223
+ log('error', e.name === 'AbortError' || ctl?.signal.aborted ? 'task interrupted by the user' : e.stack)
1224
+ if (e.name === 'AbortError' || ctl?.signal.aborted) ui.print(c.yellow('\ninterrupted'))
1225
+ else ui.print(c.red(`\nerror: ${e.message}`))
1226
+ return false
1227
+ }
1228
+ return true
1229
+ }
1230
+
1231
+ async function repl(app, f = {}) {
1232
+ const { ui, cfg } = app
1233
+ ui.fullscreen()
1234
+ ui.onDoubleEscape = () => '/rewind'
1235
+ let ctl = null
1236
+ ui.onEscape = () => ctl?.abort()
1237
+ ui.onBtw = (q) => app.btw(q, true) // /btw typed while it works
1238
+ ui.voiceConfig = () => ({ voice: cfg.voice, providers: cfg.providers })
1239
+ // Ctrl+C: stops what's running; clears the input if there's text; otherwise asks before quitting
1240
+ // (a second Ctrl+C within 2s quits, also when something is stuck).
1241
+ let lastInterrupt = 0
1242
+ // Ctrl+C twice: all of HeartCode (its tabs come back next time, in continueTabs)
1243
+ const quit = () => {
1244
+ if (app.tabs) app.tabs.quitting = true
1245
+ ui.print()
1246
+ ui.close()
1247
+ process.exit(0)
1248
+ }
1249
+ // Ctrl+Q: this tab (working: press it again to stop the work too); the last one quits
1250
+ let lastClose = 0
1251
+ ui.onCloseTab = () => {
1252
+ if (ctl && Date.now() - lastClose > 2000) {
1253
+ lastClose = Date.now()
1254
+ return ui.notice('Still working: Ctrl+Q again stops it and closes this tab', 2000)
1255
+ }
1256
+ if (app.tabs.list.length < 2) app.tabs.quitting = true
1257
+ ctl?.abort()
1258
+ ui.cancelAsk()
1259
+ ui.close()
1260
+ }
1261
+ const others = () => (app.tabs?.list.length ?? 1) - 1
1262
+ ui.onInterrupt = () => {
1263
+ const again = Date.now() - lastInterrupt < 2000
1264
+ lastInterrupt = Date.now()
1265
+ if (ctl) {
1266
+ if (again) return quit()
1267
+ ctl.abort()
1268
+ ui.cancelAsk()
1269
+ return ui.warn(`stopping… (Ctrl+C again to quit${others() ? ` HeartCode, all ${others() + 1} tabs` : ''})`)
1270
+ }
1271
+ if (ui.rl?.line) {
1272
+ lastInterrupt = 0 // clearing isn't the first half of quitting
1273
+ return ui.clearInput()
1274
+ }
1275
+ if (again) return quit() // the second within 2s (also when the first stopped the work)
1276
+ ui.notice(others() ? `Press Ctrl+C again to quit HeartCode (all ${others() + 1} tabs; Ctrl+Q closes just this one)` : 'Press Ctrl+C again to quit', 2000)
1277
+ }
1278
+ // Pinned to the top of the screen; /clear_chat and scrolling leave it alone.
1279
+ const home = process.env.HOME
1280
+ const where = home && app.root.startsWith(home) ? '~' + app.root.slice(home.length) : app.root
1281
+ ui.header = (cols = 100) => [
1282
+ `${c.brand('♥')} ${c.bright(c.bold('HeartCode'))} ${c.faint(VERSION)} ${c.muted(where.length > cols - 16 ? '…' + where.slice(-(cols - 17)) : where)}`,
1283
+ [['/', 'commands'], ['@', 'files'], [NEWLINE_KEY, 'new line'], ['Esc', 'stop'], ['Shift+Tab', 'permissions'], ['Ctrl+R', 'voice']].map(([k, d]) => `${c.muted(k)} ${c.faint(d)}`).join(c.ghost(' · ')),
1284
+ '',
1285
+ ]
1286
+ if (!ui.full) ui.print(ui.header().join('\n'))
1287
+ if (f.onStart) await f.onStart(app)
1288
+ if (f.continue) {
1289
+ const last = listChats(app.root)[0]
1290
+ if (last) await app.resume(last)
1291
+ else ui.print(c.dim('No earlier session in this folder to continue.'))
1292
+ }
1293
+ if (f['resume-id']) await app.resume(loadChat(f['resume-id']))
1294
+ else if (f.resume) await app.resume()
1295
+ if (f.model) useModel(app, f.model) // a continued tab's own model
1296
+ app.tabs?.onChange?.() // the conversation it opened with
1297
+ for (;;) {
1298
+ const line = await ui.ask(` ${c.magenta('♥')} `, { suggest: true, initial: app.nextInput, history: true })
1299
+ app.nextInput = undefined
1300
+ if (line === null) break
1301
+ const t = line.trim()
1302
+ if (!t) continue
1303
+ ctl = new AbortController()
1304
+ app.signal = ctl.signal // Esc stops whatever this line started (a task, a probe, …)
1305
+ ui.busy() // the spinner stays until this line's work is done
1306
+ if (t.startsWith('/')) {
1307
+ try {
1308
+ if ((await slash(app, t)) === 'exit') {
1309
+ if (app.tabs) app.tabs.quitting = true
1310
+ break
1311
+ }
1312
+ } catch (e) {
1313
+ ui.stopSpin()
1314
+ log('error', e.stack)
1315
+ ui.print(c.red(`error: ${e.message}`))
1316
+ }
1317
+ } else {
1318
+ let ok = await runTask(app, t, ctl)
1319
+ // plan mode: the plan is done; go ahead with it?
1320
+ while (ok && ui.permission === 'plan' && !ctl.signal.aborted) {
1321
+ const back = ui.beforePlan && ui.beforePlan !== 'plan' ? ui.beforePlan : 'all'
1322
+ const go = await ui.pick('Go ahead with this plan?', [
1323
+ { label: 'Yes', desc: Object.fromEntries(PERMISSIONS)[back], mode: back },
1324
+ ...(back !== 'ask' ? [{ label: 'Yes, and ask before every change', mode: 'ask' }] : []),
1325
+ { label: 'No, keep planning', desc: 'then type what to change in the plan' },
1326
+ ])
1327
+ if (!go?.mode) break
1328
+ ui.setPermission(go.mode)
1329
+ ok = await runTask(app, prompt('agent/plan-approved'), ctl)
1330
+ }
1331
+ }
1332
+ ui.idle()
1333
+ ctl = null
1334
+ app.afterLine()
1335
+ app.tabs?.onChange?.() // the conversation may be another now (/resume, /fork, /clear_chat)
1336
+ if (ui.tail) ui.print() // nothing after /clear_chat
1337
+ }
1338
+ ui.close()
1339
+ }
1340
+
1341
+ async function main() {
1342
+ // "--resume <id>" (as in Claude Code) vs a bare "--resume" (pick one): an id follows it
1343
+ const argv = process.argv.slice(2)
1344
+ const print = argv.includes('-p') || argv.includes('--print') // headless: an id after --resume is always an id
1345
+ for (let i = 0; i < argv.length; i++) if (argv[i] === '-w' || argv[i] === '--worktree') argv[i] = '--worktree=' // a name only as --worktree=name
1346
+ for (let i = 0; i < argv.length; i++) if ((argv[i] === '--resume' || argv[i] === '-r') && /^[\w.-]{6,}$/.test(argv[i + 1] ?? '') && (print || loadChat(argv[i + 1]))) argv[i] = '--resume-id'
1347
+ const { values: f, positionals } = parseArgs({
1348
+ args: argv,
1349
+ allowPositionals: true,
1350
+ options: {
1351
+ 'base-url': { type: 'string', short: 'u' },
1352
+ model: { type: 'string', short: 'm' },
1353
+ 'api-key': { type: 'string' },
1354
+ yes: { type: 'boolean', short: 'y' },
1355
+ ask: { type: 'boolean' },
1356
+ 'hide-thinking': { type: 'boolean' },
1357
+ cwd: { type: 'string', short: 'C' },
1358
+ context: { type: 'string' },
1359
+ force: { type: 'boolean' },
1360
+ save: { type: 'boolean' },
1361
+ continue: { type: 'boolean', short: 'c' },
1362
+ resume: { type: 'boolean', short: 'r' },
1363
+ 'resume-id': { type: 'string' },
1364
+ worktree: { type: 'string' },
1365
+ acp: { type: 'boolean' },
1366
+ provider: { type: 'string' },
1367
+ print: { type: 'boolean', short: 'p' },
1368
+ 'output-format': { type: 'string' },
1369
+ 'input-format': { type: 'string' },
1370
+ 'append-system-prompt': { type: 'string' },
1371
+ 'mcp-config': { type: 'string', multiple: true },
1372
+ 'strict-mcp-config': { type: 'boolean' },
1373
+ 'skills-dir': { type: 'string', multiple: true },
1374
+ allowedTools: { type: 'string', multiple: true },
1375
+ 'allowed-tools': { type: 'string', multiple: true },
1376
+ disallowedTools: { type: 'string', multiple: true },
1377
+ 'disallowed-tools': { type: 'string', multiple: true },
1378
+ 'permission-mode': { type: 'string' },
1379
+ 'dangerously-skip-permissions': { type: 'boolean' },
1380
+ 'session-id': { type: 'string' },
1381
+ effort: { type: 'string' },
1382
+ 'no-memory': { type: 'boolean' },
1383
+ verbose: { type: 'boolean' },
1384
+ 'max-retries': { type: 'string' },
1385
+ 'max-turns': { type: 'string' },
1386
+ all: { type: 'boolean' }, // heart models --all
1387
+ json: { type: 'boolean' }, // heart models --json
1388
+ // accepted for drop-in use where Claude Code is expected; nothing to do in HeartCode
1389
+ autocompact: { type: 'string' },
1390
+ 'exclude-dynamic-system-prompt-sections': { type: 'boolean' },
1391
+ 'include-partial-messages': { type: 'boolean' },
1392
+ help: { type: 'boolean', short: 'h' },
1393
+ version: { type: 'boolean', short: 'v' },
1394
+ },
1395
+ })
1396
+ if (f.help) return console.log(HELP)
1397
+ if (f.version) return console.log(VERSION)
1398
+ if (f.context && !(Number(f.context) > 0)) throw new Error('--context must be a number of tokens')
1399
+ for (const k of ['output-format', 'input-format']) if (f[k] && !['text', 'json', 'stream-json'].includes(f[k])) throw new Error(`--${k} must be text, json or stream-json`)
1400
+ if (f['permission-mode'] && !PERMISSION_MODES[f['permission-mode']]) throw new Error(`--permission-mode must be one of ${Object.keys(PERMISSION_MODES).join(', ')}`)
1401
+
1402
+ const cfg = loadConfig({ baseUrl: f['base-url'], model: f.model, apiKey: f['api-key'], context: f.context })
1403
+ setTheme(cfg.theme)
1404
+ useLogs(cfg)
1405
+ const safe = { ...cfg, providers: Object.fromEntries(Object.entries(cfg.providers).map(([n, p]) => [n, { ...p, apiKey: p.apiKey && mask(p.apiKey) }])) }
1406
+ log('start', `heartcode ${VERSION} · node ${process.version} · ${process.platform} · cwd ${process.cwd()}`, { args: process.argv.slice(2).map((a) => (a === f['api-key'] ? mask(a) : a)), config: safe })
1407
+ cfg.force = f.force
1408
+ if (f.provider) {
1409
+ if (!cfg.providers[f.provider]) throw new Error(`no provider "${f.provider}" (heartcode providers lists them)`)
1410
+ Object.assign(cfg, { provider: f.provider, fixed: true })
1411
+ }
1412
+ const [p0, ...rest] = String(f.model ?? '').split(/\s+/) // -m "groq openai/gpt-oss-120b"
1413
+ if (rest.length && cfg.providers[p0]) Object.assign(cfg, { provider: p0, model: rest.join(' ') })
1414
+ if (f.effort) cfg.effort = f.effort === 'default' ? undefined : f.effort
1415
+ if (f['mcp-config'] || f['strict-mcp-config']) cfg.mcp = { extra: f['mcp-config'] ?? [], strict: Boolean(f['strict-mcp-config']) }
1416
+ extraSkillDirs.push(...(f['skills-dir'] ?? []).map((d) => path.resolve(d)))
1417
+ if (f.save) {
1418
+ cfg.adhoc = null
1419
+ saveConfig(cfg)
1420
+ console.log(c.dim(`saved to ${CONFIG_FILE}`))
1421
+ }
1422
+ const sub = ['probe', 'models', 'providers', 'config', 'settings'].includes(positionals[0]) && positionals.length <= 2 ? positionals[0] : null
1423
+
1424
+ if (sub === 'settings') {
1425
+ const { url } = await startSettings({ cfg })
1426
+ console.log(`${c.brand('♥')} HeartCode settings: ${url}\n${c.dim('opened in your browser · Ctrl+C to stop')}`)
1427
+ openBrowser(url)
1428
+ return new Promise(() => {}) // until Ctrl+C
1429
+ }
1430
+
1431
+ if (sub === 'config') {
1432
+ const file = readJson(CONFIG_FILE, {})
1433
+ for (const p of Object.values(file.providers ?? {})) if (p.apiKey) p.apiKey = mask(p.apiKey)
1434
+ return console.log(`${CONFIG_FILE}\n${JSON.stringify(file, null, 2)}`)
1435
+ }
1436
+
1437
+ if (f.acp) {
1438
+ // each editor session gets its own App, for its folder and its MCP servers
1439
+ cfg.maxRetries ??= 10
1440
+ const makeApp = (root, ui, mcp) => new App(mcp ? { ...cfg, mcp: { extra: [mcp] } } : cfg, ui, root)
1441
+ process.exit(await runAcp({ makeApp, version: VERSION, permission: f['permission-mode'] ? PERMISSION_MODES[f['permission-mode']] : (cfg.permissions ?? 'ask') }))
1442
+ }
1443
+ const headless = f.print || f['output-format'] || f['input-format'] === 'stream-json'
1444
+ if (f['max-retries'] !== undefined || headless) cfg.maxRetries = Number(f['max-retries'] ?? 10) // nobody is there to press Esc
1445
+ if (f['max-turns']) cfg.maxTurns = Number(f['max-turns'])
1446
+ // A UI and an App on it (one per tab; io: the tab's input and output).
1447
+ const newApp = (cfg, root, io) => {
1448
+ const ui = headless ? new HeadlessUI({ verbose: f.verbose }) : new UI(io)
1449
+ // Everything is allowed unless the user chose otherwise (saved), or --ask for this run.
1450
+ ui.permission = f['dangerously-skip-permissions'] ? 'all' : f['permission-mode'] ? PERMISSION_MODES[f['permission-mode']] : f.ask ? 'ask' : f.yes ? 'all' : (cfg.permissions ?? 'all')
1451
+ if (!headless) {
1452
+ ui.onPermission = (mode) => {
1453
+ if (mode === 'plan') return // for this session only: HeartCode doesn't start in plan mode
1454
+ cfg.permissions = mode
1455
+ saveConfig(cfg)
1456
+ }
1457
+ }
1458
+ ui.showThinking = !f['hide-thinking']
1459
+ const app = new App(cfg, ui, root)
1460
+ usePromptsFrom(app.root) // its .heartcode/prompts override the built-in sections
1461
+ ui.suggest = (line, cursor) => app.suggest(line, cursor)
1462
+ ui.status = () => app.status()
1463
+ ui.requestSince = () => app.s?.client.inFlight // the spinner shows how long the model has been answering
1464
+ return app
1465
+ }
1466
+ let root = path.resolve(f.cwd ?? process.cwd())
1467
+ const wt = f.worktree !== undefined ? createWorktree(root, f.worktree) : null
1468
+ if (wt) {
1469
+ root = wt.cwd
1470
+ log('worktree', `working in ${wt.dir} on ${wt.branch}`)
1471
+ process.on('exit', () => wt.done || activeUI?.noUser || console.error(c.dim(`worktree kept: ${wt.dir} (branch ${wt.branch})`)))
1472
+ }
1473
+ // The full screen: in tabs (the first one is this folder; /tab and Ctrl+T open more).
1474
+ if (!headless && !sub && !positionals.length && process.stdin.isTTY && process.stdout.isTTY) {
1475
+ if (f['resume-id'] && !loadChat(f['resume-id'])) throw new Error(`no session ${f['resume-id']}`)
1476
+ const saved = readJson(TABS_FILE, {})[root] // before this run's first tab replaces it
1477
+ const asked = f.continue || f.resume || f['resume-id'] || f['session-id'] // a session was chosen already
1478
+ const tabs = new Tabs((tab, o) => {
1479
+ const app = newApp(o.first ? cfg : { ...cfg }, o.root, tab)
1480
+ Object.assign(tab, { app, ui: app.ui })
1481
+ Object.assign(app.ui, tab.hooks)
1482
+ Object.assign(app, { tabs, tab, worktree: o.wt ?? null })
1483
+ if (o.first) {
1484
+ if (f['no-memory']) app.memory = null
1485
+ if (f['session-id']) app.setChat(loadChat(f['session-id']) ?? { ...newChat(app.root), id: f['session-id'] })
1486
+ }
1487
+ return (async () => {
1488
+ await repl(app, o.first ? { ...f, onStart: !asked && ((a) => continueTabs(a, saved)) } : o.chat ? { 'resume-id': o.chat.id, model: o.model } : {})
1489
+ if (tabs.quitting) {
1490
+ // quitting: every tab's worktree is kept or removed, then out (other tabs' work stops)
1491
+ for (const t of tabs.list) await leaveWorktree(t.app)
1492
+ process.exit(process.exitCode ?? 0)
1493
+ }
1494
+ if (tabs.list.includes(tab)) await leaveWorktree(app) // the last tab: the full screen is gone
1495
+ else leaveTabWorktree(app)
1496
+ })()
1497
+ })
1498
+ tabs.root = root
1499
+ tabs.onChange = () => saveTabs(tabs)
1500
+ watchSettings(() => tabs.list.map((t) => t.app), cfg)
1501
+ tabs.onShow = (t) => (activeUI = t.ui)
1502
+ tabs.add({ root, wt, first: true })
1503
+ await tabs.done
1504
+ process.exit(process.exitCode ?? 0) // the last tab closed: quit, even if a timer or an MCP server is still around
1505
+ }
1506
+ const app = newApp(cfg, root)
1507
+ const { ui } = app
1508
+ activeUI = ui
1509
+ app.worktree = wt
1510
+ if (sub === 'providers') return app.listProviders(), ui.close()
1511
+ if (sub === 'models') return await app.listModels(positionals[1] ?? '', { all: f.all, json: f.json }), ui.close()
1512
+ if (sub === 'probe') {
1513
+ cfg.force = true
1514
+ const s = await app.session()
1515
+ if (s) ui.print(describe(s.profile, s.strat))
1516
+ ui.close()
1517
+ process.exitCode = s ? 0 : 1
1518
+ return
1519
+ }
1520
+ if (f['no-memory']) app.memory = null
1521
+ const chat = f['resume-id'] ? loadChat(f['resume-id']) : f.continue && headless ? listChats(app.root)[0] : f['session-id'] ? (loadChat(f['session-id']) ?? { ...newChat(app.root), id: f['session-id'] }) : null
1522
+ if (f['resume-id'] && !chat) throw new Error(`no session ${f['resume-id']}`)
1523
+ if (chat && (headless || !f['resume-id'])) app.setChat(chat) // interactive: resumed below, with its transcript
1524
+ if (headless) {
1525
+ app.allowTools = [...(f.allowedTools ?? []), ...(f['allowed-tools'] ?? [])]
1526
+ app.denyTools = [...(f.disallowedTools ?? []), ...(f['disallowed-tools'] ?? [])]
1527
+ const code = await runHeadless(app, { prompt: positionals.join(' '), input: f['input-format'], output: f['output-format'], appendSystem: f['append-system-prompt'] })
1528
+ if (wt && worktreeState(wt).empty) removeWorktree(wt) // else kept, for the caller to merge (its path is in the log)
1529
+ process.exit(code) // MCP servers are stopped by the exit hooks
1530
+ }
1531
+ if (positionals.length) {
1532
+ const ok = await runTask(app, positionals.join(' '))
1533
+ ui.close()
1534
+ await leaveWorktree(app)
1535
+ process.exit(ok ? 0 : 1) // MCP servers are stopped by the exit hooks
1536
+ }
1537
+ watchSettings(() => [app], cfg)
1538
+ await repl(app, f)
1539
+ await leaveWorktree(app)
1540
+ process.exit(process.exitCode ?? 0) // /exit or Ctrl+D: quit, even if a timer or an MCP server is still around
1541
+ }
1542
+
1543
+ let activeUI = null
1544
+ process.on('uncaughtException', (e) => {
1545
+ log('crash', e.stack)
1546
+ activeUI?.close()
1547
+ console.error(`${c.red('crash:')} ${e.stack}`)
1548
+ process.exit(1)
1549
+ })
1550
+ // Killed from outside (kill, closing the tab): give the terminal back as it was first.
1551
+ for (const sig of ['SIGTERM', 'SIGHUP']) {
1552
+ process.on(sig, () => {
1553
+ log('exit', `got ${sig}`)
1554
+ activeUI?.close()
1555
+ process.exit(128 + (sig === 'SIGTERM' ? 15 : 1))
1556
+ })
1557
+ }
1558
+ process.on('unhandledRejection', (e) => log('crash', 'unhandled rejection:', e?.stack ?? e))
1559
+ // After the full screen is gone (the UI registers its exit hook later, so leave it here first).
1560
+ process.on('exit', (code) => {
1561
+ log('exit', `code ${code}`)
1562
+ const logs = [LOG_FILE ?? (LOG_DIR && fs.existsSync(LOG_DIR) ? LOG_DIR : null), requestLog.dir].filter(Boolean)
1563
+ if (!logs.length) return
1564
+ activeUI?.leaveFullscreen()
1565
+ process.stderr.write(c.dim(`${logs.map((l) => `${l === requestLog.dir ? 'requests' : 'log'}: ${l}`).join('\n')}\n`))
1566
+ })
1567
+ main().catch((e) => {
1568
+ log('error', e.stack)
1569
+ activeUI?.close() // leave full screen first, so the error stays visible
1570
+ console.error(`${c.red('error:')} ${e.message}`)
1571
+ process.exit(1)
1572
+ })