heartcode 0.1.3 → 0.1.6

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/SKILL.md CHANGED
@@ -132,10 +132,12 @@ nothing is sent to the model to find out. A model models.dev doesn't know works
132
132
  assumptions, and the user can set it up on the settings page. Models that can't call tools still
133
133
  work, in a slower guided mode.
134
134
 
135
- The model in use knows the user's other models and what each can do, and hands a job it can't do
136
- (see an image, call tools…) to one that can: an attached image goes to a model that sees images, an
137
- image's path to one through its `agent` tool, and a model without tool calls can hand the task to one
138
- with them. Their tokens are in `usage`, and each model's in `modelUsage`.
135
+ The model in use knows the user's other models (what each can do, how often it failed this
136
+ session) and works with them through its `agent` tool: it asks a model that can for what it
137
+ can't do itself (a model that can't see asks one that can about a screenshot: "which tiles show a
138
+ chair?"), several at once when there are many images or parts, and a helper that fails is handed to
139
+ the next capable model until one does the job. Their tokens are in `usage`, and each model's in
140
+ `modelUsage`.
139
141
 
140
142
  ## Setting up a model
141
143
 
@@ -177,6 +179,12 @@ Stdout: one JSON event per line, `system`/`init` (its `tools` include the MCP se
177
179
  `mcp_servers` says which are `connected`), then `assistant` (text, thinking, `tool_use`),
178
180
  `user` (`tool_result`), and a `result` after each message. It runs until stdin closes.
179
181
 
182
+ A helper's steps (the `agent` tool: another of the user's models, or the same one, doing a job;
183
+ several can run at once) come as `assistant` and `user` lines too, as Claude Code sends a
184
+ subagent's: `parent_tool_use_id` is the id of the `agent` call they belong to (`null` for the main
185
+ conversation's), and `message.model` says which model it is (`provider/model`). The helper's last
186
+ reply is that `agent` call's `tool_result`.
187
+
180
188
  ## Don't
181
189
 
182
190
  - Don't run `heart` without `-p` from an agent: it's a full-screen app and waits for a person.
