hillclimb 0.4.4 → 0.5.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 (2) hide show
  1. package/dist/cli.js +478 -240
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import fs18 from "fs";
5
- import path22 from "path";
4
+ import fs19 from "fs";
5
+ import path23 from "path";
6
6
  import * as p6 from "@clack/prompts";
7
7
 
8
8
  // src/commands/init.ts
@@ -617,6 +617,15 @@ var TOOLS = [
617
617
  command: HOOK_CMD("upload"),
618
618
  timeout: CLAUDE_SESSIONEND_UPLOAD_TIMEOUT_SECONDS
619
619
  },
620
+ // Per-turn transcript capture. SessionEnd is the clean one-shot
621
+ // upload, but it never fires for sessions that are killed or never
622
+ // cleanly closed (e.g. long-lived remote sessions), so those
623
+ // transcripts are lost entirely. Uploading on Stop captures them
624
+ // turn-by-turn. `--tool=claude` is required: resolveSourceTool()
625
+ // defaults a bare `upload` on a Stop event to Codex, and the explicit
626
+ // tool also keys the debug-log agent/git completion correlation to
627
+ // "claude" (see expectedKinds() in debug-logs.ts).
628
+ { eventName: "Stop", command: `${HOOK_CMD("upload")} --tool=claude` },
620
629
  { eventName: "SessionStart", command: GIT_TRACES_CMD("claude") },
621
630
  { eventName: "Stop", command: GIT_TRACES_CMD("claude") },
622
631
  { eventName: "SessionEnd", command: GIT_TRACES_CMD("claude") }
