heartcode 0.1.9 → 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,63 +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. When a
59
- provider answers with an error (status 400 or above), the next model tried is from another
60
- provider; that provider's other models are tried only after every provider has had a go.
61
- A model that takes over with a smaller context window gets the conversation summarized first, if it
62
- wouldn't fit.
63
- The run stops with that error and exit code `1` when every model in the list fails that way, or
64
- when a single model (`-m <model>`) does. A request a server refuses (400) is sent as it is to two
65
- more models of the list at most, then the run stops (unless the user set the fallback rule to "at
66
- any error"). A provider that refuses a part
67
- of the request HeartCode can do without (the model's thinking sent back, keys in the tools'
68
- 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.
69
75
  Each request is fitted to the model it goes to: one that can't see gets a note in place of each
70
76
  image, one that can't call tools gets the tool calls as text.
71
77
 
72
78
  `--provider <name>` and `-u <url>` (a server for this run) also mean one model, alone.
73
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
+
74
84
  `heart` is installed by `npm start` in the HeartCode folder. `heartcode` is the same command.
75
85
  Needs Node.js 20 or newer. No providers? See "Setting up a model" below.
76
86
 
@@ -134,8 +144,9 @@ server's tools, `mcp` all of them.
134
144
  ## Choose the model
135
145
 
136
146
  ```sh
137
- heart -p -m default "…" # the user's list, with its fallbacks (the same as no -m)
138
- 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
139
150
  heart -p --provider ollama -m qwen3-coder "…" # the same, written apart
140
151
  heart -p --effort high "…" # how hard a thinking model thinks
141
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.9",
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/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()
package/src/agent.js CHANGED
@@ -104,7 +104,7 @@ export class Session {
104
104
  const body = native ? chat.messages.slice(head.length) : []
105
105
  const conversation = native ? transcript(body) : chat.history.map((h) => `User: ${h.task}\nAssistant: ${h.answer}`).join('\n\n')
106
106
  if (!conversation.trim()) return null
107
- let room = Math.floor((strat.context * 0.6 * 3.5) / (strat.ratio ?? 1)) // characters of conversation the summary request can carry
107
+ const room = Math.floor((strat.context * 0.6 * 3.5) / (strat.ratio ?? 1)) // characters of conversation the summary request can carry
108
108
  const cut = () => (conversation.length > room ? `[… the earliest part is cut]\n${conversation.slice(-room)}` : conversation)
109
109
  const before = native ? size(body) : est(conversation)
110
110
  ui.spinner(midTask ? 'context almost full: compacting the conversation' : 'compacting the conversation')
@@ -114,17 +114,7 @@ export class Session {
114
114
  const request = (extra) => prompt('compact/summarize', { conversation: cut(), focus }) + extra
115
115
  const send = (extra) =>
116
116
  client.chat({ messages: strat.mergeSystem ? [{ role: 'user', content: `${prompt('compact/summarizer')}\n\n${request(extra)}` }] : [{ role: 'system', content: prompt('compact/summarizer') }, { role: 'user', content: request(extra) }] }, { signal })
117
- // too large even for the summary request: send less of it (the newest part) until it fits
118
- const ask = async (extra = '') => {
119
- for (;;) {
120
- try {
121
- return await send(extra)
122
- } catch (e) {
123
- if (!e.inputLimit || room < 2000) throw e
124
- room = Math.floor(Math.min(room, (e.inputLimit * 3.5) / (strat.ratio ?? 1)) * 0.7)
125
- }
126
- }
127
- }
117
+ const ask = (extra = '') => send(extra)
128
118
  let summary = notSummary((await ask()).content)
129
119
  if (!summary) {
130
120
  log('compact', 'the reply was not a summary; asking again')
@@ -603,18 +593,12 @@ async function runNative(s, task, images, signal) {
603
593
  s.messages.push({ role: 'user', content: s.chat.branchNote }, { role: 'assistant', content: prompt('compact/acknowledged') })
604
594
  delete s.chat.branchNote
605
595
  }
606
- let shrink = 1 // drops when the server says a request is too large
607
596
  s.compactedAt = -1
608
- // What may be sent: the window less a tenth (estimates are estimates) and less room for the
609
- // reply (the longest the model gives, from models.dev, at most a quarter of the window; 8k when
610
- // unknown) and the tools; trimming keeps to it. Auto-compact starts at 90% of it, so it comes
611
- // before any trimming.
612
- // s.sizeCap: "too large" without the server saying its limit: a guess for this conversation only
613
- const windowSize = () => Math.min(strat.context, s.sizeCap ?? Infinity)
614
- const reply = () => Math.min(strat.reply || 8192, Math.floor(windowSize() * 0.25))
615
- const budget = () => Math.floor(((windowSize() * 0.9 - reply()) * shrink) / (strat.ratio ?? 1)) - 800 // in estimated tokens
616
- const nearlyFull = () => s.autoCompact && s.messages.length > 3 && size(s.messages) > budget() * 0.9
617
- // Already near full from earlier messages: compact first, so the new message stays word for word.
597
+ // Auto-compact: every 100 messages, the conversation is summarized (what fits each model is
598
+ // models.dev's business and the model list's: one that fails, the next takes over)
599
+ const COMPACT_AT = 100
600
+ const nearlyFull = () => s.autoCompact && s.messages.length >= COMPACT_AT + (s.compactFailedAt ?? 0)
601
+ // Already that long from earlier messages: compact first, so the new message stays word for word.
618
602
  if (nearlyFull()) {
619
603
  await s.compact({ signal }).catch((e) => (e.name === 'AbortError' ? Promise.reject(e) : ui.warn(`could not compact: ${e.message}`)))
620
604
  }
@@ -657,17 +641,17 @@ async function runNative(s, task, images, signal) {
657
641
  ui.warn(`stopped after ${s.maxTurns} steps (--max-turns); say "continue" to go on`)
658
642
  throw Object.assign(new Error(`stopped after ${s.maxTurns} steps (--max-turns)`), { maxTurns: true })
659
643
  }
660
- // after a failed compaction, the next try waits until the conversation grew by a fifth
661
- if (nearlyFull() && s.messages.length > 4 && step !== s.compactedAt && size(s.messages) > (s.compactFailedAt ?? 0) * 1.2) {
644
+ // after a failed compaction, the next try waits for 20 more messages
645
+ if (nearlyFull() && step !== s.compactedAt) {
662
646
  s.compactedAt = step
663
- await s.compact({ signal, midTask: true, task }).catch((e) => {
664
- if (e.name === 'AbortError') throw e
665
- // no summary: free the space anyway, by shortening older tool output and dropping the oldest turns
666
- fitMessages(s.messages, Math.floor(budget() * 0.5))
667
- s.compactFailedAt = size(s.messages)
668
- ui.warn(`could not compact (${e.message}); shortened older tool output and messages instead`)
669
- log('compact', `failed (${e.message}); trimmed to ~${s.compactFailedAt} tokens`)
670
- })
647
+ await s.compact({ signal, midTask: true, task }).then(
648
+ () => (s.compactFailedAt = 0),
649
+ (e) => {
650
+ if (e.name === 'AbortError') throw e
651
+ s.compactFailedAt = (s.compactFailedAt ?? 0) + 20
652
+ log('compact', `failed (${e.message}); trying again 20 messages later`)
653
+ },
654
+ )
671
655
  }
672
656
  // what the user typed while it worked goes in before the next step (steering)
673
657
  const added = step && !s.isSub ? (ui.takeAhead?.() ?? []) : []
@@ -679,31 +663,13 @@ async function runNative(s, task, images, signal) {
679
663
  // the model answering can change mid-message (your model list): the system prompt follows it
680
664
  const now = SYSTEM(s)
681
665
  if (s.messages[0]?.content !== now && (s.messages[0]?.role === 'system' || strat.mergeSystem)) s.messages[0] = { ...s.messages[0], content: now }
682
- fitMessages(s.messages, budget())
683
666
  pairTools(s.messages)
684
667
  ui.spinner('thinking')
685
668
  const out = ui.response(client.model) // named: helpers think too (theirs are named in helperUI)
686
669
  const stream = strat.toolsStream
687
- let res
688
- try {
689
- // A long tool call (a whole file) streams in silently: say what's being written.
690
- const onToolDelta = (name, args) => ui.spinner(`${name === 'run_command' ? 'preparing a command' : `writing ${/"path"\s*:\s*"([^"]+)"/.exec(args)?.[1] ?? name}`} · ${args.length >= 1000 ? `${(args.length / 1000).toFixed(1)}k` : args.length} chars`)
691
- res = await client.chat({ messages: s.messages, tools, stream }, { signal, onToken: out.text, onReasoning: out.think, onToolDelta })
692
- } catch (e) {
693
- if (!e.inputLimit || shrink < 0.3) throw e
694
- // what every request carries (the system prompt and the tools) is over the limit already: no
695
- // shorter conversation fits (a per-minute cap smaller than that), so not tried again
696
- const fixed = Math.round((est(JSON.stringify(tools)) + sizeOf(s.messages[0])) * (strat.ratio ?? 1))
697
- if (fixed >= e.inputLimit) throw Object.assign(e, { message: `${e.message} (the system prompt and tools alone are ~${fixed} tokens: this can't fit)` })
698
- out.end()
699
- // e.fitted: the next model's window is smaller (strat already follows it): compacted at once
700
- if (!e.fitted) {
701
- s.sizeCap = Math.min(s.sizeCap ?? Infinity, e.inputLimit)
702
- shrink *= 0.8 // strat.context already dropped to the limit; leave more headroom each time
703
- }
704
- step--
705
- continue
706
- }
670
+ // A long tool call (a whole file) streams in silently: say what's being written.
671
+ const onToolDelta = (name, args) => ui.spinner(`${name === 'run_command' ? 'preparing a command' : `writing ${/"path"\s*:\s*"([^"]+)"/.exec(args)?.[1] ?? name}`} · ${args.length >= 1000 ? `${(args.length / 1000).toFixed(1)}k` : args.length} chars`)
672
+ const res = await client.chat({ messages: s.messages, tools, stream }, { signal, onToken: out.text, onReasoning: out.think, onToolDelta })
707
673
  ui.stopSpin()
708
674
  if (!stream) {
709
675
  out.think(res.reasoning)
package/src/client.js CHANGED
@@ -164,7 +164,8 @@ export class Client {
164
164
  // 'stay' on the one that worked). onSwitch(target): a different model answered than before.
165
165
  setRotation(list, { on = 'failure', retries = 2, next = 'first' } = {}) {
166
166
  if (!list || list.length < 2) {
167
- if (this.rotation) this.#use(this.rotation.list[0])
167
+ const t = list?.[0] ?? this.rotation?.list[0] // one model: that one (a new #1 from the settings page too)
168
+ if (t && (t.model !== this.model || normalizeBaseUrl(t.baseUrl) !== this.baseUrl || t.apiKey !== this.apiKey)) this.#use(t)
168
169
  this.rotation = null
169
170
  return
170
171
  }
@@ -228,7 +229,8 @@ export class Client {
228
229
  #rotate(e, tries, pick) {
229
230
  const r = this.rotation
230
231
  if (!r) return false
231
- if (!(r.on === 'any' || (r.on === 'failure' && (outage(e) || unusable(e))) || (r.on === 'retries' && (tries >= r.retries || unusable(e))))) return false
232
+ // at any failure; "after retries": a failure waiting can fix (a rate limit, an outage) is tried again first
233
+ if (r.on === 'retries' && outage(e) && tries < r.retries) return false
232
234
  r.at = pick ? pick() : (r.at + 1) % r.list.length
233
235
  log('rotate', `${e.message.slice(0, 200)} → ${r.list[r.at].provider} · ${r.list[r.at].model}`)
234
236
  this.#use(r.list[r.at])
@@ -400,40 +402,16 @@ export class Client {
400
402
  // 60s, or the server's Retry-After) until it works or the signal aborts. onRetry(error, ms,
401
403
  // attempt) reports each wait; onRetry(null) reports the recovery.
402
404
  async chat(body, opts = {}) {
403
- // "too large": never retried as is; the limit is learned and the caller sends less
404
- const tooLarge = (e) => {
405
- const limit = inputLimit(e, Math.round(estMessages(body.messages) * this.tokenRatio), this.knownGood)
406
- if (!limit) return false
407
- e.inputLimit = limit // this request is sent smaller
408
- // remembered only when the server said the number (a guess isn't); a per-minute cap apart
409
- const said = limitSaid(e)
410
- log('limit', `request too large; ${said ? `the server says ${said.tokens}${said.rate ? ' per minute' : ''}` : `no limit given, trying ~${limit}`}`)
411
- if (said) this.onInputLimit?.(said.tokens, { rate: said.rate })
412
- return true
413
- }
414
405
  if (!this.onRetry) {
415
- try {
416
- return await this.#timed(body, opts)
417
- } catch (e) {
418
- tooLarge(e)
419
- throw e
420
- }
406
+ return await this.#timed(body, opts)
421
407
  }
422
408
  let tries = 0 // of the model being tried now
423
409
  let round = 0 // models tried since the last wait (a whole round of them failing: a short pause)
424
410
  let hard = true // every failure in this round was one that waiting can't fix (see outage)
425
- let refusedBy = 0 // models that refused this request (a bad request), tried in turn
411
+ let rounds = 0 // whole rounds of the list that failed
426
412
  const tried = new Set() // places in the model list tried for this request
427
413
  const down = new Set() // providers that answered it with an error (400 or above): their other models are skipped
428
- const first = this.target
429
414
  for (let attempt = 0; ; attempt++) {
430
- // moved on to a model with a smaller window than the conversation was fitted to: handed back
431
- // (as "too large", at that window) so the caller summarizes it first, instead of a refusal
432
- const sent = this.target !== first && body.messages && this.can?.context && Math.round(estMessages(body.messages) * this.tokenRatio)
433
- if (sent > this.can.context * 0.9) {
434
- log('limit', `${this.model}: ~${sent} tokens don't fit its window (${this.can.context}); handed back to be made smaller`)
435
- throw Object.assign(new Error(`the conversation (~${sent} tokens) is too large for ${this.model} (${this.can.context})`), { inputLimit: this.can.context, fitted: true })
436
- }
437
415
  try {
438
416
  const r = await this.#timed(body, { ...opts, once: true })
439
417
  if (attempt) this.onRetry(null)
@@ -442,22 +420,11 @@ export class Client {
442
420
  return r
443
421
  } catch (e) {
444
422
  if (e.name === 'AbortError' || opts.signal?.aborted) throw e
445
- if (tooLarge(e)) throw e // the caller shrinks the conversation and sends again
446
423
  this.onFail?.(e, { provider: this.provider, model: this.model }) // counted for orchestration
447
- if (this.retryIf && !this.retryIf(e, attempt)) throw e
448
- // A bad request (400…) with a list of models: the next one may take it (it's often what the
449
- // conversation holds from another model). Two more at most, then it stops.
450
- if (!outage(e) && !unusable(e) && this.rotation && this.rotation.on !== 'any' && refusedBy < Math.min(2, this.rotation.list.length - 1)) {
451
- refusedBy++
452
- this.#next(`refused (${e.status})`, () => this.#pick(e, tried, down))
453
- this.onRetry(e, 0, attempt + 1, this.target)
454
- continue
455
- }
456
- // A bad request (400…): the same request fails the same way every time, so it isn't sent
457
- // again. It stops here, unless the model list moves on at any error (the settings page). A
458
- // model that can't be used (a wrong key or name: 401, 403, 404) makes way for the next one
459
- // in the list; a whole round of those stops too, rather than going round.
460
- if (!outage(e) && !(unusable(e) && this.rotation) && this.rotation?.on !== 'any') throw e
424
+ // With a list of models, any failure makes way for the next one (another provider's first).
425
+ // Without one, only what waiting can fix is tried again.
426
+ if (!(this.rotation?.list.length > 1) && !outage(e)) throw e
427
+ if (this.retryIf && !this.retryIf(e, tries)) throw e // headless: --max-retries for one model
461
428
  hard &&= !outage(e)
462
429
  tries++
463
430
  if (this.#rotate(e, tries, () => this.#pick(e, tried, down))) {
@@ -466,6 +433,7 @@ export class Client {
466
433
  // requests a minute); the user chose "round again at once"
467
434
  if (++round >= this.rotation.list.length) {
468
435
  if (hard) throw e // every model refused it outright: going round again won't change that
436
+ if (this.retryIf && !this.retryIf(e, ++rounds)) throw e // headless: --max-retries whole rounds
469
437
  hard = true
470
438
  round = 0
471
439
  this.onRetry(e, 1000, attempt + 1, this.target)
package/src/config.js CHANGED
@@ -72,9 +72,22 @@ function fromFile() {
72
72
  // cfg: { providers: {name: {baseUrl, apiKey}}, provider, model, context, adhoc }
73
73
  // flags > env > ~/.heart/config.json. A URL from flags or env becomes a provider for this run
74
74
  // only (cfg.adhoc), unless saved.
75
- // The model name that means your list: #1, then the fallbacks when one fails (also: no model given).
76
- // Any other model given for a run (-m, HEART_MODEL, a URL of its own) is that model alone: fixed.
77
- export const DEFAULT_MODEL = 'default'
75
+ // The model name that means your list: #1, then the next when one fails (also: no model given;
76
+ // "default" is its old name). Any other model given for a run (-m, HEART_MODEL, a URL of its own)
77
+ // is that model alone: fixed (--fallback-model adds others after it).
78
+ export const AUTO_MODEL = 'auto'
79
+ export const isAuto = (m) => m === AUTO_MODEL || m === 'default'
80
+
81
+ // A model as given (-m, --fallback-model, /model): "provider/model" (as opencode writes it; split at
82
+ // the first "/", provider names have none), the older "provider model", or a model of `current`.
83
+ export function parseModel(v, providers, current) {
84
+ v = String(v).trim()
85
+ const spaced = /^(\S+)\s+(.+)$/.exec(v)
86
+ if (spaced && providers[spaced[1]]) return { provider: spaced[1], model: spaced[2] }
87
+ const i = v.indexOf('/')
88
+ if (i > 0 && providers[v.slice(0, i)]) return { provider: v.slice(0, i), model: v.slice(i + 1) }
89
+ return { provider: current, model: v }
90
+ }
78
91
 
79
92
  // providers by name, A to Z: every list of them (settings page, `heart providers`, pickers) follows
80
93
  export const byName = (providers) => Object.fromEntries(Object.entries(providers ?? {}).sort(([a], [b]) => a.localeCompare(b, undefined, { sensitivity: 'base' })))
@@ -98,7 +111,7 @@ export function loadConfig(flags = {}) {
98
111
  cfg.providers[cfg.provider].apiKey = apiKey
99
112
  }
100
113
  const model = flags.model || process.env.HEART_MODEL
101
- if (model && model !== DEFAULT_MODEL) Object.assign(cfg, { model, fixed: true })
114
+ if (model && !isAuto(model)) Object.assign(cfg, parseModel(model, cfg.providers, cfg.provider), { fixed: true })
102
115
  return cfg
103
116
  }
104
117
 
package/src/guided.js CHANGED
@@ -225,14 +225,8 @@ class Guided {
225
225
 
226
226
  // `answer: true` streams the reply to the user as the answer; otherwise only thinking is shown.
227
227
  // room: tokens of context to keep free for the reply (only for fitting the context; never sent).
228
- async query(question, opts = {}, tries = 0) {
229
- try {
230
- return await this.ask1(question, opts, tries)
231
- } catch (e) {
232
- // too large for the model: strat.context has dropped to its limit, so the context shrinks
233
- if (!e.inputLimit || tries >= 3) throw e
234
- return this.query(question, opts, tries + 1)
235
- }
228
+ query(question, opts = {}) {
229
+ return this.ask1(question, opts)
236
230
  }
237
231
 
238
232
  async ask1(question, { room = 32, schema, oneLine, onToken, answer, skipKey } = {}, tries = 0) {
package/src/headless.js CHANGED
@@ -150,6 +150,7 @@ export async function runHeadless(app, opts) {
150
150
  said = false
151
151
  current = { ctl: new AbortController() }
152
152
  app.signal = current.ctl.signal
153
+ app.working = true // settings changed meanwhile: MCP servers and skills wait for the end of it
153
154
  let answer = ''
154
155
  let error = null
155
156
  try {
@@ -162,6 +163,8 @@ export async function runHeadless(app, opts) {
162
163
  log('error', error, e.stack)
163
164
  } finally {
164
165
  app.save()
166
+ app.working = false
167
+ app.afterLine?.()
165
168
  }
166
169
  const subtype = current.maxTurns ? 'error_max_turns' : error ? 'error_during_execution' : 'success' // as Claude Code names them
167
170
  current = null
package/src/settings.html CHANGED
@@ -228,11 +228,15 @@
228
228
  .olist .test button { flex: none; }
229
229
  .olist .test.ok { color: var(--ok); }
230
230
  .olist .test.warn { color: var(--accent); }
231
- dialog#ms { width: min(480px, calc(100vw - 32px)); border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); color: var(--text); padding: 22px 24px; }
232
- dialog#ms::backdrop { background: rgba(0, 0, 0, .55); }
233
- dialog#ms h2 { margin: 0 0 6px; font-family: var(--mono); font-size: 15px; overflow-wrap: anywhere; }
231
+ dialog { width: 70vw; max-width: none; max-height: calc(100vh - 32px); overflow: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); color: var(--text); padding: 22px 24px; }
232
+ dialog::backdrop { background: rgba(0, 0, 0, .55); }
233
+ dialog h2 { margin: 0 0 6px; font-family: var(--mono); font-size: 15px; overflow-wrap: anywhere; }
234
234
  dialog#ms .opt:last-of-type { border-bottom: 0; }
235
- dialog#ms .actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
235
+ #pr-list { list-style: none; margin: 12px 0 0; padding: 0; counter-reset: rank; }
236
+ #pr-list li { counter-increment: rank; padding: 9px 12px; margin-bottom: 6px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); cursor: grab; user-select: none; }
237
+ #pr-list li::before { content: counter(rank) ". ⋮⋮ "; color: var(--muted); }
238
+ #pr-list li.dragging { opacity: .4; }
239
+ dialog .actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
236
240
  .olist .test.failed { color: var(--err); }
237
241
  .olist .test.testing, .olist .test.waiting { color: var(--accent); }
238
242
  .olist .test button { padding: 0 7px; font-size: 11px; }
@@ -431,19 +435,6 @@
431
435
  </div>
432
436
  <div class="steps" id="steps"></div>
433
437
  <div class="stack">
434
- <div class="card" id="p-form" hidden>
435
- <h2 id="p-title">Add a provider</h2>
436
- <p class="sub">Pick one to fill in its address, or type any OpenAI-compatible API below.</p>
437
- <div class="presets" id="presets"></div>
438
- <div class="row" style="margin-top:6px">
439
- <div class="grow"><label class="f" for="p-name">Name</label><input type="text" id="p-name" placeholder="openrouter, groq, work…"></div>
440
- <div class="grow" style="flex:2"><label class="f" for="p-url">Base URL</label><input type="text" id="p-url" placeholder="https://openrouter.ai/api/v1"></div>
441
- </div>
442
- <label class="f" for="p-key">API key <span class="muted" id="p-keynote" style="font-weight:normal"></span></label>
443
- <input type="password" id="p-key" autocomplete="off" placeholder="paste it here (none for a server on this machine, like Ollama)">
444
- <div class="hint">Kept in config.json on this machine. This page only shows its first and last characters.</div>
445
- <div class="actions"><button class="primary" id="p-save">Add provider</button><button class="ghost" id="p-cancel">Cancel</button></div>
446
- </div>
447
438
  <div class="work" id="p-work" hidden>
448
439
  <div class="card catalog">
449
440
  <div class="cat-top">
@@ -565,20 +556,8 @@
565
556
 
566
557
  <!-- MCP servers -->
567
558
  <section class="page" id="page-mcp">
568
- <div class="head"><div><h1>MCP servers</h1><p class="lead">Programs that give the model more tools: a browser, a database, your issue tracker. Off: HeartCode doesn't start it or offer it to the model.</p></div></div>
559
+ <div class="head"><div><h1>MCP servers</h1><p class="lead">Programs that give the model more tools: a browser, a database, your issue tracker. Off: HeartCode doesn't start it or offer it to the model.</p></div><button class="primary" id="c-open">+ Add a server</button></div>
569
560
  <div class="stack">
570
- <div class="card">
571
- <h2>Add a server</h2>
572
- <p class="sub">The command that starts it on this machine, or the URL of a remote one (a sign-in page opens if it needs one).</p>
573
- <div class="row">
574
- <div style="width:200px"><label class="f" for="c-name">Name</label><input type="text" id="c-name" placeholder="github"></div>
575
- <div><label class="f">Kind</label><div class="seg" id="c-kind">
576
- <label class="on"><input type="radio" name="c-kind" value="command" checked>Command</label><label><input type="radio" name="c-kind" value="url">URL</label>
577
- </div></div>
578
- <div class="grow" style="flex:3"><label class="f" for="c-what" id="c-what-label">Command</label><input type="text" id="c-what" placeholder="npx -y @modelcontextprotocol/server-github"></div>
579
- <button class="primary" id="c-add">Add server</button>
580
- </div>
581
- </div>
582
561
  <div class="card" id="c-list"></div>
583
562
  <details class="card" id="c-raw"><summary style="cursor:pointer;font-weight:600">Edit mcp.json directly</summary><p class="sub" style="margin-top:10px">For what the form doesn't have: environment variables (<span class="mono">"env"</span>), headers, and so on.</p><div id="c-file"></div></details>
584
563
  </div>
@@ -617,6 +596,40 @@
617
596
  <div class="actions"><button id="ms-cancel" type="button">Cancel</button><button class="primary" id="ms-save" type="button">Save</button></div>
618
597
  </form>
619
598
  </dialog>
599
+ <dialog id="p-form" aria-labelledby="p-title">
600
+ <h2 id="p-title">Add a provider</h2>
601
+ <p class="sub">Pick one to fill in its address, or type any OpenAI-compatible API below.</p>
602
+ <div class="presets" id="presets"></div>
603
+ <div class="row" style="margin-top:6px">
604
+ <div class="grow"><label class="f" for="p-name">Name</label><input type="text" id="p-name" placeholder="openrouter, groq, work…"></div>
605
+ <div class="grow" style="flex:2"><label class="f" for="p-url">Base URL</label><input type="text" id="p-url" placeholder="https://openrouter.ai/api/v1"></div>
606
+ </div>
607
+ <label class="f" for="p-key">API key <span class="muted" id="p-keynote" style="font-weight:normal"></span></label>
608
+ <input type="password" id="p-key" autocomplete="off" placeholder="paste it here (none for a server on this machine, like Ollama)">
609
+ <div class="hint">Kept in config.json on this machine. This page only shows its first and last characters.</div>
610
+ <div class="actions"><button id="p-cancel">Cancel</button><button class="primary" id="p-save">Add provider</button></div>
611
+ </dialog>
612
+ <dialog id="c-form" aria-labelledby="c-title">
613
+ <h2 id="c-title">Add a server</h2>
614
+ <p class="sub">The command that starts it on this machine, or the URL of a remote one (a sign-in page opens if it needs one).</p>
615
+ <div class="row">
616
+ <div style="width:200px"><label class="f" for="c-name">Name</label><input type="text" id="c-name" placeholder="github"></div>
617
+ <div><label class="f">Kind</label><div class="seg" id="c-kind">
618
+ <label class="on"><input type="radio" name="c-kind" value="command" checked>Command</label><label><input type="radio" name="c-kind" value="url">URL</label>
619
+ </div></div>
620
+ <div class="grow" style="flex:3"><label class="f" for="c-what" id="c-what-label">Command</label><input type="text" id="c-what" placeholder="npx -y @modelcontextprotocol/server-github"></div>
621
+ </div>
622
+ <div class="actions"><button id="c-cancel">Cancel</button><button class="primary" id="c-add">Add server</button></div>
623
+ </dialog>
624
+ <dialog id="k-form" aria-labelledby="k-title"></dialog>
625
+ <dialog id="pr" aria-labelledby="pr-title">
626
+ <form>
627
+ <h2 id="pr-title">Provider ranking</h2>
628
+ <p class="sub">Drag the providers into order, best first. Your models are then sorted by it.</p>
629
+ <ol id="pr-list"></ol>
630
+ <div class="actions"><button id="pr-cancel" type="button">Cancel</button><button class="primary" id="pr-save" type="button">Save</button></div>
631
+ </form>
632
+ </dialog>
620
633
  <div id="toast" role="status"></div>
621
634
  <div id="tip" role="tooltip"></div>
622
635
  <script>
@@ -766,7 +779,7 @@ const DRAFTS = { prompts: {}, skills: {}, files: {} }
766
779
  const SEL = { prompts: 'agent/system', skills: null, files: null }
767
780
  function masterDetail(kind, itemsEl, detailEl, groups, draw) {
768
781
  const all = groups.flatMap((g) => g.items)
769
- if (!all.some((x) => x.id === SEL[kind]) && SEL[kind] !== '+new') SEL[kind] = all[0]?.id ?? null
782
+ if (!all.some((x) => x.id === SEL[kind])) SEL[kind] = all[0]?.id ?? null
770
783
  // the list: built when its items change; otherwise each item is updated in place (the chosen
771
784
  // one, a dot for unsaved changes, "yours"), so it stays where you scrolled it
772
785
  const inside = (x) => [h('div', { className: 't' }, DRAFTS[kind][x.id] !== undefined ? h('span', { className: 'dot', title: 'not saved' }) : null, x.title, x.badge ?? null), x.sub ? h('div', { className: 'd', textContent: x.sub }) : null]
@@ -787,7 +800,7 @@ function masterDetail(kind, itemsEl, detailEl, groups, draw) {
787
800
  }
788
801
  itemsEl.closest('.md').classList.toggle('solo', !all.length) // nothing to list: no list column
789
802
  itemsEl.closest('.md').classList.toggle('tabs', all.length > 0 && all.length <= 8) // a few: tabs on top
790
- put(detailEl, draw(SEL[kind] === '+new' ? '+new' : all.find((x) => x.id === SEL[kind])))
803
+ put(detailEl, draw(all.find((x) => x.id === SEL[kind])))
791
804
  itemsEl.closest('.md').classList.add('fill')
792
805
  fillHeight()
793
806
  }
@@ -879,10 +892,8 @@ const useModel = (provider, model) => act('/api/model', { provider, model }, `He
879
892
  const saveOrder = (list, done, opts = {}) => act('/api/order', { list, ...opts }, done)
880
893
 
881
894
  let editing = null
882
- let formOpen = false
883
895
  function openForm(p = null) {
884
896
  editing = p
885
- formOpen = true
886
897
  $('p-title').textContent = p ? `Edit ${p.name}` : 'Add a provider'
887
898
  $('p-name').value = p?.name ?? ''
888
899
  $('p-url').value = p?.baseUrl ?? ''
@@ -891,15 +902,11 @@ function openForm(p = null) {
891
902
  $('p-keynote').textContent = p?.key ? `(now ${p.key})` : ''
892
903
  $('p-save').textContent = p ? 'Save changes' : 'Add provider'
893
904
  drawPresets()
894
- drawProviders(S)
895
- $('p-form').scrollIntoView({ behavior: 'smooth', block: 'start' })
905
+ $('p-form').showModal()
896
906
  ;(p ? $('p-url') : $('p-name')).focus()
897
907
  }
898
- function closeForm() {
899
- editing = null
900
- formOpen = false
901
- drawProviders(S)
902
- }
908
+ const closeForm = () => $('p-form').close()
909
+ $('p-form').onclose = () => (editing = null)
903
910
  $('p-open').onclick = () => openForm()
904
911
  $('p-cancel').onclick = closeForm
905
912
  $('p-save').onclick = async () => {
@@ -1183,17 +1190,12 @@ addEventListener('scroll', () => $('tip').classList.remove('show'), true)
1183
1190
  function drawProviders(s) {
1184
1191
  const order = s.order
1185
1192
  const none = !s.providers.length
1186
- // the steps: always on the page (nothing jumps when one is done), ticked off as you go; with
1187
- // no provider yet, the form is open (the only way in: no other button)
1193
+ // the steps: always on the page (nothing jumps when one is done), ticked off as you go
1188
1194
  put($('steps'), [
1189
1195
  ['Add a provider', 'the service with your models', s.providers.length ? 'done' : 'now'],
1190
1196
  ['Choose a model', 'click one in the list', order.length ? 'done' : s.providers.length ? 'now' : ''],
1191
1197
  ['Start HeartCode', 'heart, in your project', order.length ? 'now' : ''],
1192
1198
  ].map(([t, d, cls], i) => h('div', { className: 'step ' + cls }, h('span', { className: 'num', textContent: cls === 'done' ? '✓' : i + 1 }), h('div', {}, h('b', { textContent: t }), d))))
1193
- const showForm = formOpen || none
1194
- $('p-form').hidden = !showForm
1195
- $('p-cancel').hidden = none // nothing to go back to
1196
- $('p-open').hidden = showForm
1197
1199
  $('p-work').hidden = none
1198
1200
  fillHeight()
1199
1201
 
@@ -1241,9 +1243,9 @@ function drawProviders(s) {
1241
1243
  h('label', { className: 'f', for: 'r-on', textContent: 'When the one in use fails, go on to the next' }),
1242
1244
  h('div', { className: 'row', style: 'gap:8px' },
1243
1245
  h('select', { id: 'r-on', style: 'flex:1', onchange: (e) => save(order, 'saved', { on: e.target.value }) },
1244
- [['failure', 'at its first failure'], ['retries', 'after some tries of the same model'], ['any', 'at any error']].map(([v, t]) => h('option', { value: v, textContent: t, selected: s.rotation.on === v }))),
1246
+ [['failure', 'at its first failure'], ['retries', 'after some tries of the same model (rate limits, outages)']].map(([v, t]) => h('option', { value: v, textContent: t, selected: s.rotation.on === v }))),
1245
1247
  s.rotation.on === 'retries' ? h('input', { type: 'number', 'aria-label': 'tries', title: 'tries of the same model first', style: 'width:76px', min: 1, max: 20, value: s.rotation.retries, onchange: (e) => save(order, 'saved', { retries: Number(e.target.value) }) }) : null),
1246
- h('div', { className: 'hint', textContent: { failure: 'A rate limit, server error, timeout or lost connection moves on at once, and so does a model it can\'t use (a wrong key, no access, "model not found"). Other errors, like a bad request, stop.', retries: 'The same model is tried again (with a short wait) that many times first.', any: 'Also errors like "model not found" or a bad request move on.' }[s.rotation.on] }),
1248
+ h('div', { className: 'hint', textContent: { failure: 'Any error moves on at once, to another provider\'s model first. It stops only when every model fails.', retries: 'A rate limit or outage: the same model is tried again (with a short wait) that many times first. Any other error moves on at once.' }[s.rotation.on] ?? 'Any error moves on at once, to another provider\'s model first.' }),
1247
1249
  h('label', { className: 'f', for: 'r-next', textContent: 'A new message starts with' }),
1248
1250
  h('select', { id: 'r-next', onchange: (e) => save(order, 'saved', { next: e.target.value }) },
1249
1251
  [['first', '#1 again'], ['stay', 'the model that answered last']].map(([v, t]) => h('option', { value: v, textContent: t, selected: s.rotation.next === v })))) : null)
@@ -1479,7 +1481,7 @@ const can = (t) => ({ tools: t.tested ? t.tested.mode === 'tools' : t.known?.too
1479
1481
  // how: text (A to Z first), cheap (lowest first), else most first
1480
1482
  const COLS = {
1481
1483
  model: { label: 'Model', how: 'text', get: (t) => t.model },
1482
- provider: { label: 'Provider', how: 'text', get: (t) => t.provider },
1484
+ provider: { label: 'Provider', how: 'rank', get: (t) => RANKED.get(t.provider) }, // by your ranking of the providers
1483
1485
  context: { label: 'Context', get: (t) => t.tested?.context ?? t.known?.context ?? 0 },
1484
1486
  abilities: { label: 'Abilities', get: (t) => Object.values(can(t)).filter(Boolean).length },
1485
1487
  tools: { label: 'Tools', get: (t) => can(t).tools },
@@ -1496,6 +1498,44 @@ let SORT = (() => {
1496
1498
  return []
1497
1499
  }
1498
1500
  })()
1501
+ // your ranking of the providers, best first, kept in this browser; a provider not ranked yet comes after, A to Z
1502
+ let RANK = (() => {
1503
+ try {
1504
+ return JSON.parse(localStorage.getItem('heart.rank') ?? '[]').filter((x) => typeof x === 'string')
1505
+ } catch {
1506
+ return []
1507
+ }
1508
+ })()
1509
+ const ranked = () => {
1510
+ const names = [...new Set(S.order.map((t) => t.provider))]
1511
+ return [...RANK.filter((n) => names.includes(n)), ...names.filter((n) => !RANK.includes(n)).sort(byName)]
1512
+ }
1513
+ // the ranking dialog: drag a provider to its place, then Save
1514
+ function openRanking() {
1515
+ const list = $('pr-list')
1516
+ let held = null
1517
+ put(list, ranked().map((name) => h('li', { draggable: true, textContent: name })))
1518
+ list.ondragstart = (e) => (held = e.target).classList.add('dragging')
1519
+ list.ondragend = () => held?.classList.remove('dragging')
1520
+ list.ondragover = (e) => {
1521
+ e.preventDefault()
1522
+ const over = e.target.closest('li')
1523
+ if (!held || !over || over === held) return
1524
+ const r = over.getBoundingClientRect()
1525
+ list.insertBefore(held, e.clientY > r.top + r.height / 2 ? over.nextSibling : over)
1526
+ }
1527
+ $('pr').showModal()
1528
+ }
1529
+ $('pr-cancel').onclick = () => $('pr').close()
1530
+ $('pr-save').onclick = () => {
1531
+ const list = [...$('pr-list').children].map((li) => li.textContent)
1532
+ RANK = [...list, ...RANK.filter((n) => !list.includes(n))] // providers not in your list now keep their place for later
1533
+ try {
1534
+ localStorage.setItem('heart.rank', JSON.stringify(RANK))
1535
+ } catch {}
1536
+ $('pr').close()
1537
+ setSort(SORT.some((x) => x.key === 'provider') ? SORT : [{ key: 'provider', dir: 'up' }, ...SORT]) // sorted by it now
1538
+ }
1499
1539
  const firstDir = (key) => (COLS[key].how ? 'up' : 'down') // up: A to Z, low to high
1500
1540
  function setSort(next) {
1501
1541
  SORT = next
@@ -1511,7 +1551,9 @@ function clickSort(key) {
1511
1551
  if (at.dir === firstDir(key)) return setSort(SORT.map((x) => (x === at ? { key, dir: at.dir === 'up' ? 'down' : 'up' } : x)))
1512
1552
  setSort(SORT.filter((x) => x !== at))
1513
1553
  }
1554
+ let RANKED = new Map() // provider → its place in the ranking, for one sort
1514
1555
  function sorted(order) {
1556
+ RANKED = new Map(ranked().map((n, i) => [n, i]))
1515
1557
  const cmp = (a, b, { key, dir }) => {
1516
1558
  let x = COLS[key].get(a)
1517
1559
  let y = COLS[key].get(b)
@@ -1523,7 +1565,7 @@ function sorted(order) {
1523
1565
  }
1524
1566
  return order.map((t, i) => ({ t, i })).sort((a, b) => SORT.reduce((r, s) => r || cmp(a.t, b.t, s), 0) || a.i - b.i).map((x) => x.t)
1525
1567
  }
1526
- const dirText = (key, dir) => (key === 'works' ? (dir === 'down' ? 'working first' : 'not working first') : COLS[key].how === 'text' ? (dir === 'up' ? 'A→Z' : 'Z→A') : COLS[key].how === 'cheap' ? (dir === 'up' ? 'cheapest first' : 'priciest first') : dir === 'down' ? 'most first' : 'least first')
1568
+ const dirText = (key, dir) => (key === 'provider' ? (dir === 'up' ? 'your ranking' : 'your ranking, reversed') : key === 'works' ? (dir === 'down' ? 'working first' : 'not working first') : COLS[key].how === 'text' ? (dir === 'up' ? 'A→Z' : 'Z→A') : COLS[key].how === 'cheap' ? (dir === 'up' ? 'cheapest first' : 'priciest first') : dir === 'down' ? 'most first' : 'least first')
1527
1569
  // The models ticked on the Your models page ("provider/model"), to remove several at once; the last
1528
1570
  // one ticked, so a Shift+click ticks the ones between
1529
1571
  const PICKED = new Set()
@@ -1594,6 +1636,7 @@ function drawMine(s) {
1594
1636
  PICKED.size ? h('button', { className: 'small danger', textContent: `Remove ${PICKED.size} selected`, onclick: removePicked }) : null,
1595
1637
  PICKED.size ? h('button', { className: 'small', textContent: `Test ${PICKED.size} selected`, disabled: testingAll, onclick: () => tryAll(order.filter((t) => PICKED.has(keyOf(t)))) }) : null,
1596
1638
  PICKED.size ? h('button', { className: 'small', textContent: 'Clear selection', onclick: () => (PICKED.clear(), drawMine(S)) }) : null,
1639
+ order.some((t) => t.provider !== order[0].provider) ? h('button', { className: 'small', textContent: 'Provider ranking', title: 'drag your providers into order; the Provider column sorts by it', onclick: openRanking }) : null,
1597
1640
  SORT.length ? h('button', { className: 'small', textContent: 'Clear sort', onclick: () => setSort([]) }) : null,
1598
1641
  SORT.length ? h('button', { className: 'small primary', textContent: same ? 'This is your order' : 'Use this order', disabled: same, onclick: () => keep(view) }) : null,
1599
1642
  order.length ? h('button', { className: 'small', textContent: 'Refresh from models.dev', title: 'ask models.dev again what each model can do: context, tools, thinking, images, price', onclick: () => lookUp() }) : null,
@@ -1845,8 +1888,10 @@ for (const r of document.querySelectorAll('input[name="c-kind"]')) r.onchange =
1845
1888
  }
1846
1889
  $('c-add').onclick = async () => {
1847
1890
  const r = await act('/api/mcp/add', { name: $('c-name').value, [mcpKind()]: $('c-what').value }, `added ${$('c-name').value.trim()}: it starts from the next message`)
1848
- if (r) $('c-name').value = $('c-what').value = ''
1891
+ if (r) ($('c-name').value = $('c-what').value = ''), $('c-form').close()
1849
1892
  }
1893
+ $('c-open').onclick = () => ($('c-form').showModal(), $('c-name').focus())
1894
+ $('c-cancel').onclick = () => $('c-form').close()
1850
1895
  function drawMcp(s) {
1851
1896
  put($('c-list'), h('h2', { textContent: 'Servers' }), h('p', { className: 'sub', textContent: 'Built in, and yours. Switch one off to keep it without using it.' }),
1852
1897
  s.mcp.length ? s.mcp.map((m) => h('div', { className: 'opt' },
@@ -1873,7 +1918,27 @@ function parseSkill(text) {
1873
1918
  }
1874
1919
  const skillText = (k) => `---\n${k.description.trim() ? `description: ${k.description.trim().replace(/\n/g, ' ')}\n` : ''}use: ${k.use}\n${k.extra.map((l) => l + '\n').join('')}---\n\n${k.body.trim()}\n`
1875
1920
  const NEW = { name: '', description: '', use: 'both', extra: [], body: '' }
1876
- $('k-new').onclick = () => ((SEL.skills = '+new'), S && drawSkills(S))
1921
+ // a new skill: the form in a dialog (what you typed is kept if you close it)
1922
+ function newSkill() {
1923
+ const f = skillForm('+new', NEW, { isNew: true })
1924
+ const first = !S.skills.length
1925
+ put($('k-form'), h('h2', { id: 'k-title', textContent: first ? 'Make your first skill' : 'New skill' }),
1926
+ first ? h('p', { className: 'sub', textContent: 'A skill is a prompt you use again and again, like "review this" or "write the release notes". Skills from Claude Code and others: /import in HeartCode.' }) : null,
1927
+ f.el,
1928
+ h('div', { className: 'actions' },
1929
+ h('button', { textContent: 'Cancel', onclick: () => $('k-form').close() }),
1930
+ h('button', { className: 'primary', textContent: 'Create skill', onclick: async () => {
1931
+ const slug = f.d.name.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
1932
+ if (!f.d.body.trim()) return toast('Write the prompt first', true)
1933
+ const was = SEL.skills
1934
+ SEL.skills = slug // the new one, chosen in the list once it's there
1935
+ if (await saveItem('skills', '+new', '/api/skill', { name: f.d.name, text: skillText(f.d) }, `created /${slug}`)) $('k-form').close()
1936
+ else SEL.skills = was
1937
+ } })))
1938
+ $('k-form').showModal()
1939
+ $('k-form').querySelector('input')?.focus()
1940
+ }
1941
+ $('k-new').onclick = newSkill
1877
1942
  function skillForm(id, start, { isNew, readOnly }) {
1878
1943
  const d = DRAFTS.skills[id] ?? { ...start, extra: [...start.extra] } // your unsaved changes, else as saved
1879
1944
  const touch = () => (DRAFTS.skills[id] = d) // a change: kept (and dotted in the list) until it's saved
@@ -1891,27 +1956,11 @@ function skillForm(id, start, { isNew, readOnly }) {
1891
1956
  ] }
1892
1957
  }
1893
1958
  function drawSkills(s) {
1894
- if (!s.skills.length) SEL.skills = '+new' // none yet: the form is the page (one way in, no empty card)
1895
- $('k-new').hidden = SEL.skills === '+new'
1896
1959
  const mine = s.skills.filter((k) => k.mine)
1897
1960
  const theirs = s.skills.filter((k) => !k.mine)
1898
1961
  const item = (k) => ({ id: k.name, title: '/' + k.name, sub: k.description, k })
1899
1962
  masterDetail('skills', $('k-items'), $('k-detail'), [{ title: mine.length && theirs.length ? 'Yours' : '', items: mine.map(item) }, { title: 'From projects', items: theirs.map(item) }], (x) => {
1900
- if (x === '+new') {
1901
- const f = skillForm('+new', NEW, { isNew: true })
1902
- const first = !s.skills.length
1903
- return h('div', { className: 'card' }, h('h2', { textContent: first ? 'Make your first skill' : 'New skill' }),
1904
- first ? h('p', { className: 'sub', textContent: 'A skill is a prompt you use again and again, like "review this" or "write the release notes". Skills from Claude Code and others: /import in HeartCode.' }) : null,
1905
- f.el,
1906
- h('div', { className: 'actions' },
1907
- h('button', { className: 'primary', textContent: 'Create skill', onclick: async () => {
1908
- const slug = f.d.name.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
1909
- if (!f.d.body.trim()) return toast('Write the prompt first', true)
1910
- SEL.skills = slug // the new one, chosen in the list once it's there
1911
- if (!(await saveItem('skills', '+new', '/api/skill', { name: f.d.name, text: skillText(f.d) }, `created /${slug}`))) SEL.skills = '+new'
1912
- } }),
1913
- first ? null : h('button', { className: 'ghost', textContent: 'Cancel', onclick: () => (saved('skills', '+new'), (SEL.skills = null), drawSkills(S)) })))
1914
- }
1963
+ if (!x) return h('div', { className: 'card' }, h('h2', { textContent: 'No skills yet' }), h('p', { className: 'sub', textContent: 'A skill is a prompt you use again and again, like "review this" or "write the release notes". Make one with + New skill, or bring them from Claude Code and others with /import in HeartCode.' }))
1915
1964
  const k = x.k
1916
1965
  const f = skillForm(k.name, parseSkill(k.text), { readOnly: !k.mine })
1917
1966
  return h('div', { className: 'card' },