@hizliemre/horse-code 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/{app-LAJN3TWC.js → app-5FXHE7GX.js} +354 -350
  2. package/dist/{chunk-2SVAHH5N.js → chunk-63E73TGI.js} +2 -4
  3. package/dist/{chunk-DTWKSZXY.js → chunk-6OSEQOYY.js} +2 -2
  4. package/dist/{chunk-SSDLHWSF.js → chunk-6W4UH2BQ.js} +33 -1
  5. package/dist/{chunk-VPAWRRHL.js → chunk-AE36LLL2.js} +31 -21
  6. package/dist/{chunk-4EWK7HWQ.js → chunk-EAF22QIG.js} +32 -0
  7. package/dist/chunk-G45RWL7S.js +289 -0
  8. package/dist/{chunk-FFYBY2NA.js → chunk-KAGKX2YT.js} +2 -4
  9. package/dist/{chunk-DRZSUQ7Q.js → chunk-LLL7QWXB.js} +18 -7
  10. package/dist/{chunk-WYQBRCKY.js → chunk-LNW557IO.js} +2 -2
  11. package/dist/{chunk-IW2KBAVZ.js → chunk-LPQU436C.js} +12 -1
  12. package/dist/chunk-M2RKCIGV.js +11 -0
  13. package/dist/{chunk-NNTIACT4.js → chunk-MRZVA5JB.js} +4 -4
  14. package/dist/{chunk-RPVAIS3P.js → chunk-UEWVVN5L.js} +1511 -305
  15. package/dist/{chunk-EQX7BQYN.js → chunk-UGESK765.js} +1 -1
  16. package/dist/{chunk-PGOYDOI4.js → chunk-XEGQT5EN.js} +3 -5
  17. package/dist/{chunk-K2VERI5Q.js → chunk-XYZVZPAY.js} +365 -666
  18. package/dist/{chunk-KOWMHL23.js → chunk-YPZP7LYL.js} +2 -2
  19. package/dist/{chunk-FGVJFMK5.js → chunk-ZSQ24YDJ.js} +1 -1
  20. package/dist/cli.js +675 -95
  21. package/dist/{discover-5URG7C4J.js → discover-G2Z6XC3O.js} +9 -3
  22. package/dist/fix-ONLA45HD.js +34 -0
  23. package/dist/git-QZTDZSJY.js +8 -0
  24. package/dist/{ongoing-XP6WXNI7.js → ongoing-WHYXPW24.js} +4 -4
  25. package/dist/{project-graph-IOPCSZUA.js → project-graph-5HNPRFQG.js} +2 -3
  26. package/dist/{run-LQOZ5I7Z.js → run-P6ZYL5JL.js} +2 -3
  27. package/dist/{save-skills-NSLBU33X.js → save-skills-ZW5GY6KV.js} +2 -1
  28. package/dist/{trace-ZMB7LT7W.js → trace-X6TU3AG6.js} +2 -3
  29. package/dist/{trace-adopt-C6TUWFJL.js → trace-adopt-URECQWJV.js} +2 -3
  30. package/dist/{trace-run-F23MFTY4.js → trace-run-7U4WJZ3V.js} +6 -7
  31. package/dist/{triage-ES5OHZOS.js → triage-FCYHD2AQ.js} +8 -9
  32. package/dist/{verify-3R7DGUSI.js → verify-LC57A6H2.js} +19 -19
  33. package/package.json +1 -1
  34. package/dist/chunk-3UYA3KUG.js +0 -212
  35. package/dist/chunk-B67BK5GQ.js +0 -34
  36. package/dist/chunk-O74BDQKS.js +0 -28
  37. package/dist/fix-CMARU6JR.js +0 -34
  38. package/dist/git-VTSZALSR.js +0 -6
package/dist/cli.js CHANGED
@@ -2,25 +2,24 @@
2
2
  import {
3
3
  MAX_PARALLEL_TASKS,
4
4
  MemoryStore,
5
- OmniRouteProvider,
6
5
  Timings,
7
6
  autonomousAskHuman,
8
7
  defaultCmdRunner,
8
+ describeTally,
9
9
  detectPlatform,
10
10
  makeApprove,
11
11
  makeAskUser,
12
12
  makePRAdapter,
13
13
  nodeLineReader,
14
+ proposeSplit,
14
15
  restoreTerminal,
15
16
  runJob,
16
17
  sttySane
17
- } from "./chunk-K2VERI5Q.js";
18
- import {
19
- listOmniRouteModels
20
- } from "./chunk-O74BDQKS.js";
21
- import "./chunk-3UYA3KUG.js";
18
+ } from "./chunk-XYZVZPAY.js";
19
+ import "./chunk-M2RKCIGV.js";
22
20
  import {
23
21
  CODE_TEAM,
22
+ CliProvider,
24
23
  DEFAULT_COUNCIL,
25
24
  DEFAULT_PROMPTS,
26
25
  DEFAULT_ROLE_SKILLS,
@@ -28,16 +27,20 @@ import {
28
27
  REQUIRED_ROLES,
29
28
  RoleFitness,
30
29
  RoleRegistry,
30
+ SHORT_CALL_MS,
31
31
  SPEC_TEAM,
32
32
  WorktreeManager,
33
+ ZAI_MODELS,
33
34
  buildCouncilRegistry,
34
35
  buildTeamRegistry,
36
+ cliCatalog,
35
37
  mainWorktreeRoot,
38
+ modelsFor,
36
39
  toSlug
37
- } from "./chunk-RPVAIS3P.js";
40
+ } from "./chunk-UEWVVN5L.js";
38
41
  import "./chunk-QF4MP6BS.js";
39
- import "./chunk-EQX7BQYN.js";
40
- import "./chunk-FGVJFMK5.js";
42
+ import "./chunk-UGESK765.js";
43
+ import "./chunk-ZSQ24YDJ.js";
41
44
  import {
42
45
  parseFrontmatter
43
46
  } from "./chunk-BY4DP7IE.js";
@@ -45,7 +48,12 @@ import {
45
48
  UNSET_MODEL,
46
49
  loadConfig,
47
50
  saveSkillSource
48
- } from "./chunk-4EWK7HWQ.js";
51
+ } from "./chunk-EAF22QIG.js";
52
+ import {
53
+ CLI_KINDS,
54
+ checkProfile,
55
+ runLogin
56
+ } from "./chunk-G45RWL7S.js";
49
57
  import "./chunk-H2FDGPVW.js";
50
58
  import {
51
59
  buildBrief,
@@ -53,50 +61,460 @@ import {
53
61
  planFor,
54
62
  runTraces,
55
63
  traceableFiles
56
- } from "./chunk-NNTIACT4.js";
64
+ } from "./chunk-MRZVA5JB.js";
57
65
  import {
58
66
  defaultGitRunner
59
- } from "./chunk-IW2KBAVZ.js";
67
+ } from "./chunk-LPQU436C.js";
60
68
  import {
61
69
  InjectionLog,
62
70
  memoryNote
63
- } from "./chunk-DRZSUQ7Q.js";
64
- import "./chunk-2SVAHH5N.js";
71
+ } from "./chunk-LLL7QWXB.js";
72
+ import "./chunk-63E73TGI.js";
65
73
  import {
66
74
  Telemetry,
75
+ ToolRegistry,
67
76
  clearPerfMarks,
68
77
  sampleMemory,
69
78
  setTelemetry,
70
79
  stripThinking,
71
80
  telemetry,
72
81
  writeHeapSnapshot
73
- } from "./chunk-VPAWRRHL.js";
82
+ } from "./chunk-AE36LLL2.js";
74
83
  import {
75
84
  briefStatus
76
- } from "./chunk-DTWKSZXY.js";
85
+ } from "./chunk-6OSEQOYY.js";
77
86
  import {
78
87
  discoverTraceRoot,
79
88
  setTraceRoot
80
- } from "./chunk-FFYBY2NA.js";
89
+ } from "./chunk-KAGKX2YT.js";
81
90
  import {
82
91
  buildProjectGraph,
83
92
  graphStatus,
84
93
  graphifyPython
85
- } from "./chunk-PGOYDOI4.js";
94
+ } from "./chunk-XEGQT5EN.js";
86
95
  import {
87
96
  sessionBase
88
- } from "./chunk-SSDLHWSF.js";
89
- import "./chunk-B67BK5GQ.js";
97
+ } from "./chunk-6W4UH2BQ.js";
90
98
 