@@ -630,6 +639,10 @@ var TOOLS = [
630
639
  format: "cursor",
631
640
  events: [
632
641
  { eventName: "sessionEnd", command: HOOK_CMD("upload") },
642
+ // Per-turn capture, mirroring Claude. Cursor's stop payload carries a
643
+ // populated transcript_path (verified on Cursor 3.0.16); --tool=cursor
644
+ // is required so a bare Stop upload isn't misattributed to Codex.
645
+ { eventName: "stop", command: `${HOOK_CMD("upload")} --tool=cursor` },
633
646
  { eventName: "sessionStart", command: GIT_TRACES_CMD("cursor") },
634
647
  { eventName: "stop", command: GIT_TRACES_CMD("cursor") },
635
648
  { eventName: "sessionEnd", command: GIT_TRACES_CMD("cursor") }
@@ -854,7 +867,7 @@ function copilotUninstallMatching(settings, eventName, predicate) {
854
867
  }
855
868
  return true;
856
869
  }
857
- var OPENCODE_PLUGIN_VERSION = 6;
870
+ var OPENCODE_PLUGIN_VERSION = 7;
858
871
  var OPENCODE_PLUGIN_MARKER = `// HILLCLIMB_OPENCODE_PLUGIN_VERSION=${OPENCODE_PLUGIN_VERSION}`;
859
872
  var OPENCODE_PLUGIN_CONTENT = `${OPENCODE_PLUGIN_MARKER}
860
873
  // Auto-installed by \`npx hillclimb\`. Do not edit manually \u2014 re-running
@@ -983,12 +996,29 @@ export const HillclimbPlugin = async ({ directory }) => ({
983
996
  }
984
997
 
985
998
  if (type === "session.idle" && sessionID) {
986
- // Per-turn git-traces snapshot only. Transcript upload is deferred to
987
- // session.deleted (user-ended) or server.instance.disposed (process exit)
988
- // so a single session produces one contribution, not one per turn.
999
+ // Per-turn snapshot: upload the transcript-so-far AND take a git-traces
1000
+ // snapshot. The CLI keeps this to one contribution per session (reuse),
1001
+ // attaching the latest transcript on each turn, so this is one
1002
+ // contribution per session, not per turn. Keep the buffer \u2014 it's dropped
1003
+ // only on session.deleted / server.instance.disposed.
1004
+ //
1005
+ // Pass transcript_path to BOTH spawns: the debug-log eventId for a stop
1006
+ // event folds in transcript_path, so diverging payloads would orphan the
1007
+ // two completions and double-upload the debug log.
1008
+ const transcriptPath = writeTranscript(sessionID);
1009
+ if (transcriptPath) {
1010
+ spawnHillclimb("upload", {
1011
+ session_id: sessionID,
1012
+ cwd,
1013
+ transcript_path: transcriptPath,
1014
+ hook_event_name: "session.idle",
1015
+ tool: TOOL,
1016
+ });
1017
+ }
989
1018
  spawnHillclimb("git-traces --tool=opencode", {
990
1019
  session_id: sessionID,
991
1020
  cwd,
1021
+ transcript_path: transcriptPath || undefined,
992
1022
  hook_event_name: "session.idle",
993
1023
  tool: TOOL,
994
1024
  });
@@ -2149,15 +2179,32 @@ async function runStatus(args = []) {
2149
2179
 
2150
2180
  // src/commands/upload.ts
2151
2181
  import { spawn as spawn2 } from "child_process";
2152
- import fs10 from "fs";
2153
- import os5 from "os";
2154
- import path13 from "path";
2182
+ import fs11 from "fs";
2183
+ import os6 from "os";
2184
+ import path14 from "path";
2155
2185
 
2156
2186
  // src/debug-logs.ts
2157
2187
  import crypto from "crypto";
2158
2188
  import fs9 from "fs";
2159
2189
  import path11 from "path";
2160
2190
 
2191
+ // src/hook-events.ts
2192
+ function classifyHookEvent(event) {
2193
+ switch (event) {
2194
+ case "Stop":
2195
+ case "stop":
2196
+ case "session.idle":
2197
+ return "stop";
2198
+ case "SessionEnd":
2199
+ case "sessionEnd":
2200
+ case "session.deleted":
2201
+ case "server.instance.disposed":
2202
+ return "sessionEnd";
2203
+ default:
2204
+ return null;
2205
+ }
2206
+ }
2207
+
2161
2208
  // src/middleware/pattern-redact.ts
2162
2209
  import os3 from "os";
2163
2210
  import { Worker } from "worker_threads";
@@ -10916,14 +10963,23 @@ var PlatformUploadOutput = class {
10916
10963
  contributionTitle,
10917
10964
  contributionBody,
10918
10965
  zipFilename,
10919
- autoSubmit
10966
+ autoSubmit,
10967
+ existingContributionId,
10968
+ onContributionCreated
10920
10969
  } = this.opts;
10921
- const contribution = await client.createContribution(projectId, {
10922
- contributionTypeSlug,
10923
- title: contributionTitle,
10924
- body: contributionBody
10925
- });
10926
- const presigned = await client.createUpload(contribution.id, {
10970
+ let contributionId;
10971
+ if (existingContributionId) {
10972
+ contributionId = existingContributionId;
10973
+ } else {
10974
+ const contribution = await client.createContribution(projectId, {
10975
+ contributionTypeSlug,
10976
+ title: contributionTitle,
10977
+ body: contributionBody
10978
+ });
10979
+ contributionId = contribution.id;
10980
+ if (onContributionCreated) await onContributionCreated(contributionId);
10981
+ }
10982
+ const presigned = await client.createUpload(contributionId, {
10927
10983
  originalFilename: zipFilename,
10928
10984
  mimeType: "application/zip",
10929
10985
  sizeBytes: buffer.byteLength
@@ -10939,11 +10995,11 @@ var PlatformUploadOutput = class {
10939
10995
  );
10940
10996
  appendLog("info", `PUT to presigned URL succeeded for ${zipFilename}`);
10941
10997
  if (autoSubmit) {
10942
- appendLog("info", `submitting contribution ${contribution.id}`);
10943
- await client.submitContribution(contribution.id);
10944
- appendLog("info", `contribution ${contribution.id} submitted`);
10998
+ appendLog("info", `submitting contribution ${contributionId}`);
10999
+ await client.submitContribution(contributionId);
11000
+ appendLog("info", `contribution ${contributionId} submitted`);
10945
11001
  }
10946
- return contribution.id;
11002
+ return contributionId;
10947
11003
  }
10948
11004
  };
10949
11005
 
@@ -11073,21 +11129,6 @@ function toolLabel(tool) {
11073
11129
  };
11074
11130
  return labels[tool] ?? tool;
11075
11131
  }
11076
- function classifyHookEvent(event) {
11077
- switch (event) {
11078
- case "Stop":
11079
- case "stop":
11080
- case "session.idle":
11081
- return "stop";
11082
- case "SessionEnd":
11083
- case "sessionEnd":
11084
- case "session.deleted":
11085
- case "server.instance.disposed":
11086
- return "sessionEnd";
11087
- default:
11088
- return null;
11089
- }
11090
- }
11091
11132
  function resolveCwd(payload) {
11092
11133
  return payload.cwd ?? payload.workspace_roots?.[0] ?? null;
11093
11134
  }
@@ -11100,7 +11141,7 @@ function stringOrNull(value) {
11100
11141
  function expectedKinds(tool, eventKind, payload) {
11101
11142
  if (eventKind === "stop") {
11102
11143
  return new Set(
11103
- tool === "codex" || tool === "copilot-chat" ? ["agent", "git"] : ["git"]
11144
+ tool === "codex" || tool === "copilot-chat" || tool === "claude" || tool === "cursor" || tool === "opencode" ? ["agent", "git"] : ["git"]
11104
11145
  );
11105
11146
  }
11106
11147
  if (tool === "opencode" && !resolveSessionId(payload)) {
@@ -13153,6 +13194,126 @@ var NormalizeMiddleware = class {
13153
13194
  }
13154
13195
  };
13155
13196
 
13197
+ // src/upload-state.ts
13198
+ import crypto2 from "crypto";
13199
+ import fs10 from "fs";
13200
+ import os5 from "os";
13201
+ import path13 from "path";
13202
+ var CURRENT_SCHEMA_VERSION2 = 1;
13203
+ var DEFAULT_STATE_DIR = path13.join(
13204
+ os5.homedir(),
13205
+ ".hillclimb",
13206
+ "agent-uploads"
13207
+ );
13208
+ var LOCK_RETRIES2 = 120;
13209
+ var LOCK_RETRY_DELAY_MS2 = 500;
13210
+ var DEFAULT_STATE_TTL_MS = 24 * 60 * 60 * 1e3;
13211
+ function stateDir2() {
13212
+ return process.env.HILLCLIMB_UPLOAD_STATE_DIR ?? DEFAULT_STATE_DIR;
13213
+ }
13214
+ function readPositiveEnvMs(name, fallback) {
13215
+ const raw = process.env[name];
13216
+ if (raw === void 0) return fallback;
13217
+ const n = Number(raw);
13218
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
13219
+ }
13220
+ function stateTtlMs() {
13221
+ return readPositiveEnvMs(
13222
+ "HILLCLIMB_UPLOAD_STATE_TTL_MS",
13223
+ DEFAULT_STATE_TTL_MS
13224
+ );
13225
+ }
13226
+ function stateFileFor(repoRoot, tool, sessionId) {
13227
+ const hash = crypto2.createHash("sha256").update(`${path13.resolve(repoRoot)}\0${tool}\0${sessionId}`).digest("hex").slice(0, 16);
13228
+ return path13.join(stateDir2(), `${hash}.json`);
13229
+ }
13230
+ function lockFileFor(repoRoot, tool, sessionId) {
13231
+ return `${stateFileFor(repoRoot, tool, sessionId)}.lock`;
13232
+ }
13233
+ async function readUploadState(repoRoot, tool, sessionId) {
13234
+ try {
13235
+ const raw = await fs10.promises.readFile(
13236
+ stateFileFor(repoRoot, tool, sessionId),
13237
+ "utf-8"
13238
+ );
13239
+ const parsed = JSON.parse(raw);
13240
+ if (parsed.schemaVersion !== CURRENT_SCHEMA_VERSION2) return null;
13241
+ return parsed;
13242
+ } catch {
13243
+ return null;
13244
+ }
13245
+ }
13246
+ async function writeUploadState(state) {
13247
+ const file = stateFileFor(state.repoRoot, state.tool, state.sessionId);
13248
+ await fs10.promises.mkdir(stateDir2(), { recursive: true, mode: 448 });
13249
+ const tmp = `${file}.tmp`;
13250
+ await fs10.promises.writeFile(tmp, JSON.stringify(state, null, 2), {
13251
+ mode: 384
13252
+ });
13253
+ await fs10.promises.rename(tmp, file);
13254
+ }
13255
+ async function deleteUploadState(repoRoot, tool, sessionId) {
13256
+ try {
13257
+ await fs10.promises.unlink(stateFileFor(repoRoot, tool, sessionId));
13258
+ } catch {
13259
+ }
13260
+ }
13261
+ async function acquireLock2(repoRoot, tool, sessionId, retries = LOCK_RETRIES2, delayMs = LOCK_RETRY_DELAY_MS2) {
13262
+ const lockPath = lockFileFor(repoRoot, tool, sessionId);
13263
+ await fs10.promises.mkdir(stateDir2(), { recursive: true, mode: 448 });
13264
+ for (let i = 0; i < retries; i++) {
13265
+ try {
13266
+ const fd = await fs10.promises.open(
13267
+ lockPath,
13268
+ fs10.constants.O_CREAT | fs10.constants.O_EXCL | fs10.constants.O_WRONLY
13269
+ );
13270
+ await fd.write(String(process.pid));
13271
+ await fd.close();
13272
+ return;
13273
+ } catch (err) {
13274
+ if (err.code === "EEXIST" && i < retries - 1) {
13275
+ await new Promise((r) => setTimeout(r, delayMs));
13276
+ continue;
13277
+ }
13278
+ throw err;
13279
+ }
13280
+ }
13281
+ throw new Error(`Failed to acquire upload lock after ${retries} retries`);
13282
+ }
13283
+ async function releaseLock2(repoRoot, tool, sessionId) {
13284
+ try {
13285
+ await fs10.promises.unlink(lockFileFor(repoRoot, tool, sessionId));
13286
+ } catch {
13287
+ }
13288
+ }
13289
+ async function withUploadLock(repoRoot, tool, sessionId, fn) {
13290
+ await acquireLock2(repoRoot, tool, sessionId);
13291
+ try {
13292
+ return await fn();
13293
+ } finally {
13294
+ await releaseLock2(repoRoot, tool, sessionId);
13295
+ }
13296
+ }
13297
+ async function sweepStaleUploadStates(ttlMs = stateTtlMs(), now = Date.now()) {
13298
+ let entries;
13299
+ try {
13300
+ entries = await fs10.promises.readdir(stateDir2(), { withFileTypes: true });
13301
+ } catch {
13302
+ return;
13303
+ }
13304
+ for (const entry of entries) {
13305
+ if (!entry.isFile()) continue;
13306
+ const file = path13.join(stateDir2(), entry.name);
13307
+ try {
13308
+ const st = await fs10.promises.stat(file);
13309
+ if (now - st.mtimeMs > ttlMs) {
13310
+ await fs10.promises.unlink(file);
13311
+ }
13312
+ } catch {
13313
+ }
13314
+ }
13315
+ }
13316
+
13156
13317
  // src/commands/upload.ts
13157
13318
  async function readStdin() {
13158
13319
  if (process.stdin.isTTY) return "";
@@ -13172,7 +13333,7 @@ function lineHasAssistant(line) {
13172
13333
  return line.includes('"type":"assistant"') || line.includes('"role":"assistant"') || line.includes('"type":"assistant.');
13173
13334
  }
13174
13335
  async function hasAssistantMessage(transcriptPath) {
13175
- const stream = fs10.createReadStream(transcriptPath, { encoding: "utf-8" });
13336
+ const stream = fs11.createReadStream(transcriptPath, { encoding: "utf-8" });
13176
13337
  let buffer = "";
13177
13338
  try {
13178
13339
  for await (const chunk of stream) {
@@ -13240,8 +13401,8 @@ function resolveCursorTranscriptPath(payload) {
13240
13401
  const workspace = payload.workspace_roots?.[0];
13241
13402
  if (!id || !workspace) return void 0;
13242
13403
  const encoded = workspace.replace(/^\//, "").replace(/\//g, "-");
13243
- return path13.join(
13244
- os5.homedir(),
13404
+ return path14.join(
13405
+ os6.homedir(),
13245
13406
  ".cursor",
13246
13407
  "projects",
13247
13408
  encoded,
@@ -13255,9 +13416,10 @@ async function runUploadInner(payload) {
13255
13416
  const transcriptPath = payload.transcript_path ?? resolveCursorTranscriptPath(payload);
13256
13417
  const cwd = payload.cwd ?? payload.workspace_roots?.[0];
13257
13418
  const sourceTool = resolveSourceTool(payload);
13419
+ const eventKind = classifyHookEvent(payload.hook_event_name);
13258
13420
  appendLog(
13259
13421
  "info",
13260
- `[${sessionId ?? "no-id"}] payload parsed (tool=${sourceTool}, cwd=${cwd ?? "?"})`
13422
+ `[${sessionId ?? "no-id"}] payload parsed (tool=${sourceTool}, cwd=${cwd ?? "?"}, event=${eventKind ?? "?"})`
13261
13423
  );
13262
13424
  if (!sessionId || !transcriptPath || !cwd) {
13263
13425
  appendLog(
@@ -13276,9 +13438,9 @@ async function runUploadInner(payload) {
13276
13438
  }
13277
13439
  const { repoRoot, config } = match;
13278
13440
  await selfHealHook(repoRoot, sourceTool);
13279
- const transcriptResolved = path13.resolve(transcriptPath);
13441
+ const transcriptResolved = path14.resolve(transcriptPath);
13280
13442
  try {
13281
- const stat = await fs10.promises.stat(transcriptResolved);
13443
+ const stat = await fs11.promises.stat(transcriptResolved);
13282
13444
  if (!stat.isFile()) {
13283
13445
  appendLog(
13284
13446
  "warn",
@@ -13305,89 +13467,158 @@ async function runUploadInner(payload) {
13305
13467
  transcriptPath: transcriptResolved,
13306
13468
  repoRoot,
13307
13469
  config,
13308
- sourceTool
13470
+ sourceTool,
13471
+ eventKind
13309
13472
  });
13310
13473
  }
13311
13474
  async function uploadSession(args) {
13312
- const { sessionId, transcriptPath, repoRoot, config, sourceTool } = args;
13313
- const now = /* @__PURE__ */ new Date();
13314
- const sourceFile = {
13315
- sourceName: sourceTool,
13316
- absolutePath: transcriptPath,
13317
- repoPath: repoRoot
13318
- };
13319
- const group = {
13320
- repoPath: repoRoot,
13321
- label: path13.basename(repoRoot),
13322
- files: [sourceFile],
13323
- sourceNames: [sourceTool],
13324
- lastModified: now
13325
- };
13326
- const envFileNames = await discoverEnvFiles(repoRoot);
13327
- const envFilePaths = envFileNames.map((n) => path13.join(repoRoot, n));
13328
- const secretResult = await collectSecrets(repoRoot, envFilePaths, []);
13329
- const mwChain = [];
13330
- if (secretResult.values.size > 0) {
13331
- mwChain.push(new RedactMiddleware(secretResult.values));
13332
- }
13333
- mwChain.push(new PatternRedactMiddleware());
13334
- mwChain.push(new NormalizeMiddleware());
13335
- const identity = await loadIdentity(config.apiBaseUrl);
13336
- if (!identity) {
13337
- appendLog(
13338
- "error",
13339
- `Session ${sessionId} upload skipped: no saved login for ${config.apiBaseUrl}. Run \`npx hillclimb login\`.`
13475
+ const { sessionId, transcriptPath, repoRoot, config, sourceTool, eventKind } = args;
13476
+ const isSessionEnd = eventKind === "sessionEnd";
13477
+ await withUploadLock(repoRoot, sourceTool, sessionId, async () => {
13478
+ const prior = await readUploadState(repoRoot, sourceTool, sessionId);
13479
+ let transcriptSize;
13480
+ try {
13481
+ transcriptSize = (await fs11.promises.stat(transcriptPath)).size;
13482
+ } catch {
13483
+ }
13484
+ if (prior?.contributionId && transcriptSize !== void 0 && transcriptSize === prior.lastTranscriptSize) {
13485
+ appendLog(
13486
+ "info",
13487
+ `[${sessionId}] skipping ${sourceTool} ${isSessionEnd ? "SessionEnd" : "Stop"} upload (transcript unchanged at ${transcriptSize} bytes${isSessionEnd ? "; local state cleared" : ""})`
13488
+ );
13489
+ if (isSessionEnd) {
13490
+ await deleteUploadState(repoRoot, sourceTool, sessionId);
13491
+ }
13492
+ return;
13493
+ }
13494
+ const now = /* @__PURE__ */ new Date();
13495
+ const sourceFile = {
13496
+ sourceName: sourceTool,
13497
+ absolutePath: transcriptPath,
13498
+ repoPath: repoRoot
13499
+ };
13500
+ const group = {
13501
+ repoPath: repoRoot,
13502
+ label: path14.basename(repoRoot),
13503
+ files: [sourceFile],
13504
+ sourceNames: [sourceTool],
13505
+ lastModified: now
13506
+ };
13507
+ const envFileNames = await discoverEnvFiles(repoRoot);
13508
+ const envFilePaths = envFileNames.map((n) => path14.join(repoRoot, n));
13509
+ const secretResult = await collectSecrets(repoRoot, envFilePaths, []);
13510
+ const mwChain = [];
13511
+ if (secretResult.values.size > 0) {
13512
+ mwChain.push(new RedactMiddleware(secretResult.values));
13513
+ }
13514
+ mwChain.push(new PatternRedactMiddleware());
13515
+ mwChain.push(new NormalizeMiddleware());
13516
+ const identity = await loadIdentity(config.apiBaseUrl);
13517
+ if (!identity) {
13518
+ appendLog(
13519
+ "error",
13520
+ `Session ${sessionId} upload skipped: no saved login for ${config.apiBaseUrl}. Run \`npx hillclimb login\`.`
13521
+ );
13522
+ return;
13523
+ }
13524
+ const client = new PlatformClient(
13525
+ config.apiBaseUrl,
13526
+ identity.sessionCookie
13340
13527
  );
13341
- return;
13342
- }
13343
- const client = new PlatformClient(config.apiBaseUrl, identity.sessionCookie);
13344
- const shortId = sessionId.slice(0, 12);
13345
- const toolLabels = {
13346
- cursor: "Cursor",
13347
- codex: "Codex",
13348
- claude: "Claude",
13349
- "copilot-chat": "GitHub Copilot Chat",
13350
- opencode: "opencode"
13351
- };
13352
- const toolLabel2 = toolLabels[sourceTool] ?? "Claude";
13353
- const epochSeconds = formatEpochSeconds2(now);
13354
- const title = `${toolLabel2} session ${shortId} \u2014 ${epochSeconds}`;
13355
- const body = `Session ID: ${sessionId}
13528
+ const shortId = sessionId.slice(0, 12);
13529
+ const toolLabels = {
13530
+ cursor: "Cursor",
13531
+ codex: "Codex",
13532
+ claude: "Claude",
13533
+ "copilot-chat": "GitHub Copilot Chat",
13534
+ opencode: "opencode"
13535
+ };
13536
+ const toolLabel2 = toolLabels[sourceTool] ?? "Claude";
13537
+ const epochSeconds = formatEpochSeconds2(now);
13538
+ const seq = (prior?.uploadCount ?? 0) + 1;
13539
+ const title = `${toolLabel2} session ${shortId} \u2014 ${epochSeconds}`;
13540
+ const body = `Session ID: ${sessionId}
13356
13541
  Tool: ${toolLabel2}
13357
13542
  Repo: ${repoRoot}
13358
13543
  Uploaded: ${now.toISOString()}`;
13359
- const zipFilename = `${sourceTool}-${sanitize2(shortId)}-${epochSeconds}.zip`;
13360
- const output = new PlatformUploadOutput({
13361
- client,
13362
- projectId: config.projectId,
13363
- contributionTypeSlug: config.contributionTypeSlug,
13364
- contributionTitle: title,
13365
- contributionBody: body,
13366
- zipFilename,
13367
- autoSubmit: config.autoSubmit
13368
- });
13369
- appendLog("info", `[${sessionId}] starting pipeline`);
13370
- try {
13371
- const contributionId = await runPipeline(group, mwChain, output, {
13372
- selectedSources: [sourceTool]
13544
+ const zipFilename = `${sourceTool}-${sanitize2(shortId)}-${epochSeconds}-${String(seq).padStart(3, "0")}.zip`;
13545
+ const alreadySubmitted = prior?.submitted ?? false;
13546
+ const submitThisUpload = config.autoSubmit && !alreadySubmitted;
13547
+ const output = new PlatformUploadOutput({
13548
+ client,
13549
+ projectId: config.projectId,
13550
+ contributionTypeSlug: config.contributionTypeSlug,
13551
+ contributionTitle: title,
13552
+ contributionBody: body,
13553
+ zipFilename,
13554
+ autoSubmit: submitThisUpload,
13555
+ existingContributionId: prior?.contributionId ?? void 0,
13556
+ // Persist the new contribution id before the file PUT so a failed
13557
+ // upload can't make the next Stop create a second contribution.
13558
+ onContributionCreated: (id) => writeUploadState({
13559
+ schemaVersion: CURRENT_SCHEMA_VERSION2,
13560
+ sessionId,
13561
+ tool: sourceTool,
13562
+ repoRoot,
13563
+ projectId: config.projectId,
13564
+ contributionId: id,
13565
+ submitted: alreadySubmitted,
13566
+ uploadCount: prior?.uploadCount ?? 0,
13567
+ firstUploadedAt: prior?.firstUploadedAt ?? now.toISOString(),
13568
+ lastUploadedAt: prior?.lastUploadedAt ?? now.toISOString(),
13569
+ lastTranscriptSize: prior?.lastTranscriptSize
13570
+ })
13373
13571
  });
13572
+ const reuseDesc = prior?.contributionId ? `reusing contribution ${prior.contributionId} (file #${seq})` : prior ? "new contribution (file #1; prior state had no contribution \u2014 earlier create may have failed)" : "new contribution (file #1; first upload for session)";
13374
13573
  appendLog(
13375
13574
  "info",
13376
- `Uploaded session ${sessionId} to project ${config.projectSlug} (${config.projectId}) as contribution ${contributionId}`
13575
+ `[${sessionId}] ${sourceTool} ${isSessionEnd ? "SessionEnd" : "Stop"} upload \u2192 ${reuseDesc}`
13377
13576
  );
13378
- } catch (err) {
13379
- if (err instanceof PlatformError && err.status === 401) {
13577
+ let contributionId;
13578
+ try {
13579
+ contributionId = await runPipeline(group, mwChain, output, {
13580
+ selectedSources: [sourceTool]
13581
+ });
13582
+ } catch (err) {
13583
+ if (err instanceof PlatformError && err.status === 401) {
13584
+ appendLog(
13585
+ "error",
13586
+ `Session ${sessionId} upload failed: authentication expired. Re-run \`npx hillclimb\` in ${repoRoot}.`
13587
+ );
13588
+ return;
13589
+ }
13380
13590
  appendLog(
13381
13591
  "error",
13382
- `Session ${sessionId} upload failed: authentication expired. Re-run \`npx hillclimb\` in ${repoRoot}.`
13592
+ `Session ${sessionId} upload failed: ${err instanceof Error ? err.message : String(err)}`
13383
13593
  );
13384
13594
  return;
13385
13595
  }
13596
+ const next = {
13597
+ schemaVersion: CURRENT_SCHEMA_VERSION2,
13598
+ sessionId,
13599
+ tool: sourceTool,
13600
+ repoRoot,
13601
+ projectId: config.projectId,
13602
+ contributionId,
13603
+ submitted: alreadySubmitted || submitThisUpload,
13604
+ uploadCount: seq,
13605
+ firstUploadedAt: prior?.firstUploadedAt ?? now.toISOString(),
13606
+ lastUploadedAt: now.toISOString(),
13607
+ lastTranscriptSize: transcriptSize
13608
+ };
13609
+ await writeUploadState(next);
13386
13610
  appendLog(
13387
- "error",
13388
- `Session ${sessionId} upload failed: ${err instanceof Error ? err.message : String(err)}`
13611
+ "info",
13612
+ `Uploaded session ${sessionId} to project ${config.projectSlug} (${config.projectId}) as contribution ${contributionId} (seq=${seq})`
13389
13613
  );
13390
- }
13614
+ if (isSessionEnd) {
13615
+ await deleteUploadState(repoRoot, sourceTool, sessionId);
13616
+ appendLog(
13617
+ "info",
13618
+ `[${sessionId}] session complete \u2014 contribution ${contributionId}, ${seq} file(s); local state cleared`
13619
+ );
13620
+ }
13621
+ });
13391
13622
  }