package/bin/heartcode.js CHANGED
@@ -253,10 +253,18 @@ class App {
253
253
  ui.print(c.dim(`↻ ${t.provider} · ${t.model} answered${i ? ` (#${i + 1} in your model list)` : ' (#1 again)'}`))
254
254
  }
255
255
  if (cfg.maxRetries !== undefined) client.retryIf = (e, attempt) => attempt < cfg.maxRetries // headless: then give up
256
- const profile = await getProfile(client, ui, cfg.force, signal)
256
+ let profile = await getProfile(client, ui, cfg.force, signal)
257
257
  cfg.force = false
258
258
  if (strategy(profile, cfg).thinking) client.effort = cfg.effort
259
259
  const strat = strategy(profile, cfg)
260
+ // Another model in the list is asked from now on: its own abilities (sees images? its window…)
261
+ // and profile, not the first one's (whose profile was once saved under the next one's name).
262
+ client.onUse = (t) => {
263
+ profile = loadProfile(client) || profileFrom(client, limitsOf(t.provider, client.baseUrl, t.model))
264
+ Object.assign(strat, strategy(profile, cfg))
265
+ Object.assign(client, { tokenRatio: profile.tokenRatio ?? 1, knownGood: profile.largestAccepted ?? 0 })
266
+ log('rotate', `${t.provider} · ${t.model}: ${strat.actions}${strat.vision ? ', images' : ''}, ~${strat.context} tokens`)
267
+ }
260
268
  // What the window is, learned while working (context.js); kept in the profile for next time.
261
269
  const learned = () => {
262
270
  Object.assign(strat, (({ context, contextSure, ratio, reply }) => ({ context, contextSure, ratio, reply }))(strategy(profile, cfg)))
@@ -267,7 +275,7 @@ class App {
267
275
  client.onUsage = (used, estimate) => {
268
276
  const was = profile.largestAccepted
269
277
  const change = learnFromUsage(profile, used, estimate)
270
- if (change === 'cut') ui.warn(`${model} silently cut a request to ~${profile.inputLimit} tokens: that's its window here; keeping the conversation within it`)
278
+ if (change === 'cut') ui.warn(`${client.model} silently cut a request to ~${profile.inputLimit} tokens: that's its window here; keeping the conversation within it`)
271
279
  if (change) log('context', `${change}: ${used} tokens counted, ~${estimate} estimated; window now ${strategy(profile, cfg).context}`, { was, largestAccepted: profile.largestAccepted, inputLimit: profile.inputLimit, tokenRatio: profile.tokenRatio })
272
280
  if (change) learned()
273
281
  }
@@ -277,12 +285,12 @@ class App {
277
285
  if (rate) profile.rateCap = { tokens: n, until: Date.now() + RATE_CAP_MS }
278
286
  else Object.assign(profile, { inputLimit: Math.min(profile.inputLimit ?? Infinity, n), inputLimitAt: Date.now() })
279
287
  learned()
280
- ui.warn(rate ? `${provider} takes at most ~${n} tokens a minute on your account; shortening the conversation to fit (for today)` : `${model} takes at most ~${n} tokens per request here; shortening the conversation to fit`)
288
+ ui.warn(rate ? `${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`)
281
289
  }
282
290
  // models.dev's published size for the model (a day old at most): used when the server doesn't say
283
291
  loadPrices().then(() => {
284
292
  const known = limitsOf(provider, client.baseUrl, model) ?? undefined
285
- if (JSON.stringify(known) === JSON.stringify(profile.known)) return
293
+ if (profile.model !== model || JSON.stringify(known) === JSON.stringify(profile.known)) return // (a switched-to model: its own)
286
294
  profile.known = known
287
295
  if (known && profile.source === 'assumed') profile.source = 'models.dev' // it's listed now
288
296
  learned()
@@ -509,7 +517,7 @@ class App {
509
517
  modelChoices(typed, start, end) {
510
518
  const mine = this.myModels()
511
519
  const twice = (m) => mine.filter((t) => t.model === m).length > 1
512
- const items = mine.map((t, i) => ({ value: twice(t.model) ? `${t.provider} ${t.model}` : t.model, desc: `${t.provider} · #${i + 1}${i === 0 ? ' · in use' : ''}`, start, end }))
520
+ const items = mine.map((t, i) => ({ value: twice(t.model) ? `${t.provider} ${t.model}` : t.model, desc: this.modelDesc(t, i), start, end }))
513
521
  return rank(items, typed).slice(0, 200)
514
522
  }
515
523
 
@@ -626,11 +634,26 @@ class App {
626
634
  const now = this.s?.client.rotation?.list[this.s.client.rotation.at] ?? this.myModels()[0]
627
635
  return this.myModels()
628
636
  .filter((t) => !(t.provider === now?.provider && t.model === now?.model))
629
- .map((t) => {
630
- const cl = this.client(t.provider, t.model)
631
- const st = strategy(loadProfile(cl) || profileFrom(cl, limitsOf(t.provider, cl.baseUrl, t.model)))
632
- return { ...t, tools: st.actions === 'tools', images: st.vision, thinking: st.thinking, context: st.context }
633
- })
637
+ .map((t) => ({ ...t, ...this.abilities(t) }))
638
+ }
639
+
640
+ // What a model can do, from its saved profile or models.dev (guessed: neither knows it).
641
+ abilities(t) {
642
+ const cl = this.client(t.provider, t.model)
643
+ const p = loadProfile(cl) || profileFrom(cl, limitsOf(t.provider, cl.baseUrl, t.model))
644
+ const st = strategy(p)
645
+ return { tools: st.actions === 'tools', images: st.vision, thinking: st.thinking, context: st.context, guessed: p.source === 'assumed' && !p.set }
646
+ }
647
+
648
+ // A row of the model lists (the picker, the /select_model suggestions): "openrouter · #1 · in use · tools · 1M".
649
+ modelDesc(t, i) {
650
+ return `${t.provider} · #${i + 1}${i === 0 ? ' · in use' : ''} · ${this.canText(t)}`
651
+ }
652
+
653
+ // "tools · images · thinking · 1M", for the model lists.
654
+ canText(t) {
655
+ const a = this.abilities(t)
656
+ return [a.tools ? 'tools' : 'no tools', a.images && 'images', a.thinking && 'thinking', kTokens(a.context) + (a.guessed ? '?' : '')].filter(Boolean).join(' · ')
634
657
  }
635
658
 
636
659
  // Your models, in order: this one (#1), then the fallbacks (chosen on /settings, where every
@@ -658,7 +681,7 @@ class App {
658
681
  if (!Object.keys(cfg.providers).length && (await this.detectLocal())) return this.session()
659
682
  return ui.print(NO_MODELS)
660
683
  }
661
- const items = mine.map((t, i) => ({ label: t.model, group: t.provider, t, current: i === 0, desc: [i === 0 ? '#1 · in use' : `#${i + 1} · fallback`, loadProfile(this.client(t.provider, t.model)) && '✓ tested'].filter(Boolean).join(' · ') }))
684
+ const items = mine.map((t, i) => ({ label: t.model, t, current: i === 0, desc: this.modelDesc(t, i) }))
662
685
  const it = await ui.pick('Choose a model (more on /settings)', items, { filter })
663
686
  if (it && !it.current) await this.useMine(it.t)
664
687
  }
@@ -1258,6 +1281,7 @@ async function repl(app, f = {}) {
1258
1281
  ui.onEscape = () => ctl?.abort()
1259
1282
  ui.onBtw = (q) => app.btw(q, true) // /btw typed while it works
1260
1283
  ui.isCommand = (l) => app.isCommand(l)
1284
+ loadPrices().then(() => ui.render?.()) // models.dev: what each model can do, for the model lists
1261
1285
  ui.voiceConfig = () => ({ voice: cfg.voice, providers: cfg.providers })
1262
1286
  // Ctrl+C: stops what's running; clears the input if there's text; otherwise asks before quitting
1263
1287
  // (a second Ctrl+C within 2s quits, also when something is stuck).
package/package.json CHANGED
@@ -1,7 +1,33 @@
1
1
  {
2
2
  "name": "heartcode",
3
- "version": "0.1.3",
3
+ "version": "0.1.6",
4
4
  "description": "A coding CLI for any local or OpenAI-compatible model, including small models without tool calling.",
5
+ "keywords": [
6
+ "ai",
7
+ "cli",
8
+ "coding-agent",
9
+ "ai-agent",
10
+ "llm",
11
+ "openai-compatible",
12
+ "openrouter",
13
+ "groq",
14
+ "ollama",
15
+ "lm-studio",
16
+ "gemini",
17
+ "mcp",
18
+ "terminal",
19
+ "developer-tools",
20
+ "claude-code-alternative"
21
+ ],
22
+ "homepage": "https://github.com/p32929/HeartCode#readme",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/p32929/HeartCode.git"
26
+ },
27
+ "bugs": {
28
+ "url": "https://github.com/p32929/HeartCode/issues"
29
+ },
30
+ "author": "p32929",
5
31
  "type": "module",
6
32
  "bin": {
7
33
  "heart": "bin/heart.js",
package/prompts/agent.md CHANGED
@@ -170,20 +170,47 @@ The user didn't answer. Decide yourself, carry on, and say in your reply what yo
170
170
  <!-- Starts a helper agent (only in the main conversation, not in helpers). -->
171
171
  Hand a side job to a helper agent with its own fresh context, so the details stay out of this conversation: searching or reading a lot of code to answer a question, researching on the web, reviewing changes. It has the same tools (no helpers of its own) and sees only your prompt, so say exactly what to do and what to report. Its final reply comes back as the result. description: 3-5 words for the user.
172
172
 
173
+ Several agent calls in one reply run at the same time. Use that to split independent work (one helper per part), or to ask several helpers the same thing (different models, or the same one more than once) and combine their answers: compare them, keep what they agree on, check where they differ.
174
+
173
175
  ## tool-agent-model
174
176
 
175
177
  <!-- The agent tool's "model", there when the user has other models. -->
176
- Another of your models to do the job (from "Your other models" in the system prompt, as "provider model"), when it needs what you can't do. Leave out to do it with yourself.
178
+ Which of your models does the job (from "Your other models" in the system prompt, as "provider model"): one that can do what it needs. Left out: your own model, or with images and you can't see them, the first of yours that can.
177
179
 
178
180
  ## team
179
181
 
180
- <!-- In the system prompt when the user set up other models. {{models}}: one per line with what each can do. {{you}}: what this model can do. -->
182
+ <!-- In the system prompt when the user set up other models. {{models}}: one per line with what each can do (and failures this session). {{you}}: what this model can do. -->
181
183
  # Your other models
182
184
 
183
- The user set up these models besides you (you: tool calls, {{you}}):
185
+ Besides you (tool calls, {{you}}), the user set up these models:
184
186
  {{models}}
185
187
 
186
- You can hand a job to one of them with the agent tool and its "model". Never decline a part of the task, or say you can't do it, because it needs something you can't do (see an image, call tools, think longer, read more than fits your context): give that part to a model here that can, with everything it needs in the prompt (file paths, what to look for, what to report), then go on with its answer. Answering yourself is better when you can.
188
+ 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.
189
+
190
+ - 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.
191
+ - 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.
192
+ - 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.
193
+ - 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.
194
+ - 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: never two helpers on the same browser or the same files at once.
195
+ - 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.
196
+ - Check what a helper reports before acting on it.
197
+
198
+ ## you-see
199
+
200
+ <!-- In the system prompt of a model that can see images (models that can often say they can't). -->
201
+ You can see images: those in the user's messages, those tools return (a browser screenshot), and image files you open with read_file. To know what's on a page or in a picture, look at it yourself and act on what you see; never say you can't see or interpret images, and never ask for an image as text (base64).
202
+
203
+ ## tool-agent-images
204
+
205
+ <!-- The agent tool's "images", there when the user has other models. -->
206
+ 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).
207
+
208
+ ## images-unseen
209
+
210
+ <!-- After a tool's result that had images (a browser screenshot) when this model can't see them. {{files}}: where they were saved. -->
211
+ (You can't see the image(s) this tool returned. Saved as:
212
+ {{files}}
213
+ To act on what's in them, ask a model that can see: the agent tool with these in "images" and a precise question about what you need to do next.)
187
214
 
188
215
  ## images-described
189
216
 
package/src/acp.js CHANGED
@@ -197,7 +197,7 @@ function events(ui, s, e) {
197
197
  ui.update({
198
198
  sessionUpdate: 'tool_call',
199
199
  toolCallId: t.id,
200
- title: clip(`${name}${what ? ` ${what}` : ''}`, 120),
200
+ title: clip(`${e.helper ? `${e.helper} › ` : ''}${name}${what ? ` ${what}` : ''}`, 120), // a helper's steps: named
201
201
  kind: KIND[name] ?? 'other',
202
202
  status: 'pending',
203
203
  rawInput: args,
package/src/agent.js CHANGED
@@ -6,13 +6,38 @@ import { newChat } from './sessions.js'
6
6
  import path from 'node:path'
7
7
  import { est, Client } from './client.js'
8
8
  import { runGuided } from './guided.js'
9
- import { TOOL_DEFS, PLAN_HIDDEN, extraTools, runTool, listFiles, namedFiles, readFileTool, clip, allowed, loadMcpTools, saveOutput } from './tools.js'
9
+ import { TOOL_DEFS, PLAN_HIDDEN, extraTools, runTool, listFiles, namedFiles, readFileTool, clip, allowed, loadMcpTools, saveOutput, OUTPUTS } from './tools.js'
10
10
  import { loadHooks, runHooks } from './hooks.js'
11
11
  import { takeConfigErrors } from './config.js'
12
12
  import { c } from './ui.js'
13
13
 
14
14
  const MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' }
15
15
 
16
+ // Images for a helper (the agent tool's "images"), as data: URLs: image URLs (fetched), data: URLs,
17
+ // or files: in the project, saved screenshots (OUTPUTS), or a path the user gave in a message.
18
+ async function loadImages(list, s, signal) {
19
+ const out = []
20
+ for (const x of [].concat(list ?? []).map((v) => String(v).trim()).filter(Boolean)) {
21
+ if (x.startsWith('data:image/')) {
22
+ out.push(x)
23
+ } else if (/^https?:\/\//i.test(x)) {
24
+ const res = await fetch(x, { signal: AbortSignal.any([AbortSignal.timeout(30_000), ...(signal ? [signal] : [])]) })
25
+ const type = res.headers.get('content-type')?.split(';')[0].trim() ?? ''
26
+ if (!res.ok || !type.startsWith('image/')) throw new Error(`${x} is not an image (${res.status}${type ? `, ${type}` : ''})`)
27
+ out.push(`data:${type};base64,${Buffer.from(await res.arrayBuffer()).toString('base64')}`)
28
+ } else {
29
+ const abs = path.resolve(s.root, x.replace(/^~(?=\/)/, os.homedir()))
30
+ const ext = path.extname(abs).slice(1).toLowerCase()
31
+ if (!MIME[ext]) throw new Error(`${x} is not an image file (png, jpg, gif, webp)`)
32
+ if (!fs.existsSync(abs)) throw new Error(`${x} does not exist`)
33
+ const inside = (dir) => abs.startsWith(dir + path.sep)
34
+ if (!inside(s.root) && !inside(OUTPUTS) && !s.toolImages?.has(abs) && !s.chat.turns.some((t) => t.task.includes(x) || t.task.includes(abs))) throw new Error(`${x} is outside the project; only images in it, saved screenshots and ones the user named can be sent`)
35
+ out.push(`data:${MIME[ext]};base64,${fs.readFileSync(abs).toString('base64')}`)
36
+ }
37
+ }
38
+ return out
39
+ }
40
+
16
41
  // An image the message names is attached: "@shot.png", or a path anywhere (/Users/me/Downloads/a.png,
17
42
  // ~/a.png; dragged in: quoted, or with "\ " for spaces), when the file is there. The path stays in the text.
18
43
  const IMAGE_PATH = /(^|\s)@?(?:(["'])([^"'\n]+?\.(?:png|jpe?g|gif|webp))\2|((?:\\ |[^\s"'])+?\.(?:png|jpe?g|gif|webp)))(?=$|[\s,;:!?).])/gi
@@ -157,27 +182,63 @@ export class Session {
157
182
  // The agent tool: a helper with a fresh conversation and the same tools (but no helpers of its
158
183
  // own, no memory, no director) does one job; its final reply is the tool's result. Its steps show
159
184
  // under the main conversation's, its file changes count for /rewind like the main agent's.
160
- // ponytail: helpers run one after another; run several at once if waiting on them gets long.
161
- // model: another of your models (team) does it, for what this one can't do (see images, call tools…).
162
- async subagent(args, signal) {
185
+ // model: one of your models (this one when left out); images: for it to look at (it must see).
186
+ // A helper that fails or replies with nothing: the job goes to the next of your models that can
187
+ // do it, until one does. id: the agent tool call's; the helper's steps go to onEvent (headless,
188
+ // ACP) as that call's.
189
+ async subagent(args, signal, id) {
163
190
  const task = String(args.prompt ?? args.task ?? '').trim()
164
191
  if (!task) return 'error: give the helper its job in "prompt"'
165
- let { client, profile, strat } = this
192
+ let images
193
+ try {
194
+ images = await loadImages(args.images, this, signal)
195
+ } catch (e) {
196
+ if (e.name === 'AbortError') throw e
197
+ return `error: ${e.message}`
198
+ }
199
+ const name = (t) => `${t.provider} ${t.model}`
200
+ const self = { provider: this.client.provider, model: this.client.model, images: this.strat.vision, self: true }
201
+ const all = [self, ...(this.team?.() ?? [])]
166
202
  const want = String(args.model ?? '').trim()
167
- if (want) {
168
- const team = this.team?.() ?? []
169
- const t = team.find((x) => `${x.provider} ${x.model}` === want) ?? team.find((x) => x.model === want || want.endsWith(` ${x.model}`))
170
- if (!t) return `error: no model "${want}". Yours: ${team.map((x) => `${x.provider} ${x.model}`).join(', ') || 'none besides this one'}`
203
+ const first = !want ? self : (all.find((t) => name(t) === want) ?? all.find((t) => t.model === want || want.endsWith(` ${t.model}`)))
204
+ if (!first) return `error: no model "${want}". Yours: ${all.map(name).join(', ')}`
205
+ const able = (t) => !images.length || t.images
206
+ const order = [first, ...all.filter((t) => t !== first)].filter(able)
207
+ if (!order.length) return 'error: none of your models can see images'
208
+ const failed = []
209
+ for (const [i, t] of order.entries()) {
210
+ let answer = ''
211
+ try {
212
+ answer = (await this.#helper(t, task, images, args.description, id, signal))?.trim()
213
+ } catch (e) {
214
+ if (e.name === 'AbortError' || signal?.aborted) throw e
215
+ failed.push(`${t.model}: ${clip(e.message, 150)}`)
216
+ }
217
+ if (answer) return t === first ? answer : `(${name(t)} did it${failed.length ? `; before it: ${failed.join('; ')}` : ''})\n\n${answer}`
218
+ if (failed.length < i + 1) failed.push(`${t.model}: no reply`)
219
+ this.failures = { ...this.failures, [name(t)]: (this.failures?.[name(t)] ?? 0) + 1 } // shown to the model (Your other models)
220
+ if (order[i + 1]) this.ui.warn(`${t.model} couldn't do it (${failed.at(-1).slice(t.model.length + 2)}); handing it to ${order[i + 1].model}`)
221
+ }
222
+ return `error: none of your models that can do it managed: ${failed.join('; ')}`
223
+ }
224
+
225
+ // One helper on one model (t: this one, or another of yours).
226
+ async #helper(t, task, images, description, id, signal) {
227
+ let { client, profile, strat } = this
228
+ if (!t.self) {
171
229
  ;({ client, profile, strat } = await this.engineFor(t))
230
+ const was = client.retryIf
231
+ client.retryIf = (e, attempt) => attempt < 3 && (!was || was(e, attempt)) // then the next model gets the job
172
232
  }
173
- const label = clip((want ? `${client.model}: ` : '') + (String(args.description ?? '').trim() || task.split('\n')[0]), 60)
233
+ const label = clip(`${client.model}: ${String(description ?? '').trim() || task.split('\n')[0]}`, 60)
174
234
  const helper = new Session({ client, profile, strat, root: this.root, ui: helperUI(this.ui, label), chat: newChat(this.root) })
175
235
  Object.assign(helper, { isSub: true, mcp: this.mcp, skills: this.skills, hooks: this.hooks, allowTools: this.allowTools, denyTools: this.denyTools, maxTurns: this.maxTurns, autoCompact: this.autoCompact, appendSystem: this.appendSystem, fileCache: this.fileCache, noFormat: this.noFormat, noLsp: this.noLsp })
176
236
  helper.track = (rel, before) => this.track(rel, before)
177
- log('agent', `helper "${label}": ${task}`)
178
- const answer = await (strat.actions === 'tools' ? runNative : runGuided)(helper, task, [], signal)
237
+ if (this.onEvent) helper.onEvent = (e) => this.onEvent({ ...e, parent: id, helper: label })
238
+ log('agent', `helper "${label}" (${images.length} image(s)): ${task}`)
239
+ const answer = await (strat.actions === 'tools' ? runNative : runGuided)(helper, task, images, signal)
179
240
  log('agent', `helper "${label}" done`, answer)
180
- return answer?.trim() || '(the helper finished without a reply)'
241
+ return answer
181
242
  }
182
243
 
183
244
  // Images for a model that can't see them: another of yours (seer) describes each, for the task.
@@ -309,6 +370,7 @@ const SYSTEM = (s) => {
309
370
  prompt('agent/system', { root: s.root, files: '', more: '', caller: Boolean(s.appendSystem) }),
310
371
  instructions && prompt('agent/instructions', { instructions }),
311
372
  s.memory && prompt('agent/memory', { tags: s.memory.tagSummary() || '(none yet)' }),
373
+ s.strat.vision && prompt('agent/you-see'), // models that can see often say they can't
312
374
  s.mcpInstructions && prompt('agent/mcp-instructions', { instructions: s.mcpInstructions }), // what the MCP servers say about their tools
313
375
  s.director && !s.appendSystem && s.director.app.cfg.director !== false && prompt('agent/director'),
314
376
  !s.isSub && teamPrompt(s),
@@ -324,7 +386,8 @@ const SYSTEM = (s) => {
324
386
  const teamPrompt = (s) => {
325
387
  const team = s.team?.() ?? []
326
388
  if (!team.length) return ''
327
- const can = (t) => [t.tools ? 'tool calls' : 'no tool calls', t.images && 'images', t.thinking && 'thinking', `${Math.round(t.context / 1000)}k context`].filter(Boolean).join(', ')
389
+ const fails = (t) => s.failures?.[`${t.provider} ${t.model}`] && `failed ${s.failures[`${t.provider} ${t.model}`]}× this session`
390
+ const can = (t) => [t.tools ? 'tool calls' : 'no tool calls', t.images && 'images', t.thinking && 'thinking', `${Math.round(t.context / 1000)}k context`, fails(t)].filter(Boolean).join(', ')
328
391
  const you = [s.strat.vision ? 'images' : 'no images', s.strat.thinking && 'thinking'].filter(Boolean).join(', ')
329
392
  return prompt('agent/team', { models: team.map((t) => `- ${t.provider} ${t.model}: ${can(t)}`).join('\n'), you })
330
393
  }
@@ -542,10 +605,13 @@ async function runNative(s, task, images, signal) {
542
605
  log('task', 'the user added while working:', added)
543
606
  s.messages.push({ role: 'user', content: prompt('agent/user-added', { text: added.join('\n\n') }) })
544
607
  }
608
+ // the model answering can change mid-message (your model list): the system prompt follows it
609
+ const now = SYSTEM(s)
610
+ if (s.messages[0]?.content !== now && (s.messages[0]?.role === 'system' || strat.mergeSystem)) s.messages[0] = { ...s.messages[0], content: now }
545
611
  fitMessages(s.messages, budget())
546
612
  pairTools(s.messages)
547
613
  ui.spinner('thinking')
548
- const out = ui.response()
614
+ const out = ui.response(client.model) // named: helpers think too (theirs are named in helperUI)
549
615
  const stream = strat.toolsStream
550
616
  let res
551
617
  try {
@@ -575,7 +641,7 @@ async function runNative(s, task, images, signal) {
575
641
  res.content = ''
576
642
  s.messages[s.messages.length - 1] = { role: 'assistant', content: '', tool_calls: res.toolCalls }
577
643
  }
578
- s.onEvent?.({ type: 'assistant', content: res.content, reasoning: res.reasoning, toolCalls: res.toolCalls }) // headless output
644
+ s.onEvent?.({ type: 'assistant', model: `${client.provider}/${client.model}`, content: res.content, reasoning: res.reasoning, toolCalls: res.toolCalls }) // headless output
579
645
  if (!res.toolCalls.length && !res.content?.trim()) {
580
646
  // Nothing at all (seen after a failed tool call): not an answer. The same request again
581
647
  // first (often fine the second time), then asked to go on; three in a row ends the task.
@@ -604,12 +670,25 @@ async function runNative(s, task, images, signal) {
604
670
  continue
605
671
  }
606
672
 
673
+ // Helpers (agent) asked for in one reply work at the same time: the same model or several.
674
+ // ponytail: no cap on how many at once; add one if a provider's rate limit makes them crawl.
675
+ const argsOf = (call) => {
676
+ try {
677
+ return JSON.parse(call.function.arguments || '{}')
678
+ } catch {
679
+ return null
680
+ }
681
+ }
682
+ const together = new Map()
683
+ for (const call of res.toolCalls) {
684
+ const args = argsOf(call)
685
+ if (call.function.name !== 'agent' || !args || !allowed(s, 'agent') || res.toolCalls.filter((x) => x.function.name === 'agent').length < 2) continue
686
+ ui.step('agent', clip([args.model && `${args.model}:`, args.description].filter(Boolean).join(' '), 80))
687
+ together.set(call.id, runTool('agent', args, s, signal, call.id))
688
+ }
607
689
  for (const call of res.toolCalls) {
608
690
  const name = call.function.name
609
- let args = null
610
- try {
611
- args = JSON.parse(call.function.arguments || '{}')
612
- } catch {}
691
+ const args = argsOf(call)
613
692
  const detail = !args
614
693
  ? ''
615
694
  : args.server
@@ -619,12 +698,12 @@ async function runNative(s, task, images, signal) {
619
698
  : name === 'manage'
620
699
  ? [args.action, args.kind, args.name].filter(Boolean).join(' ')
621
700
  : name === 'agent'
622
- ? (args.description ?? '')
701
+ ? [args.model && `${args.model}:`, args.description].filter(Boolean).join(' ')
623
702
  : name === 'todo' || name === 'ask_user'
624
703
  ? ''
625
704
  : (args.path ?? args.url ?? args.query ?? args.filter ?? args.name ?? '')
626
- if (name !== 'run_command') ui.step(name, clip(detail, 80))
627
- const out = !allowed(s, name) ? `error: the tool ${name} is not allowed here` : args ? await runTool(name, args, s, signal) : 'error: the tool arguments were not valid JSON'
705
+ if (name !== 'run_command' && !together.has(call.id)) ui.step(name, clip(detail, 80))
706
+ const out = together.has(call.id) ? await together.get(call.id) : !allowed(s, name) ? `error: the tool ${name} is not allowed here` : args ? await runTool(name, args, s, signal, call.id) : 'error: the tool arguments were not valid JSON'
628
707
  if (name === 'read_file' || name === 'search' || name === 'list_files' || name === 'web_fetch' || name === 'web_search') ui.result(`${out.split('\n').length} lines`)
629
708
  else if (name === 'agent' && !out.startsWith('error')) ui.result(`${s.ui.permission === 'plan' ? 'planned' : 'done'}: ${clip(out.split('\n').find((l) => l.trim()) ?? '', 100)}`)
630
709
  else if (out.startsWith('error')) ui.result(clip(out, 120))
@@ -657,13 +736,36 @@ async function notDone(s, signal, again) {
657
736
 
658
737
  // A helper agent's view of the screen: its steps are labelled, and its replies (which go to the
659
738
  // main agent) aren't shown as answers. Everything else is the main UI's.
739
+ // Its thinking is shown whole when its reply is in (helpers working at once don't mix), named.
740
+ // Questions to the user (confirm, pick, ask) wait their turn: helpers working at once ask one by one.
741
+ const asking = new WeakMap() // ui → the question being asked
660
742
  function helperUI(ui, label) {
661
- const quiet = { think() {}, text() {}, end() {} }
743
+ const response = () => {
744
+ const t0 = Date.now()
745
+ let thought = ''
746
+ return {
747
+ think: (t) => (thought += t ?? ''),
748
+ text() {},
749
+ end() {
750
+ if (!thought.trim()) return
751
+ const secs = ((Date.now() - t0) / 1000).toFixed(1)
752
+ ui.print(c.faint(c.italic(` ✻ ${label} thought for ${secs}s`)) + (ui.showThinking ? `\n${c.think(c.italic(thought.trim().replace(/^/gm, ' ')))}` : ''))
753
+ },
754
+ }
755
+ }
756
+ const inTurn = (k) => (...a) => {
757
+ const next = (asking.get(ui) ?? Promise.resolve()).then(() => ui[k](...a))
758
+ asking.set(ui, next.catch(() => {}))
759
+ return next
760
+ }
662
761
  const own = {
663
762
  step: (name, detail) => ui.step(` ${label} › ${name}`, detail),
664
763
  result: (text) => ui.result(` ${text}`),
665
- response: () => quiet,
764
+ response,
666
765
  spinner: (text) => ui.spinner(`${label} · ${text}`),
766
+ confirm: inTurn('confirm'),
767
+ pick: inTurn('pick'),
768
+ ask: inTurn('ask'),
667
769
  }
668
770
  return new Proxy(ui, { get: (t, k) => (k in own ? own[k] : typeof t[k] === 'function' ? t[k].bind(t) : t[k]), set: (t, k, v) => ((t[k] = v), true) })
669
771
  }
package/src/client.js CHANGED
@@ -161,9 +161,12 @@ export class Client {
161
161
  const r = this.rotation
162
162
  if (r && r.next === 'first' && r.at) this.#use(r.list[(r.at = 0)])
163
163
  }
164
+ // onUse(target): the model the next request goes to changed (its abilities and profile follow)
164
165
  #use(t) {
166
+ const changed = this.model !== t.model || this.baseUrl !== normalizeBaseUrl(t.baseUrl)
165
167
  Object.assign(this, { baseUrl: normalizeBaseUrl(t.baseUrl), apiKey: t.apiKey, model: t.model, provider: t.provider })
166
168
  this.quirks = t.quirks ?? (t.quirks = {})
169
+ if (changed) this.onUse?.(t)
167
170
  }
168
171
  // After a failure: go on to the next model? (then it's tried at once)
169
172
  #rotate(e, tries) {
package/src/headless.js CHANGED
@@ -127,10 +127,11 @@ export async function runHeadless(app, opts) {
127
127
  ...(e.content ? [{ type: 'text', text: e.content }] : []),
128
128
  ...e.toolCalls.map((t) => ({ type: 'tool_use', id: t.id, name: t.function.name, input: parseArgs(t.function.arguments) })),
129
129
  ]
130
- if (e.content) said = true
131
- if (content.length) emit({ type: 'assistant', message: { role: 'assistant', model, content }, session_id })
130
+ if (e.content && !e.parent) said = true
131
+ // a helper's (agent) steps too, as Claude Code sends a subagent's: parent_tool_use_id is its agent call
132
+ if (content.length) emit({ type: 'assistant', message: { role: 'assistant', model: e.parent ? e.model : model, content }, parent_tool_use_id: e.parent ?? null, session_id })
132
133
  } else if (e.type === 'tool') {
133
- emit({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: e.id, content: e.output, is_error: e.output.startsWith('error') }] }, session_id })
134
+ emit({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: e.id, content: e.output, is_error: e.output.startsWith('error') }] }, parent_tool_use_id: e.parent ?? null, session_id })
134
135
  }
135
136
  }
136
137
  if (s.strat.actions === 'tools' && s.mcp?.names.length) await loadMcpTools(s) // their tools are in the list, as in Claude Code's
@@ -165,7 +166,7 @@ export async function runHeadless(app, opts) {
165
166
  current = null
166
167
  const result = { type: 'result', subtype, is_error: Boolean(error), result: error ?? answer, num_turns: Math.max(1, requests), duration_ms: Date.now() - t0, total_cost_usd: (app.chat.usage?.cost ?? 0) - spent, usage, modelUsage: { [model]: { inputTokens: 0, outputTokens: 0 }, ...byModel }, session_id }
167
168
  if (out === 'stream-json') {
168
- if (!error && answer && !said) emit({ type: 'assistant', message: { role: 'assistant', model, content: [{ type: 'text', text: answer }] }, session_id }) // guided mode
169
+ if (!error && answer && !said) emit({ type: 'assistant', message: { role: 'assistant', model, content: [{ type: 'text', text: answer }] }, parent_tool_use_id: null, session_id }) // guided mode
169
170
  emit(result)
170
171
  } else if (out === 'json') emit(result)
171
172
  else if (error) process.stderr.write(`heartcode: ${error}\n`)
package/src/probe.js CHANGED
@@ -21,7 +21,8 @@ export const profilePath = (client) => {
21
21
  const PROBE_VERSION = 2
22
22
  export const loadProfile = (client) => {
23
23
  const p = readJson(profilePath(client), null)
24
- return p?.version === PROBE_VERSION ? p : null
24
+ // another model's (a model list switch once saved the first model's profile under the next one's name)
25
+ return p?.version === PROBE_VERSION && (!p.model || p.model === client.model) ? p : null
25
26
  }
26
27
 
27
28
  // --- tiny PNG encoder, for the vision probe ---
package/src/tools.js CHANGED
@@ -23,12 +23,13 @@ export const tail = (s, n) => (s.length > n ? '…' + s.slice(-n) : s)
23
23
  // Tool output too long for the conversation is saved here (one folder per chat), where read_file
24
24
  // and search can read it although it's outside the project.
25
25
  export const OUTPUTS = path.join(os.tmpdir(), 'heartcode-outputs')
26
- export function saveOutput(chatId, name, text) {
26
+ // data: text, or an image's bytes (ext: png…)
27
+ export function saveOutput(chatId, name, data, ext = 'txt') {
27
28
  try {
28
29
  const dir = path.join(OUTPUTS, String(chatId ?? 'chat'))
29
30
  fs.mkdirSync(dir, { recursive: true })
30
- const file = path.join(dir, `${Date.now().toString(36)}-${String(name).replace(/[^\w-]/g, '_').slice(0, 60)}.txt`)
31
- fs.writeFileSync(file, text)
31
+ const file = path.join(dir, `${Date.now().toString(36)}-${String(name).replace(/[^\w-]/g, '_').slice(0, 60)}.${ext}`)
32
+ fs.writeFileSync(file, data)
32
33
  return file
33
34
  } catch (e) {
34
35
  log('tool', `could not save the output: ${e.message}`)
@@ -487,7 +488,8 @@ export function extraTools(s) {
487
488
  if (!s.isSub && s.subagent) {
488
489
  const team = s.team?.() ?? []
489
490
  const model = { type: 'string', description: prompt('agent/tool-agent-model') }
490
- out.push({ name: 'agent', description: prompt('agent/tool-agent'), parameters: obj({ description: str, prompt: str, ...(team.length && { model }) }, ['description', 'prompt']) })
491
+ const images = { type: 'array', items: str, description: prompt('agent/tool-agent-images') }
492
+ out.push({ name: 'agent', description: prompt('agent/tool-agent'), parameters: obj({ description: str, prompt: str, ...(team.length && { model, images }) }, ['description', 'prompt']) })
491
493
  }
492
494
  return [...out, ...(s.director && !s.appendSystem ? s.director.toolDefs(s) : [])] // manage (director.js); not when a program gives the instructions (--append-system-prompt)
493
495
  }
@@ -628,10 +630,21 @@ async function callMcp(s, server, tool, a, signal) {
628
630
  const parts = r.content ?? []
629
631
  const text = parts.filter((p) => p.type === 'text').map((p) => p.text).join('\n')
630
632
  const images = parts.filter((p) => p.type === 'image' && p.data)
633
+ // image files the tool says it saved ("Saved screenshot to /…/shot.webp"): as if returned
634
+ const saved = [...new Set([...text.matchAll(/(?:^|[\s"'`(])((?:\/|~\/)[^\s"'`)]+\.(?:png|jpe?g|gif|webp))\b/gi)].map((m) => m[1].replace(/^~(?=\/)/, os.homedir())))].filter((f) => fs.existsSync(f))
635
+ if (saved.length) {
636
+ s.toolImages ??= new Set()
637
+ for (const f of saved) s.toolImages.add(f) // may be sent to a helper (agent images)
638
+ if (s.strat?.vision) images.push(...saved.map((f) => ({ data: fs.readFileSync(f).toString('base64'), mimeType: `image/${path.extname(f).slice(1).toLowerCase().replace('jpg', 'jpeg')}`, file: f })))
639
+ }
631
640
  let note = ''
632
641
  if (images.length && s.strat?.vision) {
633
642
  ;(s.pendingImages ??= []).push(...images.map((p) => `data:${p.mimeType ?? 'image/png'};base64,${p.data}`))
634
643
  note = `\n(${images.length} image(s) attached in the next message)`
644
+ } else if ((images.length || saved.length) && s.team?.().some((t) => t.images)) {
645
+ // saved, for a model that can see to be asked about (the agent tool's images)
646
+ const files = [...images.map((p, i) => saveOutput(s.chat?.id, `${tool}-${i + 1}`, Buffer.from(p.data, 'base64'), (p.mimeType ?? 'image/png').split('/')[1].replace('jpeg', 'jpg'))).filter(Boolean), ...saved]
647
+ note = `\n${prompt('agent/images-unseen', { files: files.join('\n') })}`
635
648
  } else if (images.length) note = `\n(${images.length} image(s) not shown: this model can't see images)`
636
649
  return `${r.isError ? 'error: ' : ''}${text || '(no text)'}${note}`
637
650
  }
@@ -782,11 +795,12 @@ async function applyPatch(s, text) {
782
795
  }
783
796
 
784
797
  // Every tool call goes through the user's PreToolUse and PostToolUse hooks (hooks.js).
785
- export async function runTool(name, args, s, signal) {
798
+ // id: the tool call's (a helper's steps are sent as its, for other apps to show under it)
799
+ export async function runTool(name, args, s, signal, id) {
786
800
  log('tool', name, args)
787
801
  const pre = await runHooks(s, 'PreToolUse', { tool_name: name, tool_input: args }, signal)
788
802
  if (pre.block) return prompt('agent/hook-blocked-tool', { reason: pre.block })
789
- let out = await tool(name, args, s, signal)
803
+ let out = await tool(name, args, s, signal, id)
790
804
  const post = await runHooks(s, 'PostToolUse', { tool_name: name, tool_input: args, tool_response: out }, signal)
791
805
  if (post.block) out += `\n\n${prompt('agent/hook-after-tool', { reason: post.block })}`
792
806
  log('tool', `${name} result:`, clip(out, 4000))
@@ -822,7 +836,7 @@ export function allowed(s, name) {
822
836
  return names.some((n) => s.allowTools.has(n)) || (name === 'mcp' && [...s.allowTools].some((n) => n.startsWith('mcp__')))
823
837
  }
824
838
 
825
- async function tool(name, args, s, signal) {
839
+ async function tool(name, args, s, signal, id) {
826
840
  if (!name.startsWith('mcp__') && !mcpTarget(s, name)) args = normalize(args) // an MCP tool's arguments are its own
827
841
  try {
828
842
  const { root } = s
@@ -865,7 +879,7 @@ async function tool(name, args, s, signal) {
865
879
  case 'web_fetch':
866
880
  return await webTool(s, name, args, signal)
867
881
  case 'agent':
868
- return await s.subagent(args, signal)
882
+ return await s.subagent(args, signal, id)
869
883
  case 'ask_user':
870
884
  return await askUser(s, args)
871
885
  case 'skill': {
package/src/ui.js CHANGED
@@ -399,7 +399,8 @@ export class UI {
399
399
  }
400
400
 
401
401
  // One model reply: thinking (dim, its own block) kept apart from the answer.
402
- response() {
402
+ // who: the model thinking, named over its thinking (helpers say their own: helperUI in agent.js)
403
+ response(who) {
403
404
  const t0 = Date.now()
404
405
  const secs = () => ((Date.now() - t0) / 1000).toFixed(1)
405
406
  const write = (x) => this.write(x)
@@ -407,7 +408,7 @@ export class UI {
407
408
  let text = null
408
409
  let thought = false
409
410
  const summary = () => {
410
- if (thought && !this.showThinking) this.print(c.faint(c.italic(`✻ thought for ${secs()}s`)))
411
+ if (thought && !this.showThinking) this.print(c.faint(c.italic(`✻ ${who ? `${who} ` : ''}thought for ${secs()}s`)))
411
412
  }
412
413
  return {
413
414
  think: (t) => {
@@ -416,7 +417,7 @@ export class UI {
416
417
  if (!this.showThinking) return this.spinner(`thinking · ${secs()}s`)
417
418
  if (!think) {
418
419
  this.gap()
419
- this.print(c.faint(c.italic('✻ Thinking…')))
420
+ this.print(c.faint(c.italic(`✻ ${who ? `${who} is thinking` : 'Thinking'}…`)))
420
421
  think = new Block(write, { style: (x) => c.think(c.italic(x)) })
421
422
  }
422
423
  think.push(t)
@@ -705,7 +706,13 @@ export class UI {
705
706
  m.off = rl.line
706
707
  return this.refreshMenu()
707
708
  }
708
- if (m.on && key.name === 'tab') return it && this.#accept(it)
709
+ // Tab only completes the text: the menu closes (no list of a command's choices pops up); Enter runs it
710
+ if (m.on && key.name === 'tab') {
711
+ if (!it) return
712
+ this.#accept(it)
713
+ m.off = rl.line
714
+ return this.refreshMenu()
715
+ }
709
716
  if (it && key.name === 'return' && rl.line.slice(it.start, it.end) !== it.value) {
710
717
  this.#accept(it)
711
718
  if (it.insertOnly) return