91
99
  // src/cli.ts
92
100
  import { execFileSync } from "child_process";
93
- import { readFileSync as readFileSync3, existsSync as existsSync3, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3, realpathSync } from "fs";
94
- import { join as join8, dirname as dirname4 } from "path";
101
+ import { readFileSync as readFileSync5, existsSync as existsSync3, writeFileSync as writeFileSync4, mkdirSync as mkdirSync5, realpathSync, rmSync } from "fs";
102
+ import { join as join11, dirname as dirname5 } from "path";
95
103
  import { pathToFileURL } from "url";
96
104
 
105
+ // src/agents/cli-accounts.ts
106
+ import { readFileSync, writeFileSync, mkdirSync } from "fs";
107
+ import { dirname, join } from "path";
108
+ var SPENT = 0.95;
109
+ function readingKey(kind, name) {
110
+ return `${kind}:${name}`;
111
+ }
112
+ function fileUsageStore(home) {
113
+ const path = join(home, ".horsecode", "usage.json");
114
+ return {
115
+ load() {
116
+ try {
117
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
118
+ if (typeof parsed !== "object" || parsed === null) return {};
119
+ const out = {};
120
+ for (const [k, v] of Object.entries(parsed)) {
121
+ const r = v;
122
+ if (typeof r?.spent === "number" && typeof r?.at === "number") out[k] = { spent: r.spent, at: r.at };
123
+ }
124
+ return out;
125
+ } catch {
126
+ return {};
127
+ }
128
+ },
129
+ save(readings) {
130
+ try {
131
+ mkdirSync(dirname(path), { recursive: true });
132
+ writeFileSync(path, JSON.stringify(readings, null, 2) + "\n");
133
+ } catch {
134
+ }
135
+ }
136
+ };
137
+ }
138
+ var AccountPool = class {
139
+ accounts;
140
+ store;
141
+ readings;
142
+ constructor(accounts = [], store) {
143
+ this.accounts = accounts;
144
+ this.store = store;
145
+ this.readings = store?.load() ?? {};
146
+ }
147
+ /**
148
+ * The profile a call of this kind should run under, or undefined to run under the ambient login.
149
+ *
150
+ * Round-robin over the accounts that still have room, so two subscriptions drain together rather than one
151
+ * after the other. A spent one drops out of the rotation and rejoins the moment a reading says it can
152
+ * serve again.
153
+ */
154
+ pick(kind) {
155
+ const mine = this.accounts.filter((a) => a.kind === kind);
156
+ if (!mine.length) return void 0;
157
+ const live = mine.filter((a) => (this.readings[readingKey(kind, a.name)]?.spent ?? 0) < SPENT);
158
+ const pool = live.length ? live : mine;
159
+ const n = this.turn.get(kind) ?? 0;
160
+ this.turn.set(kind, n + 1);
161
+ return pool[n % pool.length];
162
+ }
163
+ /** Whose turn it is, per kind. Turns are per SOURCE: Claude's rotation says nothing about Codex's. */
164
+ turn = /* @__PURE__ */ new Map();
165
+ /**
166
+ * File a quota reading against a profile.
167
+ *
168
+ * The windows are taken at their worst: a seven-day limit that is nearly gone stops the profile just as
169
+ * surely as a five-hour one, and reading only the shorter window would keep sending calls to a
170
+ * subscription whose longer window ran out days ago.
171
+ */
172
+ record(kind, name, windows, now = Date.now()) {
173
+ const spent = Math.max(0, ...Object.values(windows));
174
+ this.readings = { ...this.readings, [readingKey(kind, name)]: { spent, at: now } };
175
+ this.store?.save(this.readings);
176
+ }
177
+ /** Every configured profile with its last reading, for a startup summary or a status line. */
178
+ usage() {
179
+ return this.accounts.map((a) => {
180
+ const r = this.readings[readingKey(a.kind, a.name)];
181
+ return { account: a, ...r ? { reading: r } : {} };
182
+ });
183
+ }
184
+ /** How many profiles are configured, in total or for one CLI. */
185
+ count(kind) {
186
+ return kind ? this.accounts.filter((a) => a.kind === kind).length : this.accounts.length;
187
+ }
188
+ };
189
+ function modelsPanel(usage2, models, kinds, inUse = [], now = Date.now()) {
190
+ const used = new Set(inUse);
191
+ const rows = [];
192
+ for (const kind of kinds) {
193
+ const mine = usage2.filter((u) => u.account.kind === kind);
194
+ const who = mine.length ? mine.map(({ account, reading }) => {
195
+ const name = account.email ?? account.plan ?? account.name;
196
+ return `${name}${reading ? ` \xB7 ${Math.round(reading.spent * 100)}% used ${ageOf(reading.at, now)} ago` : ""}`;
197
+ }).join(" \xB7 ") : "_not connected \u2014 these will fail every call_";
198
+ rows.push(`**${kind}** \u2014 ${who}`);
199
+ rows.push(` ${models(kind).map((m) => `${used.has(m) ? "\u25CF" : "\xB7"} ${m}`).join(" ")}`);
200
+ }
201
+ const idle = kinds.filter((k) => usage2.some((u) => u.account.kind === k)).filter((k) => !models(k).some((m) => used.has(m)));
202
+ const note = idle.length ? `
203
+
204
+ _\u25CF = a role is running it. ${idle.join(" and ")} ${idle.length > 1 ? "are" : "is"} connected but in no chain \u2014 \`/roles adjust\` assigns them._` : "\n\n_\u25CF = a role is running it._";
205
+ return `**Models** \u2014 what your connected subscriptions serve
206
+
207
+ ${rows.join("\n")}${note}`;
208
+ }
209
+ function ageOf(at, now) {
210
+ const mins = Math.max(0, Math.round((now - at) / 6e4));
211
+ if (mins < 60) return `${mins}m`;
212
+ const hours = Math.round(mins / 60);
213
+ return hours < 48 ? `${hours}h` : `${Math.round(hours / 24)}d`;
214
+ }
215
+ function accountsLine(usage2, notSignedIn = [], now = Date.now()) {
216
+ const parts = usage2.map(({ account, reading }) => {
217
+ const who = `${account.email ?? account.plan ?? account.name}${account.email && account.plan ? ` (${account.plan})` : ""}`;
218
+ const left = reading ? ` ${Math.round(reading.spent * 100)}% used ${ageOf(reading.at, now)} ago` : "";
219
+ return `${account.kind} ${who}${left}`;
220
+ });
221
+ for (const kind of notSignedIn) parts.push(`${kind} NOT signed in \u2014 every ${kind} call will fail`);
222
+ return parts.length ? parts.join(" \xB7 ") : void 0;
223
+ }
224
+
225
+ // src/agents/add-provider.ts
226
+ import { join as join2 } from "path";
227
+ function freshDir(kind, home, taken) {
228
+ const used = new Set(taken.map((a) => a.configDir));
229
+ for (let n = 2; ; n++) {
230
+ const dir = join2(home, ".horsecode", "profiles", `${kind}-${n}`);
231
+ if (!used.has(dir)) return dir;
232
+ }
233
+ }
234
+ function nameFor(kind, status, dir) {
235
+ const base = dir.split("/").pop() ?? "profile";
236
+ return status.email ?? (base.startsWith(`${kind}-`) ? base : `${kind}-${base}`);
237
+ }
238
+ function withAmbient(kind, existing, ambient) {
239
+ if (kind === "zai") return [...existing];
240
+ if (existing.some((a) => a.kind === kind)) return [...existing];
241
+ if (!ambient.loggedIn) return [...existing];
242
+ return [
243
+ ...existing,
244
+ {
245
+ kind,
246
+ name: ambient.email ?? `${kind}-default`,
247
+ ...ambient.email ? { email: ambient.email } : {},
248
+ ...ambient.plan ? { plan: ambient.plan } : {}
249
+ }
250
+ ];
251
+ }
252
+ function accountsIn(config) {
253
+ const raw = config.accounts;
254
+ if (!Array.isArray(raw)) return [];
255
+ return raw.filter((a) => {
256
+ const e = a;
257
+ if (typeof a !== "object" || a === null) return false;
258
+ if (!CLI_KINDS.includes(e.kind)) return false;
259
+ return e.configDir === void 0 || typeof e.configDir === "string";
260
+ });
261
+ }
262
+ async function addProvider(kind, io) {
263
+ const config = io.readConfig();
264
+ const existing = accountsIn(config);
265
+ const accounts = withAmbient(kind, existing, io.check(kind));
266
+ if (accounts.length > existing.length) {
267
+ const a = accounts.at(-1);
268
+ io.log(`Recorded the ${kind} account you are already signed in as: ${a.email ?? a.name}${a.plan ? ` (${a.plan})` : ""}`);
269
+ io.log(`It stays first in line, so runs keep spending it before anything added now.`);
270
+ }
271
+ const dir = freshDir(kind, io.home, accounts);
272
+ if (kind === "zai") {
273
+ io.log(`
274
+ z.ai has no sign-in \u2014 an account is an API key, and the profile at ${dir} will hold it.`);
275
+ io.log(`Take the key from your z.ai dashboard; it is written to that profile alone and nowhere else.
276
+ `);
277
+ } else {
278
+ io.log(`
279
+ Opening ${kind}'s own sign-in, for a NEW profile at ${dir}.`);
280
+ io.log(`Sign in with the account you want to add \u2014 not the one already connected.
281
+ `);
282
+ }
283
+ const r = await io.login(kind, dir);
284
+ if (!r.ok) {
285
+ if (kind === "zai") {
286
+ io.log(`
287
+ z.ai did not accept that key${r.error ? `: ${r.error}` : ""}. Nothing was changed.`);
288
+ io.log(`The key is checked by making one real call, because \`claude auth status\` reports any token as connected \u2014 valid or not.`);
289
+ io.log(`Nothing needs cleaning up: running this again reuses ${dir}.`);
290
+ return 1;
291
+ }
292
+ io.log(`
293
+ ${kind} sign-in did not complete${r.error ? `: ${r.error}` : ""}. Nothing was changed.`);
294
+ io.log(`If a browser page opened, its address holds the reason \u2014 an \`error=\` there is the account being refused, not this command failing.`);
295
+ io.log(`Nothing needs cleaning up: ${dir} holds no session, and running this again reuses it.`);
296
+ return 1;
297
+ }
298
+ const status = io.check(kind, dir);
299
+ if (!status.loggedIn) {
300
+ io.log(`
301
+ ${kind} still reports no session in ${dir}. Nothing was changed \u2014 run this again to retry.`);
302
+ return 1;
303
+ }
304
+ const already = status.email ? accounts.find((a) => a.kind === kind && a.email === status.email) : void 0;
305
+ if (already) {
306
+ io.log(`
307
+ That is the account already connected as "${already.name}". Nothing was changed.`);
308
+ return 1;
309
+ }
310
+ const entry = {
311
+ kind,
312
+ name: nameFor(kind, status, dir),
313
+ configDir: dir,
314
+ ...status.email ? { email: status.email } : {},
315
+ ...status.plan ? { plan: status.plan } : {}
316
+ };
317
+ io.writeConfig({ ...config, accounts: [...accounts, entry] });
318
+ const mine = [...accounts, entry].filter((a) => a.kind === kind);
319
+ io.log(`
320
+ Connected ${entry.email ?? entry.name}${entry.plan ? ` (${entry.plan})` : ""}.`);
321
+ io.log(`${mine.length} ${kind} account${mine.length === 1 ? "" : "s"} now connected. Runs take turns between them.`);
322
+ if (kind === "claude") io.log(`One nearly out of quota steps aside for the others.`);
323
+ else io.log(`${kind} reports no quota figures, so a spent account is discovered by being refused, not before.`);
324
+ if (kind === "zai" && mine.length > 1) {
325
+ io.log(`z.ai reports no account identity, so this cannot be checked: if that key is one already connected, remove the duplicate from ~/.horsecode/config.json.`);
326
+ }
327
+ return 0;
328
+ }
329
+
330
+ // src/agents/zai-profile.ts
331
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
332
+ import { join as join3 } from "path";
333
+ var ZAI_BASE_URL = "https://api.z.ai/api/anthropic";
334
+ function zaiSettings(token) {
335
+ return { env: { ANTHROPIC_BASE_URL: ZAI_BASE_URL, ANTHROPIC_AUTH_TOKEN: token } };
336
+ }
337
+ function settingsPath(dir) {
338
+ return join3(dir, "settings.json");
339
+ }
340
+ function writeZaiProfile(dir, token) {
341
+ mkdirSync2(dir, { recursive: true, mode: 448 });
342
+ writeFileSync2(settingsPath(dir), JSON.stringify(zaiSettings(token), null, 2) + "\n", { mode: 384 });
343
+ }
344
+ async function verifyZaiKey(token, model, timeoutMs = 3e4) {
345
+ const ctl = new AbortController();
346
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
347
+ try {
348
+ const res = await fetch(`${ZAI_BASE_URL}/v1/messages`, {
349
+ method: "POST",
350
+ signal: ctl.signal,
351
+ headers: {
352
+ "content-type": "application/json",
353
+ // Both spellings: z.ai accepts the bearer form, and `x-api-key` is what the Anthropic wire format
354
+ // names. Sending each costs nothing and removes a guess about which one this endpoint reads.
355
+ authorization: `Bearer ${token}`,
356
+ "x-api-key": token,
357
+ "anthropic-version": "2023-06-01"
358
+ },
359
+ body: JSON.stringify({ model, max_tokens: 16, messages: [{ role: "user", content: "Reply with: ok" }] })
360
+ });
361
+ const body = await res.json().catch(() => void 0);
362
+ if (!res.ok) return { ok: false, error: zaiErrorText(body) ?? `the endpoint answered HTTP ${res.status}` };
363
+ const served = body?.model;
364
+ return { ok: true, ...served ? { served } : {} };
365
+ } catch (e) {
366
+ const aborted = e instanceof Error && e.name === "AbortError";
367
+ return {
368
+ ok: false,
369
+ error: aborted ? `${ZAI_BASE_URL} did not answer within ${Math.round(timeoutMs / 1e3)}s` : e instanceof Error ? e.message : String(e)
370
+ };
371
+ } finally {
372
+ clearTimeout(timer);
373
+ }
374
+ }
375
+ function zaiErrorText(body) {
376
+ const msg = body?.error?.message;
377
+ return typeof msg === "string" && msg.trim() ? msg.trim() : void 0;
378
+ }
379
+
380
+ // src/agents/prompt.ts
381
+ import { spawnSync } from "child_process";
382
+ import { closeSync, openSync, readSync } from "fs";
383
+ function promptLine() {
384
+ return readTty(false);
385
+ }
386
+ function promptSecret() {
387
+ return readTty(true);
388
+ }
389
+ function confirm(question) {
390
+ process.stdout.write(`${question} [y/N] `);
391
+ const answer = promptLine();
392
+ process.stdout.write("\n");
393
+ return /^y(es)?$/i.test((answer ?? "").trim());
394
+ }
395
+ function chooseFrom(question, options) {
396
+ process.stdout.write(`${question}
397
+ `);
398
+ options.forEach((o, i) => process.stdout.write(` ${i + 1}) ${o}
399
+ `));
400
+ process.stdout.write(`Number (anything else cancels): `);
401
+ const line = promptLine();
402
+ process.stdout.write("\n");
403
+ const n = Number((line ?? "").trim());
404
+ return Number.isInteger(n) && n >= 1 && n <= options.length ? n - 1 : void 0;
405
+ }
406
+ function readTty(hidden) {
407
+ let fd;
408
+ try {
409
+ fd = openSync("/dev/tty", "r");
410
+ } catch {
411
+ return void 0;
412
+ }
413
+ const hushed = hidden && spawnSync("stty", ["-echo"], { stdio: ["inherit", "ignore", "ignore"] }).status === 0;
414
+ try {
415
+ const buf = Buffer.alloc(1);
416
+ let out = "";
417
+ for (; ; ) {
418
+ let n = 0;
419
+ try {
420
+ n = readSync(fd, buf, 0, 1, null);
421
+ } catch {
422
+ break;
423
+ }
424
+ if (n === 0) break;
425
+ const ch = buf.toString("utf8");
426
+ if (ch === "\n" || ch === "\r") break;
427
+ out += ch;
428
+ }
429
+ return out.trim() || void 0;
430
+ } finally {
431
+ if (hushed) spawnSync("stty", ["echo"], { stdio: ["inherit", "ignore", "ignore"] });
432
+ closeSync(fd);
433
+ }
434
+ }
435
+
436
+ // src/agents/remove-provider.ts
437
+ function ownedProfile(home, dir) {
438
+ if (!dir) return false;
439
+ const root = `${home}/.horsecode/profiles/`;
440
+ return dir.startsWith(root) && dir.length > root.length && !dir.slice(root.length).includes("..");
441
+ }
442
+ function describeAccount(a) {
443
+ const who = a.email ?? a.name;
444
+ return `${a.kind} ${who}${a.plan ? ` (${a.plan})` : ""}${a.configDir ? "" : " \u2014 the signed-in default"}`;
445
+ }
446
+ function pickAccount(accounts, kind, name) {
447
+ const mine = accounts.filter((a) => a.kind === kind);
448
+ if (!mine.length) return { error: `no ${kind} account is connected` };
449
+ if (!name) return mine.length === 1 ? { account: mine[0] } : { choices: mine };
450
+ const found = mine.filter((a) => a.name === name || a.email === name);
451
+ if (!found.length) {
452
+ return {
453
+ error: `no ${kind} account called "${name}". Connected:
454
+ ` + mine.map((a) => ` ${a.name}${a.email && a.email !== a.name ? ` (${a.email})` : ""}`).join("\n")
455
+ };
456
+ }
457
+ if (found.length > 1) return { error: `"${name}" matches ${found.length} ${kind} accounts \u2014 remove one by its exact name` };
458
+ return { account: found[0] };
459
+ }
460
+ function removeProvider(kind, name, io) {
461
+ const config = io.readConfig();
462
+ const accounts = accountsIn(config);
463
+ const picked = pickAccount(accounts, kind, name);
464
+ if ("error" in picked) {
465
+ io.log(picked.error);
466
+ const others = accounts.filter((a) => a.kind !== kind);
467
+ if (!accounts.filter((a) => a.kind === kind).length && others.length) {
468
+ io.log(`
469
+ Connected accounts:
470
+ ${others.map((a) => ` ${describeAccount(a)}`).join("\n")}`);
471
+ }
472
+ return { code: 1 };
473
+ }
474
+ let account;
475
+ if ("account" in picked) {
476
+ account = picked.account;
477
+ } else {
478
+ const chosen = io.choose(`Which ${kind} account should be removed?`, picked.choices.map(describeAccount));
479
+ if (chosen === void 0) {
480
+ io.log("Nothing was removed.");
481
+ return { code: 1 };
482
+ }
483
+ account = picked.choices[chosen];
484
+ }
485
+ const kept = accounts.filter((a) => a !== account);
486
+ io.writeConfig({ ...config, accounts: kept });
487
+ if (io.usage) {
488
+ const readings = io.usage.load();
489
+ const { [readingKey(kind, account.name)]: _dropped, ...rest } = readings;
490
+ io.usage.save(rest);
491
+ }
492
+ io.log(`Removed ${describeAccount(account)}.`);
493
+ if (!account.configDir) {
494
+ io.log(`That was the account you are signed into ${kind} with, not a profile horse-code created.`);
495
+ io.log(`It is still signed in, and a run will find it again. To stop using it, sign out of ${kind} itself.`);
496
+ return { code: 0, removed: account };
497
+ }
498
+ if (!ownedProfile(io.home, account.configDir)) {
499
+ io.log(`Its profile at ${account.configDir} was not created by horse-code, so it is left alone.`);
500
+ return { code: 0, removed: account };
501
+ }
502
+ const holds = kind === "zai" ? "the API key" : `the ${kind} sign-in`;
503
+ if (!io.confirm(`Also delete ${account.configDir}, which holds ${holds}?`)) {
504
+ io.log(`Left ${account.configDir} in place. Delete it by hand to remove ${holds}.`);
505
+ return { code: 0, removed: account };
506
+ }
507
+ io.removeDir(account.configDir);
508
+ io.log(`Deleted ${account.configDir}.`);
509
+ return { code: 0, removed: account };
510
+ }
511
+ function removeUsage(given) {
512
+ return `remove-provider needs a CLI to disconnect: ${CLI_KINDS.map((k) => `\`hcode remove-provider ${k}\``).join(", ")}${given ? ` (got "${given}")` : ""}`;
513
+ }
514
+
97
515
  // src/skills/registry.ts
98
516
  import { readdir, readFile } from "fs/promises";
99
- import { join } from "path";
517
+ import { join as join4 } from "path";
100
518
  var SkillRegistry = class {
101
519
  skills = /* @__PURE__ */ new Map();
102
520
  register(skill) {
@@ -119,7 +537,7 @@ var SkillRegistry = class {
119
537
  if (!e.isDirectory()) continue;
120
538
  let raw;
121
539
  try {
122
- raw = await readFile(join(dir, e.name, "SKILL.md"), "utf8");
540
+ raw = await readFile(join4(dir, e.name, "SKILL.md"), "utf8");
123
541
  } catch {
124
542
  continue;
125
543
  }
@@ -127,17 +545,17 @@ var SkillRegistry = class {
127
545
  if (!name || !description) {
128
546
  throw new Error(`skill ${e.name}: missing frontmatter (name/description)`);
129
547
  }
130
- this.register({ name, description, content: body, dir: join(dir, e.name) });
548
+ this.register({ name, description, content: body, dir: join4(dir, e.name) });
131
549
  }
132
550
  }
133
551
  };
134
552
 
135
553
  // src/skills/builtin.ts
136
554
  import { readdir as readdir2, readFile as readFile2 } from "fs/promises";
137
- import { dirname, join as join2, resolve } from "path";
555
+ import { dirname as dirname2, join as join5, resolve } from "path";
138
556
  import { fileURLToPath } from "url";
139
557
  function candidates() {
140
- const here = dirname(fileURLToPath(import.meta.url));
558
+ const here = dirname2(fileURLToPath(import.meta.url));
141
559
  return [
142
560
  resolve(here, "..", "skills"),
143
561
  // bundled: dist/… → <repo>/skills
@@ -155,14 +573,14 @@ async function registerBuiltinSkills(registry, dirs = candidates()) {
155
573
  if (!e.isDirectory()) continue;
156
574
  let raw;
157
575
  try {
158
- raw = await readFile2(join2(dir, e.name, "SKILL.md"), "utf8");
576
+ raw = await readFile2(join5(dir, e.name, "SKILL.md"), "utf8");
159
577
  } catch {
160
578
  continue;
161
579
  }
162
580
  const { parseFrontmatter: parseFrontmatter2 } = await import("./frontmatter-UNIPNLLO.js");
163
581
  const { name, description, body } = parseFrontmatter2(raw);
164
582
  if (!name || !description) continue;
165
- found.push({ name, description, content: body, dir: join2(dir, e.name) });
583
+ found.push({ name, description, content: body, dir: join5(dir, e.name) });
166
584
  }
167
585
  if (!found.length) continue;
168
586
  for (const s of found) registry.register(s);
@@ -177,12 +595,12 @@ async function registerBuiltinSkills(registry, dirs = candidates()) {
177
595
  // src/skills/external.ts
178
596
  import { mkdir, rm, readFile as readFile3, writeFile, readdir as readdir3, cp } from "fs/promises";
179
597
  import { existsSync } from "fs";
180
- import { join as join3 } from "path";
598
+ import { join as join6 } from "path";
181
599
  import { spawn } from "child_process";
182
600
  import { tmpdir } from "os";
183
601
  var PROVENANCE = ".horsecode-source.json";
184
602
  function externalSkillsDir(home) {
185
- return join3(home, ".horsecode", "skills");
603
+ return join6(home, ".horsecode", "skills");
186
604
  }
187
605
  function validName(name) {
188
606
  return /^[A-Za-z0-9._-]+$/.test(name) && !name.includes("..");
@@ -226,7 +644,7 @@ async function resolveSha(src, deps) {
226
644
  }
227
645
  async function installedSource(home, name) {
228
646
  try {
229
- return JSON.parse(await readFile3(join3(externalSkillsDir(home), name, PROVENANCE), "utf8"));
647
+ return JSON.parse(await readFile3(join6(externalSkillsDir(home), name, PROVENANCE), "utf8"));
230
648
  } catch {
231
649
  return void 0;
232
650
  }
@@ -236,27 +654,27 @@ async function installSkillSource(home, src, deps = {}) {
236
654
  if (!validRepo(src.repo)) throw new Error(`skill source repo must be "owner/repo": ${src.repo}`);
237
655
  if (src.path?.includes("..")) throw new Error(`skill source path may not contain "..": ${src.path}`);
238
656
  const sha = await resolveSha(src, deps);
239
- const dest = join3(externalSkillsDir(home), src.name);
657
+ const dest = join6(externalSkillsDir(home), src.name);
240
658
  const current = await installedSource(home, src.name);
241
- if (current?.sha === sha && existsSync(join3(dest, "SKILL.md"))) {
659
+ if (current?.sha === sha && existsSync(join6(dest, "SKILL.md"))) {
242
660
  return { name: src.name, sha, changed: false };
243
661
  }
244
- const staging = join3(tmpdir(), `hc-skill-${src.name}-${sha.slice(0, 8)}`);
662
+ const staging = join6(tmpdir(), `hc-skill-${src.name}-${sha.slice(0, 8)}`);
245
663
  await rm(staging, { recursive: true, force: true });
246
664
  await mkdir(staging, { recursive: true });
247
665
  try {
248
666
  const url = `https://codeload.github.com/${src.repo}/tar.gz/${sha}`;