13392
13623
  var WORKER_ENV_FLAG = "HILLCLIMB_UPLOAD_WORKER";
13393
13624
  var TOOL_ENV_FLAG = "HILLCLIMB_UPLOAD_TOOL";
@@ -13498,22 +13729,26 @@ async function runUploadWorker() {
13498
13729
  tool: resolveSourceTool(payload),
13499
13730
  payload
13500
13731
  });
13732
+ try {
13733
+ await sweepStaleUploadStates();
13734
+ } catch {
13735
+ }
13501
13736
  }
13502
13737
  }
13503
13738
 
13504
13739
  // src/git-traces/index.ts
13505
13740
  import { spawn as spawn3 } from "child_process";
13506
- import crypto3 from "crypto";
13741
+ import crypto4 from "crypto";
13507
13742
 
13508
13743
  // src/git-traces/handlers.ts
13509
13744
  import { execFileSync as execFileSync2 } from "child_process";
13510
- import path16 from "path";
13745
+ import path17 from "path";
13511
13746
 
13512
13747
  // src/git-traces/git-ops.ts
13513
13748
  import { execFileSync } from "child_process";
13514
- import fs11 from "fs";
13515
- import os6 from "os";
13516
- import path14 from "path";
13749
+ import fs12 from "fs";
13750
+ import os7 from "os";
13751
+ import path15 from "path";
13517
13752
  import { gzipSync } from "zlib";
