hillclimb 0.9.7 → 0.9.9

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 (2) hide show
  1. package/dist/main.js +145 -26
  2. package/package.json +1 -1
package/dist/main.js CHANGED
@@ -115,6 +115,7 @@ function formatError(err) {
115
115
  import fs2 from "fs";
116
116
  import http from "http";
117
117
  import https from "https";
118
+ import net from "net";
118
119
  var DEFAULT_SOCKET_IDLE_TIMEOUT_MS = 12e4;
119
120
  var DEFAULT_DEADLINE_MS = 60 * 6e4;
120
121
  var MAX_RESPONSE_BODY_BYTES = 64 * 1024;
@@ -129,18 +130,33 @@ async function putToPresignedUrl(url, headers, body, options) {
129
130
  let inputStream;
130
131
  let progressTimer;
131
132
  let phase = "connecting";
133
+ let requestFinished = false;
134
+ const seen = {};
135
+ const snapshot = () => ({
136
+ elapsedMs: Date.now() - start,
137
+ phase,
138
+ requestFinished,
139
+ totalBodyBytes: contentLength,
140
+ ...inputStream ? { bodyBytesRead: inputStream.bytesRead } : {},
141
+ ...seen,
142
+ // Bun fills the peer address in only after 'connect'.
143
+ remoteAddress: seen.remoteAddress ?? req.socket?.remoteAddress
144
+ });
132
145
  let settled = false;
133
146
  const settleResolve = (result) => {
134
147
  if (settled) return;
135
148
  settled = true;
149
+ const attempt = snapshot();
136
150
  clearTimeout(deadlineTimer);
137
151
  clearInterval(progressTimer);
138
152
  inputStream?.destroy();
139
- resolve(result);
153
+ resolve({ ...result, attempt });
140
154
  };
141
155
  const settleReject = (err) => {
142
156
  if (settled) return;
143
157
  settled = true;
158
+ err.message = redactLocalAddress(err.message);
159
+ err.putAttempt = snapshot();
144
160
  clearTimeout(deadlineTimer);
145
161
  clearInterval(progressTimer);
146
162
  inputStream?.destroy();
@@ -158,6 +174,13 @@ async function putToPresignedUrl(url, headers, body, options) {
158
174
  },
159
175
  (res) => {
160
176
  phase = "receiving-response";
177
+ seen.status = res.statusCode;
178
+ const header2 = (name) => {
179
+ const value = res.headers[name];
180
+ return Array.isArray(value) ? value[0] : value;
181
+ };
182
+ seen.requestId = header2("x-amz-request-id");
183
+ seen.hostId = header2("x-amz-id-2");
161
184
  const chunks = [];
162
185
  let buffered = 0;
163
186
  res.on("data", (chunk) => {
@@ -191,11 +214,32 @@ async function putToPresignedUrl(url, headers, body, options) {
191
214
  const connected = () => {
192
215
  if (phase === "connecting") phase = "sending";
193
216
  };
194
- if (parsed.protocol === "https:") socket.once("secureConnect", connected);
195
- else if (!socket.connecting) connected();
217
+ const tcpConnected = () => {
218
+ seen.connectMs = Date.now() - start;
219
+ seen.remoteAddress = socket.remoteAddress;
220
+ seen.localNet = addressClass(socket.localAddress);
221
+ };
222
+ if (!socket.connecting) tcpConnected();
223
+ else socket.once("connect", tcpConnected);
224
+ if (parsed.protocol === "https:") {
225
+ socket.once("secureConnect", () => {
226
+ seen.tlsMs = Date.now() - start;
227
+ const secure = socket;
228
+ try {
229
+ seen.tlsProtocol = secure.getProtocol?.() ?? void 0;
230
+ const issuer = secure.getPeerCertificate?.()?.issuer;
231
+ const name = [issuer?.O, issuer?.CN].filter(Boolean).join("/");
232
+ if (name) seen.tlsIssuer = name;
233
+ } catch {
234
+ }
235
+ connected();
236
+ });
237
+ } else if (!socket.connecting) connected();
196
238
  else socket.once("connect", connected);
197
239
  });
198
240
  req.on("finish", () => {
241
+ if (req.socket?.errored) return;
242
+ requestFinished = true;
199
243
  if (phase !== "receiving-response") phase = "awaiting-response";
200
244
  });
201
245
  if (options?.onProgress) {
@@ -204,7 +248,8 @@ async function putToPresignedUrl(url, headers, body, options) {
204
248
  elapsedMs: Date.now() - start,
205
249
  phase,
206
250
  socketBytesWritten: req.socket?.bytesWritten ?? 0,
207
- totalBodyBytes: contentLength
251
+ totalBodyBytes: contentLength,
252
+ requestFinished
208
253
  });
209
254
  }, options.progressIntervalMs ?? 3e4);
210
255
  progressTimer.unref();
@@ -229,10 +274,36 @@ async function putToPresignedUrl(url, headers, body, options) {
229
274
  }
230
275
  });
231
276
  }
