claude-teammate 0.1.304 → 0.1.306

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-teammate",
3
- "version": "0.1.304",
3
+ "version": "0.1.306",
4
4
  "description": "CLI bootstrapper for Claude Teammate.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,7 +1,8 @@
1
1
  import { spawn } from "node:child_process";
2
2
 
3
3
  const CHILD_CLEANUP_WAIT_MS = 1_000;
4
- const CLAUDE_MAX_ATTEMPTS = 1;
4
+ const CLAUDE_MAX_ATTEMPTS = 3;
5
+ const CLAUDE_RETRY_BASE_DELAY_MS = 2_000;
5
6
  const DEFAULT_CLAUDE_PERMISSION_MODE = "bypassPermissions";
6
7
 
7
8
  export class ClaudeCliError extends Error {
@@ -53,8 +54,44 @@ export function formatClaudeInvocationError(error, timeoutMs) {
53
54
  return `Claude CLI invocation failed${timeout ? ` after ${timeoutMs}ms` : ""}${signal ? ` (${signal})` : ""}${codeFragment}${details ? `: ${details}` : "."}`;
54
55
  }
55
56
 
56
- export function shouldRetryClaudeCommand(options = {}, attempt) {
57
- return !options.noRetry && attempt < CLAUDE_MAX_ATTEMPTS;
57
+ // Resource/transport failures that a fresh attempt can plausibly clear:
58
+ // fork failures under VM load (EAGAIN/ENOMEM/ENFILE/EMFILE), OOM-kills
59
+ // (SIGKILL), and timeouts. Deterministic failures must NOT retry: a usage
60
+ // limit, an over-long prompt, or a clean non-zero exit (a logic/permission
61
+ // error from a CLI that actually ran) will fail again identically and just
62
+ // burn quota.
63
+ const TRANSIENT_SPAWN_ERRNOS = new Set(["EAGAIN", "ENOMEM", "ENFILE", "EMFILE", "ECONNRESET", "ETIMEDOUT"]);
64
+
65
+ export function isTransientClaudeError(error) {
66
+ if (!error || typeof error !== "object") {
67
+ return false;
68
+ }
69
+ if (error.hitUsageLimit || error.promptTooLong) {
70
+ return false;
71
+ }
72
+ if (error.killed) {
73
+ return true;
74
+ }
75
+ if (typeof error.code === "string" && TRANSIENT_SPAWN_ERRNOS.has(error.code)) {
76
+ return true;
77
+ }
78
+ // External SIGKILL with no captured output is the classic OOM-killer
79
+ // signature; SIGTERM here is our own maxBuffer/limit kill and is handled
80
+ // by the flags above, so only treat SIGKILL as transient.
81
+ if (error.signal === "SIGKILL") {
82
+ return true;
83
+ }
84
+ return false;
85
+ }
86
+
87
+ export function shouldRetryClaudeCommand(options = {}, attempt, error) {
88
+ return !options.noRetry && attempt < CLAUDE_MAX_ATTEMPTS && isTransientClaudeError(error);
89
+ }
90
+
91
+ function delay(ms) {
92
+ return new Promise((resolve) => {
93
+ setTimeout(resolve, ms);
94
+ });
58
95
  }
59
96
 
60
97
  export async function runClaudeCommand(command, args, options) {
@@ -62,9 +99,13 @@ export async function runClaudeCommand(command, args, options) {
62
99
  try {
63
100
  return await runClaudeCommandOnce(command, args, options);
64
101
  } catch (error) {
65
- if (!shouldRetryClaudeCommand(options, attempt)) {
102
+ if (!shouldRetryClaudeCommand(options, attempt, error)) {
66
103
  throw error;
67
104
  }
105
+ options.onRetry?.({ attempt, error });
106
+ // Exponential backoff so a transient spike (fork storm, OOM) gets a
107
+ // moment to subside before the next attempt.
108
+ await delay(CLAUDE_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1));
68
109
  }
69
110
  }
70
111
  }
@@ -1,5 +1,28 @@
1
1
  import { formatAdditionalRepoPaths } from "./paths.js";
2
2
 
3
+ // Re-ground a re-invoked implementation run on what an earlier run already did.
4
+ // Both fields are optional; when neither is present this contributes nothing so
5
+ // first runs are unchanged. The diff is truncated by the caller to stay within
6
+ // the prompt budget.
7
+ function formatPriorWorkContext(input) {
8
+ const sections = [];
9
+ const priorSummary = String(input.priorSummary || "").trim();
10
+ if (priorSummary) {
11
+ sections.push(`\nPrior run summary (what you reported last time):\n${priorSummary}`);
12
+ }
13
+ const branchDiff = String(input.branchDiff || "").trim();
14
+ if (branchDiff) {
15
+ sections.push(`\nWork already done on this branch (diff vs base):\n${branchDiff}`);
16
+ }
17
+ if (sections.length === 0) {
18
+ return "";
19
+ }
20
+ sections.push(
21
+ "\nBuild on the work above; do not redo or revert changes that already satisfy the plan unless the latest comment asks for it."
22
+ );
23
+ return `${sections.join("\n")}\n`;
24
+ }
25
+
3
26
  const INCLUDE_RESOURCE_LINKS_RULE =
4
27
  "When your response is posted as a comment on a Jira issue, GitHub issue, or GitHub PR, include the URL of every resource you created (e.g. Jira task, spreadsheet, test design, test cases, document, Confluence page) so the reader can navigate directly to it.";
5
28
 
@@ -353,7 +376,7 @@ ${input.latestComment?.body || "(none)"}
353
376
 
354
377
  Recent PR comments:
355
378
  ${recentComments || "(none)"}
356
-
379
+ ${formatPriorWorkContext(input)}
357
380
  Instructions:
358
381
  - Checkout the branch above in this repository.
359
382
  - Implement the plan described in the pull request body.
@@ -469,7 +492,7 @@ ${JSON.stringify(input.memory?.epic || {}, null, 2)}
469
492
 
470
493
  Step to implement:
471
494
  ${input.step}
472
-
495
+ ${formatPriorWorkContext(input)}
473
496
  Instructions:
474
497
  - Checkout the branch above in this repository.
475
498
  - Implement only the step described above. Do not implement other parts of the plan.
@@ -26,6 +26,24 @@ export function formatStreamEvent(event) {
26
26
  }
27
27
  }
28
28
 
29
+ // Pull the Claude session id out of stream-json events so the caller can
30
+ // persist it and resume the same conversation on a later run (e.g. when a
31
+ // developer comments on the PR). Both `system` (init) and `result` events
32
+ // carry session_id; the last one wins.
33
+ export function extractSessionId(lines) {
34
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
35
+ try {
36
+ const event = JSON.parse(lines[i]);
37
+ if (event && typeof event.session_id === "string" && event.session_id) {
38
+ return event.session_id;
39
+ }
40
+ } catch {
41
+ // skip non-JSON lines
42
+ }
43
+ }
44
+ return "";
45
+ }
46
+
29
47
  export function extractResultFromStreamJson(lines) {
30
48
  for (let i = lines.length - 1; i >= 0; i--) {
31
49
  try {
@@ -40,13 +58,79 @@ export function extractResultFromStreamJson(lines) {
40
58
  throw new Error("No result event found in stream-json output.");
41
59
  }
42
60
 
61
+ function tryParseJson(text) {
62
+ try {
63
+ return { ok: true, value: JSON.parse(text) };
64
+ } catch {
65
+ return { ok: false };
66
+ }
67
+ }
68
+
69
+ // Recover a JSON object/array embedded in prose or markdown fences. Claude
70
+ // frequently wraps the requested JSON in ```json fences or surrounds it with a
71
+ // sentence or two; previously any such non-strict output threw a raw
72
+ // SyntaxError that crashed the whole task. We try a fenced block first, then
73
+ // fall back to the first balanced {...} / [...] span.
74
+ function extractEmbeddedJson(text) {
75
+ if (typeof text !== "string") {
76
+ return { ok: false };
77
+ }
78
+
79
+ const fence = text.match(/```(?:json)?\s*([[{][\s\S]*?[\]}])\s*```/iu);
80
+ if (fence) {
81
+ const parsed = tryParseJson(fence[1].trim());
82
+ if (parsed.ok) {
83
+ return parsed;
84
+ }
85
+ }
86
+
87
+ const start = text.search(/[[{]/u);
88
+ if (start !== -1) {
89
+ const open = text[start];
90
+ const close = open === "{" ? "}" : "]";
91
+ let depth = 0;
92
+ for (let i = start; i < text.length; i += 1) {
93
+ const ch = text[i];
94
+ if (ch === open) {
95
+ depth += 1;
96
+ } else if (ch === close) {
97
+ depth -= 1;
98
+ if (depth === 0) {
99
+ const parsed = tryParseJson(text.slice(start, i + 1));
100
+ if (parsed.ok) {
101
+ return parsed;
102
+ }
103
+ break;
104
+ }
105
+ }
106
+ }
107
+ }
108
+
109
+ return { ok: false };
110
+ }
111
+
112
+ // Parse a string that is expected to be JSON, falling back to embedded-JSON
113
+ // recovery. Throws a clear, catchable Error (never a raw SyntaxError) so the
114
+ // caller can treat "model returned prose" as a normal task failure.
115
+ function parseJsonOrRecover(text, label) {
116
+ const parsed = tryParseJson(text);
117
+ if (parsed.ok) {
118
+ return parsed.value;
119
+ }
120
+ const embedded = extractEmbeddedJson(text);
121
+ if (embedded.ok) {
122
+ return embedded.value;
123
+ }
124
+ throw new Error(`Claude CLI ${label} was not valid JSON: ${String(text).slice(0, 200)}`);
125
+ }
126
+
43
127
  export function parseClaudeOutput(output) {
44
128
  const trimmed = output.trim();
45
129
  if (!trimmed) {
46
130
  throw new Error("Claude CLI returned empty output.");
47
131
  }
48
132
 
49
- const direct = JSON.parse(trimmed);
133
+ const direct = parseJsonOrRecover(trimmed, "output");
50
134
  if (direct && typeof direct === "object") {
51
135
  if ("structured_output" in direct && direct.structured_output && typeof direct.structured_output === "object") {
52
136
  return direct.structured_output;
@@ -83,11 +167,11 @@ export function parseClaudeOutput(output) {
83
167
  }
84
168
 
85
169
  if ("result" in direct && typeof direct.result === "string") {
86
- return JSON.parse(direct.result);
170
+ return parseJsonOrRecover(direct.result, "result");
87
171
  }
88
172
 
89
173
  if ("content" in direct && typeof direct.content === "string") {
90
- return JSON.parse(direct.content);
174
+ return parseJsonOrRecover(direct.content, "content");
91
175
  }
92
176
  }
93
177
 
package/src/claude.js CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  cleanupClaudeProcesses,
14
14
  formatClaudeInvocationError,
15
15
  isClaudeCliError,
16
+ isTransientClaudeError,
16
17
  resolveClaudePermissionMode,
17
18
  runClaudeCommand,
18
19
  shouldRetryClaudeCommand
@@ -45,7 +46,12 @@ import {
45
46
  buildSuggestionRevisionSystemPrompt,
46
47
  buildSuggestionRevisionUserPrompt
47
48
  } from "./claude/prompts.js";
48
- import { extractResultFromStreamJson, formatStreamEvent, parseClaudeOutput } from "./claude/stream.js";
49
+ import {
50
+ extractResultFromStreamJson,
51
+ extractSessionId,
52
+ formatStreamEvent,
53
+ parseClaudeOutput
54
+ } from "./claude/stream.js";
49
55
  import {
50
56
  validateClaudeResult,
51
57
  validateEpicMemoryCleanupResult,
@@ -76,6 +82,7 @@ export {
76
82
  hasPlaywrightMcpConfigured,
77
83
  isClaudeCliError,
78
84
  isHeadlessPlaywrightMcpConfig,
85
+ isTransientClaudeError,
79
86
  parseClaudeOutput,
80
87
  shouldRetryClaudeCommand
81
88
  };
@@ -380,10 +387,39 @@ const REPO_EXTRACTION_SCHEMA = {
380
387
  required: ["repos", "pr_repo_url"]
381
388
  };
382
389
 
390
+ // A `--resume <id>` invocation failed specifically because the session could
391
+ // not be loaded (worker moved hosts, local session store cleared, id stale).
392
+ // Distinct from a real task failure so we only fall back to a fresh run for
393
+ // the recoverable case.
394
+ export function isResumeSessionError(error) {
395
+ if (!error || typeof error !== "object") {
396
+ return false;
397
+ }
398
+ const text = `${error.stderr || ""}\n${error.stdout || ""}\n${error.message || ""}`.toLowerCase();
399
+ return (
400
+ text.includes("no conversation found") ||
401
+ text.includes("session not found") ||
402
+ text.includes("could not find session") ||
403
+ text.includes("no session") ||
404
+ (text.includes("resume") && text.includes("not found"))
405
+ );
406
+ }
407
+
408
+ function wrapClaudeCliError(error, timeoutMs) {
409
+ const cliError = new ClaudeCliError(formatClaudeInvocationError(error, timeoutMs));
410
+ if (error?.hitUsageLimit) {
411
+ cliError.hitUsageLimit = true;
412
+ }
413
+ if (error?.promptTooLong) {
414
+ cliError.promptTooLong = true;
415
+ }
416
+ return cliError;
417
+ }
418
+
383
419
  async function invokeClaudeTask(schema, systemPrompt, userPrompt, opts = {}) {
384
420
  const { model, permissionMode, effort, extraArgs = [], validate, runOpts = {} } = opts;
385
421
 
386
- const args = [
422
+ const baseArgs = [
387
423
  "--print",
388
424
  "--model",
389
425
  model || DEFAULT_MODEL,
@@ -400,14 +436,21 @@ async function invokeClaudeTask(schema, systemPrompt, userPrompt, opts = {}) {
400
436
  userPrompt
401
437
  ];
402
438
 
403
- const { issueKey, logger, phase, epicContext, ...restRunOpts } = runOpts;
439
+ const { issueKey, logger, phase, epicContext, resumeSessionId, onSessionId, ...restRunOpts } = runOpts;
404
440
 
405
441
  // Always use stream-json so we have full event data for skill failure detection.
406
442
  const loggingEnabled = Boolean(issueKey && logger && typeof logger.issueLog === "function");
407
- const effectiveArgs = buildStreamArgs(args);
443
+
444
+ // Resume the prior conversation when a session id is supplied so the model
445
+ // keeps its earlier context (e.g. responding to a PR comment). `--resume`
446
+ // is prepended; if it fails because the session is gone we fall back to a
447
+ // fresh run below so a stale id never permanently blocks the task.
448
+ const resumeId = String(resumeSessionId || "").trim();
449
+ const buildArgs = (withResume) =>
450
+ buildStreamArgs(withResume && resumeId ? ["--resume", resumeId, ...baseArgs] : baseArgs);
408
451
 
409
452
  const collectedLines = [];
410
- const onData = (chunk) => {
453
+ const makeOnData = () => (chunk) => {
411
454
  const lines = String(chunk)
412
455
  .split("\n")
413
456
  .filter((l) => l.trim());
@@ -427,21 +470,40 @@ async function invokeClaudeTask(schema, systemPrompt, userPrompt, opts = {}) {
427
470
  }
428
471
  };
429
472
 
430
- try {
431
- await runClaudeCommand("claude", effectiveArgs, {
473
+ const runOnce = (withResume) => {
474
+ collectedLines.length = 0;
475
+ return runClaudeCommand("claude", buildArgs(withResume), {
432
476
  maxBuffer: 10 * 1024 * 1024,
433
477
  ...restRunOpts,
434
- onData
478
+ onData: makeOnData()
435
479
  });
480
+ };
481
+
482
+ try {
483
+ await runOnce(Boolean(resumeId));
436
484
  } catch (error) {
437
- const cliError = new ClaudeCliError(formatClaudeInvocationError(error, runOpts.timeout));
438
- if (error?.hitUsageLimit) {
439
- cliError.hitUsageLimit = true;
485
+ // A stale/missing session is recoverable: retry once from a clean slate.
486
+ if (resumeId && isResumeSessionError(error)) {
487
+ void logger?.info?.("Claude session resume failed; retrying without resume", { issueKey, phase });
488
+ try {
489
+ await runOnce(false);
490
+ } catch (retryError) {
491
+ throw wrapClaudeCliError(retryError, runOpts.timeout);
492
+ }
493
+ } else {
494
+ throw wrapClaudeCliError(error, runOpts.timeout);
440
495
  }
441
- if (error?.promptTooLong) {
442
- cliError.promptTooLong = true;
496
+ }
497
+
498
+ if (typeof onSessionId === "function") {
499
+ const sessionId = extractSessionId(collectedLines);
500
+ if (sessionId) {
501
+ try {
502
+ onSessionId(sessionId);
503
+ } catch {
504
+ // Persisting the session id is best-effort; never fail the task on it.
505
+ }
443
506
  }
444
- throw cliError;
445
507
  }
446
508
 
447
509
  const outputToParse = extractResultFromStreamJson(collectedLines);
@@ -787,7 +849,9 @@ export async function runClaudeImplementation(input) {
787
849
  issueKey: input.issueKey,
788
850
  logger: input.logger,
789
851
  phase: "implementation",
790
- epicContext: input.epicContext
852
+ epicContext: input.epicContext,
853
+ resumeSessionId: input.resumeSessionId,
854
+ onSessionId: input.onSessionId
791
855
  }
792
856
  }
793
857
  );
@@ -870,7 +934,9 @@ export async function runClaudeImplementationStep(input) {
870
934
  issueKey: input.issueKey,
871
935
  logger: input.logger,
872
936
  phase: "implementation-step",
873
- epicContext: input.epicContext
937
+ epicContext: input.epicContext,
938
+ resumeSessionId: input.resumeSessionId,
939
+ onSessionId: input.onSessionId
874
940
  }
875
941
  }
876
942
  );
@@ -150,17 +150,17 @@ export function createGitLabClient(config) {
150
150
 
151
151
  async createIssueReaction(repoUrl, issueNumber, content = "eyes") {
152
152
  const repo = parseGitLabRepoUrl(repoUrl);
153
- return requestGitLabProject(config, repo, `${issueNotePath(issueNumber)}/award_emoji`, {
154
- method: "POST",
155
- body: {
156
- name: mapReaction(content)
157
- }
158
- });
153
+ return awardEmojiOnce(
154
+ config,
155
+ projectPath(repo, `${issueNotePath(issueNumber)}/award_emoji`),
156
+ `${repo.origin}/`,
157
+ mapReaction(content)
158
+ );
159
159
  },
160
160
 
161
161
  async createIssueCommentReaction(repoUrl, commentId, content = "eyes", issueNumber = "") {
162
162
  const repo = parseGitLabRepoUrl(repoUrl);
163
- const body = { name: mapReaction(content) };
163
+ const name = mapReaction(content);
164
164
  const candidates = [
165
165
  projectPath(repo, `${mergeRequestNotePath(issueNumber)}/${commentId}/award_emoji`),
166
166
  projectPath(repo, `/issues/${issueNumber}/notes/${commentId}/award_emoji`)
@@ -169,15 +169,9 @@ export function createGitLabClient(config) {
169
169
  let lastError = null;
170
170
  for (const candidate of candidates) {
171
171
  try {
172
- return await requestGitLab(config, candidate, {
173
- baseUrl: `${repo.origin}/`,
174
- method: "POST",
175
- body
176
- });
172
+ // Don't swallow 404 here: a wrong-kind path must fall through to the next candidate.
173
+ return await awardEmojiOnce(config, candidate, `${repo.origin}/`, name, { ignoreMissing: false });
177
174
  } catch (error) {
178
- if (String(error?.message || "").includes("Award Emoji Name has already been taken")) {
179
- return;
180
- }
181
175
  lastError = error;
182
176
  }
183
177
  }
@@ -187,13 +181,12 @@ export function createGitLabClient(config) {
187
181
 
188
182
  async createPullRequestCommentReaction(repoUrl, commentId, content = "eyes", pullNumber = "") {
189
183
  const repo = parseGitLabRepoUrl(repoUrl);
190
- return requestGitLab(config, projectPath(repo, `${mergeRequestNotePath(pullNumber)}/${commentId}/award_emoji`), {
191
- baseUrl: `${repo.origin}/`,
192
- method: "POST",
193
- body: {
194
- name: mapReaction(content)
195
- }
196
- });
184
+ return awardEmojiOnce(
185
+ config,
186
+ projectPath(repo, `${mergeRequestNotePath(pullNumber)}/${commentId}/award_emoji`),
187
+ `${repo.origin}/`,
188
+ mapReaction(content)
189
+ );
197
190
  },
198
191
 
199
192
  async isRepoCollaborator(repoUrl, username) {
@@ -544,11 +537,12 @@ export function createGitLabClient(config) {
544
537
 
545
538
  async addReactionToNote(repoUrl, prNumber, noteId, reaction = "eyes") {
546
539
  const repo = parseGitLabRepoUrl(repoUrl);
547
- return requestGitLab(config, projectPath(repo, `/merge_requests/${prNumber}/notes/${noteId}/award_emoji`), {
548
- baseUrl: `${repo.origin}/`,
549
- method: "POST",
550
- body: { name: mapReaction(reaction) }
551
- });
540
+ return awardEmojiOnce(
541
+ config,
542
+ projectPath(repo, `/merge_requests/${prNumber}/notes/${noteId}/award_emoji`),
543
+ `${repo.origin}/`,
544
+ mapReaction(reaction)
545
+ );
552
546
  },
553
547
 
554
548
  async listAiReviewedPrs(repoUrl) {
@@ -1014,6 +1008,47 @@ function mapReaction(content) {
1014
1008
  }
1015
1009
  }
1016
1010
 
1011
+ async function findExistingAward(config, awardEmojiPath, baseUrl, name) {
1012
+ // GitLab returns every award on the resource; reuse ours instead of re-POSTing each poll.
1013
+ try {
1014
+ const existing = await requestGitLab(config, awardEmojiPath, { baseUrl });
1015
+ if (Array.isArray(existing)) {
1016
+ return existing.find((emoji) => emoji?.name === name) || null;
1017
+ }
1018
+ } catch {
1019
+ // List failed — fall through to POST, which still tolerates a duplicate.
1020
+ }
1021
+ return null;
1022
+ }
1023
+
1024
+ function isAwardEmojiDuplicate(error) {
1025
+ return String(error?.message || "").includes("has already been taken");
1026
+ }
1027
+
1028
+ function isMissingResource(error) {
1029
+ return error?.statusCode === 404 || /failed with 404\b/u.test(String(error?.message || ""));
1030
+ }
1031
+
1032
+ // Idempotent award: skip when ours already exists, swallow the duplicate race,
1033
+ // and (for single-path callers) treat a deleted resource as a no-op success.
1034
+ async function awardEmojiOnce(config, awardEmojiPath, baseUrl, name, { ignoreMissing = true } = {}) {
1035
+ const found = await findExistingAward(config, awardEmojiPath, baseUrl, name);
1036
+ if (found) {
1037
+ return found;
1038
+ }
1039
+ try {
1040
+ return await requestGitLab(config, awardEmojiPath, { baseUrl, method: "POST", body: { name } });
1041
+ } catch (error) {
1042
+ if (isAwardEmojiDuplicate(error)) {
1043
+ return {};
1044
+ }
1045
+ if (ignoreMissing && isMissingResource(error)) {
1046
+ return {};
1047
+ }
1048
+ throw error;
1049
+ }
1050
+ }
1051
+
1017
1052
  async function requestGitLab(config, pathOrUrl, init = {}) {
1018
1053
  const url = pathOrUrl.startsWith("http")
1019
1054
  ? new URL(pathOrUrl)
@@ -1050,7 +1085,9 @@ async function requestGitLab(config, pathOrUrl, init = {}) {
1050
1085
  authError.statusCode = response.status;
1051
1086
  throw authError;
1052
1087
  }
1053
- throw new Error(buildGitLabRequestError(response.status, url, body));
1088
+ const requestError = new Error(buildGitLabRequestError(response.status, url, body));
1089
+ requestError.statusCode = response.status;
1090
+ throw requestError;
1054
1091
  }
1055
1092
 
1056
1093
  if (response.status === 204) {
@@ -2,6 +2,22 @@ import process from "node:process";
2
2
 
3
3
  const GITHUB_HOST = "github.com";
4
4
 
5
+ // Path segments that mark a URL as something other than a repo root: forge
6
+ // sub-pages (blob/tree/pull/merge_requests/…) and auth/account endpoints
7
+ // (sign_in/users/…). Matched as a whole path component (slash-delimited).
8
+ const NON_REPO_PATHS =
9
+ /\/(?:blob|tree|raw|actions|issues|pull|commit|releases|compare|discussions|wiki|tags|branches|security|pulse|graphs|settings|merge_requests|pipelines|environments|packages|network|activity|sign_in|sign_up|users|sessions|oauth|-)\//u;
10
+ // GitLab uses /-/ as a separator before sub-paths (e.g. /-/merge_requests/12).
11
+ const GITLAB_SUBPATH = /\/-\//u;
12
+
13
+ // True when a URL path is NOT a plain `owner/repo` root. Used both to filter
14
+ // discovered URLs and (authoritatively) inside parseRepoParts so a junk URL
15
+ // can never be turned into a clone target.
16
+ export function isNonRepoPath(pathOrUrl) {
17
+ const probe = `/${String(pathOrUrl || "").replace(/^\/+/u, "")}/`;
18
+ return NON_REPO_PATHS.test(probe) || GITLAB_SUBPATH.test(probe);
19
+ }
20
+
5
21
  export function parseRepoUrl(repoUrl) {
6
22
  const value = String(repoUrl || "").trim();
7
23
  if (!value) {
@@ -101,12 +117,8 @@ export function extractRepoUrls(text) {
101
117
  return [];
102
118
  }
103
119
 
104
- // Filter out URLs that are not repo roots (e.g. blob, tree, actions, issues, pull, commit paths)
105
- const NON_REPO_PATHS =
106
- /\/(?:blob|tree|raw|actions|issues|pull|commit|releases|compare|discussions|wiki|tags|branches|security|pulse|graphs|settings|merge_requests|pipelines|environments|packages|network|activity)\//u;
107
- // GitLab uses /-/ as a separator before sub-paths (e.g. /-/merge_requests/12, /-/issues/5)
108
- const GITLAB_SUBPATH = /\/-\//u;
109
- const repoUrls = matches.filter((url) => !NON_REPO_PATHS.test(url) && !GITLAB_SUBPATH.test(url));
120
+ // Filter out URLs that are not repo roots (blob/tree/issues/merge_requests/…)
121
+ const repoUrls = matches.filter((url) => !isNonRepoPath(url));
110
122
 
111
123
  return [...new Set(repoUrls)];
112
124
  }
@@ -116,6 +128,14 @@ function parseRepoParts(host, rawPath) {
116
128
  const cleanPath = String(rawPath || "")
117
129
  .replace(/\.git$/u, "")
118
130
  .replace(/\/+$/u, "");
131
+
132
+ // Single source of truth: reject MR/issue/auth/redirect paths here so a junk
133
+ // URL reaching parseRepoUrl by any route can never be built into a bogus
134
+ // clone target (e.g. `.../-/merge_requests/15.git`, `.../users/sign_in.git`).
135
+ if (isNonRepoPath(cleanPath)) {
136
+ throw new Error(`Not a ${getProviderLabel(provider)} repo URL (sub-page or auth path): ${rawPath}`);
137
+ }
138
+
119
139
  const segments = cleanPath.split("/").filter(Boolean);
120
140
 
121
141
  if (segments.length < 2) {
@@ -0,0 +1,23 @@
1
+ import { rename, writeFile } from "node:fs/promises";
2
+
3
+ // Monotonic per-process counter so two writes from the same process never
4
+ // share a temp path, even within the same millisecond.
5
+ let tempCounter = 0;
6
+
7
+ // Build a temp path that is unique per process + call. Concurrent writers
8
+ // (same process or two worker processes) each rename their own temp file onto
9
+ // the target, so the rename can never hit ENOENT from another writer having
10
+ // already consumed a shared `<file>.tmp`.
11
+ export function buildTempPath(filePath) {
12
+ tempCounter = (tempCounter + 1) % Number.MAX_SAFE_INTEGER;
13
+ return `${filePath}.${process.pid}.${tempCounter}.tmp`;
14
+ }
15
+
16
+ // Atomically write `content` to `filePath`: write to a unique temp file then
17
+ // rename onto the target. The rename is atomic on POSIX, so readers always see
18
+ // either the old or the new complete file, never a partial write.
19
+ export async function atomicWriteFile(filePath, content) {
20
+ const tempFile = buildTempPath(filePath);
21
+ await writeFile(tempFile, content, "utf8");
22
+ await rename(tempFile, filePath);
23
+ }