heartcode 0.1.8 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,78 @@
1
+ # Changelog
2
+
3
+ What changed in each version of HeartCode, newest first.
4
+
5
+ ## 0.1.12 (2026-10-30)
6
+
7
+ - `auto` is the name for your model list (#1, then the next when one fails); `default` still works.
8
+ - Models are written `provider/model` (`-m groq/openai/gpt-oss-120b`); the old `"provider model"` still works.
9
+ - `heart models` prints what `-m` takes, one per line (`auto` first); `--json` gives an `id` for each.
10
+ - `--fallback-model <provider/model>`, as in `claude -p`: tried after the `-m` model when it fails.
11
+ - Settings changed while HeartCode runs (also `-p`, stream-json and editors over ACP) are taken in
12
+ without a restart: a new model order from the running task's next request.
13
+ - Settings page: adding a provider, an MCP server or a skill opens a dialog; every dialog is 70% of
14
+ the screen wide. Your models can be sorted by a provider ranking you drag into order.
15
+
16
+ ## 0.1.11 (2026-10-30)
17
+
18
+ - A changelog (this file) in place of the old TODO list.
19
+
20
+ ## 0.1.10 (2026-10-30)
21
+
22
+ - Any model error (a rate limit, an outage, "request too large", a bad model name, a wrong key…)
23
+ moves on to the next model in your list, another provider's first, without the app using
24
+ HeartCode seeing it. A run stops with an error only when every model has failed.
25
+ - No more token estimates deciding when to shrink a request or switch models. A long
26
+ conversation is summarized every 100 messages instead.
27
+ - Settings: the rotation choice "at any error" is gone; it now works the same as "at its first
28
+ failure".
29
+
30
+ ## 0.1.9 (2026-10-29)
31
+
32
+ - After an error, the next model tried is from another provider; the failing provider's other
33
+ models come only after every provider has had a go.
34
+ - Model lists in Cloudflare's format (`{ "result": [...] }`) are read.
35
+ - Settings: model lists no longer stay stuck on "loading…", and an empty saved list is fetched again.
36
+ - A shorter system prompt, with the duplicates taken out.
37
+
38
+ ## 0.1.8 (2026-10-29)
39
+
40
+ - Settings page: model tests run on the server, long model lists scroll smoothly, prices are
41
+ shown, and "Test all" shows its progress.
42
+
43
+ ## 0.1.7 (2026-10-29)
44
+
45
+ - Each request is fitted to the model it goes to (images, tool calls, thinking).
46
+ - Safer file changes.
47
+ - Better handing of work to the other models.
48
+
49
+ ## 0.1.6 (2026-10-29)
50
+
51
+ - Better handing of work to the other models.
52
+ - A helper's steps are sent to apps using HeartCode, like Claude Code's subagents.
53
+ - Fixed switching models.
54
+ - npm page: GitHub links and keywords.
55
+
56
+ ## 0.1.5 (2026-10-28)
57
+
58
+ - Several helpers can run at once; thinking is shown with its name; the model lists show what
59
+ each model can do.
60
+ - Tab only completes, it doesn't send.
61
+
62
+ ## 0.1.3 (2026-10-28)
63
+
64
+ - Another of your models does what the one in use can't (for example, looking at an image).
65
+ - A path typed at the prompt is no longer taken for a command.
66
+
67
+ ## 0.1.2 (2026-10-28)
68
+
69
+ - Logging: the size on disk, and a Delete all button.
70
+ - Refresh model data from models.dev.
71
+ - A Test button on the Your models page, a simpler list, and `-latest` model names matched in models.dev.
72
+ - Screenshots in the README.
73
+
74
+ ## 0.1.1 (2026-10-28)
75
+
76
+ - First release: MCP tools as real tools, fallback models that keep going, a model Test button.
77
+ - The npm package holds only what HeartCode needs to run.
78
+ - README, MIT license, and failures described in SKILL.md.
package/SKILL.md CHANGED
@@ -14,59 +14,73 @@ answer (or JSON) and exits. Its flags and output match Claude Code's `claude -p`
14
14
  ```sh
15
15
  heart --version # installed? (else: node /path/to/heart_cli/bin/heart.js --version)
16
16
  heart providers # the providers set up, and their URLs (keys are masked)
17
- heart models --json # the models to pass to -m: "default" first (the user's list, with fallbacks), then each model
18
- heart models --all --json # every provider's models too (slower: asks each provider)
17
+ heart models # what -m takes, one per line: "auto" first, then the user's models (provider/model), #1 first
18
+ heart models --json # the same, as JSON
19
+ heart models --all # every provider's models too (slower: asks each provider)
19
20
  ```
20
21
 
21
- Read `--json`, not the text: the text layout is for people and may change.
22
+ `heart models` prints one id per line, nothing else (like `opencode models`):
23
+
24
+ ```
25
+ auto
26
+ groq/openai/gpt-oss-120b
27
+ openrouter/qwen/qwen3-coder
28
+ ```
29
+
30
+ With `--json`:
22
31
 
23
32
  ```json
24
33
  { "models": [
25
- { "place": null, "provider": null, "model": "default", "role": "default", "rotates": true, "about": "your models in order: #1, then the next when one fails (-m default, or no -m)" },
26
- { "place": 1, "provider": "groq", "model": "openai/gpt-oss-120b", "role": "in use", "rotates": false },
27
- { "place": 2, "provider": "openrouter", "model": "qwen/qwen3-coder", "role": "fallback", "rotates": false } ],
34
+ { "id": "auto", "fallbacks": true },
35
+ { "id": "groq/openai/gpt-oss-120b", "provider": "groq", "model": "openai/gpt-oss-120b", "place": 1 },
36
+ { "id": "openrouter/qwen/qwen3-coder", "provider": "openrouter", "model": "qwen/qwen3-coder", "place": 2 } ],
28
37
  "providers": [
29
- { "name": "groq", "baseUrl": "https://api.groq.com/openai/v1", "list": true, "models": ["…"] },
38
+ { "name": "groq", "baseUrl": "https://api.groq.com/openai/v1", "list": true, "models": ["groq/…"] },
30
39
  { "name": "local", "baseUrl": "http://127.0.0.1:9/v1", "list": true, "models": [], "error": "cannot reach …" } ] }
31
40
  ```
32
41
 
33
- Each entry in `models` is something to pass to `-m`, in order:
42
+ `id` is exactly what to pass to `-m`:
34
43
 
35
- - **`default`** (always first; `place` and `provider` are `null`): the user's list. `rotates: true`
36
- when they have more than one model: #1 is used, and the next one in order when it fails.
37
- - **the user's models** (`place` 1, 2, …): `role` is `in use` for #1 and `fallback` for the rest;
38
- `rotates: false`, since a model named on its own is used alone.
44
+ - **`auto`** (always first): the user's list. `fallbacks: true` when it has more than one model:
45
+ #1 is used, and the next one in order when it fails. No `-m` means `auto` too. (`default` is
46
+ its old name and still works.)
47
+ - **the user's models** (`place` 1, 2, …), as `provider/model`: #1 is the one `auto` starts with.
39
48
 