249
667
  const dl = await run("sh", ["-c", `curl -fsSL --max-time 120 ${JSON.stringify(url)} | tar xz -C ${JSON.stringify(staging)} --strip-components=1`]);
250
668
  if (dl.code !== 0) throw new Error(`download failed for ${src.repo}@${sha.slice(0, 8)}: ${dl.err.trim().slice(0, 200)}`);
251
- const from = src.path ? join3(staging, src.path) : staging;
252
- if (!existsSync(join3(from, "SKILL.md"))) {
669
+ const from = src.path ? join6(staging, src.path) : staging;
670
+ if (!existsSync(join6(from, "SKILL.md"))) {
253
671
  throw new Error(`${src.repo}${src.path ? `/${src.path}` : ""} has no SKILL.md`);
254
672
  }
255
673
  await rm(dest, { recursive: true, force: true });
256
674
  await mkdir(dest, { recursive: true });
257
675
  await cp(from, dest, { recursive: true });
258
676
  const record = { ...src, sha, installedAt: (deps.now ?? Date.now)() };
259
- await writeFile(join3(dest, PROVENANCE), `${JSON.stringify(record, null, 2)}
677
+ await writeFile(join6(dest, PROVENANCE), `${JSON.stringify(record, null, 2)}
260
678
  `, "utf8");
261
679
  return { name: src.name, sha, changed: true };
262
680
  } finally {
@@ -546,8 +964,8 @@ var ProposalQueue = class {
546
964
  };
547
965
 
548
966
  // src/speckit/templates.ts
549
- import { existsSync as existsSync2, mkdirSync, readFileSync, writeFileSync } from "fs";
550
- import { join as join4, dirname as dirname2 } from "path";
967
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
968
+ import { join as join7, dirname as dirname3 } from "path";
551
969
  var TEMPLATE_FILES = {
552
970
  spec: "spec-template.md",
553
971
  plan: "plan-template.md",
@@ -570,10 +988,10 @@ async function loadSpecKit(opts) {
570
988
  );
571
989
  }
572
990
  const fetchFn = opts.fetch ?? globalThis.fetch;
573
- const cacheDir = join4(opts.home, ".horsecode", "spec-kit", opts.version, "templates");
991
+ const cacheDir = join7(opts.home, ".horsecode", "spec-kit", opts.version, "templates");
574
992
  const get = async (relPath) => {
575
- const cachePath = join4(cacheDir, relPath);
576
- if (existsSync2(cachePath)) return readFileSync(cachePath, "utf8");
993
+ const cachePath = join7(cacheDir, relPath);
994
+ if (existsSync2(cachePath)) return readFileSync3(cachePath, "utf8");
577
995
  const url = `${RAW_BASE}/${opts.version}/templates/${relPath}`;
578
996
  const res = await fetchFn(url);
579
997
  if (!res.ok) {
@@ -583,8 +1001,8 @@ Check your network or set specKit.version to a valid tag.`
583
1001
  );
584
1002
  }