277
+ function addressClass(address) {
278
+ if (!address) return void 0;
279
+ const ip = address.startsWith("::ffff:") ? address.slice(7) : address;
280
+ if (net.isIPv4(ip)) {
281
+ const [a, b] = ip.split(".").map(Number);
282
+ if (a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168)
283
+ return "private";
284
+ if (a === 100 && b >= 64 && b <= 127) return "cgnat";
285
+ if (a === 169 && b === 254) return "link-local";
286
+ if (a === 127) return "loopback";
287
+ return "public";
288
+ }
289
+ const lower = ip.toLowerCase();
290
+ if (lower === "::1") return "loopback";
291
+ if (/^fe[89ab]/.test(lower)) return "link-local";
292
+ if (/^f[cd]/.test(lower)) return "private";
293
+ return "public";
294
+ }
295
+ function redactLocalAddress(message) {
296
+ return message.replace(/ - Local \([^)]*\)/g, "");
297
+ }
232
298
 
233
299
  // src/platform/retry.ts
234
300
  var MAX_ATTEMPTS = 3;
235
- var TRANSIENT_DELAYS_MS = [1e3, 3e3];
301
+ var LARGE_PUT_BYTES = 8 * 1024 * 1024;
302
+ var LARGE_PUT_MAX_ATTEMPTS = 5;
303
+ function putMaxAttempts(bodyBytes) {
304
+ return bodyBytes >= LARGE_PUT_BYTES ? LARGE_PUT_MAX_ATTEMPTS : MAX_ATTEMPTS;
305
+ }
306
+ var TRANSIENT_DELAYS_MS = [1e3, 3e3, 1e4, 3e4];
236
307
  var RATE_LIMIT_DELAYS_MS = [5e3, 15e3];
237
308
  var MAX_RETRY_AFTER_MS = 3e4;
