ultrahope 0.1.2 → 0.1.4

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.
@@ -1,10 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // commands/commit.ts
4
- import { execSync as execSync2, spawn as spawn2 } from "child_process";
5
- import { mkdtempSync as mkdtempSync2, readFileSync as readFileSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "fs";
6
- import { tmpdir as tmpdir2 } from "os";
7
- import { join as join4 } from "path";
4
+ import { execSync as execSync2 } from "child_process";
8
5
 
9
6
  // lib/api-client.ts
10
7
  import createClient from "openapi-fetch";
@@ -39,11 +36,33 @@ function log(message, data) {
39
36
  // lib/api-client.ts
40
37
  var API_BASE_URL = process.env.ULTRAHOPE_API_URL ?? "https://ultrahope.dev";
41
38
  var InsufficientBalanceError = class extends Error {
42
- constructor(balance) {
39
+ constructor(balance, plan = "free", hint, actions) {
43
40
  super("Token balance exhausted");
44
41
  this.balance = balance;
42
+ this.plan = plan;
43
+ this.hint = hint;
44
+ this.actions = actions;
45
45
  this.name = "InsufficientBalanceError";
46
46
  }
47
+ formatMessage() {
48
+ const lines = [];
49
+ if (this.plan === "pro") {
50
+ lines.push(
51
+ "Error: Your usage credit has been exhausted. Purchase additional credits to continue."
52
+ );
53
+ if (this.actions?.buyCredits) {
54
+ lines.push(` Buy credits: ${this.actions.buyCredits}`);
55
+ }
56
+ } else {
57
+ lines.push(
58
+ "Error: Your free credit has been exhausted. Upgrade to Pro for unlimited requests with $5 included credit."
59
+ );
60
+ if (this.actions?.upgrade) {
61
+ lines.push(` Upgrade: ${this.actions.upgrade}`);
62
+ }
63
+ }
64
+ return lines.join("\n");
65
+ }
47
66
  };
48
67
  var DailyLimitExceededError = class extends Error {
49
68
  constructor(count, limit, resetsAt) {
@@ -60,6 +79,14 @@ var UnauthorizedError = class extends Error {
60
79
  this.name = "UnauthorizedError";
61
80
  }
62
81
  };
82
+ var InvalidModelError = class extends Error {
83
+ constructor(model, allowedModels, message) {
84
+ super(message ?? `Model '${model}' is not supported.`);
85
+ this.model = model;
86
+ this.allowedModels = allowedModels;
87
+ this.name = "InvalidModelError";
88
+ }
89
+ };
63
90
  async function getErrorText(response, error) {
64
91
  if (error) {
65
92
  try {
@@ -102,18 +129,34 @@ function parseSseEvents(buffer) {
102
129
  return { events, remainder };
103
130
  }
104
131
  function handle402Error(error) {
105
- const errorBalance = error?.balance;
106
- if (typeof errorBalance === "number") {
132
+ const payload = error;
133
+ if (typeof payload?.balance === "number") {
107
134
  log("generate error (402 insufficient_balance)", error);
108
- throw new InsufficientBalanceError(errorBalance);
135
+ const plan = payload.plan === "pro" || payload.plan === "free" ? payload.plan : "free";
136
+ throw new InsufficientBalanceError(
137
+ payload.balance,
138
+ plan,
139
+ payload.hint,
140
+ payload.actions
141
+ );
109
142
  }
110
- const payload = error;
111
143
  const count = typeof payload?.count === "number" ? payload.count : 0;
112
144
  const limit = typeof payload?.limit === "number" ? payload.limit : 0;
113
145
  const resetsAt = payload?.resetsAt ?? "";
114
146
  log("generate error (402 daily_limit)", error);
115
147
  throw new DailyLimitExceededError(count, limit, resetsAt);
116
148
  }
149
+ function throwInvalidModelError(error) {
150
+ const payload = error;
151
+ const message = payload?.message ?? "Model is not supported.";
152
+ const modelMatch = message.match(/Model '([^']+)' is not supported\./);
153
+ const model = payload?.model ?? modelMatch?.[1] ?? "unknown";
154
+ const allowedModels = Array.isArray(payload?.allowedModels) ? payload.allowedModels.filter(
155
+ (value) => typeof value === "string"
156
+ ) : [];
157
+ log("generate error (400 invalid_model)", error);
158
+ throw new InvalidModelError(model, allowedModels, message);
159
+ }
117
160
  function createApiClient(token) {
118
161
  const headers = {
119
162
  "Content-Type": "application/json"
@@ -150,6 +193,15 @@ function createApiClient(token) {
150
193
  }
151
194
  handle402Error(errorPayload);
152
195
  }
196
+ if (res.status === 400) {
197
+ let errorPayload;
198
+ try {
199
+ errorPayload = await res.json();
200
+ } catch {
201
+ errorPayload = await getErrorText(res, null);
202
+ }
203
+ throwInvalidModelError(errorPayload);
204
+ }
153
205
  if (!res.ok) {
154
206
  const text = await getErrorText(res, null);
155
207
  log("streamCommitMessage error", {
@@ -210,12 +262,8 @@ function createApiClient(token) {
210
262
  throw new UnauthorizedError();
211
263
  }
212
264
  if (response.status === 402) {
213
- const payload = error;
214
- const count = typeof payload?.count === "number" ? payload.count : 0;
215
- const limit = typeof payload?.limit === "number" ? payload.limit : 0;
216
- const resetsAt = payload?.resetsAt ?? "";
217
265
  log("command_execution error (402)", error);
218
- throw new DailyLimitExceededError(count, limit, resetsAt);
266
+ handle402Error(error);
219
267
  }
220
268
  if (!response.ok) {
221
269
  const text = await getErrorText(response, error);
@@ -244,6 +292,9 @@ function createApiClient(token) {
244
292
  if (response.status === 402) {
245
293
  handle402Error(error);
246
294
  }
295
+ if (response.status === 400) {
296
+ throwInvalidModelError(error);
297
+ }
247
298
  if (!response.ok) {
248
299
  const text = await getErrorText(response, error);
249
300
  log("generateCommitMessage error", { status: response.status, text });
@@ -295,6 +346,9 @@ function createApiClient(token) {
295
346
  if (response.status === 402) {
296
347
  handle402Error(error);
297
348
  }
349
+ if (response.status === 400) {
350
+ throwInvalidModelError(error);
351
+ }
298
352
  if (!response.ok) {
299
353
  const text = await getErrorText(response, error);
300
354
  log("generatePrTitleBody error", { status: response.status, text });
@@ -319,6 +373,9 @@ function createApiClient(token) {
319
373
  if (response.status === 402) {
320
374
  handle402Error(error);
321
375
  }
376
+ if (response.status === 400) {
377
+ throwInvalidModelError(error);
378
+ }
322
379
  if (!response.ok) {
323
380
  const text = await getErrorText(response, error);
324
381
  log("generatePrIntent error", { status: response.status, text });
@@ -558,7 +615,7 @@ async function showDailyLimitPrompt(info) {
558
615
  }
559
616
  }
560
617
  function promptChoice() {
561
- return new Promise((resolve) => {
618
+ return new Promise((resolve3) => {
562
619
  const fd = openSync("/dev/tty", "r");
563
620
  const ttyInput = new tty.ReadStream(fd);
564
621
  const rl = readline.createInterface({
@@ -581,17 +638,17 @@ function promptChoice() {
581
638
  if (!key && !str) return;
582
639
  if (str === "q" || str === "Q" || key?.name === "q" || key?.name === "c" && key.ctrl || key?.name === "escape") {
583
640
  cleanup();
584
- resolve("q");
641
+ resolve3("q");
585
642
  return;
586
643
  }
587
644
  if (str === "1" || key?.name === "1" || key?.sequence === "1") {
588
645
  cleanup();
589
- resolve("1");
646
+ resolve3("1");
590
647
  return;
591
648
  }
592
649
  if (str === "2" || key?.name === "2" || key?.sequence === "2") {
593
650
  cleanup();
594
- resolve("2");
651
+ resolve3("2");
595
652
  return;
596
653
  }
597
654
  };
@@ -608,7 +665,7 @@ function handleRetryLater() {
608
665
  );
609
666
  console.log(` ${ui.link("ultrahope jj describe")}`);
610
667
  console.log("");
611
- return new Promise((resolve) => {
668
+ return new Promise((resolve3) => {
612
669
  const fd = openSync("/dev/tty", "r");
613
670
  const ttyInput = new tty.ReadStream(fd);
614
671
  const rl = readline.createInterface({
@@ -628,7 +685,7 @@ function handleRetryLater() {
628
685
  if (!key && !str) return;
629
686
  if (str === "\r" || str === "\n" || str === "q" || str === "Q" || key?.name === "return" || key?.name === "q" || key?.name === "c" && key.ctrl) {
630
687
  cleanup();
631
- resolve();
688
+ resolve3();
632
689
  }
633
690
  };
634
691
  ttyInput.on("keypress", handleKeypress);
@@ -692,11 +749,358 @@ async function handleCommandExecutionError(error, options) {
692
749
  });
693
750
  process.exit(1);
694
751
  }
752
+ if (error instanceof InsufficientBalanceError) {
753
+ console.error(error.formatMessage());
754
+ process.exit(1);
755
+ }
695
756
  const message = error instanceof Error ? error.message : String(error);
696
757
  console.error(`Error: Failed to start command execution. ${message}`);
697
758
  process.exit(1);
698
759
  }
699
760
 
761
+ // lib/config.ts
762
+ import * as fs2 from "fs";
763
+ import * as os2 from "os";
764
+ import * as path2 from "path";
765
+ import { parse } from "smol-toml";
766
+
767
+ // ../shared/async-race.ts
768
+ async function* raceAsyncIterators({
769
+ iterators,
770
+ signal,
771
+ onError
772
+ }) {
773
+ if (iterators.length === 0) {
774
+ return;
775
+ }
776
+ if (signal?.aborted) {
777
+ return;
778
+ }
779
+ const abortPromise = signal ? new Promise((resolve3) => {
780
+ signal.addEventListener("abort", () => resolve3(null), { once: true });
781
+ }) : null;
782
+ const pending = /* @__PURE__ */ new Map();
783
+ const startNext = (index) => {
784
+ const iterator = iterators[index];
785
+ if (!iterator) {
786
+ return;
787
+ }
788
+ pending.set(
789
+ index,
790
+ iterator.next().then(
791
+ (result) => ({ kind: "result", index, result }),
792
+ (error) => ({ kind: "error", index, error })
793
+ )
794
+ );
795
+ };
796
+ const throwOrContinue = (error, index) => {
797
+ if (!onError) {
798
+ throw error;
799
+ }
800
+ const decision = onError(index, error);
801
+ if (decision === "throw") {
802
+ throw error;
803
+ }
804
+ };
805
+ try {
806
+ for (let index = 0; index < iterators.length; index++) {
807
+ startNext(index);
808
+ }
809
+ while (pending.size > 0) {
810
+ const winner = abortPromise ? await Promise.race([
811
+ ...pending.values(),
812
+ abortPromise.then(() => null)
813
+ ]) : await Promise.race([...pending.values()]);
814
+ if (winner === null) {
815
+ return;
816
+ }
817
+ const { index } = winner;
818
+ pending.delete(index);
819
+ if (winner.kind === "error") {
820
+ throwOrContinue(winner.error, winner.index);
821
+ continue;
822
+ }
823
+ const { result } = winner;
824
+ if (!result.done) {
825
+ startNext(index);
826
+ yield { index, result };
827
+ }
828
+ }
829
+ } finally {
830
+ for (const iterator of iterators) {
831
+ try {
832
+ await iterator.return?.();
833
+ } catch {
834
+ }
835
+ }
836
+ for (const promise of pending.values()) {
837
+ promise.catch(() => void 0);
838
+ }
839
+ }
840
+ }
841
+
842
+ // lib/vcs-message-generator.ts
843
+ var DEFAULT_MODELS = [
844
+ // "mistral/ministral-3b",
845
+ // "cerebras/qwen-3-235b",
846
+ // "openai/gpt-5.1",
847
+ "mistral/ministral-3b",
848
+ "xai/grok-code-fast-1"
849
+ ];
850
+ var isAbortError = (error) => error instanceof Error && error.name === "AbortError";
851
+ var isInvalidCliSessionIdError = (error) => error instanceof Error && error.message.includes("Invalid cliSessionId");
852
+ var delay = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
853
+ async function* generateCommitMessages(options) {
854
+ const {
855
+ diff,
856
+ models,
857
+ signal,
858
+ cliSessionId,
859
+ commandExecutionPromise,
860
+ useStream = false,
861
+ streamCaptureRecorder
862
+ } = options;
863
+ const resolvedCliSessionId = cliSessionId;
864
+ if (!resolvedCliSessionId) {
865
+ throw new Error("Missing cliSessionId for generate request.");
866
+ }
867
+ const requiredCliSessionId = resolvedCliSessionId;
868
+ const captureGenerationId = streamCaptureRecorder?.startGeneration({
869
+ cliSessionId: requiredCliSessionId,
870
+ models
871
+ });
872
+ const token = await getToken();
873
+ if (!token) {
874
+ console.error("Error: Not authenticated. Run `ultrahope login` first.");
875
+ process.exit(1);
876
+ }
877
+ const api = createApiClient(token);
878
+ const generateWithRetry = async function* (payload) {
879
+ const maxAttempts = 3;
880
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
881
+ const attemptNumber = attempt + 1;
882
+ try {
883
+ for await (const event of api.streamCommitMessage(payload, {
884
+ signal
885
+ })) {
886
+ if (captureGenerationId != null) {
887
+ streamCaptureRecorder?.recordEvent(captureGenerationId, {
888
+ model: payload.model,
889
+ attempt: attemptNumber,
890
+ event
891
+ });
892
+ }
893
+ log("generate", event);
894
+ yield event;
895
+ }
896
+ return;
897
+ } catch (error) {
898
+ if (signal?.aborted || isAbortError(error)) throw error;
899
+ if (isInvalidCliSessionIdError(error)) {
900
+ if (commandExecutionPromise) {
901
+ try {
902
+ await commandExecutionPromise;
903
+ continue;
904
+ } catch {
905
+ const abortError = new Error("Aborted");
906
+ abortError.name = "AbortError";
907
+ throw abortError;
908
+ }
909
+ }
910
+ if (attempt < maxAttempts - 1) {
911
+ await delay(80 * (attempt + 1));
912
+ continue;
913
+ }
914
+ }
915
+ throw error;
916
+ }
917
+ }
918
+ throw new Error("Failed to generate after retries.");
919
+ };
920
+ async function* generateForModel(model, slotIndex) {
921
+ try {
922
+ if (signal?.aborted) return;
923
+ let lastCommitMessage = "";
924
+ let providerMetadata;
925
+ for await (const event of generateWithRetry({
926
+ cliSessionId: requiredCliSessionId,
927
+ input: diff,
928
+ model,
929
+ guide: options.guide
930
+ })) {
931
+ if (event.type === "commit-message") {
932
+ lastCommitMessage = event.commitMessage;
933
+ if (useStream) {
934
+ yield {
935
+ content: lastCommitMessage,
936
+ slotId: model,
937
+ model,
938
+ isPartial: true,
939
+ slotIndex
940
+ };
941
+ }
942
+ } else if (event.type === "provider-metadata") {
943
+ providerMetadata = event.providerMetadata;
944
+ } else if (event.type === "error") {
945
+ throw new Error(event.message);
946
+ }
947
+ }
948
+ if (lastCommitMessage) {
949
+ const { generationId, cost } = extractGatewayMetadata(providerMetadata);
950
+ yield {
951
+ content: lastCommitMessage,
952
+ slotId: model,
953
+ model,
954
+ cost,
955
+ generationId,
956
+ ...useStream ? { isPartial: false } : {},
957
+ slotIndex
958
+ };
959
+ }
960
+ } catch (error) {
961
+ if (signal?.aborted || isAbortError(error)) return;
962
+ if (error instanceof InvalidModelError) throw error;
963
+ if (error instanceof InsufficientBalanceError) throw error;
964
+ if (isInvalidCliSessionIdError(error)) throw error;
965
+ }
966
+ }
967
+ const iterators = models.map(
968
+ (model, index) => generateForModel(model, index)[Symbol.asyncIterator]()
969
+ );
970
+ try {
971
+ for await (const { result } of raceAsyncIterators({
972
+ iterators,
973
+ signal,
974
+ onError(index, error) {
975
+ if (isAbortError(error) || signal?.aborted) {
976
+ return "continue";
977
+ }
978
+ void index;
979
+ return "throw";
980
+ }
981
+ })) {
982
+ yield result.value;
983
+ }
984
+ } catch (error) {
985
+ if (error instanceof InvalidModelError) {
986
+ throw error;
987
+ }
988
+ if (error instanceof InsufficientBalanceError) {
989
+ console.error(error.formatMessage());
990
+ process.exit(1);
991
+ }
992
+ throw error;
993
+ } finally {
994
+ if (captureGenerationId != null) {
995
+ streamCaptureRecorder?.finishGeneration(captureGenerationId);
996
+ }
997
+ }
998
+ }
999
+
1000
+ // lib/config.ts
1001
+ var PROJECT_CONFIG_FILENAMES = [".ultrahope.toml", "ultrahope.toml"];
1002
+ function fail(message) {
1003
+ console.error(`Error: ${message}`);
1004
+ process.exit(1);
1005
+ }
1006
+ function validateModels(models, sourcePath) {
1007
+ if (!Array.isArray(models)) {
1008
+ fail(
1009
+ `Invalid config in ${sourcePath}: models must be an array of strings.`
1010
+ );
1011
+ }
1012
+ if (models.length === 0) {
1013
+ fail(`Invalid config in ${sourcePath}: models must not be empty.`);
1014
+ }
1015
+ const normalized = models.map((model, index) => {
1016
+ if (typeof model !== "string") {
1017
+ fail(
1018
+ `Invalid config in ${sourcePath}: models[${index}] must be a string.`
1019
+ );
1020
+ }
1021
+ const trimmed = model.trim();
1022
+ if (!trimmed) {
1023
+ fail(
1024
+ `Invalid config in ${sourcePath}: models[${index}] must not be empty.`
1025
+ );
1026
+ }
1027
+ return trimmed;
1028
+ });
1029
+ return normalized;
1030
+ }
1031
+ function parseModelsArg(value) {
1032
+ const rawModels = value.split(",");
1033
+ const models = rawModels.map((m) => m.trim());
1034
+ if (models.length === 0 || models.every((m) => m.length === 0)) {
1035
+ fail("--models requires at least one non-empty model.");
1036
+ }
1037
+ const emptyIndex = models.findIndex((m) => m.length === 0);
1038
+ if (emptyIndex !== -1) {
1039
+ fail(`--models contains an empty value at position ${emptyIndex + 1}.`);
1040
+ }
1041
+ return models;
1042
+ }
1043
+ function getGlobalConfigPath() {
1044
+ const configDir = process.env.XDG_CONFIG_HOME ?? path2.join(os2.homedir(), ".config");
1045
+ return path2.join(configDir, "ultrahope", "config.toml");
1046
+ }
1047
+ function findNearestProjectConfig(cwd) {
1048
+ let current = path2.resolve(cwd);
1049
+ while (true) {
1050
+ for (const filename of PROJECT_CONFIG_FILENAMES) {
1051
+ const candidate = path2.join(current, filename);
1052
+ if (fs2.existsSync(candidate)) {
1053
+ return candidate;
1054
+ }
1055
+ }
1056
+ const parent = path2.dirname(current);
1057
+ if (parent === current) {
1058
+ return null;
1059
+ }
1060
+ current = parent;
1061
+ }
1062
+ }
1063
+ function readConfigModels(configPath) {
1064
+ let raw = "";
1065
+ try {
1066
+ raw = fs2.readFileSync(configPath, "utf-8");
1067
+ } catch (error) {
1068
+ const message = error instanceof Error ? error.message : String(error);
1069
+ fail(`Failed to read config file ${configPath}: ${message}`);
1070
+ }
1071
+ let parsed;
1072
+ try {
1073
+ parsed = parse(raw);
1074
+ } catch (error) {
1075
+ const message = error instanceof Error ? error.message : String(error);
1076
+ fail(`Failed to parse TOML config ${configPath}: ${message}`);
1077
+ }
1078
+ if (parsed.models === void 0) {
1079
+ return void 0;
1080
+ }
1081
+ return validateModels(parsed.models, configPath);
1082
+ }
1083
+ function resolveModels(cliModels) {
1084
+ if (cliModels && cliModels.length > 0) {
1085
+ return cliModels;
1086
+ }
1087
+ const projectConfigPath = findNearestProjectConfig(process.cwd());
1088
+ if (projectConfigPath) {
1089
+ const projectModels = readConfigModels(projectConfigPath);
1090
+ if (projectModels) {
1091
+ return projectModels;
1092
+ }
1093
+ }
1094
+ const globalConfigPath = getGlobalConfigPath();
1095
+ if (fs2.existsSync(globalConfigPath)) {
1096
+ const globalModels = readConfigModels(globalConfigPath);
1097
+ if (globalModels) {
1098
+ return globalModels;
1099
+ }
1100
+ }
1101
+ return DEFAULT_MODELS;
1102
+ }
1103
+
700
1104
  // lib/diff-stats.ts
701
1105
  import { execSync } from "child_process";
702
1106
  function getGitStagedStats() {
@@ -736,83 +1140,28 @@ import {
736
1140
  constants as constants2,
737
1141
  mkdtempSync,
738
1142
  openSync as openSync2,
739
- readFileSync,
1143
+ readFileSync as readFileSync2,
740
1144
  unlinkSync,
741
1145
  writeFileSync
742
1146
  } from "fs";
743
1147
  import { tmpdir } from "os";
744
- import { join as join3 } from "path";
1148
+ import { join as join4 } from "path";
745
1149
  import * as readline3 from "readline";
746
1150
  import * as tty2 from "tty";
747
1151
 
748
- // lib/renderer.ts
749
- import * as readline2 from "readline";
750
- var SPINNER_FRAMES = [
751
- "\u280B",
752
- "\u2819",
753
- "\u2839",
754
- "\u2838",
755
- "\u283C",
756
- "\u2834",
757
- "\u2826",
758
- "\u2827",
759
- "\u2807",
760
- "\u280F"
761
- ];
762
- function isTTY(output) {
763
- return output.isTTY === true;
764
- }
765
- function createRenderer(output) {
766
- let pendingHeight = 0;
767
- let committedHeight = 0;
768
- const render = (content) => {
769
- if (!isTTY(output)) {
770
- output.write(content);
771
- return;
772
- }
773
- if (pendingHeight > 0) {
774
- readline2.moveCursor(output, 0, -pendingHeight);
775
- readline2.cursorTo(output, 0);
776
- readline2.clearScreenDown(output);
777
- }
778
- output.write(content);
779
- pendingHeight = content.split("\n").length - 1;
780
- };
781
- const flush = () => {
782
- committedHeight += pendingHeight;
783
- pendingHeight = 0;
784
- };
785
- const clearAll = () => {
786
- if (!isTTY(output)) {
787
- return;
788
- }
789
- const totalHeight = pendingHeight + committedHeight;
790
- if (totalHeight > 0) {
791
- readline2.moveCursor(output, 0, -totalHeight);
792
- readline2.cursorTo(output, 0);
793
- readline2.clearScreenDown(output);
794
- }
795
- pendingHeight = 0;
796
- committedHeight = 0;
797
- };
798
- const reset = () => {
799
- pendingHeight = 0;
800
- committedHeight = 0;
801
- };
802
- return { render, flush, clearAll, reset };
803
- }
804
-
805
- // lib/selector.ts
806
- var TTY_PATH = "/dev/tty";
807
- function formatModelName(model) {
808
- const parts = model.split("/");
809
- return parts.length > 1 ? parts[1] : model;
1152
+ // ../shared/terminal-selector-helpers.ts
1153
+ function formatModelName(model) {
1154
+ const parts = model.split("/");
1155
+ return parts.length > 1 ? parts[1] : model;
810
1156
  }
811
1157
  function formatCost(cost) {
812
1158
  return `$${cost.toFixed(7).replace(/0+$/, "").replace(/\.$/, "")}`;
813
1159
  }
1160
+ function formatTotalCostLabel(cost) {
1161
+ return `$${cost.toFixed(6)}`;
1162
+ }
814
1163
  function getReadyCount(slots) {
815
- return slots.filter((s) => s.status === "ready").length;
1164
+ return slots.filter((slot) => slot.status === "ready").length;
816
1165
  }
817
1166
  function getTotalCost(slots) {
818
1167
  return slots.reduce((sum, slot) => {
@@ -831,20 +1180,80 @@ function getLatestQuota(slots) {
831
1180
  return void 0;
832
1181
  }
833
1182
  function hasReadySlot(slots) {
834
- return slots.some((s) => s.status === "ready");
1183
+ return getReadyCount(slots) > 0;
1184
+ }
1185
+ function selectNearestReady(slots, startIndex, direction) {
1186
+ for (let index = startIndex + direction; index >= 0 && index < slots.length; index += direction) {
1187
+ if (slots[index]?.status === "ready") {
1188
+ return index;
1189
+ }
1190
+ }
1191
+ return startIndex;
835
1192
  }
836
1193
  function getSelectedCandidate(slots, selectedIndex) {
837
1194
  const slot = slots[selectedIndex];
838
1195
  return slot?.status === "ready" ? slot.candidate : void 0;
839
1196
  }
840
- function selectNearestReady(slots, startIndex, direction) {
841
- for (let i = startIndex + direction; i >= 0 && i < slots.length; i += direction) {
842
- if (slots[i]?.status === "ready") {
843
- return i;
1197
+
1198
+ // lib/renderer.ts
1199
+ import * as readline2 from "readline";
1200
+ var SPINNER_FRAMES = [
1201
+ "\u280B",
1202
+ "\u2819",
1203
+ "\u2839",
1204
+ "\u2838",
1205
+ "\u283C",
1206
+ "\u2834",
1207
+ "\u2826",
1208
+ "\u2827",
1209
+ "\u2807",
1210
+ "\u280F"
1211
+ ];
1212
+ function isTTY(output) {
1213
+ return output.isTTY === true;
1214
+ }
1215
+ function createRenderer(output) {
1216
+ let pendingHeight = 0;
1217
+ let committedHeight = 0;
1218
+ const render = (content) => {
1219
+ if (!isTTY(output)) {
1220
+ output.write(content);
1221
+ return;
844
1222
  }
845
- }
846
- return startIndex;
1223
+ if (pendingHeight > 0) {
1224
+ readline2.moveCursor(output, 0, -pendingHeight);
1225
+ readline2.cursorTo(output, 0);
1226
+ readline2.clearScreenDown(output);
1227
+ }
1228
+ output.write(content);
1229
+ pendingHeight = content.split("\n").length - 1;
1230
+ };
1231
+ const flush = () => {
1232
+ committedHeight += pendingHeight;
1233
+ pendingHeight = 0;
1234
+ };
1235
+ const clearAll = () => {
1236
+ if (!isTTY(output)) {
1237
+ return;
1238
+ }
1239
+ const totalHeight = pendingHeight + committedHeight;
1240
+ if (totalHeight > 0) {
1241
+ readline2.moveCursor(output, 0, -totalHeight);
1242
+ readline2.cursorTo(output, 0);
1243
+ readline2.clearScreenDown(output);
1244
+ }
1245
+ pendingHeight = 0;
1246
+ committedHeight = 0;
1247
+ };
1248
+ const reset = () => {
1249
+ pendingHeight = 0;
1250
+ committedHeight = 0;
1251
+ };
1252
+ return { render, flush, clearAll, reset };
847
1253
  }
1254
+
1255
+ // lib/selector.ts
1256
+ var TTY_PATH = "/dev/tty";
848
1257
  function collapseToReady(slots) {
849
1258
  const readySlots = slots.filter((s) => s.status === "ready");
850
1259
  slots.length = 0;
@@ -859,6 +1268,11 @@ function formatSlot(slot, selected) {
859
1268
  const meta2 = slot.model ? `${theme.dim} ${formatModelName(slot.model)}${theme.reset}` : "";
860
1269
  return meta2 ? [line2, meta2] : [line2];
861
1270
  }
1271
+ if (slot.status === "error") {
1272
+ const radio2 = "\u25CB";
1273
+ const line2 = `${theme.dim} ${radio2} ${slot.content}${theme.reset}`;
1274
+ return [line2];
1275
+ }
862
1276
  const candidate = slot.candidate;
863
1277
  const title = candidate.content.split("\n")[0]?.trim() || "";
864
1278
  const modelInfo = candidate.model ? candidate.cost ? `${formatModelName(candidate.model)} ${formatCost(candidate.cost)}` : formatModelName(candidate.model) : "";
@@ -873,9 +1287,6 @@ function formatSlot(slot, selected) {
873
1287
  const meta = modelInfo ? `${theme.dim} ${modelInfo}${theme.reset}` : "";
874
1288
  return meta ? [line, meta] : [line];
875
1289
  }
876
- function formatTotalCostLabel(cost) {
877
- return `$${cost.toFixed(6)}`;
878
- }
879
1290
  function renderSelector(state, nowMs, renderer) {
880
1291
  const { slots, selectedIndex, isGenerating, totalSlots } = state;
881
1292
  const lines = [];
@@ -927,10 +1338,10 @@ function renderError(error, slots, totalSlots, output) {
927
1338
  }
928
1339
  }
929
1340
  function openEditor(content) {
930
- return new Promise((resolve, reject) => {
1341
+ return new Promise((resolve3, reject) => {
931
1342
  const editor = process.env.GIT_EDITOR || process.env.EDITOR || "vi";
932
- const tmpDir = mkdtempSync(join3(tmpdir(), "ultrahope-"));
933
- const tmpFile = join3(tmpDir, "EDIT_MESSAGE");
1343
+ const tmpDir = mkdtempSync(join4(tmpdir(), "ultrahope-"));
1344
+ const tmpFile = join4(tmpDir, "EDIT_MESSAGE");
934
1345
  writeFileSync(tmpFile, content);
935
1346
  const child = spawn(editor, [tmpFile], { stdio: "inherit" });
936
1347
  child.on("close", (code) => {
@@ -939,9 +1350,9 @@ function openEditor(content) {
939
1350
  reject(new Error(`Editor exited with code ${code}`));
940
1351
  return;
941
1352
  }
942
- const result = readFileSync(tmpFile, "utf-8").trim();
1353
+ const result = readFileSync2(tmpFile, "utf-8").trim();
943
1354
  unlinkSync(tmpFile);
944
- resolve(result);
1355
+ resolve3(result);
945
1356
  });
946
1357
  child.on("error", (err) => {
947
1358
  try {
@@ -1001,12 +1412,12 @@ async function selectCandidate(options) {
1001
1412
  );
1002
1413
  }
1003
1414
  async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1004
- return new Promise((resolve) => {
1415
+ return new Promise((resolve3) => {
1005
1416
  let resolved = false;
1006
1417
  const resolveOnce = (result) => {
1007
1418
  if (resolved) return;
1008
1419
  resolved = true;
1009
- resolve(result);
1420
+ resolve3(result);
1010
1421
  };
1011
1422
  const slots = [...initialSlots];
1012
1423
  const state = {
@@ -1066,10 +1477,10 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1066
1477
  };
1067
1478
  const nextCandidate = async (iterator) => {
1068
1479
  const abortPromise = new Promise(
1069
- (resolve2) => {
1480
+ (resolve4) => {
1070
1481
  asyncCtx?.abortController.signal.addEventListener(
1071
1482
  "abort",
1072
- () => resolve2({ done: true, value: void 0 }),
1483
+ () => resolve4({ done: true, value: void 0 }),
1073
1484
  { once: true }
1074
1485
  );
1075
1486
  }
@@ -1094,9 +1505,12 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1094
1505
  const result = await nextCandidate(iterator);
1095
1506
  if (result.done || cleanedUp) break;
1096
1507
  const candidate = result.value;
1097
- const targetIndex = slots.findIndex(
1098
- (slot) => slot.status === "pending" ? slot.slotId === candidate.slotId : slot.candidate.slotId === candidate.slotId
1099
- );
1508
+ const targetIndex = slots.findIndex((slot) => {
1509
+ if (slot.status === "ready") {
1510
+ return slot.candidate.slotId === candidate.slotId;
1511
+ }
1512
+ return slot.status === "pending" && slot.slotId === candidate.slotId;
1513
+ });
1100
1514
  if (targetIndex >= 0 && targetIndex < slots.length) {
1101
1515
  const isNewSlot = slots[targetIndex].status === "pending";
1102
1516
  updateState((draft) => {
@@ -1115,10 +1529,15 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1115
1529
  }
1116
1530
  if (!cleanedUp) {
1117
1531
  cancelGeneration();
1532
+ if (err instanceof InvalidModelError) {
1533
+ cleanup();
1534
+ resolveOnce({ action: "abort", error: err });
1535
+ return;
1536
+ }
1118
1537
  renderer.clearAll();
1119
1538
  renderError(err, slots, state.totalSlots, ttyOutput);
1120
1539
  cleanup(false);
1121
- resolveOnce({ action: "abort" });
1540
+ resolveOnce({ action: "abort", error: err });
1122
1541
  }
1123
1542
  } finally {
1124
1543
  iterator.return?.();
@@ -1232,173 +1651,169 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1232
1651
  });
1233
1652
  }
1234
1653
 
1235
- // lib/vcs-message-generator.ts
1236
- var DEFAULT_MODELS = [
1237
- // "mistral/ministral-3b",
1238
- // "cerebras/qwen-3-235b",
1239
- // "openai/gpt-5.1",
1240
- "mistral/ministral-3b",
1241
- "xai/grok-code-fast-1"
1242
- ];
1243
- var isAbortError = (error) => error instanceof Error && error.name === "AbortError";
1244
- var isInvalidCliSessionIdError = (error) => error instanceof Error && error.message.includes("Invalid cliSessionId");
1245
- var delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1246
- var createAbortPromise = (signal) => signal ? new Promise((resolve) => {
1247
- if (signal.aborted) {
1248
- resolve(null);
1249
- return;
1654
+ // lib/stream-capture.ts
1655
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
1656
+ import { dirname as dirname3, resolve as resolve2 } from "path";
1657
+ var noopRecorder = {
1658
+ enabled: false,
1659
+ startGeneration: () => 0,
1660
+ recordEvent: () => {
1661
+ },
1662
+ finishGeneration: () => {
1663
+ },
1664
+ flush: () => null
1665
+ };
1666
+ function sanitizeEvent(event) {
1667
+ if (event.type === "provider-metadata") {
1668
+ return {
1669
+ type: "provider-metadata",
1670
+ providerMetadata: event.providerMetadata
1671
+ };
1250
1672
  }
1251
- signal.addEventListener("abort", () => resolve(null), { once: true });
1252
- }) : null;
1253
- async function* generateCommitMessages(options) {
1254
- const {
1255
- diff,
1256
- models,
1257
- signal,
1258
- cliSessionId,
1259
- commandExecutionPromise,
1260
- useStream = false
1261
- } = options;
1262
- const resolvedCliSessionId = cliSessionId;
1263
- if (!resolvedCliSessionId) {
1264
- throw new Error("Missing cliSessionId for generate request.");
1673
+ if (event.type === "usage") {
1674
+ return {
1675
+ type: "usage",
1676
+ usage: {
1677
+ inputTokens: event.usage.inputTokens,
1678
+ outputTokens: event.usage.outputTokens,
1679
+ totalTokens: event.usage.totalTokens
1680
+ }
1681
+ };
1265
1682
  }
1266
- const requiredCliSessionId = resolvedCliSessionId;
1267
- const token = await getToken();
1268
- if (!token) {
1269
- console.error("Error: Not authenticated. Run `ultrahope login` first.");
1270
- process.exit(1);
1683
+ if (event.type === "commit-message") {
1684
+ return {
1685
+ type: "commit-message",
1686
+ commitMessage: event.commitMessage
1687
+ };
1271
1688
  }
1272
- const api = createApiClient(token);
1273
- const generateWithRetry = async function* (payload) {
1274
- const maxAttempts = 3;
1275
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
1276
- try {
1277
- for await (const event of api.streamCommitMessage(payload, {
1278
- signal
1279
- })) {
1280
- log("generate", event);
1281
- yield event;
1282
- }
1283
- return;
1284
- } catch (error) {
1285
- if (signal?.aborted || isAbortError(error)) throw error;
1286
- if (isInvalidCliSessionIdError(error)) {
1287
- if (commandExecutionPromise) {
1288
- try {
1289
- await commandExecutionPromise;
1290
- continue;
1291
- } catch {
1292
- const abortError = new Error("Aborted");
1293
- abortError.name = "AbortError";
1294
- throw abortError;
1295
- }
1296
- }
1297
- if (attempt < maxAttempts - 1) {
1298
- await delay(80 * (attempt + 1));
1299
- continue;
1300
- }
1301
- }
1302
- throw error;
1303
- }
1689
+ return {
1690
+ type: "error",
1691
+ message: event.message
1692
+ };
1693
+ }
1694
+ function toSerializableCapture(capture) {
1695
+ return {
1696
+ ...capture,
1697
+ runs: capture.runs.map((run) => ({
1698
+ ...run,
1699
+ args: [...run.args],
1700
+ generations: run.generations.map((generation) => ({
1701
+ ...generation,
1702
+ models: [...generation.models],
1703
+ events: generation.events.map((event) => ({
1704
+ atMs: event.atMs,
1705
+ model: event.model,
1706
+ attempt: event.attempt,
1707
+ event: sanitizeEvent(event.event)
1708
+ }))
1709
+ }))
1710
+ }))
1711
+ };
1712
+ }
1713
+ function createStreamCaptureRecorder(options) {
1714
+ if (!options.path) {
1715
+ return noopRecorder;
1716
+ }
1717
+ const outputPath = resolve2(process.cwd(), options.path);
1718
+ const runStartedAtMs = Date.now();
1719
+ const run = {
1720
+ id: "run-1",
1721
+ command: options.command,
1722
+ args: options.args,
1723
+ apiPath: options.apiPath,
1724
+ startedAt: new Date(runStartedAtMs).toISOString(),
1725
+ generations: []
1726
+ };
1727
+ const capture = {
1728
+ version: 2,
1729
+ source: "ultrahope-commit-message-stream",
1730
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
1731
+ runs: [run]
1732
+ };
1733
+ let generationSequence = 0;
1734
+ let runFinished = false;
1735
+ const activeGenerations = /* @__PURE__ */ new Map();
1736
+ const writeCapture = () => {
1737
+ capture.capturedAt = (/* @__PURE__ */ new Date()).toISOString();
1738
+ mkdirSync2(dirname3(outputPath), { recursive: true });
1739
+ writeFileSync2(
1740
+ outputPath,
1741
+ `${JSON.stringify(toSerializableCapture(capture), null, 2)}
1742
+ `,
1743
+ "utf-8"
1744
+ );
1745
+ };
1746
+ const ensureRunFinished = () => {
1747
+ if (runFinished) {
1748
+ return;
1304
1749
  }
1305
- throw new Error("Failed to generate after retries.");
1750
+ runFinished = true;
1751
+ run.endedAt = (/* @__PURE__ */ new Date()).toISOString();
1306
1752
  };
1307
- async function* generateForModel(model, slotIndex) {
1308
- try {
1309
- if (signal?.aborted) return;
1310
- let lastCommitMessage = "";
1311
- let providerMetadata;
1312
- for await (const event of generateWithRetry({
1313
- cliSessionId: requiredCliSessionId,
1314
- input: diff,
1315
- model
1316
- })) {
1317
- if (event.type === "commit-message") {
1318
- lastCommitMessage = event.commitMessage;
1319
- if (useStream) {
1320
- yield {
1321
- content: lastCommitMessage,
1322
- slotId: model,
1323
- model,
1324
- isPartial: true,
1325
- slotIndex
1326
- };
1327
- }
1328
- } else if (event.type === "provider-metadata") {
1329
- providerMetadata = event.providerMetadata;
1330
- } else if (event.type === "error") {
1331
- throw new Error(event.message);
1332
- }
1753
+ return {
1754
+ enabled: true,
1755
+ startGeneration: ({ cliSessionId, models }) => {
1756
+ generationSequence += 1;
1757
+ const startedAtMs = Date.now();
1758
+ const generation = {
1759
+ id: `generation-${generationSequence}`,
1760
+ cliSessionId,
1761
+ models: [...models],
1762
+ startedAt: new Date(startedAtMs).toISOString(),
1763
+ events: []
1764
+ };
1765
+ run.generations.push(generation);
1766
+ activeGenerations.set(generationSequence, { generation, startedAtMs });
1767
+ return generationSequence;
1768
+ },
1769
+ recordEvent: (generationId, record) => {
1770
+ const activeGeneration = activeGenerations.get(generationId);
1771
+ if (!activeGeneration) {
1772
+ return;
1333
1773
  }
1334
- if (lastCommitMessage) {
1335
- const { generationId, cost } = extractGatewayMetadata(providerMetadata);
1336
- yield {
1337
- content: lastCommitMessage,
1338
- slotId: model,
1339
- model,
1340
- cost,
1341
- generationId,
1342
- ...useStream ? { isPartial: false } : {},
1343
- slotIndex
1344
- };
1774
+ const eventAtMs = typeof record.event.atMs === "number" && Number.isFinite(record.event.atMs) ? record.event.atMs : Date.now() - activeGeneration.startedAtMs;
1775
+ activeGeneration.generation.events.push({
1776
+ atMs: Math.max(0, Math.round(eventAtMs)),
1777
+ model: record.model,
1778
+ attempt: record.attempt,
1779
+ event: sanitizeEvent(record.event)
1780
+ });
1781
+ },
1782
+ finishGeneration: (generationId) => {
1783
+ const activeGeneration = activeGenerations.get(generationId);
1784
+ if (!activeGeneration) {
1785
+ return;
1345
1786
  }
1346
- } catch (error) {
1347
- if (signal?.aborted || isAbortError(error)) return;
1348
- if (error instanceof InsufficientBalanceError) throw error;
1349
- if (isInvalidCliSessionIdError(error)) throw error;
1350
- }
1351
- }
1352
- const iterators = models.map((model, index) => ({
1353
- iterator: generateForModel(model, index)[Symbol.asyncIterator](),
1354
- index
1355
- }));
1356
- const pending = /* @__PURE__ */ new Map();
1357
- const startNext = (it) => {
1358
- const promise = it.iterator.next().then((result) => ({
1359
- result,
1360
- index: it.index
1361
- }));
1362
- pending.set(it.index, {
1363
- iterator: it.iterator,
1364
- promise,
1365
- index: it.index
1366
- });
1367
- };
1368
- for (const it of iterators) {
1369
- startNext(it);
1370
- }
1371
- const abortPromise = createAbortPromise(signal);
1372
- try {
1373
- while (pending.size > 0) {
1374
- if (signal?.aborted) break;
1375
- const next = Promise.race(
1376
- Array.from(pending.values()).map((p) => p.promise)
1377
- );
1378
- const winner = abortPromise ? await Promise.race([next, abortPromise]) : await next;
1379
- if (!winner || signal?.aborted) break;
1380
- const { result, index } = winner;
1381
- const entry = pending.get(index);
1382
- if (!entry) continue;
1383
- if (result.done) {
1384
- pending.delete(index);
1385
- } else {
1386
- yield result.value;
1387
- startNext({ iterator: entry.iterator, index });
1787
+ activeGeneration.generation.endedAt = (/* @__PURE__ */ new Date()).toISOString();
1788
+ activeGenerations.delete(generationId);
1789
+ writeCapture();
1790
+ },
1791
+ flush: () => {
1792
+ for (const [generationId, activeGeneration] of activeGenerations) {
1793
+ activeGeneration.generation.endedAt = (/* @__PURE__ */ new Date()).toISOString();
1794
+ activeGenerations.delete(generationId);
1388
1795
  }
1796
+ ensureRunFinished();
1797
+ writeCapture();
1798
+ return outputPath;
1389
1799
  }
1390
- } catch (error) {
1391
- if (error instanceof InsufficientBalanceError) {
1392
- console.error(
1393
- "Error: Token balance exhausted. Upgrade your plan at https://ultrahope.dev/pricing"
1394
- );
1395
- process.exit(1);
1396
- }
1397
- throw error;
1398
- }
1800
+ };
1399
1801
  }
1400
1802
 
1401
1803
  // commands/commit.ts
1804
+ function normalizeGuide(value) {
1805
+ if (!value) return void 0;
1806
+ const trimmed = value.trim();
1807
+ if (!trimmed) return void 0;
1808
+ return trimmed.length > 1024 ? trimmed.slice(0, 1024) : trimmed;
1809
+ }
1810
+ function exitWithInvalidModelError(error) {
1811
+ console.error(`Error: Model '${error.model}' is not supported.`);
1812
+ if (error.allowedModels.length > 0) {
1813
+ console.error(`Available models: ${error.allowedModels.join(", ")}`);
1814
+ }
1815
+ process.exit(1);
1816
+ }
1402
1817
  function showQuotaInfo(quota) {
1403
1818
  const { relative, local } = formatResetTime(quota.resetsAt);
1404
1819
  console.log("");
@@ -1409,21 +1824,42 @@ function showQuotaInfo(quota) {
1409
1824
  );
1410
1825
  }
1411
1826
  function parseArgs(args2) {
1412
- let models = [];
1827
+ let cliModels;
1828
+ let captureStreamPath;
1829
+ let guide;
1413
1830
  for (let i = 0; i < args2.length; i++) {
1414
1831
  const arg = args2[i];
1415
- if (arg === "--models" && args2[i + 1]) {
1416
- models = args2[i + 1].split(",").map((m) => m.trim());
1832
+ if (arg === "--models") {
1833
+ const value = args2[i + 1];
1834
+ if (!value) {
1835
+ console.error("Error: --models requires a value");
1836
+ process.exit(1);
1837
+ }
1838
+ cliModels = parseModelsArg(value);
1839
+ i++;
1840
+ } else if (arg === "--capture-stream") {
1841
+ const value = args2[i + 1];
1842
+ if (!value) {
1843
+ console.error("Error: --capture-stream requires a file path");
1844
+ process.exit(1);
1845
+ }
1846
+ captureStreamPath = value;
1847
+ i++;
1848
+ } else if (arg === "--guide") {
1849
+ const value = args2[i + 1];
1850
+ if (!value) {
1851
+ console.error("Error: --guide requires a text value.");
1852
+ process.exit(1);
1853
+ }
1854
+ guide = normalizeGuide(value);
1417
1855
  i++;
1418
1856
  }
1419
1857
  }
1420
- if (models.length === 0) {
1421
- models = DEFAULT_MODELS;
1422
- }
1423
1858
  return {
1424
- message: args2.includes("-m") || args2.includes("--message"),
1425
1859
  interactive: !args2.includes("--no-interactive"),
1426
- models
1860
+ cliModels,
1861
+ captureStreamPath,
1862
+ guide
1427
1863
  };
1428
1864
  }
1429
1865
  function getStagedDiff() {
@@ -1436,31 +1872,6 @@ function getStagedDiff() {
1436
1872
  process.exit(1);
1437
1873
  }
1438
1874
  }
1439
- function openEditor2(initialMessage) {
1440
- return new Promise((resolve, reject) => {
1441
- const editor = process.env.GIT_EDITOR || process.env.EDITOR || "vi";
1442
- const tmpDir = mkdtempSync2(join4(tmpdir2(), "ultrahope-"));
1443
- const tmpFile = join4(tmpDir, "COMMIT_EDITMSG");
1444
- writeFileSync2(tmpFile, initialMessage);
1445
- const child = spawn2(editor, [tmpFile], {
1446
- stdio: "inherit"
1447
- });
1448
- child.on("close", (code) => {
1449
- if (code !== 0) {
1450
- unlinkSync2(tmpFile);
1451
- reject(new Error(`Editor exited with code ${code}`));
1452
- return;
1453
- }
1454
- const message = readFileSync2(tmpFile, "utf-8").trim();
1455
- unlinkSync2(tmpFile);
1456
- resolve(message);
1457
- });
1458
- child.on("error", (err) => {
1459
- unlinkSync2(tmpFile);
1460
- reject(err);
1461
- });
1462
- });
1463
- }
1464
1875
  function commitWithMessage(message) {
1465
1876
  try {
1466
1877
  execSync2(`git commit -m ${JSON.stringify(message)}`, { stdio: "inherit" });
@@ -1470,6 +1881,13 @@ function commitWithMessage(message) {
1470
1881
  }
1471
1882
  async function commit(args2) {
1472
1883
  const options = parseArgs(args2);
1884
+ const captureRecorder = createStreamCaptureRecorder({
1885
+ path: options.captureStreamPath,
1886
+ command: "git ultrahope commit",
1887
+ args: ["commit", ...args2],
1888
+ apiPath: "/v1/commit-message/stream"
1889
+ });
1890
+ const models = resolveModels(options.cliModels);
1473
1891
  const diff = getStagedDiff();
1474
1892
  if (!diff.trim()) {
1475
1893
  console.error(
@@ -1477,123 +1895,123 @@ async function commit(args2) {
1477
1895
  );
1478
1896
  process.exit(1);
1479
1897
  }
1480
- const token = await getToken();
1481
- if (!token) {
1482
- console.error("Error: Not authenticated. Run `ultrahope login` first.");
1483
- process.exit(1);
1484
- }
1485
- const api = createApiClient(token);
1486
- const {
1487
- commandExecutionPromise: promise,
1488
- abortController,
1489
- cliSessionId: id
1490
- } = startCommandExecution({
1491
- api,
1492
- command: "commit",
1493
- args: args2,
1494
- apiPath: "/v1/commit-message",
1495
- requestPayload: {
1496
- input: diff,
1497
- target: "vcs-commit-message",
1498
- models: options.models
1898
+ try {
1899
+ const token = await getToken();
1900
+ if (!token) {
1901
+ console.error("Error: Not authenticated. Run `ultrahope login` first.");
1902
+ process.exit(1);
1499
1903
  }
1500
- });
1501
- const cliSessionId = id;
1502
- const commandExecutionSignal = abortController.signal;
1503
- const commandExecutionPromise = promise;
1504
- const apiClient = api;
1505
- commandExecutionPromise.catch(async (error) => {
1506
- abortController.abort(abortReasonForError(error));
1507
- await handleCommandExecutionError(error, {
1508
- progress: { ready: 0, total: options.models.length }
1904
+ const api = createApiClient(token);
1905
+ const {
1906
+ commandExecutionPromise: promise,
1907
+ abortController,
1908
+ cliSessionId: id
1909
+ } = startCommandExecution({
1910
+ api,
1911
+ command: "commit",
1912
+ args: args2,
1913
+ apiPath: "/v1/commit-message",
1914
+ requestPayload: {
1915
+ input: diff,
1916
+ target: "vcs-commit-message",
1917
+ model: models[0],
1918
+ ...options.guide ? { guide: options.guide } : {}
1919
+ }
1509
1920
  });
1510
- });
1511
- const recordSelection = async (generationId) => {
1512
- if (!generationId || !apiClient) return;
1513
- try {
1514
- await apiClient.recordGenerationScore({
1515
- generationId,
1516
- value: 1
1921
+ const cliSessionId = id;
1922
+ const commandExecutionSignal = abortController.signal;
1923
+ const commandExecutionPromise = promise;
1924
+ const apiClient = api;
1925
+ commandExecutionPromise.catch(async (error) => {
1926
+ abortController.abort(abortReasonForError(error));
1927
+ await handleCommandExecutionError(error, {
1928
+ progress: { ready: 0, total: models.length }
1517
1929
  });
1518
- } catch (error) {
1519
- const message = error instanceof Error ? error.message : String(error);
1520
- if (message.includes("Generation not found")) {
1521
- return;
1930
+ });
1931
+ const recordSelection = async (generationId) => {
1932
+ if (!generationId || !apiClient) return;
1933
+ try {
1934
+ await apiClient.recordGenerationScore({
1935
+ generationId,
1936
+ value: 1
1937
+ });
1938
+ } catch (error) {
1939
+ const message = error instanceof Error ? error.message : String(error);
1940
+ if (message.includes("Generation not found")) {
1941
+ return;
1942
+ }
1943
+ console.error(`Warning: Failed to record selection. ${message}`);
1522
1944
  }
1523
- console.error(`Warning: Failed to record selection. ${message}`);
1524
- }
1525
- };
1526
- const createCandidates = (signal) => generateCommitMessages({
1527
- diff,
1528
- models: options.models,
1529
- signal: mergeAbortSignals(signal, commandExecutionSignal),
1530
- cliSessionId,
1531
- commandExecutionPromise
1532
- });
1533
- if (!options.interactive) {
1534
- const gen = generateCommitMessages({
1945
+ };
1946
+ const createCandidates = (signal) => generateCommitMessages({
1535
1947
  diff,
1536
- models: options.models.slice(0, 1),
1537
- signal: commandExecutionSignal,
1948
+ models,
1949
+ guide: options.guide,
1950
+ signal: mergeAbortSignals(signal, commandExecutionSignal),
1538
1951
  cliSessionId,
1539
- commandExecutionPromise
1952
+ commandExecutionPromise,
1953
+ streamCaptureRecorder: captureRecorder
1540
1954
  });
1541
- const first = await gen.next();
1542
- await recordSelection(first.value?.generationId);
1543
- const message = first.value?.content ?? "";
1544
- if (options.message) {
1955
+ if (!options.interactive) {
1956
+ const gen = generateCommitMessages({
1957
+ diff,
1958
+ models: models.slice(0, 1),
1959
+ guide: options.guide,
1960
+ signal: commandExecutionSignal,
1961
+ cliSessionId,
1962
+ commandExecutionPromise,
1963
+ streamCaptureRecorder: captureRecorder
1964
+ });
1965
+ const first = await gen.next().catch((error) => {
1966
+ if (error instanceof InvalidModelError) {
1967
+ exitWithInvalidModelError(error);
1968
+ }
1969
+ throw error;
1970
+ });
1971
+ await recordSelection(first.value?.generationId);
1972
+ const message = first.value?.content ?? "";
1545
1973
  commitWithMessage(message);
1546
1974
  return;
1547
1975
  }
1548
- const editedMessage = await openEditor2(message);
1549
- if (!editedMessage) {
1550
- console.error("Aborting commit due to empty message.");
1551
- process.exit(1);
1552
- }
1553
- commitWithMessage(editedMessage);
1554
- return;
1555
- }
1556
- const stats = getGitStagedStats();
1557
- console.log(ui.success(`Found ${formatDiffStats(stats)}`));
1558
- while (true) {
1559
- const result = await selectCandidate({
1560
- createCandidates,
1561
- maxSlots: options.models.length,
1562
- abortSignal: commandExecutionSignal,
1563
- models: options.models
1564
- });
1565
- if (result.action === "abort") {
1566
- if (isCommandExecutionAbort(commandExecutionSignal)) {
1567
- return;
1976
+ const stats = getGitStagedStats();
1977
+ console.log(ui.success(`Found ${formatDiffStats(stats)}`));
1978
+ while (true) {
1979
+ const result = await selectCandidate({
1980
+ createCandidates,
1981
+ maxSlots: models.length,
1982
+ abortSignal: commandExecutionSignal,
1983
+ models
1984
+ });
1985
+ if (result.action === "abort") {
1986
+ if (result.error instanceof InvalidModelError) {
1987
+ exitWithInvalidModelError(result.error);
1988
+ }
1989
+ if (isCommandExecutionAbort(commandExecutionSignal)) {
1990
+ return;
1991
+ }
1992
+ console.error("Aborting commit.");
1993
+ process.exit(1);
1568
1994
  }
1569
- console.error("Aborting commit.");
1570
- process.exit(1);
1571
- }
1572
- if (result.action === "reroll") {
1573
- continue;
1574
- }
1575
- if (result.action === "confirm" && result.selected) {
1576
- await recordSelection(result.selectedCandidate?.generationId);
1577
- const costLabel = result.totalCost != null ? ` (total: ${formatTotalCost(result.totalCost)})` : "";
1578
- console.log(ui.success(`Message selected${costLabel}`));
1579
- if (options.message) {
1995
+ if (result.action === "reroll") {
1996
+ continue;
1997
+ }
1998
+ if (result.action === "confirm" && result.selected) {
1999
+ await recordSelection(result.selectedCandidate?.generationId);
2000
+ const costLabel = result.totalCost != null ? ` (total: ${formatTotalCost(result.totalCost)})` : "";
2001
+ console.log(ui.success(`Message selected${costLabel}`));
1580
2002
  console.log(`${ui.success("Running git commit")}
1581
2003
  `);
1582
2004
  commitWithMessage(result.selected);
1583
- } else {
1584
- const editedMessage = await openEditor2(result.selected);
1585
- if (!editedMessage) {
1586
- console.error("Aborting commit due to empty message.");
1587
- process.exit(1);
2005
+ if (result.quota) {
2006
+ showQuotaInfo(result.quota);
1588
2007
  }
1589
- console.log(`${ui.success("Running git commit")}
1590
- `);
1591
- commitWithMessage(editedMessage);
1592
- }
1593
- if (result.quota) {
1594
- showQuotaInfo(result.quota);
2008
+ return;
1595
2009
  }
1596
- return;
2010
+ }
2011
+ } finally {
2012
+ const capturePath = captureRecorder.flush();
2013
+ if (capturePath) {
2014
+ console.log(ui.hint(`Captured stream replay to ${capturePath}`));
1597
2015
  }
1598
2016
  }
1599
2017
  }
@@ -1623,13 +2041,16 @@ Commands:
1623
2041
  commit Generate commit message from staged changes
1624
2042
 
1625
2043
  Commit options:
1626
- -m, --message Commit directly with generated message
1627
- --no-interactive Single candidate, open in editor
2044
+ --no-interactive Single candidate, commit directly
2045
+ --guide <text> Additional context to guide message generation
2046
+ --models <list> Comma-separated model list (overrides config)
2047
+ --capture-stream <path> Save commit-message stream as replay JSON
1628
2048
 
1629
2049
  Examples:
1630
2050
  git ultrahope commit # interactive selector (default)
1631
- git ultrahope commit -m # select and commit directly
1632
- git ultrahope commit --no-interactive # single candidate, open editor`);
2051
+ git ultrahope commit --guide "GHSA-gq3j-xvxp-8hrf: override reason"
2052
+ git ultrahope commit --capture-stream /tmp/git-commit-stream.capture.json
2053
+ git ultrahope commit --no-interactive # single candidate, commit directly`);
1633
2054
  }
1634
2055
  main().catch((err) => {
1635
2056
  console.error(err);