13518
13753
  var GIT_COMMAND_TIMEOUT_MS = 12e4;
13519
13754
  var EXEC_OPTS = {
@@ -13676,8 +13911,8 @@ function removePathsFromIndex(repoRoot, env, paths) {
13676
13911
  function filterOversizedFilesFromTree(repoRoot, treeSha, options = {}) {
13677
13912
  const oversizedFiles = listOversizedTreeFiles(repoRoot, treeSha, options);
13678
13913
  if (oversizedFiles.length === 0) return treeSha;
13679
- const tmpIndex = path14.join(
13680
- os6.tmpdir(),
13914
+ const tmpIndex = path15.join(
13915
+ os7.tmpdir(),
13681
13916
  `hillclimb-filter-${Date.now()}-${process.pid}`
13682
13917
  );
13683
13918
  const env = { ...process.env, GIT_INDEX_FILE: tmpIndex };
@@ -13691,7 +13926,7 @@ function filterOversizedFilesFromTree(repoRoot, treeSha, options = {}) {
13691
13926
  return gitWithEnv(repoRoot, ["write-tree"], env);
13692
13927
  } finally {
13693
13928
  try {
13694
- fs11.unlinkSync(tmpIndex);
13929
+ fs12.unlinkSync(tmpIndex);
13695
13930
  } catch {
13696
13931
  }
13697
13932
  }
@@ -13708,7 +13943,7 @@ function buildUntrackedTree(repoRoot, omittedFiles) {
13708
13943
  for (const relPath of list.split("\0")) {
13709
13944
  if (!relPath) continue;
13710
13945
  try {
13711
- const stat = fs11.lstatSync(path14.join(repoRoot, relPath));
13946
+ const stat = fs12.lstatSync(path15.join(repoRoot, relPath));
13712
13947
  if (stat.size > MAX_SNAPSHOT_FILE_BYTES) {
13713
13948
  recordOmittedSnapshotFile(omittedFiles, {
13714
13949
  path: relPath,
@@ -13723,8 +13958,8 @@ function buildUntrackedTree(repoRoot, omittedFiles) {
13723
13958
  }
13724
13959
  }
13725
13960
  if (kept.length === 0) return null;
13726
- const tmpIndex = path14.join(
13727
- os6.tmpdir(),
13961
+ const tmpIndex = path15.join(
13962
+ os7.tmpdir(),
13728
13963
  `hillclimb-untracked-${Date.now()}-${process.pid}`
13729
13964
  );
13730
13965
  const env = { ...process.env, GIT_INDEX_FILE: tmpIndex };
@@ -13736,7 +13971,7 @@ function buildUntrackedTree(repoRoot, omittedFiles) {
13736
13971
  return gitWithEnv(repoRoot, ["write-tree"], env);
13737
13972
  } finally {
13738
13973
  try {
13739
- fs11.unlinkSync(tmpIndex);
13974
+ fs12.unlinkSync(tmpIndex);
13740
13975
  } catch {
13741
13976
  }
13742
13977
  }
@@ -13753,8 +13988,8 @@ function buildSnapshotTree(repoRoot, stashSha, options = {}) {
13753
13988
  const untrackedTree = buildUntrackedTree(repoRoot, options.omittedFiles);
13754
13989
  if (!untrackedTree || untrackedTree === EMPTY_TREE_SHA)
13755
13990
  return filteredTrackedTree;
13756
- const tmpIndex = path14.join(
13757
- os6.tmpdir(),
13991
+ const tmpIndex = path15.join(
13992
+ os7.tmpdir(),
13758
13993
  `hillclimb-index-${Date.now()}-${process.pid}`
13759
13994
  );
13760
13995
  const env = { ...process.env, GIT_INDEX_FILE: tmpIndex };
@@ -13781,7 +14016,7 @@ function buildSnapshotTree(repoRoot, stashSha, options = {}) {
13781
14016
  return gitWithEnv(repoRoot, ["write-tree"], env);
13782
14017
  } finally {
13783
14018
  try {
13784
- fs11.unlinkSync(tmpIndex);
14019
+ fs12.unlinkSync(tmpIndex);
13785
14020
  } catch {
13786
14021
  }
13787
14022
  }
@@ -13795,16 +14030,19 @@ function createBundleFromTree(repoRoot, treeSha, sessionId, label) {
13795
14030
  ]);
13796
14031
  const orphanRef = `refs/hillclimb/bundle/${sessionId}`;
13797
14032
  pinRef(repoRoot, orphanRef, orphanCommit);
13798
- const tmpFile = path14.join(
13799
- os6.tmpdir(),
13800
- `hillclimb-bundle-${Date.now()}.bundle`
14033
+ const tmpFile = path15.join(
14034
+ os7.tmpdir(),
14035
+ // Include the pid (like the other temp files in this module) so concurrent
14036
+ // git-traces workers — e.g. two sessions, or a parent + subagent — don't
14037
+ // collide on the same `git bundle create` path and its `.lock`.
14038
+ `hillclimb-bundle-${Date.now()}-${process.pid}.bundle`
13801
14039
  );
13802
14040
  try {
13803
14041
  git(repoRoot, ["bundle", "create", tmpFile, orphanRef]);
13804
- return fs11.readFileSync(tmpFile);
14042
+ return fs12.readFileSync(tmpFile);
13805
14043
  } finally {
13806
14044
  try {
13807
- fs11.unlinkSync(tmpFile);
14045
+ fs12.unlinkSync(tmpFile);
13808
14046
  } catch {
13809
14047
  }
13810
14048
  deleteRef(repoRoot, orphanRef);
@@ -13902,7 +14140,7 @@ function buildBaselineMetadata(repoRoot, sessionId, tool, baselineSha, cliVersio
13902
14140
  ...omittedFiles.length > 0 ? { omittedFiles } : {}
13903
14141
  },
13904
14142
  author: { name: authorName, email: authorEmail },
13905
- hostname: os6.hostname(),
14143
+ hostname: os7.hostname(),
13906
14144
  cliVersion,
13907
14145
  commits
13908
14146
  };
@@ -13991,9 +14229,9 @@ function parseCommitFiles(repoRoot, sha) {
13991
14229
  oldPath
13992
14230
  });
13993
14231
  } else {
13994
- const path23 = parts[parts.length - 1];
13995
- indexByPath.set(path23, files.length);
13996
- files.push({ path: path23, status, additions: 0, deletions: 0 });
14232
+ const path24 = parts[parts.length - 1];
14233
+ indexByPath.set(path24, files.length);
14234
+ files.push({ path: path24, status, additions: 0, deletions: 0 });
13997
14235
  }
13998
14236
  }
13999
14237
  for (const line of numstat.split("\n")) {
@@ -14059,31 +14297,31 @@ function cleanupSessionRefs(repoRoot, sessionId) {
14059
14297
  }
14060
14298
 
14061
14299
  // src/git-traces/session-state.ts
14062
- import crypto2 from "crypto";
14063
- import fs12 from "fs";
14064
- import os7 from "os";
14065
- import path15 from "path";
14066
- var CURRENT_SCHEMA_VERSION2 = 3;
14067
- var DEFAULT_STATE_DIR = path15.join(os7.homedir(), ".hillclimb", "git-traces");
14068
- var LOCK_RETRIES2 = 120;
14069
- var LOCK_RETRY_DELAY_MS2 = 500;
14070
- function stateDir2() {
14071
- return process.env.HILLCLIMB_GIT_TRACES_STATE_DIR ?? DEFAULT_STATE_DIR;
14300
+ import crypto3 from "crypto";
14301
+ import fs13 from "fs";
14302
+ import os8 from "os";
14303
+ import path16 from "path";
14304
+ var CURRENT_SCHEMA_VERSION3 = 3;
14305
+ var DEFAULT_STATE_DIR2 = path16.join(os8.homedir(), ".hillclimb", "git-traces");
14306
+ var LOCK_RETRIES3 = 120;
14307
+ var LOCK_RETRY_DELAY_MS3 = 500;
14308
+ function stateDir3() {
14309
+ return process.env.HILLCLIMB_GIT_TRACES_STATE_DIR ?? DEFAULT_STATE_DIR2;
14072
14310
  }
14073
14311
  function stateFileForRepo(repoRoot, tool, sessionId) {
14074
- const hash = crypto2.createHash("sha256").update(
14075
- sessionId ? `${path15.resolve(repoRoot)}\0${tool}\0${sessionId}` : `${path15.resolve(repoRoot)}\0${tool}`
14312
+ const hash = crypto3.createHash("sha256").update(
14313
+ sessionId ? `${path16.resolve(repoRoot)}\0${tool}\0${sessionId}` : `${path16.resolve(repoRoot)}\0${tool}`
14076
14314
  ).digest("hex").slice(0, 16);
14077
- return path15.join(stateDir2(), `${hash}.json`);
14315
+ return path16.join(stateDir3(), `${hash}.json`);
14078
14316
  }
14079
14317
  function lockFileForRepo(repoRoot, tool) {
14080
14318
  return `${stateFileForRepo(repoRoot, tool)}.lock`;
14081
14319
  }
14082
14320
  async function readStateFile(file) {
14083
14321
  try {
14084
- const raw = await fs12.promises.readFile(file, "utf-8");
14322
+ const raw = await fs13.promises.readFile(file, "utf-8");
14085
14323
  const parsed = JSON.parse(raw);
14086
- if (parsed.schemaVersion !== CURRENT_SCHEMA_VERSION2) {
14324
+ if (parsed.schemaVersion !== CURRENT_SCHEMA_VERSION3) {
14087
14325
  return null;
14088
14326
  }
14089
14327
  return parsed;
@@ -14094,26 +14332,26 @@ async function readStateFile(file) {
14094
14332
  async function listScopedSessionStates(repoRoot, tool) {
14095
14333
  let entries;
14096
14334
  try {
14097
- entries = await fs12.promises.readdir(stateDir2(), { withFileTypes: true });
14335
+ entries = await fs13.promises.readdir(stateDir3(), { withFileTypes: true });
14098
14336
  } catch {
14099
14337
  return [];
14100
14338
  }
14101
14339
  const states = [];
14102
14340
  for (const entry of entries) {
14103
14341
  if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
14104
- const file = path15.join(stateDir2(), entry.name);
14342
+ const file = path16.join(stateDir3(), entry.name);
14105
14343
  const state = await readStateFile(file);
14106
14344
  if (!state) continue;
14107
14345
  if (typeof state.repoRoot !== "string" || typeof state.sessionId !== "string") {
14108
14346
  continue;
14109
14347
  }
14110
- if (path15.resolve(state.repoRoot) !== path15.resolve(repoRoot)) continue;
14111
- if (path15.resolve(file) !== path15.resolve(stateFileForRepo(repoRoot, tool, state.sessionId))) {
14348
+ if (path16.resolve(state.repoRoot) !== path16.resolve(repoRoot)) continue;
14349
+ if (path16.resolve(file) !== path16.resolve(stateFileForRepo(repoRoot, tool, state.sessionId))) {
14112
14350
  continue;
14113
14351
  }
14114
14352
  let mtimeMs = 0;
14115
14353
  try {
14116
- mtimeMs = (await fs12.promises.stat(file)).mtimeMs;
14354
+ mtimeMs = (await fs13.promises.stat(file)).mtimeMs;
14117
14355
  } catch {
14118
14356
  continue;
14119
14357
  }
@@ -14124,26 +14362,26 @@ async function listScopedSessionStates(repoRoot, tool) {
14124
14362
  async function listSessionStatesForSession(tool, sessionId) {
14125
14363
  let entries;
14126
14364
  try {
14127
- entries = await fs12.promises.readdir(stateDir2(), { withFileTypes: true });
14365
+ entries = await fs13.promises.readdir(stateDir3(), { withFileTypes: true });
14128
14366
  } catch {
14129
14367
  return [];
14130
14368
  }
14131
14369
  const states = [];
14132
14370
  for (const entry of entries) {
14133
14371
  if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
14134
- const file = path15.join(stateDir2(), entry.name);
14372
+ const file = path16.join(stateDir3(), entry.name);
14135
14373
  const state = await readStateFile(file);
14136
14374
  if (!state) continue;
14137
14375
  if (typeof state.repoRoot !== "string" || typeof state.sessionId !== "string") {
14138
14376
  continue;
14139
14377
  }
14140
14378
  if (state.sessionId !== sessionId) continue;
14141
- if (path15.resolve(file) !== path15.resolve(stateFileForRepo(state.repoRoot, tool, sessionId))) {
14379
+ if (path16.resolve(file) !== path16.resolve(stateFileForRepo(state.repoRoot, tool, sessionId))) {
14142
14380
  continue;
14143
14381
  }
14144
14382
  let mtimeMs = 0;
14145
14383
  try {
14146
- mtimeMs = (await fs12.promises.stat(file)).mtimeMs;
14384
+ mtimeMs = (await fs13.promises.stat(file)).mtimeMs;
14147
14385
  } catch {
14148
14386
  continue;
14149
14387
  }
@@ -14164,12 +14402,12 @@ async function readSessionState(repoRoot, tool, sessionId) {
14164
14402
  }
14165
14403
  async function writeSessionState(state, tool) {
14166
14404
  const file = stateFileForRepo(state.repoRoot, tool, state.sessionId);
14167
- await fs12.promises.mkdir(stateDir2(), { recursive: true, mode: 448 });
14405
+ await fs13.promises.mkdir(stateDir3(), { recursive: true, mode: 448 });
14168
14406
  const tmp = `${file}.tmp`;
14169
- await fs12.promises.writeFile(tmp, JSON.stringify(state, null, 2), {
14407
+ await fs13.promises.writeFile(tmp, JSON.stringify(state, null, 2), {
14170
14408
  mode: 384
14171
14409
  });
14172
- await fs12.promises.rename(tmp, file);
14410
+ await fs13.promises.rename(tmp, file);
14173
14411
  const legacyFile = stateFileForRepo(state.repoRoot, tool);
14174
14412
  const legacy = await readStateFile(legacyFile);
14175
14413
  if (legacy?.sessionId === state.sessionId) {
@@ -14178,7 +14416,7 @@ async function writeSessionState(state, tool) {
14178
14416
  }
14179
14417
  async function deleteStateFile(file) {
14180
14418
  try {
14181
- await fs12.promises.unlink(file);
14419
+ await fs13.promises.unlink(file);
14182
14420
  } catch {
14183
14421
  }
14184
14422
  }
@@ -14194,14 +14432,14 @@ async function deleteSessionState(repoRoot, tool, sessionId) {
14194
14432
  }
14195
14433
  await deleteStateFile(stateFileForRepo(repoRoot, tool));
14196
14434
  }
14197
- async function acquireLock2(repoRoot, tool, retries = LOCK_RETRIES2, delayMs = LOCK_RETRY_DELAY_MS2) {
14435
+ async function acquireLock3(repoRoot, tool, retries = LOCK_RETRIES3, delayMs = LOCK_RETRY_DELAY_MS3) {
14198
14436
  const lockPath = lockFileForRepo(repoRoot, tool);
14199
- await fs12.promises.mkdir(stateDir2(), { recursive: true, mode: 448 });
14437
+ await fs13.promises.mkdir(stateDir3(), { recursive: true, mode: 448 });
14200
14438
  for (let i = 0; i < retries; i++) {
14201
14439
  try {
14202
- const fd = await fs12.promises.open(
14440
+ const fd = await fs13.promises.open(
14203
14441
  lockPath,
14204
- fs12.constants.O_CREAT | fs12.constants.O_EXCL | fs12.constants.O_WRONLY
14442
+ fs13.constants.O_CREAT | fs13.constants.O_EXCL | fs13.constants.O_WRONLY
14205
14443
  );
14206
14444
  await fd.write(String(process.pid));
14207
14445
  await fd.close();
@@ -14216,9 +14454,9 @@ async function acquireLock2(repoRoot, tool, retries = LOCK_RETRIES2, delayMs = L
14216
14454
  }
14217
14455
  throw new Error(`Failed to acquire lock after ${retries} retries`);
14218
14456
  }
14219
- async function releaseLock2(repoRoot, tool) {
14457
+ async function releaseLock3(repoRoot, tool) {
14220
14458
  try {
14221
- await fs12.promises.unlink(lockFileForRepo(repoRoot, tool));
14459
+ await fs13.promises.unlink(lockFileForRepo(repoRoot, tool));
14222
14460
  } catch {
14223
14461
  }
14224
14462
  }
@@ -14241,12 +14479,12 @@ var TOOL_LABELS = {
14241
14479
  async function loadConfiguredRepos() {
14242
14480
  const file = await loadProjects();
14243
14481
  return Object.entries(file.projects).map(([repoRoot, config]) => ({
14244
- repoRoot: path16.resolve(repoRoot),
14482
+ repoRoot: path17.resolve(repoRoot),
14245
14483
  config
14246
14484
  })).sort((a, b) => a.repoRoot.localeCompare(b.repoRoot));
14247
14485
  }
14248
14486
  function repoLabel(repoRoot) {
14249
- return path16.basename(repoRoot) || repoRoot;
14487
+ return path17.basename(repoRoot) || repoRoot;
14250
14488
  }
14251
14489
  function resolveCwd2(payload) {
14252
14490
  return payload.cwd ?? payload.workspace_roots?.[0] ?? null;
@@ -14519,7 +14757,7 @@ async function initializeSession(repoRoot, tool, sessionId) {
14519
14757
  });
14520
14758
  if (!frozen) return null;
14521
14759
  const state = {
14522
- schemaVersion: CURRENT_SCHEMA_VERSION2,
14760
+ schemaVersion: CURRENT_SCHEMA_VERSION3,
14523
14761
  sessionId,
14524
14762
  contributionId: null,
14525
14763
  baselineSha: frozen.baselineSha,
@@ -14569,7 +14807,7 @@ async function processSessionStartRepo(repo, tool, sessionId) {
14569
14807
  );
14570
14808
  return "skipped";
14571
14809
  }
14572
- await acquireLock2(repoRoot, tool);
14810
+ await acquireLock3(repoRoot, tool);
14573
14811
  try {
14574
14812
  const staleLegacy = await readSessionState(repoRoot, tool);
14575
14813
  if (staleLegacy && staleLegacy.sessionId !== sessionId) {
@@ -14600,7 +14838,7 @@ async function processSessionStartRepo(repo, tool, sessionId) {
14600
14838
  );
14601
14839
  return "failed";
14602
14840
  } finally {
14603
- await releaseLock2(repoRoot, tool);
14841
+ await releaseLock3(repoRoot, tool);
14604
14842
  }
14605
14843
  }
14606
14844
  async function handleSessionStart(payload, tool) {
@@ -14700,7 +14938,7 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14700
14938
  );
14701
14939
  return "skipped";
14702
14940
  }
14703
- await acquireLock2(repoRoot, tool);
14941
+ await acquireLock3(repoRoot, tool);
14704
14942
  let state = null;
14705
14943
  try {
14706
14944
  state = await readSessionState(repoRoot, tool, sessionId);
@@ -14901,7 +15139,7 @@ async function processStopRepo(repo, tool, sessionId, recordedAt) {
14901
15139
  }
14902
15140
  return "failed";
14903
15141
  } finally {
14904
- await releaseLock2(repoRoot, tool);
15142
+ await releaseLock3(repoRoot, tool);
14905
15143
  }
14906
15144
  }
14907
15145
  async function handleStop(payload, tool) {
@@ -14918,7 +15156,7 @@ async function handleStop(payload, tool) {
14918
15156
  if (sessionId) {
14919
15157
  const storedStates = await listSessionStatesForSession(tool, sessionId);
14920
15158
  for (const { state } of storedStates) {
14921
- const repo = repoByRoot.get(path16.resolve(state.repoRoot));
15159
+ const repo = repoByRoot.get(path17.resolve(state.repoRoot));
14922
15160
  if (!repo) {
14923
15161
  missingConfig++;
14924
15162
  appendLog(
@@ -14954,7 +15192,7 @@ async function handleStop(payload, tool) {
14954
15192
  );
14955
15193
  }
14956
15194
  async function cleanupSessionStateForRepo(repoRoot, tool, sessionId) {
14957
- await acquireLock2(repoRoot, tool);
15195
+ await acquireLock3(repoRoot, tool);
14958
15196
  try {
14959
15197
  const state = await readSessionState(repoRoot, tool, sessionId);
14960
15198
  if (!state) return "no-state";
@@ -14972,7 +15210,7 @@ async function cleanupSessionStateForRepo(repoRoot, tool, sessionId) {
14972
15210
  );
14973
15211
  return "failed";
14974
15212
  } finally {
14975
- await releaseLock2(repoRoot, tool);
15213
+ await releaseLock3(repoRoot, tool);
14976
15214
  }
14977
15215
  }
14978
15216
  async function handleSessionEnd(payload, tool) {
@@ -14984,7 +15222,7 @@ async function handleSessionEnd(payload, tool) {
14984
15222
  if (sessionId) {
14985
15223
  const states = await listSessionStatesForSession(tool, sessionId);
14986
15224
  for (const { state } of states) {
14987
- repoRoots.push(path16.resolve(state.repoRoot));
15225
+ repoRoots.push(path17.resolve(state.repoRoot));
14988
15226
  }
14989
15227
  }
14990
15228
  if (repoRoots.length === 0 && cwd) {
@@ -15011,7 +15249,7 @@ var WORKER_ENV_FLAG2 = "HILLCLIMB_GIT_TRACES_WORKER";
15011
15249
  var TOOL_ENV_FLAG2 = "HILLCLIMB_GIT_TRACES_TOOL";
15012
15250
  var FLOW_ID_ENV = "HILLCLIMB_GIT_TRACES_FLOW";
15013
15251
  function newFlowId() {
15014
- return crypto3.randomBytes(3).toString("hex");
15252
+ return crypto4.randomBytes(3).toString("hex");
15015
15253
  }
15016
15254
  var KNOWN_TOOLS = /* @__PURE__ */ new Set([
15017
15255
  "claude",
@@ -15297,29 +15535,29 @@ ${stack}` : ""}`
15297
15535
  }
15298
15536
 
15299
15537
  // src/outputs/zip.ts
15300
- import fs14 from "fs";
15301
- import path18 from "path";
15538
+ import fs15 from "fs";
15539
+ import path19 from "path";
15302
15540
  import archiver2 from "archiver";
15303
15541
 
15304
15542
  // src/outputs/downloads.ts
15305
15543
  import { execSync as execSync2 } from "child_process";
15306
- import fs13 from "fs";
15307
- import os8 from "os";
15308
- import path17 from "path";
15544
+ import fs14 from "fs";
15545
+ import os9 from "os";
15546
+ import path18 from "path";
15309
15547
  function getDownloadsFolder() {
15310
- const home = os8.homedir();
15548
+ const home = os9.homedir();
15311
15549
  if (process.platform === "linux") {
15312
15550
  try {
15313
15551
  const xdgDir = execSync2("xdg-user-dir DOWNLOAD", {
15314
15552
  encoding: "utf-8",
15315
15553
  timeout: 3e3
15316
15554
  }).trim();
15317
- if (xdgDir && fs13.existsSync(xdgDir)) return xdgDir;
15555
+ if (xdgDir && fs14.existsSync(xdgDir)) return xdgDir;
15318
15556
  } catch {
15319
15557
  }
15320
15558
  }
15321
- const downloads = path17.join(home, "Downloads");
15322
- if (fs13.existsSync(downloads)) return downloads;
15559
+ const downloads = path18.join(home, "Downloads");
15560
+ if (fs14.existsSync(downloads)) return downloads;
15323
15561
  return home;
15324
15562
  }
15325
15563
 
@@ -15328,11 +15566,11 @@ function sanitizeFilename(name) {
15328
15566
  return name.replace(/[^a-zA-Z0-9._-]/g, "_");
15329
15567
  }
15330
15568
  function getUniqueFilename(dir, base, ext) {
15331
- let candidate = path18.join(dir, `${base}${ext}`);
15332
- if (!fs14.existsSync(candidate)) return candidate;
15569
+ let candidate = path19.join(dir, `${base}${ext}`);
15570
+ if (!fs15.existsSync(candidate)) return candidate;
15333
15571
  let i = 1;
15334
- while (fs14.existsSync(candidate)) {
15335
- candidate = path18.join(dir, `${base}-${i}${ext}`);
15572
+ while (fs15.existsSync(candidate)) {
15573
+ candidate = path19.join(dir, `${base}-${i}${ext}`);
15336
15574
  i++;
15337
15575
  }
15338
15576
  return candidate;
@@ -15342,13 +15580,13 @@ var ZipOutput = class {
15342
15580
  label = "Save as .zip to Downloads";
15343
15581
  async emit(group, options) {
15344
15582
  const downloadsDir = getDownloadsFolder();
15345
- const repoName = sanitizeFilename(path18.basename(group.repoPath));
15583
+ const repoName = sanitizeFilename(path19.basename(group.repoPath));
15346
15584
  const timeRange = options.timeRange;
15347
15585
  const rangePart = timeRange?.label ?? "all";
15348
15586
  const epochSeconds = Math.floor(Date.now() / 1e3);
15349
15587
  const baseName = `logs-${repoName}-${rangePart}-${epochSeconds}`;
15350
15588
  const outputPath = getUniqueFilename(downloadsDir, baseName, ".zip");
15351
- const output = fs14.createWriteStream(outputPath);
15589
+ const output = fs15.createWriteStream(outputPath);
15352
15590
  const archive = archiver2("zip", { zlib: { level: 6 } });
15353
15591
  const done = new Promise((resolve, reject) => {
15354
15592
  output.on("close", resolve);
@@ -15542,15 +15780,15 @@ async function confirmExport(group, output) {
15542
15780
  }
15543
15781
 
15544
15782
  // src/sources/claude.ts
15545
- import fs15 from "fs";
15546
- import os9 from "os";
15547
- import path19 from "path";
15783
+ import fs16 from "fs";
15784
+ import os10 from "os";
15785
+ import path20 from "path";
15548
15786
  import readline from "readline";
15549
15787
  var SKIP_DIRS = /* @__PURE__ */ new Set(["tool-results"]);
15550
15788
  async function resolveRepoPath(projectDir) {
15551
- const indexPath = path19.join(projectDir, "sessions-index.json");
15789
+ const indexPath = path20.join(projectDir, "sessions-index.json");
15552
15790
  try {
15553
- const raw = await fs15.promises.readFile(indexPath, "utf-8");
15791
+ const raw = await fs16.promises.readFile(indexPath, "utf-8");
15554
15792
  const data = JSON.parse(raw);
15555
15793
  if (data.originalPath && typeof data.originalPath === "string") {
15556
15794
  return data.originalPath;
@@ -15558,12 +15796,12 @@ async function resolveRepoPath(projectDir) {
15558
15796
  } catch {
15559
15797
  }
15560
15798
  const cwdCounts = /* @__PURE__ */ new Map();
15561
- const entries = await fs15.promises.readdir(projectDir, {
15799
+ const entries = await fs16.promises.readdir(projectDir, {
15562
15800
  withFileTypes: true
15563
15801
  });
15564
15802
  for (const entry of entries) {
15565
15803
  if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
15566
- const cwd = await extractCwdFromJsonl(path19.join(projectDir, entry.name));
15804
+ const cwd = await extractCwdFromJsonl(path20.join(projectDir, entry.name));
15567
15805
  if (cwd) {
15568
15806
  cwdCounts.set(cwd, (cwdCounts.get(cwd) ?? 0) + 1);
15569
15807
  }
@@ -15582,7 +15820,7 @@ async function resolveRepoPath(projectDir) {
15582
15820
  return null;
15583
15821
  }
15584
15822
  async function extractCwdFromJsonl(filePath) {
15585
- const stream = fs15.createReadStream(filePath, { encoding: "utf-8" });
15823
+ const stream = fs16.createReadStream(filePath, { encoding: "utf-8" });
15586
15824
  const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
15587
15825
  try {
15588
15826
  for await (const line of rl) {
@@ -15604,12 +15842,12 @@ async function extractCwdFromJsonl(filePath) {
15604
15842
  async function collectFiles(dir, baseDir, sourceName, repoPath, results) {
15605
15843
  let entries;
15606
15844
  try {
15607
- entries = await fs15.promises.readdir(dir, { withFileTypes: true });
15845
+ entries = await fs16.promises.readdir(dir, { withFileTypes: true });
15608
15846
  } catch {
15609
15847
  return;
15610
15848
  }
15611
15849
  for (const entry of entries) {
15612
- const fullPath = path19.join(dir, entry.name);
15850
+ const fullPath = path20.join(dir, entry.name);
15613
15851
  if (entry.isDirectory()) {
15614
15852
  if (SKIP_DIRS.has(entry.name)) continue;
15615
15853
  await collectFiles(fullPath, baseDir, sourceName, repoPath, results);
@@ -15631,19 +15869,19 @@ function fallbackDecode(encodedName) {
15631
15869
  var ClaudeSource = class {
15632
15870
  name = "claude";
15633
15871
  async scan() {
15634
- const baseDir = path19.join(os9.homedir(), ".claude", "projects");
15872
+ const baseDir = path20.join(os10.homedir(), ".claude", "projects");
15635
15873
  try {
15636
- await fs15.promises.access(baseDir);
15874
+ await fs16.promises.access(baseDir);
15637
15875
  } catch {
15638
15876
  return [];
15639
15877
  }
15640
- const projectDirs = await fs15.promises.readdir(baseDir, {
15878
+ const projectDirs = await fs16.promises.readdir(baseDir, {
15641
15879
  withFileTypes: true
15642
15880
  });
15643
15881
  const dirEntries = projectDirs.filter((d) => d.isDirectory());
15644
15882
  const resultArrays = await Promise.all(
15645
15883
  dirEntries.map(async (dir) => {
15646
- const projectPath = path19.join(baseDir, dir.name);
15884
+ const projectPath = path20.join(baseDir, dir.name);
15647
15885
  const repoPath = await resolveRepoPath(projectPath) ?? fallbackDecode(dir.name);
15648
15886
  const files = [];
15649
15887
  await collectFiles(
@@ -15661,12 +15899,12 @@ var ClaudeSource = class {
15661
15899
  };
15662
15900
 
15663
15901
  // src/sources/codex.ts
15664
- import fs16 from "fs";
15665
- import os10 from "os";
15666
- import path20 from "path";
15902
+ import fs17 from "fs";
15903
+ import os11 from "os";
15904
+ import path21 from "path";
15667
15905
  import readline2 from "readline";
15668
15906
  async function parseSessionMeta(filePath) {
15669
- const stream = fs16.createReadStream(filePath, { encoding: "utf-8" });
15907
+ const stream = fs17.createReadStream(filePath, { encoding: "utf-8" });
15670
15908
  const rl = readline2.createInterface({ input: stream, crlfDelay: Infinity });
15671
15909
  try {
15672
15910
  for await (const line of rl) {
@@ -15691,12 +15929,12 @@ async function findJsonlFiles(dir) {
15691
15929
  async function walk(d) {
15692
15930
  let entries;
15693
15931
  try {
15694
- entries = await fs16.promises.readdir(d, { withFileTypes: true });
15932
+ entries = await fs17.promises.readdir(d, { withFileTypes: true });
15695
15933
  } catch {
15696
15934
  return;
15697
15935
  }
15698
15936
  for (const entry of entries) {
15699
- const full = path20.join(d, entry.name);
15937
+ const full = path21.join(d, entry.name);
15700
15938
  if (entry.isDirectory()) {
15701
15939
  await walk(full);
15702
15940
  } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
@@ -15710,11 +15948,11 @@ async function findJsonlFiles(dir) {
15710
15948
  async function loadHistory(historyPath) {
15711
15949
  const map = /* @__PURE__ */ new Map();
15712
15950
  try {
15713
- await fs16.promises.access(historyPath);
15951
+ await fs17.promises.access(historyPath);
15714
15952
  } catch {
15715
15953
  return map;
15716
15954
  }
15717
- const stream = fs16.createReadStream(historyPath, { encoding: "utf-8" });
15955
+ const stream = fs17.createReadStream(historyPath, { encoding: "utf-8" });
15718
15956
  const rl = readline2.createInterface({ input: stream, crlfDelay: Infinity });
15719
15957
  try {
15720
15958
  for await (const line of rl) {
@@ -15741,14 +15979,14 @@ async function loadHistory(historyPath) {
15741
15979
  var CodexSource = class {
15742
15980
  name = "codex";
15743
15981
  async scan() {
15744
- const codexDir = path20.join(os10.homedir(), ".codex");
15745
- const sessionsDir = path20.join(codexDir, "sessions");
15982
+ const codexDir = path21.join(os11.homedir(), ".codex");
15983
+ const sessionsDir = path21.join(codexDir, "sessions");
15746
15984
  try {
15747
- await fs16.promises.access(sessionsDir);
15985
+ await fs17.promises.access(sessionsDir);
15748
15986
  } catch {
15749
15987
  return [];
15750
15988
  }
15751
- const historyPath = path20.join(codexDir, "history.jsonl");
15989
+ const historyPath = path21.join(codexDir, "history.jsonl");
15752
15990
  const [jsonlFiles, historyMap] = await Promise.all([
15753
15991
  findJsonlFiles(sessionsDir),
15754
15992
  loadHistory(historyPath)
@@ -15771,8 +16009,8 @@ var CodexSource = class {
15771
16009
  });
15772
16010
  const historyLines = historyMap.get(meta.sessionId);
15773
16011
  if (historyLines) {
15774
- const sessionDir = path20.relative(sessionsDir, path20.dirname(filePath));
15775
- const historyAbsPath = path20.join(
16012
+ const sessionDir = path21.relative(sessionsDir, path21.dirname(filePath));
16013
+ const historyAbsPath = path21.join(
15776
16014
  sessionsDir,
15777
16015
  sessionDir,
15778
16016
  `history-${meta.sessionId}.jsonl`
@@ -15792,18 +16030,18 @@ var CodexSource = class {
15792
16030
  };
15793
16031
 
15794
16032
  // src/sources/copilotChat.ts
15795
- import fs17 from "fs";
15796
- import os11 from "os";
15797
- import path21 from "path";
16033
+ import fs18 from "fs";
16034
+ import os12 from "os";
16035
+ import path22 from "path";
15798
16036
  import { fileURLToPath } from "url";
15799
16037
  function vsCodeUserDirs() {
15800
- const home = os11.homedir();
16038
+ const home = os12.homedir();
15801
16039
  const dirs = [
15802
- path21.join(home, "Library", "Application Support", "Code", "User"),
15803
- path21.join(home, ".config", "Code", "User")
16040
+ path22.join(home, "Library", "Application Support", "Code", "User"),
16041
+ path22.join(home, ".config", "Code", "User")
15804
16042
  ];
15805
16043
  if (process.env.APPDATA) {
15806
- dirs.push(path21.join(process.env.APPDATA, "Code", "User"));
16044
+ dirs.push(path22.join(process.env.APPDATA, "Code", "User"));
15807
16045
  }
15808
16046
  return dirs;
15809
16047
  }
@@ -15818,7 +16056,7 @@ function uriToFsPath(uri) {
15818
16056
  async function readWorkspaceFolder(workspaceJsonPath) {
15819
16057
  let raw;
15820
16058
  try {
15821
- raw = await fs17.promises.readFile(workspaceJsonPath, "utf-8");
16059
+ raw = await fs18.promises.readFile(workspaceJsonPath, "utf-8");
15822
16060
  } catch {
15823
16061
  return null;
15824
16062
  }
@@ -15840,10 +16078,10 @@ var CopilotChatSource = class {
15840
16078
  async scan() {
15841
16079
  const results = [];
15842
16080
  for (const userDir of vsCodeUserDirs()) {
15843
- const workspaceStorage = path21.join(userDir, "workspaceStorage");
16081
+ const workspaceStorage = path22.join(userDir, "workspaceStorage");
15844
16082
  let hashDirs;
15845
16083
  try {
15846
- hashDirs = await fs17.promises.readdir(workspaceStorage, {
16084
+ hashDirs = await fs18.promises.readdir(workspaceStorage, {
15847
16085
  withFileTypes: true
15848
16086
  });
15849
16087
  } catch {
@@ -15851,22 +16089,22 @@ var CopilotChatSource = class {
15851
16089
  }
15852
16090
  for (const hash of hashDirs) {
15853
16091
  if (!hash.isDirectory()) continue;
15854
- const wsRoot = path21.join(workspaceStorage, hash.name);
15855
- const transcriptsDir = path21.join(
16092
+ const wsRoot = path22.join(workspaceStorage, hash.name);
16093
+ const transcriptsDir = path22.join(
15856
16094
  wsRoot,
15857
16095
  "GitHub.copilot-chat",
15858
16096
  "transcripts"
15859
16097
  );
15860
16098
  let transcriptEntries;
15861
16099
  try {
15862
- transcriptEntries = await fs17.promises.readdir(transcriptsDir, {
16100
+ transcriptEntries = await fs18.promises.readdir(transcriptsDir, {
15863
16101
  withFileTypes: true
15864
16102
  });
15865
16103
  } catch {
15866
16104
  continue;
15867
16105
  }
15868
16106
  const repoPath = await readWorkspaceFolder(
15869
- path21.join(wsRoot, "workspace.json")
16107
+ path22.join(wsRoot, "workspace.json")
15870
16108
  );
15871
16109
  if (!repoPath) continue;
15872
16110
  for (const entry of transcriptEntries) {
@@ -15874,7 +16112,7 @@ var CopilotChatSource = class {
15874
16112
  const sessionId = entry.name.slice(0, -".jsonl".length);
15875
16113
  results.push({
15876
16114
  sourceName: this.name,
15877
- absolutePath: path21.join(transcriptsDir, entry.name),
16115
+ absolutePath: path22.join(transcriptsDir, entry.name),
15878
16116
  repoPath,
15879
16117
  metadata: { sessionId }
15880
16118
  });
@@ -15914,7 +16152,7 @@ function reportRedactionStats(noun, stats) {
15914
16152
  async function filterByTimeRange(group, range) {
15915
16153
  const results = await Promise.all(
15916
16154
  group.files.map(
15917
- (f) => fs18.promises.stat(f.absolutePath).then((s) => ({ file: f, stat: s })).catch(() => null)
16155
+ (f) => fs19.promises.stat(f.absolutePath).then((s) => ({ file: f, stat: s })).catch(() => null)
15918
16156
  )
15919
16157
  );
15920
16158
  const filtered = [];
@@ -15941,10 +16179,10 @@ async function runInteractive() {
15941
16179
  s.start(`Scanning ${source.name} logs...`);
15942
16180
  const allFiles = await source.scan();
15943
16181
  const allGroups = await mergeByRepo(allFiles);
15944
- const repoRoot = path22.resolve(repo.root);
16182
+ const repoRoot = path23.resolve(repo.root);
15945
16183
  const matching = allGroups.filter((g) => {
15946
- const resolved = path22.resolve(g.repoPath);
15947
- return resolved === repoRoot || resolved.startsWith(repoRoot + path22.sep);
16184
+ const resolved = path23.resolve(g.repoPath);
16185
+ return resolved === repoRoot || resolved.startsWith(repoRoot + path23.sep);
15948
16186
  });
15949
16187
  if (matching.length === 0) {
15950
16188
  s.stop(`No ${source.name} logs found for ${repo.name}.`);
@@ -15975,7 +16213,7 @@ async function runInteractive() {
15975
16213
  }
15976
16214
  }
15977
16215
  const envFileNames = await discoverEnvFiles(repoRoot);
15978
- const envFilePaths = envFileNames.map((n) => path22.join(repoRoot, n));
16216
+ const envFilePaths = envFileNames.map((n) => path23.join(repoRoot, n));
15979
16217
  const additionalFiles = await promptSecretFiles(envFileNames);
15980
16218
  const secretResult = await collectSecrets(
15981
16219
  repoRoot,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hillclimb",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
4
4
  "description": "Extract and export AI coding tool logs grouped by repo",
5
5
  "license": "MIT",
6
6
  "type": "module",