585
1003
  const text = await res.text();
586
- mkdirSync(dirname2(cachePath), { recursive: true });
587
- writeFileSync(cachePath, text, "utf8");
1004
+ mkdirSync3(dirname3(cachePath), { recursive: true });
1005
+ writeFileSync3(cachePath, text, "utf8");
588
1006
  return text;
589
1007
  };
590
1008
  const templates = {};
@@ -600,7 +1018,7 @@ Check your network or set specKit.version to a valid tag.`
600
1018
 
601
1019
  // src/wiring.ts
602
1020
  import { homedir } from "os";
603
- import { join as join5 } from "path";
1021
+ import { join as join8 } from "path";
604
1022
  async function buildJobDeps(opts) {
605
1023
  const { config } = opts;
606
1024
  const roles = {};
@@ -612,6 +1030,15 @@ async function buildJobDeps(opts) {
612
1030
  roles[name] = { ...cfg ?? { models: sessionChain }, ...skills.length ? { skills } : { skills: [] } };
613
1031
  }
614
1032
  const roleRegistry = new RoleRegistry(roles, DEFAULT_PROMPTS, opts.skillRegistry);
1033
+ if (opts.accounts) {
1034
+ const pool = opts.accounts;
1035
+ roleRegistry.setSourceWeights(() => ({
1036
+ claude: pool.count("claude"),
1037
+ codex: pool.count("codex"),
1038
+ grok: pool.count("grok"),
1039
+ zai: pool.count("zai")
1040
+ }));
1041
+ }
615
1042
  const fillModels = (r) => ({
616
1043
  ...r,
617
1044
  models: r.models.length > 0 ? r.models : config.roles[r.name]?.models ?? sessionChain
@@ -637,10 +1064,27 @@ async function buildJobDeps(opts) {
637
1064
  const queue = new ProposalQueue();
638
1065
  let kitPromise;
639
1066
  const specKit = () => kitPromise ??= loadSpecKit({ version: config.specKit.version, home: opts.home, fetch: opts.fetch });
640
- const fitness = new RoleFitness(join5(opts.home ?? homedir(), ".horsecode", "model-fitness.json"));
1067
+ const fitness = new RoleFitness(join8(opts.home ?? homedir(), ".horsecode", "model-fitness.json"));
641
1068
  roleRegistry.setFitness(fitness);
642
1069
  return {
643
1070
  provider: opts.provider,
1071
+ /**
1072
+ * Implementers run as the CLI, because with a CLI provider nothing else can write.
1073
+ *
1074
+ * A CLI answers with TEXT. It never emits a tool call — it ran its tools itself, in its own process — so
1075
+ * an implementer driven through this process's loop would receive prose, execute nothing, and report
1076
+ * `no_changes` for every task on the board. This is not a choice between two working paths.
1077
+ *
1078
+ * It belongs HERE and not inside `runJob`, because it is a property of the provider that was chosen. Set
1079
+ * in the pipeline it would override every caller that injected one: the job tests hand in a mock and
1080
+ * would have spawned the real binary instead, which is exactly how it was first written and exactly how
1081
+ * the tests caught it.
1082
+ *
1083
+ * `claude` is the fallback, not the decision — `cliFor` reads each role's own chain head, so a role on a
1084
+ * `gpt-*` model runs Codex.
1085
+ */
1086
+ ...opts.delegateTo === null ? {} : { delegateTo: opts.delegateTo ?? "claude" },
1087
+ ...opts.accounts ? { accounts: opts.accounts } : {},
644
1088
  roleRegistry,
645
1089
  fitness,
646
1090
  skillRegistry: opts.skillRegistry,
@@ -665,7 +1109,30 @@ async function buildJobDeps(opts) {
665
1109
  timings: new Timings(),
666
1110
  rounds: 3,
667
1111
  maxParallel: opts.config.maxParallel ?? MAX_PARALLEL_TASKS,
668
- askHuman: opts.askHuman
1112
+ askHuman: opts.askHuman,
1113
+ /**
1114
+ * Cutting a stuck card up, asked of the role that owns breakdowns in the first place.
1115
+ *
1116
+ * Read-only tools: this is a planning question about a card that already exists, and a splitter with a
1117
+ * writer's toolset would be tempted to start doing the work it is describing.
1118
+ */
1119
+ splitCard: async (card) => {
1120
+ const resolved = roleRegistry.resolve("project-manager");
1121
+ return proposeSplit({
1122
+ provider: opts.provider,
1123
+ ...resolved,
1124
+ tools: new ToolRegistry(),
1125
+ // a planning question about a card that exists; it needs no tools
1126
+ messages: [],
1127
+ permission,
1128
+ approve: opts.approve,
1129
+ cwd: process.cwd(),
1130
+ signal: opts.signal,
1131
+ // It reads a card and answers; anything longer is exploring, which is not what was asked.
1132
+ maxTurns: 3,
1133
+ perAttemptMs: SHORT_CALL_MS
1134
+ }, card);
1135
+ }
669
1136
  };
670
1137
  }
671
1138
 
@@ -717,6 +1184,7 @@ async function* stream(inner, tel, req, signal) {
717
1184
  let promptTokens;
718
1185
  let completionTokens;
719
1186
  let cachedTokens;
1187
+ let cacheWriteTokens;
720
1188
  let finish;
721
1189
  let failure;
722
1190
  let toolCalls = 0;
@@ -727,6 +1195,7 @@ async function* stream(inner, tel, req, signal) {
727
1195
  promptTokens = ev.promptTokens;
728
1196
  completionTokens = ev.completionTokens;
729
1197
  cachedTokens = ev.cachedTokens;
1198
+ cacheWriteTokens = ev.cacheWriteTokens;
730
1199
  } else if (ev.type === "tool-call") toolCalls++;
731
1200
  else if (ev.type === "text-delta") textChars += ev.text.length;
732
1201
  else if (ev.type === "done") finish = ev.finishReason;
@@ -745,6 +1214,14 @@ async function* stream(inner, tel, req, signal) {
745
1214
  "gen_ai.usage.input_tokens": promptTokens,
746
1215
  "gen_ai.usage.output_tokens": completionTokens,
747
1216
  "gen_ai.usage.cached_tokens": cachedTokens,
1217
+ /**
1218
+ * What the cache COST, beside what it saved.
1219
+ *
1220
+ * A read is billed at a fraction and a write at a premium, so a run that reports only reads reads as
1221
+ * pure profit — the first turn of every agent, which fills the cache, would cost nothing on paper.
1222
+ * The true prompt spend is fresh + read + write, and this is the third.
1223
+ */
1224
+ "gen_ai.usage.cache_write_tokens": cacheWriteTokens,
748
1225
  "gen_ai.response.finish_reason": finish,
749
1226
  "hc.duration_ms": Date.now() - started,
750
1227
  "hc.tools_requested": toolCalls,
@@ -779,10 +1256,10 @@ function redactRecord(record) {
779
1256
  }
780
1257
 
781
1258
  // src/obs/sink.ts
782
- import { createWriteStream, mkdirSync as mkdirSync2 } from "fs";
783
- import { join as join6 } from "path";
1259
+ import { createWriteStream, mkdirSync as mkdirSync4 } from "fs";
1260
+ import { join as join9 } from "path";
784
1261
  function telemetryDir(home) {
785
- return join6(home, ".horsecode", "telemetry");
1262
+ return join9(home, ".horsecode", "telemetry");
786
1263
  }
787
1264
  var MAX_QUEUE = 1e4;
788
1265
  var FileSink = class {
@@ -791,9 +1268,9 @@ var FileSink = class {
791
1268
  dropped = 0;
792
1269
  path;
793
1270
  constructor(home, runId) {
794
- this.path = join6(telemetryDir(home), `${runId}.jsonl`);
1271
+ this.path = join9(telemetryDir(home), `${runId}.jsonl`);
795
1272
  try {
796
- mkdirSync2(telemetryDir(home), { recursive: true });
1273
+ mkdirSync4(telemetryDir(home), { recursive: true });
797
1274
  this.stream = createWriteStream(this.path, { flags: "a" });
798
1275
  this.stream.on("error", () => {
799
1276
  this.stream = void 0;
@@ -850,13 +1327,13 @@ var FileSink = class {
850
1327
  };
851
1328
 
852
1329
  // src/cli-info.ts
853
- import { readFileSync as readFileSync2 } from "fs";
854
- import { dirname as dirname3, join as join7 } from "path";
1330
+ import { readFileSync as readFileSync4 } from "fs";
1331
+ import { dirname as dirname4, join as join10 } from "path";
855
1332
  import { fileURLToPath as fileURLToPath2 } from "url";
856
1333
  function hcodeVersion() {
857
1334
  try {
858
- const here = dirname3(fileURLToPath2(import.meta.url));
859
- const pkg = JSON.parse(readFileSync2(join7(here, "..", "package.json"), "utf8"));
1335
+ const here = dirname4(fileURLToPath2(import.meta.url));
1336
+ const pkg = JSON.parse(readFileSync4(join10(here, "..", "package.json"), "utf8"));
860
1337
  return pkg.version ?? "unknown";
861
1338
  } catch {
862
1339
  return "unknown";
@@ -883,6 +1360,9 @@ function usage() {
883
1360
  " hcode start the REPL",
884
1361
  ' hcode "<request>" run one request to completion, then report where the work landed',
885
1362
  " hcode init write .horsecode/ for this project and ask for the essentials",
1363
+ " hcode add-provider <cli> connect one more claude, codex, grok or zai subscription",
1364
+ " hcode remove-provider <cli> [name]",
1365
+ " disconnect one, and optionally delete the sign-in it holds",
886
1366
  "",
887
1367
  "OPTIONS",
888
1368
  " -b, --branch <name> branch the session's worktree from this ref (default: current branch)",
@@ -910,6 +1390,18 @@ function nearestFlag(flag) {
910
1390
 
911
1391
  // src/cli.ts
912
1392
  var HEAP_MB = 12288;
1393
+ async function connectZai(dir) {
1394
+ process.stdout.write("z.ai API key (not shown as you type): ");
1395
+ const token = promptSecret();
1396
+ process.stdout.write("\n");
1397
+ if (!token) return { ok: false, error: "no key was entered" };
1398
+ console.log(`Asking ${ZAI_MODELS[0]} one question, to see whether the key works\u2026`);
1399
+ const v = await verifyZaiKey(token, ZAI_MODELS[0]);
1400
+ if (!v.ok) return { ok: false, error: v.error };
1401
+ writeZaiProfile(dir, token);
1402
+ console.log(` answered by ${v.served ?? "the endpoint (which named no model)"}.`);
1403
+ return { ok: true };
1404
+ }
913
1405
  function parseArgs(argv) {
914
1406
  let fromBranch;
915
1407
  let jobName;
@@ -945,7 +1437,7 @@ function parseArgs(argv) {
945
1437
  };
946
1438
  }
947
1439
  function whereItLanded(path) {
948
- const base = sessionBase(dirname4(path));
1440
+ const base = sessionBase(dirname5(path));
949
1441
  if (base === void 0) {
950
1442
  return "Written directly in your working tree: no branch, no worktree, nothing to merge. Review it and commit it when you are happy with it.";
951
1443
  }
@@ -971,18 +1463,12 @@ function describeDelivery(d) {
971
1463
  `Or inspect it first: \`git diff HEAD...${d.branch}\` \xB7 worktree: \`${d.worktree}\``