40
49
  No model set up yet: `models` is empty (and `heart -p` fails with "no model set up").
41
- `heart models [--all] <filter>` keeps only the entries whose `model` contains `<filter>`.
50
+ `heart models [--all] <filter>` keeps only the ids that contain `<filter>`.
42
51
 
43
52
  `providers` (only with `--all`): every provider's models. `list: false`: the user switched that
44
53
  provider's list off, and `models` are the ones they added by name. A provider that can't be
45
- reached has an `error` and is skipped; the command still exits `0`.
54
+ reached has an `error` (in text: a line on stderr) and is skipped; the command still exits `0`.
46
55
 
47
56
  **Which model to pass:**
48
57
 
49
58
  | `-m` | What runs |
50
59
  |---|---|
51
- | `default`, or no `-m` | the user's list: #1, and the next one in order when it fails (rate limits, outages…). Use this unless you need one particular model. |
52
- | a model from the list: `-m "<provider> <model>"` (`-m "groq openai/gpt-oss-120b"`), or `-m <model>` alone | that model alone: never another one, also when it fails (a rate limit or an outage is tried again, up to `--max-retries`) |
53
-
54
- **Failures:** a rate limit, a server error, a timeout, a reply that stops coming (nothing for 150s),
55
- a lost connection, or an error page from a proxy in front of the provider is waited out and tried
56
- again (or handed to the next model in the list), up to `--max-retries` (10). With the default
57
- list, a model that can't be used (a wrong key, no access to it, a name the server doesn't know:
58
- 401, 403, 404) also makes way for the next one, and so does one that keeps replying with nothing.
59
- The run stops with that error and exit code `1` when every model in the list fails that way, or
60
- when a single model (`-m <model>`) does. A request a server refuses (400) is sent as it is to two
61
- more models of the list at most, then the run stops (unless the user set the fallback rule to "at
62
- any error"). A provider that refuses a part
63
- of the request HeartCode can do without (the model's thinking sent back, keys in the tools'
64
- schemas, images) gets it again without that part, and that is remembered for the model for a day.
60
+ | `auto`, or no `-m` | the user's list: #1, and the next one in order when it fails. Use this unless you need one particular model. |
61
+ | `provider/model` (`-m groq/openai/gpt-oss-120b`), or a model of #1's provider alone | that model alone: never another one, also when it fails (a rate limit or an outage is tried again, up to `--max-retries`) |
62
+ | that, plus `--fallback-model provider/model` (repeatable) | that model, then the fallbacks in order when it fails (as `claude -p --fallback-model`) |
63
+
64
+ **Failures:** with `auto` (the list), any failure (an error status of any kind, a timeout, a reply
65
+ that stops coming, a lost connection, an error page from a proxy, a model that keeps replying with
66
+ nothing) makes way for the next model, from another provider first; that provider's other models
67
+ are tried only after every provider has had a go. None of it shows: the run goes on with the model
68
+ that answers. It stops with exit code `1` only when every model in the list fails: at once if they
69
+ all refused the request outright, else after `--max-retries` (10) whole rounds of them. A single
70
+ model (`-m <model>`) waits out rate limits and outages, up to `--max-retries` tries, and stops at
71
+ any other error. A provider that refuses a part of the request HeartCode can do without (the
72
+ model's thinking sent back, keys in the tools' schemas, images) gets it again without that part,
73
+ and that is remembered for the model for a day. A long conversation is summarized every 100
74
+ messages.
65
75
  Each request is fitted to the model it goes to: one that can't see gets a note in place of each
66
76
  image, one that can't call tools gets the tool calls as text.
67
77
 
68
78
  `--provider <name>` and `-u <url>` (a server for this run) also mean one model, alone.
69
79
 
80
+ A run picks up settings the user changes meanwhile (`heart settings`, or `~/.heart` by hand)
81
+ without being restarted: a new model order from its next request, MCP servers and skills from its
82
+ next message.
83
+
70
84
  `heart` is installed by `npm start` in the HeartCode folder. `heartcode` is the same command.
71
85
  Needs Node.js 20 or newer. No providers? See "Setting up a model" below.
72
86
 
@@ -130,8 +144,9 @@ server's tools, `mcp` all of them.
130
144
  ## Choose the model
131
145
 
132
146
  ```sh
133
- heart -p -m default "…" # the user's list, with its fallbacks (the same as no -m)
134
- heart -p -m "groq openai/gpt-oss-120b" "…" # that provider and model alone, for this run
147
+ heart -p -m auto "…" # the user's list, with its fallbacks (the same as no -m)
148
+ heart -p -m groq/openai/gpt-oss-120b "…" # that provider and model alone, for this run
149
+ heart -p -m groq/openai/gpt-oss-120b --fallback-model openrouter/qwen/qwen3-coder "…" # and that one when it fails
135
150
  heart -p --provider ollama -m qwen3-coder "…" # the same, written apart
136
151
  heart -p --effort high "…" # how hard a thinking model thinks
137
152
  ```
package/bin/heartcode.js CHANGED
@@ -2,9 +2,9 @@
2
2
  import path from 'node:path'
3
3
  import { parseArgs } from 'node:util'
4
4
  import { Client } from '../src/client.js'
5
- import { loadConfig, saveConfig, readJson, writeJson, providerName, CONFIG_FILE, HOME, DEFAULT_MODEL, VERSION } from '../src/config.js'
5
+ import { loadConfig, saveConfig, readJson, writeJson, providerName, CONFIG_FILE, HOME, AUTO_MODEL, isAuto, parseModel, VERSION } from '../src/config.js'
6
6
  import { probe, loadProfile, strategy, describe, profilePath, profileFrom } from '../src/probe.js'
7
- import { learnFromUsage, RATE_CAP_MS, LIMIT_MS } from '../src/context.js'
7
+ import { learnFromUsage, LIMIT_MS } from '../src/context.js'
8
8
  import { Session, transcript } from '../src/agent.js'
9
9
  import { treeItems, codePlan, move, leaving, leftText, leftFiles } from '../src/tree.js'
10
10
  import { UI, c, PERMISSIONS, rank, setTheme, themeCodes, renderDiff, diffLines, DEFAULT_KEYS, loadKeys } from '../src/ui.js'
@@ -37,8 +37,9 @@ ${c.bold('usage')}
37
37
  heart ~/code/my-app in that folder (a folder, not a file)
38
38
  heart "fix the failing test" run one task and exit
39
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
40
+ heart models [filter] what -m takes, one per line: "auto" (your list), then your models
41
+ as provider/model, #1 first
42
+ heart models --all [filter] every provider's models (provider/model)
42
43
  heart models --json as JSON (with --all too), for other programs
43
44
  heart probe test the current model for real (otherwise its abilities come from models.dev)
44
45
  heart config show the config file
@@ -46,7 +47,9 @@ ${c.bold('usage')}
46
47
 
47
48
  ${c.bold('options')}
48
49
  -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
+ -m, --model <name> this model alone for this run (provider/model, or a model of #1's provider);
51
+ "auto" (the default): your list, #1 then the next when one fails
52
+ --fallback-model <name> after -m's model when it fails (repeatable)
50
53
  --api-key <key> API key for --base-url
51
54
  -y, --yes do everything without asking (the default)
52
55
  --ask ask before every edit and command, this run
@@ -61,7 +64,7 @@ ${c.bold('options')}
61
64
  or to continue in it); removed on exit if nothing changed
62
65
  -c, --continue continue the last session in this folder
63
66
  -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")
67
+ --provider <name> use this provider for this run (or -m provider/model)
65
68
 
66
69
  ${c.bold('headless')} (for other programs; flags and output as in Claude Code's -p)
67
70
  -p, --print run without the screen, print the answer, exit
@@ -229,13 +232,12 @@ class App {
229
232
  if (name === 'auto_compact' && this.s) this.s.autoCompact = bool
230
233
  return save(`${name}: ${bool ? 'on' : 'off'}${name === 'director' ? ' (from the next message)' : ''}`)
231
234
  case 'model': {
232
- let [p, ...m] = v.split(/\s+/)
233
- if (!cfg.providers[p]) [p, m] = [cfg.provider, [v]]
234
- if (!cfg.providers[p] || !m.join(' ')) return `error: give "provider model" or a model of the current provider; providers: ${Object.keys(cfg.providers).join(', ')}`
235
+ const { provider: p, model: m } = parseModel(v, cfg.providers, cfg.provider)
236
+ if (!cfg.providers[p] || !m) return `error: give provider/model or a model of the current provider; providers: ${Object.keys(cfg.providers).join(', ')}`
235
237
  // headless (-p) and editors (ACP): the program that started HeartCode chooses the model
236
238
  if (this.ui instanceof HeadlessUI) return 'error: the model can\'t be switched here: the program that started HeartCode chooses it'
237
- this.later.push(() => this.use(p, m.join(' ')).catch((e) => ui.warn(e.message)))
238
- return `switching to ${p} · ${m.join(' ')} after this reply`
239
+ this.later.push(() => this.use(p, m).catch((e) => ui.warn(e.message)))
240
+ return `switching to ${p} · ${m} after this reply`
239
241
  }
240
242
  default:
241
243
  return `error: no setting "${name}"; manage list kind setting shows them`
@@ -313,14 +315,6 @@ class App {
313
315
  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 })
314
316
  if (change) learned()
315
317
  }
316
- // The server said "too large" with its limit: remembered for this model (also next time). A
317
- // per-minute cap is your account's at this provider: kept apart, for a day.
318
- client.onInputLimit = (n, { rate = false } = {}) => {
319
- if (rate) profile.rateCap = { tokens: n, until: Date.now() + RATE_CAP_MS }
320
- else Object.assign(profile, { inputLimit: Math.min(profile.inputLimit ?? Infinity, n), inputLimitAt: Date.now() })
321
- learned()
322
- ui.warn(rate ? `${client.provider} takes at most ~${n} tokens a minute on your account; shortening the conversation to fit (for today)` : `${client.model} takes at most ~${n} tokens per request here; shortening the conversation to fit`)
323
- }
324
318
  // models.dev's published size for the model (a day old at most): used when the server doesn't say
325
319
  loadPrices().then(() => {
326
320
  price = priceOf(client.provider, client.baseUrl, client.model)
@@ -646,11 +640,11 @@ class App {
646
640
  }
647
641
 
648
642
  // The model list (settings page): this model first, then the others in order; set on the session.
649
- // A model given for this run (-m, not "default") is used alone: no fallbacks (cfg.fixed).
643
+ // A model given for this run (-m, not "auto") is used alone (cfg.fixed), then --fallback-model's.
650
644
  useRotation() {
651
645
  const { cfg } = this
652
646
  const seen = new Set()
653
- const list = [{ provider: cfg.provider, model: cfg.model }, ...(cfg.fixed ? [] : (cfg.rotation?.then ?? []))]
647
+ const list = [{ provider: cfg.provider, model: cfg.model }, ...(cfg.fixed ? [] : (cfg.rotation?.then ?? [])), ...(cfg.fallbacks ?? [])]
654
648
  .filter((t) => cfg.providers[t.provider] && t.model && !seen.has(`${t.provider}/${t.model}`) && seen.add(`${t.provider}/${t.model}`))
655
649
  .map((t) => ({ ...t, baseUrl: cfg.providers[t.provider].baseUrl, apiKey: cfg.providers[t.provider].apiKey }))
656
650
  this.s?.client.setRotation(list, cfg.rotation ?? {})
@@ -744,19 +738,16 @@ class App {
744
738
  }
745
739
  }
746
740
 
747
- // `heart models`: your models, in order (every provider's whole list is on the settings page).
748
- // all: every provider's models too (fetched from each; a provider whose list is switched off: the
749
- // ones added by name), yours marked. json: one JSON object on stdout, for other programs:
750
- // { "models": [{ "place": null, "provider": null, "model": "default", "role": "default", "rotates": true, "about": "…" },
751
- // { "place": 1, "provider", "model", "role": "in use" | "fallback", "rotates": false }, …],
752
- // "providers": [{ "name", "baseUrl", "list": true, "models": ["…"], "error"? }] } (--all)
753
- // "default" is the name for your list: -m default (or no -m) uses #1 and moves on to the next when
754
- // one fails; -m with one of the models is that model alone.
741
+ // `heart models`: what -m takes, one per line (as \`opencode models\`): "auto" (your list: #1, then
742
+ // the next when one fails; also no -m), then your models as provider/model, #1 first. all: every
743
+ // provider's models (fetched from each; a provider whose list is off: the ones added by name).
744
+ // json: one JSON object on stdout, for other programs:
745
+ // { "models": [{ "id": "auto", "fallbacks": true }, { "id": "groq/openai/gpt-oss-120b", "provider", "model", "place": 1 }, …],
746
+ // "providers": [{ "name", "baseUrl", "list": true, "models": ["provider/model", …], "error"? }] } (--all)
755
747
  async listModels(filter = '', { all = false, json = false } = {}) {
756
748
  const { ui, cfg } = this
757
749
  const match = (id) => id.toLowerCase().includes(filter.toLowerCase())
758
- const mine = this.myModels().map((t, i) => ({ place: i + 1, provider: t.provider, model: t.model, role: i === 0 ? 'in use' : 'fallback', rotates: false }))
759
- const placeOf = (provider, model) => mine.find((t) => t.provider === provider && t.model === model)?.place
750
+ const mine = this.myModels().map((t, i) => ({ id: `${t.provider}/${t.model}`, provider: t.provider, model: t.model, place: i + 1 }))
760
751
  const providers = !all ? [] : await Promise.all(Object.entries(cfg.providers).map(async ([name, p]) => {
761
752
  const out = { name, baseUrl: p.baseUrl, list: p.list !== false, models: [] }
762
753
  if (out.list) {
@@ -767,32 +758,17 @@ class App {
767
758
  }
768
759
  }
769
760
  // and the ones added by name, and any of yours it doesn't list (as on the settings page)
770
- 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)
761
+ out.models = [...new Set([...out.models, ...(p.models ?? []), ...mine.filter((t) => t.provider === name).map((t) => t.model)])].sort((a, b) => a.localeCompare(b)).map((id) => `${name}/${id}`).filter(match)
771
762
  return out
772
763
  }))
773
- // "default" first, in the same list: your models, rotating (when there's any)
774
- 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)`
775
- const first = mine.length ? [{ place: null, provider: null, model: DEFAULT_MODEL, role: 'default', rotates: mine.length > 1, about }] : []
776
- const shown = [...first, ...mine].filter((t) => match(t.model))
777
- if (json) {
778
- process.stdout.write(JSON.stringify({ models: shown, ...(all && { providers }) }, null, 2) + '\n')
779
- return
780
- }
764
+ const shown = [...(mine.length ? [{ id: AUTO_MODEL, fallbacks: mine.length > 1 }] : []), ...mine].filter((t) => match(t.id))
765
+ if (json) return void process.stdout.write(JSON.stringify({ models: shown, ...(all && { providers }) }, null, 2) + '\n')
781
766
  if (!all) {
782
- if (!shown.length) return ui.print(filter ? `None of your models matches "${filter}".` : NO_MODELS)
783
- 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}"`)}`)
784
- return
785
- }
786
- if (!providers.length) return ui.print(NO_MODELS)
787
- for (const p of providers) {
788
- ui.print(`${c.bold(p.name)} ${c.dim(p.baseUrl)}${p.list ? '' : c.dim(' (its list is off: the models added by name)')}`)
789
- if (p.error) ui.print(` ${c.red("can't list its models:")} ${c.dim(p.error)}`)
790
- if (!p.models.length && !p.error) ui.print(c.dim(filter ? ` none matches "${filter}"` : ' no models'))
791
- for (const id of p.models) {
792
- const at = placeOf(p.name, id)
793
- ui.print(` ${at === 1 ? c.magenta('●') : ' '} ${id}${at ? ` ${c.bold(`#${at}`)} ${c.dim(at === 1 ? 'in use' : 'fallback')}` : ''}`)
794
- }
767
+ if (!mine.length) return ui.print(NO_MODELS)
768
+ return void process.stdout.write(shown.map((t) => t.id + '\n').join(''))
795
769
  }