238
309
  var CONNECT_FAILURE_CODES = /* @__PURE__ */ new Set([
@@ -301,7 +372,7 @@ function apiRetryDelayMs(input) {
301
372
  return null;
302
373
  }
303
374
  function putRetryDelayMs(input) {
304
- if (input.attempt >= MAX_ATTEMPTS) return null;
375
+ if (input.attempt >= (input.maxAttempts ?? MAX_ATTEMPTS)) return null;
305
376
  const delayIndex = input.attempt - 1;
306
377
  if (input.status !== void 0) {
307
378
  if (input.status === 429 || input.status === 500 || input.status === 502 || input.status === 503 || input.status === 504) {
@@ -599,6 +670,7 @@ var PlatformClient = class {
599
670
  const context = {
600
671
  contributionId,
601
672
  uploadId: target2.upload.id,
673
+ filename: input.originalFilename,
602
674
  requireVerificationOnConflict: true
603
675
  };
604
676
  try {
@@ -790,6 +862,12 @@ var PlatformClient = class {
790
862
  }
791
863
  })();
792
864
  const sizeBytes = body.kind === "buffer" ? body.buffer.byteLength : body.sizeBytes;
865
+ const maxAttempts = putMaxAttempts(sizeBytes);
866
+ const label = [
867
+ refresh?.filename && `file=${refresh.filename}`,
868
+ refresh?.uploadId && `upload=${refresh.uploadId}`,
869
+ `size=${sizeBytes}`
870
+ ].filter(Boolean).join(" ");
793
871
  let hadNetworkError = false;
794
872
  let refreshed = false;
795
873
  for (let attempt = 1; ; attempt++) {
@@ -799,52 +877,56 @@ var PlatformClient = class {
799
877
  res = await putToPresignedUrl(presignedUrl, headers, body, {
800
878
  onProgress: (progress) => appendLog(
801
879
  "info",
802
- `[PUT ${host}] progress (attempt=${attempt}, phase=${progress.phase}, elapsedMs=${progress.elapsedMs}, socketBytesWritten=${progress.socketBytesWritten}, totalBodyBytes=${progress.totalBodyBytes})`
880
+ `[PUT ${host}] progress (attempt=${attempt}, phase=${progress.phase}, elapsedMs=${progress.elapsedMs}, requestFinished=${progress.requestFinished}, socketBytesWritten=${progress.socketBytesWritten}, totalBodyBytes=${progress.totalBodyBytes}) | ${label}`
803
881
  )
804
882
  });
805
883
  } catch (err) {
806
884
  hadNetworkError = true;
885
+ const diagnostics2 = `| ${label} ${formatPutAttempt(err.putAttempt)}`;
807
886
  const delayMs2 = putRetryDelayMs({
808
887
  attempt,
809
- networkErrorCode: networkErrorCode(err)
888
+ networkErrorCode: networkErrorCode(err),
889
+ maxAttempts
810
890
  });
811
891
  if (delayMs2 !== null) {
812
892
  appendLog(
813
893
  "warn",
814
- `[PUT ${host}] network error, retrying in ${delayMs2}ms (attempt ${attempt}/${MAX_ATTEMPTS}) ${formatError(err)}`
894
+ `[PUT ${host}] network error, retrying in ${delayMs2}ms (attempt ${attempt}/${maxAttempts}) ${formatError(err)} ${diagnostics2}`
815
895
  );
816
896
  await sleep(delayMs2);
817
897
  continue;
818
898
  }
819
899
  appendLog(
820
900
  "error",
821
- `[PUT ${host}] FAILED ${Date.now() - start}ms ${formatError(err)}`
901
+ `[PUT ${host}] FAILED ${Date.now() - start}ms ${formatError(err)} (attempt ${attempt}/${maxAttempts}) ${diagnostics2}`
822
902
  );
823
903
  throw err;
824
904
  }
825
905
  const elapsedMs = Date.now() - start;
906
+ const diagnostics = `| ${label} ${formatPutAttempt(res.attempt)}`;
826
907
  if (res.ok) {
827
908
  appendLog(
828
909
  "info",
829
- `[PUT ${host}] ${res.status} ${elapsedMs}ms (${sizeBytes} bytes)`
910
+ `[PUT ${host}] ${res.status} ${elapsedMs}ms (${sizeBytes} bytes) (attempt ${attempt}/${maxAttempts}) | ${label}${sizeBytes >= LARGE_PUT_BYTES ? ` ${formatPutAttempt(res.attempt)}` : ""}`
830
911
  );
831
912
  return;
832
913
  }
833
914
  if (res.status === 412 && hadNetworkError && !refresh?.requireVerificationOnConflict) {
834
915
  appendLog(
835
916
  "info",
836
- `[PUT ${host}] 412 after retry \u2014 object already stored (${sizeBytes} bytes)`
917
+ `[PUT ${host}] 412 after retry \u2014 object already stored (${sizeBytes} bytes) ${diagnostics}`
837
918
  );
838
919
  return;
839
920
  }
840
921
  const code = /<Code>\s*([A-Za-z][A-Za-z0-9]*)\s*<\/Code>/.exec(
841
922
  res.bodyText
842
923
  )?.[1];
843
- if ((res.status === 400 || res.status === 403) && code === "ExpiredToken" && refresh && attempt < MAX_ATTEMPTS && !refreshed) {
924
+ const urlExpired = res.status === 403 && code === "AccessDenied" && /<Message>\s*Request has expired\s*<\/Message>/.test(res.bodyText);
925
+ if ((res.status === 400 || res.status === 403) && (code === "ExpiredToken" || urlExpired) && refresh && attempt < maxAttempts && !refreshed) {
844
926
  refreshed = true;
845
927
  appendLog(
846
928
  "warn",
847
- `[PUT ${host}] ${res.status} ${elapsedMs}ms (code=ExpiredToken); refreshing AWS upload URL once for the existing object`
929
+ `[PUT ${host}] ${res.status} ${elapsedMs}ms (code=${code}${urlExpired ? ", request expired" : ""}); refreshing AWS upload URL once for the existing object ${diagnostics}`
848
930
  );
849
931
  const renewed = await this.request(
850
932
  "POST",
@@ -864,19 +946,23 @@ var PlatformClient = class {
864
946
  headers = renewed.headers;
865
947
  continue;
866
948
  }
867
- const delayMs = putRetryDelayMs({ attempt, status: res.status });
949
+ const delayMs = putRetryDelayMs({
950
+ attempt,
951
+ status: res.status,
952
+ maxAttempts
953
+ });
868
954
  if (delayMs !== null) {
869
955
  appendLog(
870
956
  "warn",
871
- `[PUT ${host}] ${res.status} ${elapsedMs}ms, retrying in ${delayMs}ms (attempt ${attempt}/${MAX_ATTEMPTS})`
957
+ `[PUT ${host}] ${res.status} ${elapsedMs}ms${code ? ` (code=${code})` : ""}, retrying in ${delayMs}ms (attempt ${attempt}/${maxAttempts}) ${diagnostics}`
872
958
  );
873
959
  await sleep(delayMs);
874
960
  continue;
875
961
  }
876
- const detail = code ? `(code=${code})` : res.bodyText;
962
+ const detail = code ? `(code=${code})` : res.bodyText ? `(${Buffer.byteLength(res.bodyText)}-byte body without an S3 error code)` : "";
877
963
  appendLog(
878
964
  "warn",
879
- `[PUT ${host}] ${res.status} ${elapsedMs}ms${code ? ` (code=${code})` : ""}`
965
+ `[PUT ${host}] ${res.status} ${elapsedMs}ms${code ? ` (code=${code})` : ""} (attempt ${attempt}/${maxAttempts}) ${diagnostics}`
880
966
  );
881
967
  throw new PlatformError(
882
968
  `PUT to presigned URL failed: HTTP ${res.status}${detail ? ` ${detail}` : ""}`,
@@ -906,6 +992,31 @@ var PlatformClient = class {
906
992
  );
907
993
  }
908
994
  };
995
+ function formatPutAttempt(attempt) {
996
+ const fields = [];
997
+ if (attempt) {
998
+ fields.push(
999
+ `elapsedMs=${attempt.elapsedMs}`,
1000
+ `phase=${attempt.phase}`,
1001
+ `requestFinished=${attempt.requestFinished}`
1002
+ );
1003
+ if (attempt.bodyBytesRead !== void 0)
1004
+ fields.push(`bodyBytesRead=${attempt.bodyBytesRead}`);
1005
+ if (attempt.connectMs !== void 0)
1006
+ fields.push(`connectMs=${attempt.connectMs}`);
1007
+ if (attempt.tlsMs !== void 0) fields.push(`tlsMs=${attempt.tlsMs}`);
1008
+ if (attempt.remoteAddress) fields.push(`remote=${attempt.remoteAddress}`);
1009
+ if (attempt.localNet) fields.push(`localNet=${attempt.localNet}`);
1010
+ if (attempt.tlsProtocol) fields.push(`tls=${attempt.tlsProtocol}`);
1011
+ if (attempt.tlsIssuer)
1012
+ fields.push(`issuer=${JSON.stringify(attempt.tlsIssuer)}`);
1013
+ if (attempt.status !== void 0) fields.push(`status=${attempt.status}`);
1014
+ if (attempt.requestId) fields.push(`requestId=${attempt.requestId}`);
1015
+ if (attempt.hostId) fields.push(`hostId=${attempt.hostId}`);
1016
+ }
1017
+ fields.push(`node=${process.version}`, `platform=${process.platform}`);
1018
+ return fields.join(" ");
1019
+ }
909
1020
 
910
1021
  // src/platform/hooks.ts
911
1022
  import { execFileSync } from "child_process";
@@ -965,7 +1076,7 @@ async function ensureGitignored(repoRoot, files) {
965
1076
  // src/platform/hooks.ts
966
1077
  var HOOK_CMD = (sub) => `npx hillclimb@latest ${sub}`;
967
1078
  var GIT_TRACES_CMD = (tool) => `npx hillclimb@latest git-traces --tool=${tool}`;
968
- var CLAUDE_SESSIONEND_UPLOAD_TIMEOUT_SECONDS = 30;
1079
+ var CLAUDE_SESSIONEND_HOOK_TIMEOUT_SECONDS = 30;
969
1080
  var DEV_HOOK_ENV = "HILLCLIMB_DEV_HOOK=1";
970
1081
  var SKIP_SELF_HEAL_ENV = "HILLCLIMB_SKIP_HOOK_SELF_HEAL=1";
971
1082
  var TOOLS = [
@@ -978,7 +1089,7 @@ var TOOLS = [
978
1089
  {
979
1090
  eventName: "SessionEnd",
980
1091
  command: HOOK_CMD("upload"),
981
- timeout: CLAUDE_SESSIONEND_UPLOAD_TIMEOUT_SECONDS
1092
+ timeout: CLAUDE_SESSIONEND_HOOK_TIMEOUT_SECONDS
982
1093
  },
983
1094
  // Per-turn transcript capture. SessionEnd is the clean one-shot
984
1095
  // upload, but it never fires for sessions that are killed or never
@@ -991,7 +1102,11 @@ var TOOLS = [
991
1102
  { eventName: "Stop", command: `${HOOK_CMD("upload")} --tool=claude` },
992
1103
  { eventName: "SessionStart", command: GIT_TRACES_CMD("claude") },
993
1104
  { eventName: "Stop", command: GIT_TRACES_CMD("claude") },
994
- { eventName: "SessionEnd", command: GIT_TRACES_CMD("claude") }
1105
+ {
1106
+ eventName: "SessionEnd",
1107
+ command: GIT_TRACES_CMD("claude"),
1108
+ timeout: CLAUDE_SESSIONEND_HOOK_TIMEOUT_SECONDS
1109
+ }
995
1110
  ],
996
1111
  detect: () => isDir(path4.join(homeDir(), ".claude"))
997
1112
  },
@@ -3729,7 +3844,7 @@ var DEFAULT_STATE_DIR = path9.join(
3729
3844
  "agent-uploads"
3730
3845
  );
3731
3846
  var DEFAULT_LOCK_WAIT_MS = 5 * 60 * 1e3;
3732
- var DEFAULT_LIVE_OWNER_MAX_WAIT_MS = 4 * 60 * 60 * 1e3;
3847
+ var DEFAULT_LIVE_OWNER_MAX_WAIT_MS = 6 * 60 * 60 * 1e3;
3733
3848
  var DEFAULT_LOCK_RETRY_DELAY_MS = 500;
3734
3849
  var DEFAULT_STATE_TTL_MS = 24 * 60 * 60 * 1e3;
3735
3850
  var STALE_LOCK_TTL_MS = DEFAULT_LIVE_OWNER_MAX_WAIT_MS;
@@ -14356,7 +14471,11 @@ var PlatformUploadOutput = class {
14356
14471
  presigned.headers,
14357
14472
  filePath,
14358
14473
  sizeBytes,
14359
- { contributionId, uploadId: presigned.upload.id }
14474
+ {
14475
+ contributionId,
14476
+ uploadId: presigned.upload.id,
14477
+ filename: zipFilename
14478
+ }
14360
14479
  );
14361
14480
  appendLog("info", `PUT to presigned URL succeeded for ${zipFilename}`);
14362
14481
  }
@@ -17850,13 +17969,13 @@ async function uploadArtifact(client, contributionId, filename, mimeType, buffer
17850
17969
  presigned.presignedUrl,
17851
17970
  presigned.headers,
17852
17971
  buffer,
17853
- { contributionId, uploadId: presigned.upload.id }
17972
+ { contributionId, uploadId: presigned.upload.id, filename }
17854
17973
  );
17855
17974
  appendLog("info", `PUT to presigned URL succeeded for ${filename}`);
17856
17975
  }
17857
17976
 
17858
17977
  // package.json
17859
- var version = "0.9.7";
17978
+ var version = "0.9.9";
17860
17979
 
17861
17980
  // src/version.ts
17862
17981
  var CLI_VERSION = version;
@@ -19631,7 +19750,7 @@ async function uploadFile(client, contributionId, filename, mimeType, buffer) {
19631
19750
  presigned.presignedUrl,
19632
19751
  presigned.headers,
19633
19752
  buffer,
19634
- { contributionId, uploadId: presigned.upload.id }
19753
+ { contributionId, uploadId: presigned.upload.id, filename }
19635
19754
  );
19636
19755
  appendLog(
19637
19756
  "info",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hillclimb",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
4
4
  "description": "Extract and export AI coding tool logs grouped by repo",
5
5
  "license": "MIT",
6
6
  "type": "module",