agent-relay-server 0.65.1 → 0.66.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 (108) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +2 -2
  3. package/public/assets/MessageBubble-C7L3IeeL.js +3 -0
  4. package/public/assets/MessageBubble-C7L3IeeL.js.map +1 -0
  5. package/public/assets/{PlanGraphPanel-CynI6Jkn.js → PlanGraphPanel-Dxh2Cd0B.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-CynI6Jkn.js.map → PlanGraphPanel-Dxh2Cd0B.js.map} +1 -1
  7. package/public/assets/{activity-CXduH3OT.js → activity-f0LLy20u.js} +2 -2
  8. package/public/assets/{activity-CXduH3OT.js.map → activity-f0LLy20u.js.map} +1 -1
  9. package/public/assets/{agent-profiles-jialTp_S.js → agent-profiles-DvktAPVq.js} +2 -2
  10. package/public/assets/{agent-profiles-jialTp_S.js.map → agent-profiles-DvktAPVq.js.map} +1 -1
  11. package/public/assets/{agents-Y6d4dgr2.js → agents-BjnKBLy7.js} +2 -2
  12. package/public/assets/{agents-Y6d4dgr2.js.map → agents-BjnKBLy7.js.map} +1 -1
  13. package/public/assets/{analytics-M0Pty7sl.js → analytics-CLxDxrYr.js} +2 -2
  14. package/public/assets/{analytics-M0Pty7sl.js.map → analytics-CLxDxrYr.js.map} +1 -1
  15. package/public/assets/{automation-CG_q2Xpq.js → automation-CC6F9m_t.js} +2 -2
  16. package/public/assets/{automation-CG_q2Xpq.js.map → automation-CC6F9m_t.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-D_BTP8-I.js → branch-state-badge-DGA_hkH_.js} +2 -2
  18. package/public/assets/{branch-state-badge-D_BTP8-I.js.map → branch-state-badge-DGA_hkH_.js.map} +1 -1
  19. package/public/assets/{channels-BrfLaQ0U.js → channels-c244tNlf.js} +2 -2
  20. package/public/assets/{channels-BrfLaQ0U.js.map → channels-c244tNlf.js.map} +1 -1
  21. package/public/assets/chat-5PirKNMc.js +2 -0
  22. package/public/assets/chat-5PirKNMc.js.map +1 -0
  23. package/public/assets/{connectors-BPy5G-NJ.js → connectors-B2HKhNAR.js} +2 -2
  24. package/public/assets/{connectors-BPy5G-NJ.js.map → connectors-B2HKhNAR.js.map} +1 -1
  25. package/public/assets/display-LAWaAboe.js +3 -0
  26. package/public/assets/{display-CWEz0zy_.js.map → display-LAWaAboe.js.map} +1 -1
  27. package/public/assets/formatted-body-impl-tscpWWyb.js +3 -0
  28. package/public/assets/formatted-body-impl-tscpWWyb.js.map +1 -0
  29. package/public/assets/index-F6MG4ocQ.css +2 -0
  30. package/public/assets/{index-D3ie3rwt.js → index-U-4HIYmF.js} +10 -10
  31. package/public/assets/index-U-4HIYmF.js.map +1 -0
  32. package/public/assets/{insights-Zd0e-vd4.js → insights-lc2QtO8O.js} +2 -2
  33. package/public/assets/{insights-Zd0e-vd4.js.map → insights-lc2QtO8O.js.map} +1 -1
  34. package/public/assets/{integrations-BYZbQndN.js → integrations-Qup4mhkH.js} +2 -2
  35. package/public/assets/{integrations-BYZbQndN.js.map → integrations-Qup4mhkH.js.map} +1 -1
  36. package/public/assets/{maintenance-DezsNPI4.js → maintenance-7Dy1fhTR.js} +2 -2
  37. package/public/assets/{maintenance-DezsNPI4.js.map → maintenance-7Dy1fhTR.js.map} +1 -1
  38. package/public/assets/{managed-agents-OdkCKQ7D.js → managed-agents-Ot7Yygpe.js} +2 -2
  39. package/public/assets/{managed-agents-OdkCKQ7D.js.map → managed-agents-Ot7Yygpe.js.map} +1 -1
  40. package/public/assets/{markdown-preview-impl-BqC6Fi5e.js → markdown-preview-impl-CzDWgMwj.js} +2 -2
  41. package/public/assets/{markdown-preview-impl-BqC6Fi5e.js.map → markdown-preview-impl-CzDWgMwj.js.map} +1 -1
  42. package/public/assets/{memory-BfFPcQcB.js → memory-CjAUbmt2.js} +2 -2
  43. package/public/assets/{memory-BfFPcQcB.js.map → memory-CjAUbmt2.js.map} +1 -1
  44. package/public/assets/{messages-ClkBcpYi.js → messages-B-pXamWZ.js} +2 -2
  45. package/public/assets/messages-B-pXamWZ.js.map +1 -0
  46. package/public/assets/orchestrators-BzAfrK0L.js +2 -0
  47. package/public/assets/{orchestrators-DyCvsN6E.js.map → orchestrators-BzAfrK0L.js.map} +1 -1
  48. package/public/assets/{overview-232eKGjk.js → overview-5mxLbpO7.js} +2 -2
  49. package/public/assets/{overview-232eKGjk.js.map → overview-5mxLbpO7.js.map} +1 -1
  50. package/public/assets/{pairs-CBymrP2X.js → pairs-DAfV42IZ.js} +2 -2
  51. package/public/assets/{pairs-CBymrP2X.js.map → pairs-DAfV42IZ.js.map} +1 -1
  52. package/public/assets/{projects-BvVx_AIE.js → projects-DH957G-z.js} +2 -2
  53. package/public/assets/{projects-BvVx_AIE.js.map → projects-DH957G-z.js.map} +1 -1
  54. package/public/assets/{security-FQMqi91P.js → security-ImaJC3Tp.js} +2 -2
  55. package/public/assets/{security-FQMqi91P.js.map → security-ImaJC3Tp.js.map} +1 -1
  56. package/public/assets/{select-SZYujtKA.js → select-Dp7SHtmp.js} +2 -2
  57. package/public/assets/{select-SZYujtKA.js.map → select-Dp7SHtmp.js.map} +1 -1
  58. package/public/assets/settings-CqEiOkw4.js +4 -0
  59. package/public/assets/settings-CqEiOkw4.js.map +1 -0
  60. package/public/assets/store-DSmm2NpG.js +9 -0
  61. package/public/assets/store-DSmm2NpG.js.map +1 -0
  62. package/public/assets/{tasks-DCc4hYMY.js → tasks-CWb1BuUk.js} +2 -2
  63. package/public/assets/{tasks-DCc4hYMY.js.map → tasks-CWb1BuUk.js.map} +1 -1
  64. package/public/assets/{teams-BjXD5Fs8.js → teams-SqRZA2bO.js} +2 -2
  65. package/public/assets/{teams-BjXD5Fs8.js.map → teams-SqRZA2bO.js.map} +1 -1
  66. package/public/assets/{terminal-viewer-impl-BmlEy9zo.js → terminal-viewer-impl-_E8At_tx.js} +2 -2
  67. package/public/assets/{terminal-viewer-impl-BmlEy9zo.js.map → terminal-viewer-impl-_E8At_tx.js.map} +1 -1
  68. package/public/assets/{work-queue-BODxiiDI.js → work-queue-Cw55y2bR.js} +2 -2
  69. package/public/assets/{work-queue-BODxiiDI.js.map → work-queue-Cw55y2bR.js.map} +1 -1
  70. package/public/assets/{workspaces-Bly4o-i4.js → workspaces-I7KmXqPQ.js} +2 -2
  71. package/public/assets/{workspaces-Bly4o-i4.js.map → workspaces-I7KmXqPQ.js.map} +1 -1
  72. package/public/index.html +4 -4
  73. package/runner/src/adapter.ts +1 -1
  74. package/src/agent-issue-repo.ts +29 -0
  75. package/src/branch-landed.ts +44 -2
  76. package/src/config.ts +5 -0
  77. package/src/context-advisory.ts +93 -15
  78. package/src/context-threshold-config.ts +14 -2
  79. package/src/db/migrations.ts +6 -0
  80. package/src/db/projects.ts +24 -6
  81. package/src/db/schema.ts +1 -0
  82. package/src/maintenance/jobs.ts +11 -0
  83. package/src/mcp-project-tools.ts +2 -0
  84. package/src/provider-catalog-store.ts +35 -0
  85. package/src/routes/agents.ts +3 -2
  86. package/src/routes/provider-config.ts +27 -0
  87. package/src/services/git-remote.ts +46 -0
  88. package/src/services/issue-lifecycle.ts +21 -38
  89. package/src/services/project.ts +16 -2
  90. package/src/services/register-agent.ts +7 -1
  91. package/src/services/spawn-registration-watchdog.ts +100 -0
  92. package/src/settings/registry.ts +2 -0
  93. package/src/sse.ts +4 -3
  94. package/public/assets/MessageBubble-CMZo7OAy.js +0 -2
  95. package/public/assets/MessageBubble-CMZo7OAy.js.map +0 -1
  96. package/public/assets/chat-DC-eLkyI.js +0 -2
  97. package/public/assets/chat-DC-eLkyI.js.map +0 -1
  98. package/public/assets/display-CWEz0zy_.js +0 -3
  99. package/public/assets/formatted-body-impl-CEJs5Ehz.js +0 -3
  100. package/public/assets/formatted-body-impl-CEJs5Ehz.js.map +0 -1
  101. package/public/assets/index-BY23dkkh.css +0 -2
  102. package/public/assets/index-D3ie3rwt.js.map +0 -1
  103. package/public/assets/messages-ClkBcpYi.js.map +0 -1
  104. package/public/assets/orchestrators-DyCvsN6E.js +0 -2
  105. package/public/assets/settings-CVPjbE2O.js +0 -4
  106. package/public/assets/settings-CVPjbE2O.js.map +0 -1
  107. package/public/assets/store-BfICn_iR.js +0 -9
  108. package/public/assets/store-BfICn_iR.js.map +0 -1