770
+ for (const p of providers) if (p.error) process.stderr.write(`heartcode: can't list ${p.name}'s models: ${p.error}\n`)
771
+ process.stdout.write(providers.flatMap((p) => p.models).map((id) => id + '\n').join(''))
796
772
  }
797
773
  }
798
774
 
@@ -876,8 +852,9 @@ function openBrowser(url) {
876
852
  spawnSync(cmd, args, { stdio: 'ignore' })
877
853
  }
878
854
 
879
- // A change on the settings page (in cfg, saved), brought into every open tab: right away, or the
880
- // model and MCP servers after the reply in progress.
855
+ // A change on the settings page (in cfg, saved), brought into every open tab and run (also -p and
856
+ // ACP): right away, the model list too (the running task's next request goes to the new #1); MCP
857
+ // servers and skills after the reply in progress.
881
858
  function applySettings(apps, cfg, what) {
882
859
  const before = themeCodes()
883
860
  if (what === 'theme' || what === 'setting:theme') setTheme(cfg.theme)
@@ -887,18 +864,20 @@ function applySettings(apps, cfg, what) {
887
864
  settingsSeen(what) // the watcher (watchSettings) mustn't take it in a second time
888
865
  for (const app of apps) {
889
866
  const { ui } = app
890
- const busy = Boolean(ui.busySince)
867
+ const busy = Boolean(ui.busySince || app.working)
891
868
  const now = (fn) => (busy ? app.later.push(fn) : fn())
892
869
  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]
893
870
  if (what === 'model') {
894
871
  app.lists = {}
895
872
  delete app.cfg.fixed // a model chosen on the page: your list again
896
- const want = cfg.providers[cfg.provider] && app.client(cfg.provider, cfg.model)
897
- if (app.s && !(want && ['model', 'baseUrl', 'apiKey'].every((k) => want[k] === app.s.client[k])))
898
- now(() => {
899
- app.s = null
900
- ui.print(c.dim(cfg.model ? `settings: ${cfg.provider} · ${cfg.model} from the next message` : 'settings: no model chosen'))
901
- })
873
+ if (app.s && !(cfg.providers[cfg.provider] && cfg.model)) now(() => ((app.s = null), ui.print(c.dim('settings: no model chosen'))))
874
+ }
875
+ // your model list: into the running session now, its next request goes to the new #1
876
+ if ((what === 'model' || what === 'setting:rotation') && app.s && cfg.providers[cfg.provider] && cfg.model) {
877
+ const was = `${app.s.client.provider} · ${app.s.client.model}`
878
+ app.useRotation()
879
+ const is = `${app.s.client.provider} · ${app.s.client.model}`
880
+ if (is !== was) ui.print(c.dim(`settings: ${is} now`))
902
881
  }
903
882
  if (what === 'provider') app.lists = {} // its list switched on or off, a model added by name
904
883
  if (what === 'setting:permissions') ui.setPermission(cfg.permissions)
@@ -906,21 +885,11 @@ function applySettings(apps, cfg, what) {
906
885
  if (what === 'setting:autoCompact' && app.s) app.s.autoCompact = cfg.autoCompact !== false
907
886
  if (what === 'setting:format' && app.s) app.s.noFormat = cfg.format === false
908
887
  if (what === 'setting:lsp' && app.s) app.s.noLsp = cfg.lsp === false
909
- if (what === 'setting:context') now(() => (app.s = null))
910
- if (what === 'model' || what === 'setting:rotation') now(() => app.useRotation())
888
+ // the window size, or a model set up or measured on the page: the model in use takes it again
889
+ // from its profile now (it saves its profile as it learns, so it mustn't keep the old one)
890
+ if ((what === 'setting:context' || what === 'profile') && app.s) app.s.client.onUse?.({ provider: app.s.client.provider, model: app.s.client.model })
911
891
  if (what === 'mcp' || what === 'skill') now(() => app.reload(what))
912
892
  if (what === 'keys') ui.keys = { ...DEFAULT_KEYS, ...loadKeys() }
913
- // a model set up or measured on the page: into the session's profile now (it saves its profile
914
- // as it learns, so it mustn't keep the old one); its mode from the next message
915
- if (what === 'profile' && app.s?.profile) {
916
- const disk = loadProfile({ baseUrl: app.s.profile.baseUrl, model: app.s.profile.model })
917
- if (disk) {
918
- for (const k of ['source', 'set', 'known', 'measured', 'measuredAt']) disk[k] === undefined ? delete app.s.profile[k] : (app.s.profile[k] = disk[k])
919
- const st = strategy(app.s.profile, app.cfg)
920
- Object.assign(app.s.strat, (({ context, contextSure, reply }) => ({ context, contextSure, reply }))(st))
921
- 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))
922
- }
923
- }
924
893
  if (what === 'theme' || what === 'setting:theme') ui.recolor(before, themeCodes())
925
894
  else ui.render()
926
895
  }
