heartcode 0.0.0-stage → 0.1.0

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