972
1464
  ].filter((l) => l !== void 0).join("\n");
973
1465
  }
974
- var DELIVERED_SHARE = 0.5;
975
1466
  function describeOutcome(w) {
976
1467
  if (w.status === "completed") return w.pr ? `PR: ${w.pr.url}` : "all tasks merged";
977
- const stuck = w.failed.length + w.skipped.length;
978
- const total = w.waves.flat().length || stuck;
979
- const merged = Math.max(0, total - stuck);
980
- const parts = [
981
- w.failed.length ? `${w.failed.length} failed` : "",
982
- w.skipped.length ? `${w.skipped.length} blocked behind them` : ""
983
- ].filter(Boolean).join(", ");
984
- const head = `${merged} of ${total} tasks merged`;
985
- return merged / total < DELIVERED_SHARE ? `\u26A0\uFE0F ${head} \u2014 ${parts}. Most of the plan did not land; the feature is not built.` : `${head} \u2014 ${parts}.`;
1468
+ const failed = w.failed.length;
1469
+ const blocked = w.skipped.length;
1470
+ const total = w.waves.flat().length || failed + blocked;
1471
+ return describeTally({ merged: Math.max(0, total - failed - blocked), failed, blocked, unfinished: 0 });
986
1472
  }
987
1473
  function describeTraceFailures(failed) {
988
1474
  if (!failed.length) return "";
@@ -1001,6 +1487,13 @@ ${shown.join("\n")}`;
1001
1487
  return `\u26A0\uFE0F ${failed.length} failed:
1002
1488
  ${rows.join("\n")}`;
1003
1489
  }
1490
+ async function poolWithConfigured(catalog, configured, probe) {
1491
+ const known = new Set(catalog);
1492
+ const orphaned = configured.filter((m) => !known.has(m));
1493
+ if (!orphaned.length) return catalog;
1494
+ const alive = await Promise.all(orphaned.map(async (m) => await probe(m) ? m : void 0));
1495
+ return [...catalog, ...alive.filter((m) => m !== void 0)];
1496
+ }
1004
1497
  function renderResult(res) {
1005
1498
  if (res.kind === "chat") return stripThinking(res.response);
1006
1499
  if (res.kind === "rejected") return `Not approved (stopped at the ${res.stage} stage).`;
@@ -1067,14 +1560,14 @@ async function main(argv) {
1067
1560
  read: read2,
1068
1561
  readFile: (p) => {
1069
1562
  try {
1070
- return readFileSync3(p, "utf8");
1563
+ return readFileSync5(p, "utf8");
1071
1564
  } catch {
1072
1565
  return void 0;
1073
1566
  }
1074
1567
  },
1075
1568
  writeFile: (p, c) => {
1076
- mkdirSync3(dirname4(p), { recursive: true });
1077
- writeFileSync2(p, c);
1569
+ mkdirSync5(dirname5(p), { recursive: true });
1570
+ writeFileSync4(p, c);
1078
1571
  },
1079
1572
  home: process.env.HOME ?? "",
1080
1573
  log: (s) => console.log(s)
@@ -1084,6 +1577,76 @@ async function main(argv) {
1084
1577
  }
1085
1578
  return;
1086
1579
  }
1580
+ if (argv[0] === "add-provider") {
1581
+ const kind = argv[1];
1582
+ if (!kind || !CLI_KINDS.includes(kind)) {
1583
+ console.error(`add-provider needs a CLI to connect: ${CLI_KINDS.map((k) => `\`hcode add-provider ${k}\``).join(", ")}${argv[1] ? ` (got "${argv[1]}")` : ""}`);
1584
+ process.exitCode = 1;
1585
+ return;
1586
+ }
1587
+ const path = `${process.env.HOME ?? ""}/.horsecode/config.json`;
1588
+ process.exitCode = await addProvider(kind, {
1589
+ home: process.env.HOME ?? "",
1590
+ readConfig: () => {
1591
+ try {
1592
+ const parsed = JSON.parse(readFileSync5(path, "utf8"));
1593
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
1594
+ } catch {
1595
+ return {};
1596
+ }
1597
+ },
1598
+ writeConfig: (c) => {
1599
+ mkdirSync5(dirname5(path), { recursive: true });
1600
+ writeFileSync4(path, JSON.stringify(c, null, 2) + "\n");
1601
+ },
1602
+ /**
1603
+ * "Make this profile real" means two different things, and z.ai is the one that is not a sign-in.
1604
+ *
1605
+ * The other three hand the terminal to their own OAuth. z.ai has none — a profile is a settings file
1606
+ * holding an endpoint and a key — so here the step is: ask for the key, write it, and then SPEND one
1607
+ * call proving it works. That last part is not ceremony: `claude auth status` reports any token as
1608
+ * connected, valid or not, so without a real call a mistyped key would be filed as a working account
1609
+ * and fail every call of the next run.
1610
+ */
1611
+ login: (k, dir) => k === "zai" ? connectZai(dir ?? "") : runLogin(k, dir),
1612
+ check: checkProfile,
1613
+ log: (line) => console.log(line)
1614
+ });
1615
+ return;
1616
+ }
1617
+ if (argv[0] === "remove-provider") {
1618
+ const kind = argv[1];
1619
+ if (!kind || !CLI_KINDS.includes(kind)) {
1620
+ console.error(removeUsage(argv[1]));
1621
+ process.exitCode = 1;
1622
+ return;
1623
+ }
1624
+ const path = `${process.env.HOME ?? ""}/.horsecode/config.json`;
1625
+ const home2 = process.env.HOME ?? "";
1626
+ process.exitCode = removeProvider(kind, argv[2], {
1627
+ home: home2,
1628
+ readConfig: () => {
1629
+ try {
1630
+ const parsed = JSON.parse(readFileSync5(path, "utf8"));
1631
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
1632
+ } catch {
1633
+ return {};
1634
+ }
1635
+ },
1636
+ writeConfig: (c) => {
1637
+ mkdirSync5(dirname5(path), { recursive: true });
1638
+ writeFileSync4(path, JSON.stringify(c, null, 2) + "\n");
1639
+ },
1640
+ removeDir: (dir) => {
1641
+ rmSync(dir, { recursive: true, force: true });
1642
+ },
1643
+ confirm,
1644
+ choose: chooseFrom,
1645
+ usage: fileUsageStore(home2),
1646
+ log: (line) => console.log(line)
1647
+ }).code;
1648
+ return;
1649
+ }
1087
1650
  const cwd = process.cwd();
1088
1651
  const config = loadConfig({
1089
1652
  cwd,
@@ -1091,7 +1654,7 @@ async function main(argv) {
1091
1654
  env: process.env,
1092
1655
  readFile: (p) => {
1093
1656
  try {
1094
- return readFileSync3(p, "utf8");
1657
+ return readFileSync5(p, "utf8");
1095
1658
  } catch {
1096
1659
  return void 0;
1097
1660
  }
@@ -1099,13 +1662,13 @@ async function main(argv) {
1099
1662
  });
1100
1663
  const home = process.env.HOME ?? "";
1101
1664
  if (!process.env.HC_HEAP_SET && !process.env.NODE_OPTIONS) {
1102
- const { spawnSync } = await import("child_process");
1665
+ const { spawnSync: spawnSync2 } = await import("child_process");
1103
1666
  if (process.stdin.isTTY) process.stdin.setRawMode(false);
1104
1667
  const ignore = () => {
1105
1668
  };
1106
1669
  const SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
1107
1670
  for (const sig of SIGNALS) process.on(sig, ignore);
1108
- const r = spawnSync(process.execPath, [`--max-old-space-size=${HEAP_MB}`, ...process.argv.slice(1)], {
1671
+ const r = spawnSync2(process.execPath, [`--max-old-space-size=${HEAP_MB}`, ...process.argv.slice(1)], {
1109
1672
  stdio: "inherit",
1110
1673
  /**
1111
1674
  * `NODE_ENV=production` is set here because it must be set BEFORE React is imported.
@@ -1141,12 +1704,28 @@ async function main(argv) {
1141
1704
  process.once("SIGTERM", flush);
1142
1705
  }
1143
1706
  const telemetryNote = sink ? `\u{1F4C8} Telemetry \u2192 \`${sink.path}\` (one JSON object per line)` : void 0;
1144
- const raw = new OmniRouteProvider({ baseUrl: config.baseUrl, apiKey: config.apiKey });
1707
+ const ambientLogins = CLI_KINDS.filter((k) => k !== "zai" && !config.accounts.some((a) => a.kind === k)).map((kind) => ({ kind, status: checkProfile(kind) }));
1708
+ const accounts = new AccountPool(
1709
+ [
1710
+ ...config.accounts,
1711
+ ...ambientLogins.filter((a) => a.status.loggedIn).map(({ kind, status }) => ({
1712
+ kind,
1713
+ name: status.email ?? `${kind}-default`,
1714
+ ...status.email ? { email: status.email } : {},
1715
+ ...status.plan ? { plan: status.plan } : {}
1716
+ }))
1717
+ ],
1718
+ fileUsageStore(home)
1719
+ );
1720
+ const notSignedIn = ambientLogins.filter((a) => !a.status.loggedIn).map((a) => a.kind);
1721
+ const accountsNote = () => accountsLine(accounts.usage(), notSignedIn);
1722
+ const modelsPanelNote = (inUse) => modelsPanel(accounts.usage(), modelsFor, CLI_KINDS, inUse);
1723
+ const raw = new CliProvider({ readOnly: false, accounts });
1145
1724
  const provider = config.telemetry ? telemetryProvider(raw, telemetry()) : raw;
1146
1725
  const skillRegistry = new SkillRegistry();
1147
1726
  await registerBuiltinSkills(skillRegistry);
1148
1727
  await skillRegistry.loadFromDir(externalSkillsDir(home));
1149
- const skillsDir = join8(cwd, ".horsecode", "skills");
1728
+ const skillsDir = join11(cwd, ".horsecode", "skills");
1150
1729
  if (existsSync3(skillsDir)) await skillRegistry.loadFromDir(skillsDir);
1151
1730
  const worktreeHome = await mainWorktreeRoot(defaultGitRunner, cwd);
1152
1731
  if (worktreeHome !== cwd) {
@@ -1168,6 +1747,7 @@ async function main(argv) {
1168
1747
  manager,
1169
1748
  prAdapter,
1170
1749
  rules,
1750
+ accounts,
1171
1751
  // Autonomous by default: a task that exhausts the escalation ladder is auto-retried (then abandoned) so
1172
1752
  // an unattended run finishes on its own instead of blocking on an interactive per-failure human prompt.
1173
1753
  askHuman: autonomousAskHuman(),
@@ -1178,25 +1758,12 @@ async function main(argv) {
1178
1758
  const useTui = shouldUseTui(!!process.stdin.isTTY, !!process.stdout.isTTY, !!args.noTui);
1179
1759
  if (!args.prompt) {
1180
1760
  if (useTui) {
1181
- const { runTuiRepl } = await import("./app-LAJN3TWC.js");
1182
- const { fetchCatalog, makeProbe, discoverSources } = await import("./discover-5URG7C4J.js");
1761
+ const { runTuiRepl } = await import("./app-5FXHE7GX.js");
1762
+ const { fetchCatalog, makeProbe, discoverSources } = await import("./discover-G2Z6XC3O.js");
1183
1763
  const { loadSourceCache, saveSourceCache } = await import("./source-cache-XEK5WN7I.js");
1184
1764
  const manualSources = config.modelSources.length > 0;
1185
1765
  const sourcesRef = { current: manualSources ? config.modelSources : loadSourceCache(home, config.baseUrl) ?? [] };
1186
1766
  const configuredModels = () => [...new Set(Object.values(config.roles).flatMap((r) => r.models ?? []))].filter((m) => m && m !== "default");
1187
- const listModels = async () => {
1188
- const catalog = await listOmniRouteModels({ baseUrl: config.baseUrl, apiKey: config.apiKey, sources: sourcesRef.current });
1189
- const known = new Set(catalog);
1190
- return [...catalog, ...configuredModels().filter((m) => !known.has(m))];
1191
- };
1192
- const refreshSources = async () => {
1193
- const catalog = await fetchCatalog({ baseUrl: config.baseUrl, apiKey: config.apiKey });
1194
- const found = await discoverSources({ catalog, probe: makeProbe({ baseUrl: config.baseUrl, apiKey: config.apiKey }) });
1195
- sourcesRef.current = found;
1196
- saveSourceCache(home, config.baseUrl, found);
1197
- return found;
1198
- };
1199
- const sourcesInfo = () => ({ sources: sourcesRef.current, manual: manualSources, needsDiscovery: !manualSources && sourcesRef.current.length === 0 });
1200
1767
  const probeModel = async (model) => {
1201
1768
  try {
1202
1769
  const res = await fetch(`${config.baseUrl.replace(/\/$/, "")}/api/v1/chat/completions`, {
@@ -1210,6 +1777,16 @@ async function main(argv) {
1210
1777
  return false;
1211
1778
  }
1212
1779
  };
1780
+ const routableModel = makeProbe({ baseUrl: config.baseUrl, apiKey: config.apiKey });
1781
+ const listModels = async () => cliCatalog();
1782
+ const refreshSources = async () => {
1783
+ const catalog = await fetchCatalog({ baseUrl: config.baseUrl, apiKey: config.apiKey });
1784
+ const found = await discoverSources({ catalog, probe: makeProbe({ baseUrl: config.baseUrl, apiKey: config.apiKey }) });
1785
+ sourcesRef.current = found;
1786
+ saveSourceCache(home, config.baseUrl, found);
1787
+ return found;
1788
+ };
1789
+ const sourcesInfo = () => ({ sources: sourcesRef.current, manual: manualSources, needsDiscovery: !manualSources && sourcesRef.current.length === 0 });
1213
1790
  const effectiveRoleSkills = (skill) => Object.keys(DEFAULT_ROLE_SKILLS).concat(Object.keys(config.roles)).filter((r, i, a) => a.indexOf(r) === i).filter((r) => (config.roles[r]?.skills ?? DEFAULT_ROLE_SKILLS[r] ?? []).includes(skill));
1214
1791
  const listSkills = () => skillRegistry.list().map((s) => ({
1215
1792
  ...s,
@@ -1232,7 +1809,7 @@ _Expected something like \`https://github.com/owner/repo\` or a link to the dire
1232
1809
  _Discoverable by every agent. \`/roles adjust\` assigns it to the roles it fits; \`/skills update\` re-installs it from upstream._`;
1233
1810
  };
1234
1811
  const reloadProjectSkills = async () => {
1235
- const dir = join8(cwd, ".horsecode", "skills");
1812
+ const dir = join11(cwd, ".horsecode", "skills");
1236
1813
  if (existsSync3(dir)) await skillRegistry.loadFromDir(dir);
1237
1814
  };
1238
1815
  const updateSkills = async () => {
@@ -1322,6 +1899,8 @@ _Committed with the repo, so every clone starts with them. Agents read one with
1322
1899
  await runTuiRepl({
1323
1900
  buildDeps,
1324
1901
  memStore,
1902
+ accountsNote,
1903
+ modelsPanel: modelsPanelNote,
1325
1904
  listSkills,
1326
1905
  updateSkills,
1327
1906
  addSkill,
@@ -1341,7 +1920,8 @@ _Committed with the repo, so every clone starts with them. Agents read one with
1341
1920
  ...sink ? { telemetryPath: sink.path } : {},
1342
1921
  refreshSources,
1343
1922
  sourcesInfo,
1344
- probeModel
1923
+ probeModel,
1924
+ routableModel
1345
1925
  });
1346
1926
  return;
1347
1927
  }
@@ -1358,7 +1938,7 @@ _Committed with the repo, so every clone starts with them. Agents read one with
1358
1938
  ...args.revisionRounds !== void 0 && { revisionRounds: args.revisionRounds }
1359
1939
  };
1360
1940
  if (useTui) {
1361
- const { runTui } = await import("./app-LAJN3TWC.js");
1941
+ const { runTui } = await import("./app-5FXHE7GX.js");
1362
1942
  const res = await runTui({ buildDeps, job });
1363
1943
  console.log(renderResult(res));
1364
1944
  return;
@@ -1405,12 +1985,12 @@ if (isMainModule()) {
1405
1985
  });
1406
1986
  }
1407
1987
  export {
1408
- DELIVERED_SHARE,
1409
1988
  describeDelivery,
1410
1989
  describeOutcome,
1411
1990
  describeTraceFailures,
1412
1991
  main,
1413
1992
  parseArgs,
1993
+ poolWithConfigured,
1414
1994
  renderResult,
1415
1995
  shouldUseTui,
1416
1996
  whereItLanded