@@ -1,7 +1,11 @@
1
- import { getNotificationsConfig } from "./config-store";
1
+ import { deriveContextThresholdFromWindowTokens, resolveProviderContextThreshold, type ProviderModelCatalogEntry, type ResolvedProviderContextThreshold } from "agent-relay-sdk/provider-catalog";
2
+ import { isRecord, SPAWN_PROVIDERS } from "agent-relay-sdk";
3
+ import { getConfig, getNotificationsConfig } from "./config-store";
4
+ import { DEFAULT_CONTEXT_THRESHOLD_CONFIG, validateContextThresholdConfig } from "./context-threshold-config";
2
5
  import { getDb } from "./db/connection.ts";
3
6
  import { emitRelayEvent } from "./events";
4
7
  import { routeNotification } from "./notification-router";
8
+ import { effectiveProviderCatalogList } from "./provider-catalog-store";
5
9
  import type { AgentCard, Command, ContextState } from "./types";
6
10
 
7
11
  interface ContextAdvisoryInput {
@@ -10,7 +14,17 @@ interface ContextAdvisoryInput {
10
14
  percent: number;
11
15
  windowTokens: number;
12
16
  band: number;
17
+ stage: ContextAdvisoryStage;
18
+ selfCompactPercent: number;
19
+ warnPercent: number;
13
20
  hardBackstopPercent: number;
21
+ thresholdSource: string;
22
+ }
23
+
24
+ type ContextAdvisoryStage = "warn" | "self-compact";
25
+
26
+ interface EffectiveContextThreshold extends ResolvedProviderContextThreshold {
27
+ source: "model" | "global" | "window";
14
28
  }
15
29
 
16
30
  /**
@@ -25,7 +39,11 @@ function notifyContextAdvisory(input: ContextAdvisoryInput): void {
25
39
  percent: input.percent,
26
40
  windowTokens: input.windowTokens,
27
41
  band: input.band,
42
+ stage: input.stage,
43
+ selfCompactPercent: input.selfCompactPercent,
44
+ warnPercent: input.warnPercent,
28
45
  hardBackstopPercent: input.hardBackstopPercent,
46
+ thresholdSource: input.thresholdSource,
29
47
  };
30
48
 
31
49
  emitRelayEvent({
@@ -45,19 +63,29 @@ function notifyContextAdvisory(input: ContextAdvisoryInput): void {
45
63
  scope: { agentId: input.agentId },
46
64
  recipients: [input.agentId],
47
65
  message: {
48
- subject: "Context threshold reached",
49
- body: `Context advisory: ${formatTokens(input.tokens)} / ${formatTokens(input.windowTokens)} tokens used (${input.percent}% of window, ${input.band}% band). Compact at a clean seam before the ${input.hardBackstopPercent}% hard backstop may request compaction.`,
66
+ subject: input.stage === "warn" ? "Context threshold warning" : "Context self-compact threshold reached",
67
+ body: contextAdvisoryBody(input),
50
68
  payload,
51
69
  replyExpected: false,
52
70
  },
53
71
  });
54
72
  }
55
73
 
56
- function contextAdvisoryBand(utilization: number, advisoryUtilization: number, bandSize: number): number | null {
57
- if (!Number.isFinite(utilization) || utilization < advisoryUtilization) return null;
58
- const percent = Math.min(100, Math.max(0, utilization * 100));
59
- const step = Math.max(1, bandSize * 100);
60
- return Math.min(100, Math.floor(percent / step) * step);
74
+ function contextAdvisoryBody(input: ContextAdvisoryInput): string {
75
+ const usage = `${formatTokens(input.tokens)} / ${formatTokens(input.windowTokens)} tokens used (${input.percent}% of window, ${input.band}% band)`;
76
+ const threshold = `resolved self-compact threshold ${input.selfCompactPercent}% (${input.thresholdSource})`;
77
+ if (input.stage === "warn") {
78
+ return `Context warning: ${usage}. You are approaching the ${threshold}; if the current task is at a clean seam, prepare to call relay_compact_and_resume before continuing.`;
79
+ }
80
+ return `Context advisory: ${usage}. You are over the ${threshold}; call relay_compact_and_resume at the next clean seam between tasks or waves, not mid-edit. The ${input.hardBackstopPercent}% hard backstop may request compaction if usage keeps climbing.`;
81
+ }
82
+
83
+ function contextAdvisoryBand(utilization: number, threshold: EffectiveContextThreshold): { band: number; stage: ContextAdvisoryStage } | null {
84
+ if (!Number.isFinite(utilization) || utilization < threshold.warnUtilization) return null;
85
+ if (utilization >= threshold.selfCompactUtilization) {
86
+ return { band: utilizationPercent(threshold.selfCompactUtilization), stage: "self-compact" };
87
+ }
88
+ return { band: utilizationPercent(threshold.warnUtilization), stage: "warn" };
61
89
  }
62
90
 
63
91
  export function handleContextPressure(input: {
@@ -71,9 +99,9 @@ export function handleContextPressure(input: {
71
99
  if (!context || agent.status === "busy") return false;
72
100
  if (context.lifecycleState === "compacting" || context.lifecycleState === "hibernating") return false;
73
101
  if (typeof context.utilization !== "number" || !Number.isFinite(context.utilization)) return false;
74
- const threshold = getNotificationsConfig().contextThreshold;
75
- const band = contextAdvisoryBand(context.utilization, threshold.advisoryUtilization, threshold.bandSize);
76
- if (band === null) {
102
+ const threshold = effectiveContextThreshold(agent);
103
+ const advisory = contextAdvisoryBand(context.utilization, threshold);
104
+ if (advisory === null) {
77
105
  if (context.lastContextAdvisoryBand !== undefined || context.lastContextAdvisoryAt !== undefined) {
78
106
  persistContextState(agent.id, clearContextAdvisory(context));
79
107
  }
@@ -83,7 +111,7 @@ export function handleContextPressure(input: {
83
111
  input.compact("context-hard-backstop");
84
112
  return true;
85
113
  }
86
- if (context.lastContextAdvisoryBand === band) return false;
114
+ if (context.lastContextAdvisoryBand === advisory.band) return false;
87
115
  const tokens = finiteNumber(context.tokensUsed);
88
116
  const windowTokens = finiteNumber(context.tokensMax);
89
117
  if (tokens === undefined || windowTokens === undefined) return false;
@@ -92,13 +120,55 @@ export function handleContextPressure(input: {
92
120
  tokens,
93
121
  percent: Math.round(context.utilization * 100),
94
122
  windowTokens,
95
- band,
96
- hardBackstopPercent: Math.round(threshold.hardBackstopUtilization * 100),
123
+ band: advisory.band,
124
+ stage: advisory.stage,
125
+ warnPercent: utilizationPercent(threshold.warnUtilization),
126
+ selfCompactPercent: utilizationPercent(threshold.selfCompactUtilization),
127
+ hardBackstopPercent: utilizationPercent(threshold.hardBackstopUtilization),
128
+ thresholdSource: threshold.source,
97
129
  });
98
- persistContextState(agent.id, { ...context, lastContextAdvisoryBand: band, lastContextAdvisoryAt: now() });
130
+ persistContextState(agent.id, { ...context, lastContextAdvisoryBand: advisory.band, lastContextAdvisoryAt: now() });
99
131
  return true;
100
132
  }
101
133
 
134
+ function effectiveContextThreshold(agent: AgentCard): EffectiveContextThreshold {
135
+ const model = findAgentCatalogModel(agent);
136
+ const windowTokens = model?.limits?.contextWindowTokens?.value
137
+ ?? finiteNumber(agent.providerCapabilities?.context?.windowTokens)
138
+ ?? finiteNumber(agent.context?.tokensMax);
139
+ if (model?.contextThreshold) {
140
+ return { ...resolveProviderContextThreshold(model), source: "model" };
141
+ }
142
+ const global = explicitContextThresholdConfig();
143
+ if (global) return global;
144
+ return { ...deriveContextThresholdFromWindowTokens(windowTokens), source: "window" };
145
+ }
146
+
147
+ function explicitContextThresholdConfig(): EffectiveContextThreshold | undefined {
148
+ const entry = getConfig("notifications", "default");
149
+ if (!entry || !isRecord(entry.value) || !isRecord(entry.value.contextThreshold)) return undefined;
150
+ const global = validateContextThresholdConfig({ ...DEFAULT_CONTEXT_THRESHOLD_CONFIG, ...entry.value.contextThreshold });
151
+ const selfCompactUtilization = global.selfCompactUtilization ?? global.advisoryUtilization;
152
+ return {
153
+ selfCompactUtilization,
154
+ warnUtilization: global.warnUtilization ?? Math.max(0, roundUtilization(selfCompactUtilization - 0.1)),
155
+ hardBackstopUtilization: global.hardBackstopUtilization,
156
+ source: "global",
157
+ };
158
+ }
159
+
160
+ function findAgentCatalogModel(agent: AgentCard): ProviderModelCatalogEntry | undefined {
161
+ const runtime = agent.providerCapabilities?.model;
162
+ const provider = runtime?.provider;
163
+ if (!provider || !(SPAWN_PROVIDERS as readonly string[]).includes(provider)) return undefined;
164
+ const catalog = effectiveProviderCatalogList().find((entry) => entry.provider === provider);
165
+ if (!catalog) return undefined;
166
+ const ids = [runtime.alias, runtime.id, runtime.providerModel].filter((value): value is string => Boolean(value));
167
+ const lowerIds = new Set(ids.map((id) => id.toLowerCase()));
168
+ return catalog.models.find((model) => ids.includes(model.alias) || ids.includes(model.providerModel))
169
+ ?? catalog.models.find((model) => lowerIds.has(model.alias.toLowerCase()) || lowerIds.has(model.providerModel.toLowerCase()));
170
+ }
171
+
102
172
  function clearContextAdvisory(context: ContextState): ContextState {
103
173
  const { lastContextAdvisoryBand: _band, lastContextAdvisoryAt: _at, ...rest } = context;
104
174
  return rest;
@@ -112,6 +182,14 @@ function finiteNumber(value: unknown): number | undefined {
112
182
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
113
183
  }
114
184
 
185
+ function utilizationPercent(value: number): number {
186
+ return Math.round(value * 100);
187
+ }
188
+
189
+ function roundUtilization(value: number): number {
190
+ return Math.round(value * 1000) / 1000;
191
+ }
192
+
115
193
  function formatTokens(value: number): string {
116
194
  return Math.round(value).toLocaleString("en-US");
117
195
  }
@@ -14,13 +14,25 @@ export function validateContextThresholdConfig(value: unknown): NotificationsCon
14
14
  const bandSize = cleanFraction(input.bandSize, "contextThreshold.bandSize", DEFAULT_CONTEXT_THRESHOLD_CONFIG.bandSize);
15
15
  if (bandSize <= 0) throw new ValidationError("contextThreshold.bandSize must be greater than 0");
16
16
  const advisoryUtilization = cleanFraction(input.advisoryUtilization, "contextThreshold.advisoryUtilization", DEFAULT_CONTEXT_THRESHOLD_CONFIG.advisoryUtilization);
17
+ const selfCompactUtilization = input.selfCompactUtilization === undefined || input.selfCompactUtilization === null
18
+ ? undefined
19
+ : cleanFraction(input.selfCompactUtilization, "contextThreshold.selfCompactUtilization", advisoryUtilization);
20
+ const warnUtilization = input.warnUtilization === undefined || input.warnUtilization === null
21
+ ? undefined
22
+ : cleanFraction(input.warnUtilization, "contextThreshold.warnUtilization", Math.max(0, (selfCompactUtilization ?? advisoryUtilization) - 0.1));
17
23
  const hardBackstopUtilization = cleanFraction(input.hardBackstopUtilization, "contextThreshold.hardBackstopUtilization", DEFAULT_CONTEXT_THRESHOLD_CONFIG.hardBackstopUtilization);
18
- if (hardBackstopUtilization <= advisoryUtilization) {
19
- throw new ValidationError("contextThreshold.hardBackstopUtilization must be greater than contextThreshold.advisoryUtilization");
24
+ const compactUtilization = selfCompactUtilization ?? advisoryUtilization;
25
+ if (warnUtilization !== undefined && warnUtilization >= compactUtilization) {
26
+ throw new ValidationError("contextThreshold.warnUtilization must be less than contextThreshold.selfCompactUtilization");
27
+ }
28
+ if (hardBackstopUtilization <= compactUtilization) {
29
+ throw new ValidationError("contextThreshold.hardBackstopUtilization must be greater than contextThreshold.selfCompactUtilization");
20
30
  }
21
31
  return {
22
32
  enabled: input.enabled === undefined ? DEFAULT_CONTEXT_THRESHOLD_CONFIG.enabled : cleanBoolean(input.enabled, "contextThreshold.enabled"),
23
33
  advisoryUtilization,
34
+ ...(selfCompactUtilization !== undefined ? { selfCompactUtilization } : {}),
35
+ ...(warnUtilization !== undefined ? { warnUtilization } : {}),
24
36
  hardBackstopUtilization,
25
37
  bandSize,
26
38
  };
@@ -453,6 +453,12 @@ export function applyMigrations(): void {
453
453
  getDb().run("ALTER TABLE provider_model_overrides ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0");
454
454
  }
455
455
 
456
+ // Migration: projects.issue_repo (#533) — per-project `owner/repo` for #NNN linkify/land-close.
457
+ const projectCols = (getDb().query("PRAGMA table_info(projects)").all() as any[]).map((c: any) => c.name);
458
+ if (!projectCols.includes("issue_repo")) {
459
+ getDb().run("ALTER TABLE projects ADD COLUMN issue_repo TEXT");
460
+ }
461
+
456
462
  // Migration: orchestrators.api_url
457
463
  const orchCols = getDb().query("PRAGMA table_info(orchestrators)").all() as any[];
458
464
  const orchColNames = orchCols.map((c: any) => c.name);
@@ -14,6 +14,7 @@ interface ProjectRow {
14
14
  title: string | null;
15
15
  description: string | null;
16
16
  tenant_id: string | null;
17
+ issue_repo: string | null;
17
18
  status: Project["status"];
18
19
  created_at: number;
19
20
  updated_at: number;
@@ -36,6 +37,7 @@ function rowToProject(row: ProjectRow): Project {
36
37
  ...(row.title ? { title: row.title } : {}),
37
38
  ...(row.description ? { description: row.description } : {}),
38
39
  ...(row.tenant_id ? { tenantId: row.tenant_id } : {}),
40
+ ...(row.issue_repo ? { issueRepo: row.issue_repo } : {}),
39
41
  status: row.status,
40
42
  createdAt: row.created_at,
41
43
  updatedAt: row.updated_at,
@@ -81,25 +83,29 @@ interface EnsureProjectInput {
81
83
  title?: string;
82
84
  description?: string;
83
85
  tenantId?: string;
86
+ /** Explicit `owner/repo` override (#533); when set it wins over the git-remote-derived
87
+ * default. A bare derived value is filled separately via setProjectIssueRepo(onlyIfNull). */
88
+ issueRepo?: string;
84
89
  }