@@ -1436,6 +1405,7 @@ async function main() {
1436
1405
  options: {
1437
1406
  'base-url': { type: 'string', short: 'u' },
1438
1407
  model: { type: 'string', short: 'm' },
1408
+ 'fallback-model': { type: 'string', multiple: true },
1439
1409
  'api-key': { type: 'string' },
1440
1410
  yes: { type: 'boolean', short: 'y' },
1441
1411
  ask: { type: 'boolean' },
@@ -1495,8 +1465,12 @@ async function main() {
1495
1465
  if (!cfg.providers[f.provider]) throw new Error(`no provider "${f.provider}" (heartcode providers lists them)`)
1496
1466
  Object.assign(cfg, { provider: f.provider, fixed: true })
1497
1467
  }
1498
- const [p0, ...rest] = String(f.model ?? '').split(/\s+/) // -m "groq openai/gpt-oss-120b"
1499
- if (rest.length && cfg.providers[p0]) Object.assign(cfg, { provider: p0, model: rest.join(' ') })
1468
+ // --fallback-model: after the model given (as in Claude Code's -p), when it fails
1469
+ cfg.fallbacks = (f['fallback-model'] ?? []).map((v) => {
1470
+ const t = parseModel(v, cfg.providers, cfg.provider)
1471
+ if (isAuto(v) || !cfg.providers[t.provider]) throw new Error(`--fallback-model ${v}: give provider/model, or a model of ${cfg.provider} (heart models lists yours)`)
1472
+ return t
1473
+ })
1500
1474
  if (f.effort) cfg.effort = f.effort === 'default' ? undefined : f.effort
1501
1475
  if (f['mcp-config'] || f['strict-mcp-config']) cfg.mcp = { extra: f['mcp-config'] ?? [], strict: Boolean(f['strict-mcp-config']) }
1502
1476
  extraSkillDirs.push(...(f['skills-dir'] ?? []).map((d) => path.resolve(d)))
@@ -1524,7 +1498,9 @@ async function main() {
1524
1498
  // each editor session gets its own App, for its folder and its MCP servers
1525
1499
  cfg.maxRetries ??= 10
1526
1500
  // each editor session its own settings (one choosing a model doesn't change the others')
1527
- const makeApp = (root, ui, mcp) => new App({ ...structuredClone(cfg), ...(mcp && { mcp: { extra: [mcp] } }) }, ui, root)
1501
+ const apps = []
1502
+ const makeApp = (root, ui, mcp) => apps[apps.push(new App({ ...structuredClone(cfg), ...(mcp && { mcp: { extra: [mcp] } }) }, ui, root)) - 1]
1503
+ watchSettings(() => apps, cfg) // the settings page's changes, into every editor session
1528
1504
  process.exit(await runAcp({ makeApp, version: VERSION, permission: f['permission-mode'] ? PERMISSION_MODES[f['permission-mode']] : (cfg.permissions ?? 'ask') }))
1529
1505
  }
1530
1506
  const headless = f.print || f['output-format'] || f['input-format'] === 'stream-json'
@@ -1608,6 +1584,7 @@ async function main() {
1608
1584
  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
1609
1585
  if (f['resume-id'] && !chat) throw new Error(`no session ${f['resume-id']}`)
1610
1586
  if (chat && (headless || !f['resume-id'])) app.setChat(chat) // interactive: resumed below, with its transcript
1587
+ if (headless || positionals.length) watchSettings(() => [app], cfg) // an app's run takes the settings page's changes too
1611
1588
  if (headless) {
1612
1589
  app.allowTools = [...(f.allowedTools ?? []), ...(f['allowed-tools'] ?? [])]
1613
1590
  app.denyTools = [...(f.disallowedTools ?? []), ...(f['disallowed-tools'] ?? [])]
@@ -1621,7 +1598,7 @@ async function main() {
1621
1598
  await leaveWorktree(app)
1622
1599
  process.exit(ok ? 0 : 1) // MCP servers are stopped by the exit hooks
1623
1600
  }
1624
- watchSettings(() => [app], cfg)
1601
+ if (!positionals.length) watchSettings(() => [app], cfg)
1625
1602
  await repl(app, f)
1626
1603
  await leaveWorktree(app)
1627
1604
  process.exit(process.exitCode ?? 0) // /exit or Ctrl+D: quit, even if a timer or an MCP server is still around
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heartcode",
3
- "version": "0.1.8",
3
+ "version": "0.1.12",
4
4
  "description": "A coding CLI for any local or OpenAI-compatible model, including small models without tool calling.",
5
5
  "keywords": [
6
6
  "ai",
package/prompts/agent.md CHANGED
@@ -14,6 +14,7 @@ what the task needs. {{files}} and {{more}} are still filled in, empty, for olde
14
14
  come at the end: then this says less, so it doesn't contradict them. -->
15
15
  {{#if caller}}You are running in HeartCode, an agent in a terminal. Your role and your instructions are at the end of this prompt.{{else}}You are HeartCode, a coding assistant working in a terminal on the user's project.{{/if}}
16
16
  Use the tools to look at files, change them and run commands. Find what the task needs with list_files and search rather than guessing paths; read a file before editing it, and prefer small edits with edit_file.
17
+ Check your work by what it produced, not by the absence of an error: read back the file you changed, run the test, look at the output. When something is wrong, find out why before trying again; don't repeat the same attempt. A task that repeats over many items: do one first, check it, then the rest. Before you finish, compare the result with what was asked.
17
18
  {{#if caller}}Commands run in the folder {{root}}, and relative paths are relative to it.{{else}}Paths are relative to the project folder, and commands already run in it. The project folder is {{root}} (for file:// URLs in the browser); stay inside it: don't search the rest of the disk.
18
19
  When you are done, reply with a short summary of what you did (or the answer to the question) and stop calling tools.{{/if}}
19
20
 
@@ -170,13 +171,13 @@ Which of your models does the job (from "Your other models" in the system prompt
170
171
  Besides you (tool calls, {{you}}), the user set up these models:
171
172
  {{models}}
172
173
 
173
- Work with them through the agent tool: "model" picks one (as "provider model"; left out, a helper on your own model), "images" gives it images to look at (image files, saved screenshots, image URLs). Several agent calls in one reply run at the same time.
174
+ Work with them through the agent tool: "model" picks one, "images" gives it images to look at.
174
175
 
175
176
  - Never decline, guess or work around something because you can't do it (see an image, think it through, read more than fits your context): ask a model that can. Choose by what the job needs: the ability first; a faster one for a simple job, the strongest for a hard one; not one that keeps failing.
176
177
  - Ask for what you need in order to act, not for a description. Not "describe this screenshot" but "Which of these 9 tiles (numbered 1-9, left to right, top to bottom) show a chair? Reply with the numbers only." or "Where is the Submit button? Reply with its exact text and its position (top/middle/bottom, left/center/right)." Say what to answer and in what form, and nothing more.
177
178
  - Many images, or a job in independent parts: one agent call per image or part, all in the same reply (the same model several times, or different ones). Then put the answers together and decide the next step yourself.
178
179
  - A loop that needs the ability at every step (look, act, look again, until done) goes whole to a model that has it: the goal, how to tell it's done, what to report. Better than asking about each step.
179
- - A helper starts fresh: it knows only your prompt and images. Give it all it needs: the goal, what's known (paths, URLs, the page the browser is on, what was tried), what to reply. It has your tools, the same browser and MCP servers too: helpers that need the same MCP server (a browser) take turns (HeartCode makes the next one wait), so jobs that use it don't gain from running together; and never give two helpers the same files at once.
180
+ - Give a helper all it needs: the goal, what's known (paths, URLs, the page the browser is on, what was tried), what to reply. It has your tools, the same browser and MCP servers too: helpers that need the same MCP server (a browser) take turns (HeartCode makes the next one wait), so jobs that use it don't gain from running together; and never give two helpers the same files at once.
180
181
  - A helper that fails or replies with nothing is handed on to your next model that can do the job, on its own. An answer that doesn't settle it (wrong, unsure, part of it): ask again, a sharper question or another capable model, until it's done.
181
182
  - Trust a helper's answer to what you asked; question it only when it looks doubtful (it contradicts what you know, or it's vague) or acting on it can't be undone. Don't redo its work yourself.
182
183
 
@@ -188,7 +189,7 @@ You can see images: those in the user's messages, those tools return (a browser
188
189
  ## tool-agent-images
189
190
 
190
191
  <!-- The agent tool's "images", there when the user has other models. -->
191
- Images for the helper to look at: image file paths (in the project, saved screenshots, ones the user gave) or image URLs. The model must see images (leave "model" out to get one that can).
192
+ Images for the helper to look at: image file paths (in the project, saved screenshots, ones the user gave) or image URLs. The model must see images.
192
193
 
193
194
  ## images-unseen
194
195
 
@@ -324,7 +325,7 @@ Update a note rather than adding a duplicate, and link related notes with [[name
324
325
  ## tool-memory
325
326
 
326
327
  <!-- The memory tool's description. -->
327
- Memory notes. action "write": name, description (one line), type (user, project, feedback or reference), tags (1-3), scope ("project", the default, or "global" for the user's preferences in every project), content (markdown; link notes with [[name]]). "read": name. "search": query (words, or tag:name). "delete": name (and scope if the name is in both).
328
+ Memory notes. action "write": name, description (one line), type (user, project, feedback or reference), tags (1-3), scope ("project", the default, or "global"), content (markdown; link notes with [[name]]). "read": name. "search": query (words, or tag:name). "delete": name (and scope if the name is in both).
328
329
 
329
330
  ## memory-extract
330
331
 
package/src/acp.js CHANGED
@@ -126,16 +126,22 @@ export async function runAcp({ makeApp, version, permission = 'ask', input = pro
126
126
  const { text, images } = promptOf(prompt, app.root)
127
127
  it.ctl = new AbortController()
128
128
  app.signal = it.ctl.signal
129
+ app.working = true // settings changed meanwhile: MCP servers and skills wait for the end of it
129
130
  try {
131
+ const s = app.s ?? (await app.session()) // a model chosen again on the settings page
132
+ if (!s) throw Object.assign(new Error('no model set up: run heart settings to add a provider and choose a model'), { code: -32000 })
133
+ s.onEvent ??= (e) => events(it.ui, s, e)
130
134
  const k = /^\/(\S+)\s*([\s\S]*)$/.exec(text)
131
135
  const skill = k && app.skills.get(k[1])
132
- await app.s.run(skill && skill.use !== 'model' ? await app.expandSkill(skill, k[2]) : text, it.ctl.signal, images)
136
+ await s.run(skill && skill.use !== 'model' ? await app.expandSkill(skill, k[2]) : text, it.ctl.signal, images)
133
137
  return { stopReason: 'end_turn' }
134
138
  } catch (e) {
135
139
  if (it.ctl.signal.aborted) return { stopReason: 'cancelled' }
136
140
  if (e.maxTurns) return { stopReason: 'max_turn_requests' }
137
141
  throw e
138
142
  } finally {
143
+ app.working = false
144
+ app.afterLine?.()
139
145
  it.ctl = null
140
146
  it.ui.calls = []
141
147
  app.save()