85
90
 
86
91
  /** Get-or-create a project by slug. The create-on-first-use analog of ensureTeam;
87
- * updates title/description/tenant on an existing project only when provided. */
92
+ * updates title/description/tenant/issueRepo on an existing project only when provided. */
88
93
  export function ensureProject(input: EnsureProjectInput): Project {
89
94
  const slug = input.slug.trim();
90
95
  if (!slug) throw new ValidationError("slug required");
91
96
  const existing = getProjectBySlug(slug);
92
97
  const now = Date.now();
93
98
  if (existing) {
94
- if (input.title !== undefined || input.description !== undefined || input.tenantId !== undefined) {
99
+ if (input.title !== undefined || input.description !== undefined || input.tenantId !== undefined || input.issueRepo !== undefined) {
95
100
  getDb().query(`
96
101
  UPDATE projects
97
- SET title = ?, description = ?, tenant_id = ?, updated_at = ?
102
+ SET title = ?, description = ?, tenant_id = ?, issue_repo = ?, updated_at = ?
98
103
  WHERE id = ?
99
104
  `).run(
100
105
  input.title ?? existing.title ?? null,
101
106
  input.description ?? existing.description ?? null,
102
107
  input.tenantId ?? existing.tenantId ?? null,
108
+ input.issueRepo ?? existing.issueRepo ?? null,
103
109
  now,
104
110
  existing.id,
105
111
  );
@@ -109,12 +115,24 @@ export function ensureProject(input: EnsureProjectInput): Project {
109
115
  }
110
116
  const id = projectId();
111
117
  getDb().query(`
112
- INSERT INTO projects (id, slug, title, description, tenant_id, status, created_at, updated_at)
113
- VALUES (?, ?, ?, ?, ?, 'active', ?, ?)
114
- `).run(id, slug, input.title ?? null, input.description ?? null, input.tenantId ?? null, now, now);
118
+ INSERT INTO projects (id, slug, title, description, tenant_id, issue_repo, status, created_at, updated_at)
119
+ VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?)
120
+ `).run(id, slug, input.title ?? null, input.description ?? null, input.tenantId ?? null, input.issueRepo ?? null, now, now);
115
121
  return getProject(id)!;
116
122
  }
117
123
 
124
+ /** Set (or clear, with null) a project's `issueRepo` (#533). With `onlyIfNull`, a value is
125
+ * written only when none is set yet — the mechanism for the git-remote AUTO-DERIVE to fill
126
+ * a fresh project without ever clobbering an explicit override or a prior derivation. */
127
+ export function setProjectIssueRepo(id: string, issueRepo: string | null, opts: { onlyIfNull?: boolean } = {}): Project | null {
128
+ const project = getProject(id);
129
+ if (!project) return null;
130
+ if (opts.onlyIfNull && project.issueRepo) return project;
131
+ const value = issueRepo?.trim() || null;
132
+ getDb().query("UPDATE projects SET issue_repo = ?, updated_at = ? WHERE id = ?").run(value, Date.now(), id);
133
+ return getProject(id);
134
+ }
135
+
118
136
  /** Register a root path for a project (idempotent — UNIQUE(project_id, root_path)). */
119
137
  export function addProjectRoot(id: string, rootPath: string): ProjectRoot[] {
120
138
  const project = getProject(id);
package/src/db/schema.ts CHANGED
@@ -207,6 +207,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
207
207
  title TEXT,
208
208
  description TEXT,
209
209
  tenant_id TEXT,
210
+ issue_repo TEXT,
210
211
  status TEXT NOT NULL DEFAULT 'active',
211
212
  created_at INTEGER NOT NULL,
212
213
  updated_at INTEGER NOT NULL
@@ -33,6 +33,7 @@ import {
33
33
  } from "../sse";
34
34
  import { pruneExpiredTokenRecords } from "../token-db";
35
35
  import { reapTransientAgents } from "../services/transient-agent-reaper";
36
+ import { reapUnregisteredSpawnCommands } from "../services/spawn-registration-watchdog";
36
37
  import { resumeRateLimitedAgents } from "../services/rate-limit-resume";
37
38
  import { MERGE_PAUSE_MAINTENANCE_JOB } from "../merge-pause-maintenance";
38
39
  import {
@@ -125,6 +126,16 @@ export const definitions: MaintenanceJobDefinition[] = [
125
126
  return { expiredCommandIds: commands.map((command) => command.id) };
126
127
  },
127
128
  },
129
+ {
130
+ id: "spawn-registration-watchdog",
131
+ title: "Spawn registration watchdog",
132
+ description: "Fail parented spawn commands whose runner process started but never registered with Relay.",
133
+ intervalMs: REAP_INTERVAL_MS,
134
+ runOnStart: true,
135
+ handler() {
136
+ return reapUnregisteredSpawnCommands();
137
+ },
138
+ },
128
139
  {
129
140
  id: "bus-agent-expiration",
130
141
  title: "Bus agent expiration",
@@ -62,6 +62,7 @@ export const PROJECT_TOOLS = [
62
62
  title: { type: "string" },
63
63
  description: { type: "string" },
64
64
  tenantId: { type: "string" },
65
+ issueRepo: { type: "string", description: "Explicit owner/repo for #NNN linkify; overrides the git-remote-derived default (#533)." },
65
66
  },
66
67
  required: ["slug", "rootPath"],
67
68
  additionalProperties: false,
@@ -273,6 +274,7 @@ export function relayProjectTool(auth: McpAuthLike, name: string, args: Record<s
273
274
  title: cleanString(args.title, "title", { max: 240 }),
274
275
  description: cleanString(args.description, "description", { max: 16_000 }),
275
276
  tenantId: cleanString(args.tenantId, "tenantId", { max: 200 }),
277
+ issueRepo: cleanString(args.issueRepo, "issueRepo", { max: 200 }),
276
278
  });
277
279
  return { project, roots };
278
280
  }
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  providerCatalogList,
3
3
  type ProviderCatalogEntry,
4
+ type ProviderContextThreshold,
4
5
  type ProviderModelCatalogEntry,
5
6
  } from "agent-relay-sdk/provider-catalog";
6
7
  import { SPAWN_PROVIDERS } from "agent-relay-sdk";
@@ -145,6 +146,9 @@ function normalizeOverrideInput(input: ProviderModelOverrideInput): ProviderMode
145
146
  if (!input.entry.providerModel?.trim()) throw new ValidationError("provider model is required");
146
147
  if (!Array.isArray(input.entry.efforts)) throw new ValidationError("model efforts must be an array");
147
148
  const entry = cloneModelEntry({ ...input.entry, alias });
149
+ const contextThreshold = normalizeContextThreshold(entry.contextThreshold);
150
+ if (contextThreshold) entry.contextThreshold = contextThreshold;
151
+ else delete entry.contextThreshold;
148
152
  delete entry.disabled;
149
153
  return {
150
154
  ...input,
@@ -183,3 +187,34 @@ function cloneProviderEntry(entry: ProviderCatalogEntry): ProviderCatalogEntry {
183
187
  function cloneModelEntry(entry: ProviderModelCatalogEntry): ProviderModelCatalogEntry {
184
188
  return JSON.parse(JSON.stringify(entry)) as ProviderModelCatalogEntry;
185
189
  }
190
+
191
+ function normalizeContextThreshold(value: unknown): ProviderContextThreshold | undefined {
192
+ if (value === undefined || value === null) return undefined;
193
+ if (typeof value !== "object" || Array.isArray(value)) throw new ValidationError("contextThreshold must be an object");
194
+ const input = value as Record<string, unknown>;
195
+ const selfCompactUtilization = cleanFraction(input.selfCompactUtilization, "contextThreshold.selfCompactUtilization");
196
+ const warnUtilization = input.warnUtilization === undefined || input.warnUtilization === null
197
+ ? undefined
198
+ : cleanFraction(input.warnUtilization, "contextThreshold.warnUtilization");
199
+ const hardBackstopUtilization = input.hardBackstopUtilization === undefined || input.hardBackstopUtilization === null
200
+ ? undefined
201
+ : cleanFraction(input.hardBackstopUtilization, "contextThreshold.hardBackstopUtilization");
202
+ if (warnUtilization !== undefined && warnUtilization >= selfCompactUtilization) {
203
+ throw new ValidationError("contextThreshold.warnUtilization must be less than contextThreshold.selfCompactUtilization");
204
+ }
205
+ if (hardBackstopUtilization !== undefined && hardBackstopUtilization <= selfCompactUtilization) {
206
+ throw new ValidationError("contextThreshold.hardBackstopUtilization must be greater than contextThreshold.selfCompactUtilization");
207
+ }
208
+ return {
209
+ selfCompactUtilization,
210
+ ...(warnUtilization !== undefined ? { warnUtilization } : {}),
211
+ ...(hardBackstopUtilization !== undefined ? { hardBackstopUtilization } : {}),
212
+ };
213
+ }
214
+
215
+ function cleanFraction(value: unknown, field: string): number {
216
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
217
+ throw new ValidationError(`${field} must be a number between 0 and 1`);
218
+ }
219
+ return value;
220
+ }
@@ -2,6 +2,7 @@
2
2
  import { VALID_AGENT_STATUSES, agentSessionStatus, auditEvent, error, json, memoryContext, memoryErrorResponse, normalizeAgentSessionGuard, parseBody, parseId, parseQueryInt, type Handler } from "./_shared";
3
3
  import { ValidationError, deleteAgent, getAgent, getAgentTimeline, heartbeat, listAgentChatHistory, listAgents, listArtifactsForEntity, listContextSnapshots, listPendingReplyObligations, markReady, searchAgents, setLabel, setStatus, setTags, validateAgentSession } from "../db";
4
4
  import { attachBranchState, attachBranchStates } from "../agent-branch-state";
5
+ import { attachIssueRepo, attachIssueRepos } from "../agent-issue-repo";
5
6
  import { cleanStringArray } from "../validation";
6
7
  import { clearActiveMemories, memoryBroker } from "../memory-service";
7
8
  import { emitAgentRemoved, emitAgentStatus } from "../sse";
@@ -44,7 +45,7 @@ export const getAgents: Handler = (req) => {
44
45
  const tag = url.searchParams.get("tag") ?? undefined;
45
46
  const machine = url.searchParams.get("machine") ?? undefined;
46
47
  const status = url.searchParams.get("status") ?? undefined;
47
- return json(attachBranchStates(listAgents({ tag, machine, status })));
48
+ return json(attachIssueRepos(attachBranchStates(listAgents({ tag, machine, status }))));
48
49
  };
49
50
 
50
51
  export const findAgents: Handler = (req) => {
@@ -57,7 +58,7 @@ export const findAgents: Handler = (req) => {
57
58
 
58
59
  export const getAgentById: Handler = (_req, params) => {
59
60
  const agent = getAgent(params.id!);
60
- return agent ? json(attachBranchState(agent)) : error("agent not found", 404);
61
+ return agent ? json(attachIssueRepo(attachBranchState(agent))) : error("agent not found", 404);
61
62
  };
62
63
 
63
64
  export const getAgentReplyObligations: Handler = (_req, params) => {
@@ -139,16 +139,43 @@ function cleanProviderModelEntry(alias: string, body: Record<string, unknown>):
139
139
  efforts,
140
140
  ...(defaultEffort ? { defaultEffort } : {}),
141
141
  ...(isRecord(source.limits) ? { limits: source.limits as ProviderModelCatalogEntry["limits"] } : {}),
142
+ ...(isRecord(source.contextThreshold) ? { contextThreshold: cleanProviderContextThreshold(source.contextThreshold) } : {}),
142
143
  ...(isRecord(source.capabilities) ? { capabilities: source.capabilities as unknown as ProviderModelCatalogEntry["capabilities"] } : {}),
143
144
  };
144
145
  }
145
146
 
147
+ function cleanProviderContextThreshold(source: Record<string, unknown>): NonNullable<ProviderModelCatalogEntry["contextThreshold"]> {
148
+ const selfCompactUtilization = optionalFraction(source.selfCompactUtilization, "contextThreshold.selfCompactUtilization");
149
+ if (selfCompactUtilization === undefined) throw new ValidationError("contextThreshold.selfCompactUtilization required");
150
+ const warnUtilization = optionalFraction(source.warnUtilization, "contextThreshold.warnUtilization");
151
+ const hardBackstopUtilization = optionalFraction(source.hardBackstopUtilization, "contextThreshold.hardBackstopUtilization");
152
+ if (warnUtilization !== undefined && warnUtilization >= selfCompactUtilization) {
153
+ throw new ValidationError("contextThreshold.warnUtilization must be less than contextThreshold.selfCompactUtilization");
154
+ }
155
+ if (hardBackstopUtilization !== undefined && hardBackstopUtilization <= selfCompactUtilization) {
156
+ throw new ValidationError("contextThreshold.hardBackstopUtilization must be greater than contextThreshold.selfCompactUtilization");
157
+ }
158
+ return {
159
+ selfCompactUtilization,
160
+ ...(warnUtilization !== undefined ? { warnUtilization } : {}),
161
+ ...(hardBackstopUtilization !== undefined ? { hardBackstopUtilization } : {}),
162
+ };
163
+ }
164
+
146
165
  function optionalBoolean(value: unknown, field: string): boolean | undefined {
147
166
  if (value === undefined || value === null) return undefined;
148
167
  if (typeof value !== "boolean") throw new ValidationError(`${field} must be a boolean`);
149
168
  return value;
150
169
  }
151
170
 
171
+ function optionalFraction(value: unknown, field: string): number | undefined {
172
+ if (value === undefined || value === null) return undefined;
173
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
174
+ throw new ValidationError(`${field} must be a number between 0 and 1`);
175
+ }
176
+ return value;
177
+ }
178
+
152
179
  export const deleteCommandById: Handler = (_req, params) => {
153
180
  if (!deleteCommand(params.id!)) return error("command not found or cannot be canceled", 404);
154
181
  const command = getCommand(params.id!);
@@ -0,0 +1,46 @@
1
+ // Shared git-remote → `owner/repo` resolution (#533). The SINGLE home for deriving a
2
+ // GitHub `owner/repo` from a repo root's `origin` remote. Used by two callers that must
3
+ // not drift: issue-lifecycle land-close (which repo a bare `#NNN` in a commit subject
4
+ // closes) AND project auto-seed (a project's `issueRepo` for chat #NNN linkify). Promoted
5
+ // out of issue-lifecycle.ts where it was private — see the DRY rule in CLAUDE.md.
6
+
7
+ /**
8
+ * `owner/repo` out of any git remote URL form: `git@github.com:owner/repo.git`,
9
+ * `https://github.com/owner/repo.git`, `ssh://git@host/owner/repo`, trailing slash/`.git`
10
+ * optional. Pure — exported for tests.
11
+ */
12
+ export function parseOwnerRepoFromRemote(url: string | undefined): string | undefined {
13
+ if (!url) return undefined;
14
+ const match = url.trim().match(/[:/]([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?\/?$/);
15
+ return match?.[1];
16
+ }
17
+
18
+ let remoteRepoCache: Map<string, string | undefined> | undefined;
19
+
20
+ /**
21
+ * `owner/repo` from a repo root's `origin` remote, cached per root. Works from any
22
+ * subdirectory of the repo (`git -C` walks up). Returns undefined when the dir isn't a
23
+ * git repo, has no `origin`, or the URL doesn't parse.
24
+ */
25
+ export function repoFromGitRemote(repoRoot: string | undefined): string | undefined {
26
+ if (!repoRoot) return undefined;
27
+ if (!remoteRepoCache) remoteRepoCache = new Map();
28
+ if (remoteRepoCache.has(repoRoot)) return remoteRepoCache.get(repoRoot);
29
+ let repo: string | undefined;
30
+ try {
31
+ const proc = Bun.spawnSync(["git", "-C", repoRoot, "remote", "get-url", "origin"], {
32
+ stdin: "ignore",
33
+ stdout: "pipe",
34
+ stderr: "ignore",
35
+ });
36
+ if (proc.exitCode === 0) repo = parseOwnerRepoFromRemote(Buffer.from(proc.stdout).toString("utf8"));
37
+ } catch {
38
+ repo = undefined;
39
+ }
40
+ remoteRepoCache.set(repoRoot, repo);
41
+ return repo;
42
+ }
43
+
44
+ export function __resetRemoteRepoCacheForTests(): void {
45
+ remoteRepoCache = undefined;
46
+ }
@@ -10,6 +10,11 @@ import { getIssueRef, resolveIssueRefByExternalKey, resolveIssueRefFromString, u
10
10
  import { associateIssue } from "../db/issue-associations";
11
11
  import { resolveIssueTrackerAdapter } from "../issue-tracker";
12
12
  import { defaultIssueRepo } from "../config";
13
+ // `repoFromGitRemote` / `parseOwnerRepoFromRemote` live in the shared ./git-remote module
14
+ // (#533) so project auto-seed reuses the exact same logic. Re-exported so this module's
15
+ // existing importers and tests keep their entry point.
16
+ import { parseOwnerRepoFromRemote, repoFromGitRemote, __resetRemoteRepoCacheForTests } from "./git-remote";
17
+ export { parseOwnerRepoFromRemote, repoFromGitRemote, __resetRemoteRepoCacheForTests };
13
18
  import type { IssueTrackerAdapter } from "../issue-tracker";
14
19
  import { recordAndEmitIssueActivity } from "./issue-activity";
15
20
  import type { IssueAssociationEntityType, IssueProvider, IssueRef, WorkspaceRecord } from "../types";
@@ -22,6 +27,7 @@ interface LandedIssueLifecycleInput {
22
27
  workspace: LifecycleWorkspace;
23
28
  mergedSha?: string;
24
29
  subject?: string;
30
+ commitSubjects?: string[];
25
31
  source?: string;
26
32
  /**
27
33
  * The land happened via a pull request (the squash/merge subject carries a
@@ -79,42 +85,6 @@ function logFailure(opts: IssueLifecycleOptions, message: string): void {
79
85
  else console.warn(message);
80
86
  }
81
87
 
82
- /**
83
- * `owner/repo` out of any git remote URL form: `git@github.com:owner/repo.git`,
84
- * `https://github.com/owner/repo.git`, `ssh://git@host/owner/repo`, trailing slash/`.git`
85
- * optional. Pure — exported for tests.
86
- */
87
- export function parseOwnerRepoFromRemote(url: string | undefined): string | undefined {
88
- if (!url) return undefined;
89
- const match = url.trim().match(/[:/]([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?\/?$/);
90
- return match?.[1];
91
- }
92
-
93
- let remoteRepoCache: Map<string, string | undefined> | undefined;
94
-
95
- function repoFromGitRemote(repoRoot: string | undefined): string | undefined {
96
- if (!repoRoot) return undefined;
97
- if (!remoteRepoCache) remoteRepoCache = new Map();
98
- if (remoteRepoCache.has(repoRoot)) return remoteRepoCache.get(repoRoot);
99
- let repo: string | undefined;
100
- try {
101
- const proc = Bun.spawnSync(["git", "-C", repoRoot, "remote", "get-url", "origin"], {
102
- stdin: "ignore",
103
- stdout: "pipe",
104
- stderr: "ignore",
105
- });
106
- if (proc.exitCode === 0) repo = parseOwnerRepoFromRemote(Buffer.from(proc.stdout).toString("utf8"));
107
- } catch {
108
- repo = undefined;
109
- }
110
- remoteRepoCache.set(repoRoot, repo);
111
- return repo;
112
- }
113
-
114
- export function __resetRemoteRepoCacheForTests(): void {
115
- remoteRepoCache = undefined;
116
- }
117
-
118
88
  /**
119
89
  * The repo a bare `#NNN` in a land's commit subject resolves against. Derived per-workspace
120
90
  * from its own git remote so a single relay serving many repos closes the RIGHT repo's issue;
@@ -146,6 +116,18 @@ function issueRefMap(refs: IssueRef[]): IssueRef[] {
146
116
  return [...out.values()];
147
117
  }
148
118
 
119
+ function landedSubjectTexts(input: LandedIssueLifecycleInput): string[] {
120
+ const out: string[] = [];
121
+ const seen = new Set<string>();
122
+ for (const text of [...(input.commitSubjects ?? []), input.subject]) {
123
+ const cleaned = cleanText(text);
124
+ if (!cleaned || seen.has(cleaned)) continue;
125
+ seen.add(cleaned);
126
+ out.push(cleaned);
127
+ }
128
+ return out;
129
+ }
130
+
149
131
  function associatedIssueRefs(workspace: LifecycleWorkspace): IssueRef[] {
150
132
  const refs: IssueRef[] = [];
151
133
  const owner = workspace.ownerAgentId ? getAgent(workspace.ownerAgentId) : null;
@@ -211,13 +193,14 @@ function resolveIssueRefsForLand(input: LandedIssueLifecycleInput): { source: La
211
193
  const associated = associatedIssueRefs(input.workspace);
212
194
  if (associated.length > 0) return { source: "association", refs: associated };
213
195
  const fallbackDefaultRepo = landDefaultRepo(input.workspace);
214
- const keyworded = parseClosingIssueRefs(input.subject, fallbackDefaultRepo);
196
+ const subjects = landedSubjectTexts(input);
197
+ const keyworded = issueRefMap(subjects.flatMap((subject) => parseClosingIssueRefs(subject, fallbackDefaultRepo)));
215
198
  if (keyworded.length > 0) return { source: "commit", refs: keyworded };
216
199
  // Bare `(#NNN)` is closing intent in relay-land mode (no PR), but the SAME shape is the
217
200
  // PR number GitHub appends to a squash subject — never close that. Suppress on PR lands;
218
201
  // those rely on GitHub-native keywords + durable association instead (#482).
219
202
  if (input.prLand) return { source: "commit", refs: [] };
220
- return { source: "backlink", refs: parseBacklinkIssueRefs(input.subject, fallbackDefaultRepo) };
203
+ return { source: "backlink", refs: issueRefMap(subjects.flatMap((subject) => parseBacklinkIssueRefs(subject, fallbackDefaultRepo))) };
221
204
  }
222
205
 
223
206
  function closeAuditComment(input: LandedIssueLifecycleInput): string {
@@ -1,7 +1,8 @@
1
1
  import { statSync } from "node:fs";
2
2
  import { basename, dirname, join, resolve } from "node:path";
3
- import { addProjectRoot, ensureProject, resolveProjectForCwd, rootsForProject } from "../db";
3
+ import { addProjectRoot, ensureProject, resolveProjectForCwd, rootsForProject, setProjectIssueRepo } from "../db";
4
4
  import { ingestClaudeMd } from "../project-ingest";
5
+ import { repoFromGitRemote } from "./git-remote";
5
6
  import type { Project, ProjectRoot } from "../types";
6
7
 
7
8
  interface EnsureProjectRootInput {
@@ -10,6 +11,8 @@ interface EnsureProjectRootInput {
10
11
  title?: string;
11
12
  description?: string;
12
13
  tenantId?: string;
14
+ /** Explicit `owner/repo` override (#533); wins over the git-remote-derived default. */
15
+ issueRepo?: string;
13
16
  }
14
17
 
15
18
  interface EnsureProjectRootResult {
@@ -33,8 +36,19 @@ export function ensureProjectRoot(input: EnsureProjectRootInput): EnsureProjectR
33
36
  ...(input.title !== undefined ? { title: input.title } : {}),
34
37
  ...(input.description !== undefined ? { description: input.description } : {}),
35
38
  ...(input.tenantId !== undefined ? { tenantId: input.tenantId } : {}),
39
+ ...(input.issueRepo !== undefined ? { issueRepo: input.issueRepo } : {}),
36
40
  });
37
- return { project, roots: addProjectRoot(project.id, input.rootPath) };
41
+ const roots = addProjectRoot(project.id, input.rootPath);
42
+ // Auto-derive issueRepo from the root's git remote (#533) when not explicitly provided.
43
+ // `onlyIfNull` ensures an explicit override (or earlier derivation) is never clobbered.
44
+ if (input.issueRepo === undefined && !project.issueRepo) {
45
+ const derived = repoFromGitRemote(input.rootPath);
46
+ if (derived) {
47
+ const updated = setProjectIssueRepo(project.id, derived, { onlyIfNull: true });
48
+ if (updated) return { project: updated, roots };
49
+ }
50
+ }
51
+ return { project, roots };
38
52
  }
39
53
 
40
54
  export function ingestProjectClaudeMd(projectId: string, authorId: string): ClaudeMdIngestResult {