patchrelay 0.68.7 → 0.69.1

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.
@@ -2,10 +2,13 @@ import { deriveLinearProgressFact } from "./linear-progress-facts.js";
2
2
  export class LinearProgressReporter {
3
3
  db;
4
4
  emitActivity;
5
+ options;
6
+ static DEFAULT_HEARTBEAT_INTERVAL_MS = 15 * 60 * 1000;
5
7
  publicationsByRun = new Map();
6
- constructor(db, emitActivity) {
8
+ constructor(db, emitActivity, options = {}) {
7
9
  this.db = db;
8
10
  this.emitActivity = emitActivity;
11
+ this.options = options;
9
12
  }
10
13
  maybeEmitProgress(notification, run) {
11
14
  const issue = this.db.getIssue(run.projectId, run.linearIssueId);
@@ -14,6 +17,7 @@ export class LinearProgressReporter {
14
17
  }
15
18
  const fact = deriveLinearProgressFact(notification, issue);
16
19
  if (!fact) {
20
+ this.maybeEmitHeartbeat(notification, run);
17
21
  return;
18
22
  }
19
23
  const previous = this.publicationsByRun.get(run.id);
@@ -22,9 +26,10 @@ export class LinearProgressReporter {
22
26
  if (!shouldEmitEphemeral && !shouldEmitHistory) {
23
27
  return;
24
28
  }
25
- const now = Date.now();
29
+ const now = this.now();
26
30
  const publication = {
27
31
  ...previous,
32
+ quietSinceMs: now,
28
33
  ...(shouldEmitEphemeral
29
34
  ? {
30
35
  ephemeralMeaningKey: fact.meaningKey,
@@ -53,6 +58,56 @@ export class LinearProgressReporter {
53
58
  clearProgress(runId) {
54
59
  this.publicationsByRun.delete(runId);
55
60
  }
61
+ maybeEmitHeartbeat(notification, run) {
62
+ const previous = this.publicationsByRun.get(run.id);
63
+ const now = this.now();
64
+ const quietSinceMs = previous?.quietSinceMs ?? previous?.ephemeralPublishedAtMs ?? previous?.historyPublishedAtMs ?? now;
65
+ const elapsedMs = now - quietSinceMs;
66
+ const intervalMs = this.options.heartbeatIntervalMs ?? LinearProgressReporter.DEFAULT_HEARTBEAT_INTERVAL_MS;
67
+ if (elapsedMs < intervalMs) {
68
+ if (!previous) {
69
+ this.publicationsByRun.set(run.id, { quietSinceMs });
70
+ }
71
+ return;
72
+ }
73
+ if (previous?.lastHeartbeatAtMs !== undefined && now - previous.lastHeartbeatAtMs < intervalMs) {
74
+ return;
75
+ }
76
+ const issue = this.db.getIssue(run.projectId, run.linearIssueId);
77
+ if (!issue) {
78
+ return;
79
+ }
80
+ const detail = describeHeartbeatDetail(notification);
81
+ const content = {
82
+ type: "thought",
83
+ body: detail
84
+ ? `PatchRelay is still working on ${run.runType.replaceAll("_", " ")}. Latest signal: ${detail}.`
85
+ : `PatchRelay is still working on ${run.runType.replaceAll("_", " ")}.`,
86
+ };
87
+ this.publicationsByRun.set(run.id, {
88
+ ...previous,
89
+ quietSinceMs,
90
+ lastHeartbeatAtMs: now,
91
+ });
92
+ void this.emitActivity(issue, content, { ephemeral: true }).catch(() => {
93
+ const current = this.publicationsByRun.get(run.id);
94
+ if (current?.lastHeartbeatAtMs === now) {
95
+ const restored = {
96
+ ...current,
97
+ };
98
+ if (previous?.lastHeartbeatAtMs !== undefined) {
99
+ restored.lastHeartbeatAtMs = previous.lastHeartbeatAtMs;
100
+ }
101
+ else {
102
+ delete restored.lastHeartbeatAtMs;
103
+ }
104
+ this.publicationsByRun.set(run.id, restored);
105
+ }
106
+ });
107
+ }
108
+ now() {
109
+ return this.options.now?.() ?? Date.now();
110
+ }
56
111
  clearFailedPublication(runId, channel, meaningKey, publishedAtMs) {
57
112
  const current = this.publicationsByRun.get(runId);
58
113
  if (!current) {
@@ -93,3 +148,27 @@ export class LinearProgressReporter {
93
148
  this.publicationsByRun.set(runId, next);
94
149
  }
95
150
  }
151
+ function describeHeartbeatDetail(notification) {
152
+ if (notification.method === "item/started" || notification.method === "item/updated" || notification.method === "item/completed") {
153
+ const item = notification.params.item;
154
+ if (item && typeof item === "object") {
155
+ const typed = item;
156
+ if (typed.type === "commandExecution" && typeof typed.command === "string") {
157
+ return `command ${trimHeartbeatDetail(typed.command)}`;
158
+ }
159
+ if (typed.type === "dynamicToolCall" && typeof typed.tool === "string") {
160
+ return `tool ${trimHeartbeatDetail(typed.tool)}`;
161
+ }
162
+ if (typed.type === "mcpToolCall" && typeof typed.server === "string" && typeof typed.tool === "string") {
163
+ return `tool ${trimHeartbeatDetail(`${typed.server}/${typed.tool}`)}`;
164
+ }
165
+ }
166
+ }
167
+ return notification.method;
168
+ }
169
+ function trimHeartbeatDetail(value, maxLength = 120) {
170
+ const compact = value.replace(/\s+/g, " ").trim();
171
+ if (compact.length <= maxLength)
172
+ return compact;
173
+ return `${compact.slice(0, maxLength).trimEnd()}...`;
174
+ }
@@ -61,7 +61,14 @@ export function buildBlockedDelegationActivity(blockedByKeys = []) {
61
61
  export function buildPromptDeliveredThought(runType) {
62
62
  return {
63
63
  type: "thought",
64
- body: `PatchRelay routed your latest instructions into the active ${lowerRunTypeLabel(runType)} workflow.`,
64
+ body: `PatchRelay routed your latest instructions into the active ${lowerRunTypeLabel(runType)} workflow; it will fold them in at the next checkpoint.`,
65
+ };
66
+ }
67
+ export function buildPromptDeliveryFailedActivity(runType, reason) {
68
+ const suffix = reason ? ` ${trimSummary(reason, 180)}` : "";
69
+ return {
70
+ type: "thought",
71
+ body: `PatchRelay could not route your latest instructions into the active ${lowerRunTypeLabel(runType)} workflow.${suffix}`,
65
72
  };
66
73
  }
67
74
  export function buildFollowupStatusActivity(params) {
@@ -72,14 +79,16 @@ export function buildFollowupStatusActivity(params) {
72
79
  const prNote = params.issue.prNumber ? ` PR #${params.issue.prNumber}.` : "";
73
80
  const statusNote = params.statusNote ? ` ${params.statusNote}` : "";
74
81
  return {
75
- type: "response",
82
+ type: params.activityType ?? "response",
76
83
  body: `PatchRelay status: ${subject} is ${formatFactoryState(params.issue.factoryState)}.${prNote}${runNote}${statusNote}`.trim(),
77
84
  };
78
85
  }
79
86
  export function buildNonActionableFollowupActivity(intent) {
80
87
  const body = intent === "status"
81
88
  ? "PatchRelay status is available in the current agent session."
82
- : "PatchRelay did not start implementation because this looks like a question or clarification. Ask PatchRelay to continue, retry, or implement when you want work to run.";
89
+ : intent === "context_only"
90
+ ? "PatchRelay recorded this as context and did not start a new run. Ask PatchRelay to continue, retry, or implement when you want work to run."
91
+ : "PatchRelay did not start a run because this did not clearly request work. Ask PatchRelay to continue, retry, or implement when you want work to run.";
83
92
  return { type: "response", body };
84
93
  }
85
94
  export function buildRunStartedActivity(runType) {
@@ -97,6 +106,18 @@ export function buildRunStartedActivity(runType) {
97
106
  return { type: "action", action: "Implementing", parameter: "requested change" };
98
107
  }
99
108
  }
109
+ export function buildReviewRoundStartedActivity(params) {
110
+ const reviewer = params.reviewerName ? ` from @${params.reviewerName}` : "";
111
+ const head = params.headSha ? ` on head ${params.headSha.slice(0, 8)}` : "";
112
+ const comments = params.commentCount !== undefined
113
+ ? `; ${params.commentCount} inline comment${params.commentCount === 1 ? "" : "s"} captured`
114
+ : "";
115
+ return {
116
+ type: "action",
117
+ action: "Review round",
118
+ parameter: `${params.round}${reviewer}${head}${comments}`,
119
+ };
120
+ }
100
121
  function formatFactoryState(state) {
101
122
  return state.replaceAll("_", " ");
102
123
  }
@@ -104,44 +125,54 @@ export function buildRunCompletedActivity(params) {
104
125
  const prLabel = params.prNumber ? `PR #${params.prNumber}` : "the pull request";
105
126
  const summary = trimSummary(params.completionSummary);
106
127
  const detail = summary ? ` ${summary}` : "";
128
+ const steeringSummary = buildSteeringSummary(params.steeringDeliveredCount, params.steeringFailedCount);
107
129
  switch (params.runType) {
108
130
  case "implementation":
109
131
  if (params.postRunState === "pr_open") {
132
+ const body = `${prLabel} opened:${detail || " Published and ready for review."}`;
110
133
  return {
111
134
  type: "response",
112
- body: `${prLabel} opened:${detail || " Published and ready for review."}`,
135
+ body: steeringSummary ? `${body}\n\n${steeringSummary}` : body,
113
136
  };
114
137
  }
115
138
  return undefined;
116
139
  case "review_fix":
117
- return summary
118
- ? {
119
- type: "response",
120
- body: summary,
140
+ {
141
+ const lines = [];
142
+ lines.push(params.reviewRound ? `Review round ${params.reviewRound} completed.` : "Review fix completed.");
143
+ if (params.resultHeadSha)
144
+ lines.push(`Resulting head: ${params.resultHeadSha.slice(0, 8)}.`);
145
+ if (steeringSummary)
146
+ lines.push(steeringSummary);
147
+ const resolution = buildReviewResolutionSections(summary);
148
+ lines.push("", "Addressed:", resolution.addressed, "", "Deferred:", resolution.deferred, "", "Not applicable:", resolution.notApplicable);
149
+ if (!summary && !params.resultHeadSha) {
150
+ lines[0] = `Updated ${prLabel} to address review feedback.`;
121
151
  }
122
- : {
152
+ return {
123
153
  type: "response",
124
- body: `Updated ${prLabel} to address review feedback.`,
154
+ body: lines.join("\n").trim(),
125
155
  };
156
+ }
126
157
  case "ci_repair":
127
158
  return summary
128
159
  ? {
129
160
  type: "response",
130
- body: summary,
161
+ body: steeringSummary ? `${summary}\n\n${steeringSummary}` : summary,
131
162
  }
132
163
  : {
133
164
  type: "response",
134
- body: `Updated ${prLabel} after CI repair.`,
165
+ body: steeringSummary ? `Updated ${prLabel} after CI repair.\n\n${steeringSummary}` : `Updated ${prLabel} after CI repair.`,
135
166
  };
136
167
  case "queue_repair":
137
168
  return summary
138
169
  ? {
139
170
  type: "response",
140
- body: summary,
171
+ body: steeringSummary ? `${summary}\n\n${steeringSummary}` : summary,
141
172
  }
142
173
  : {
143
174
  type: "response",
144
- body: `Updated ${prLabel} after merge-queue repair.`,
175
+ body: steeringSummary ? `Updated ${prLabel} after merge-queue repair.\n\n${steeringSummary}` : `Updated ${prLabel} after merge-queue repair.`,
145
176
  };
146
177
  case "branch_upkeep":
147
178
  return undefined;
@@ -155,6 +186,9 @@ export function buildRunCompletedActivity(params) {
155
186
  if (summary) {
156
187
  lines.push("", summary);
157
188
  }
189
+ if (steeringSummary) {
190
+ lines.push("", steeringSummary);
191
+ }
158
192
  return {
159
193
  type: "response",
160
194
  body: lines.join("\n"),
@@ -162,6 +196,32 @@ export function buildRunCompletedActivity(params) {
162
196
  }
163
197
  }
164
198
  }
199
+ function buildSteeringSummary(delivered = 0, failed = 0) {
200
+ if (delivered === 0 && failed === 0)
201
+ return undefined;
202
+ const parts = [];
203
+ if (delivered > 0) {
204
+ parts.push(`${delivered} follow-up prompt${delivered === 1 ? "" : "s"} delivered`);
205
+ }
206
+ if (failed > 0) {
207
+ parts.push(`${failed} follow-up delivery failure${failed === 1 ? "" : "s"}`);
208
+ }
209
+ return `Steering: ${parts.join("; ")}.`;
210
+ }
211
+ function buildReviewResolutionSections(summary) {
212
+ if (!summary) {
213
+ return {
214
+ addressed: "- Review feedback addressed and published.",
215
+ deferred: "- None reported.",
216
+ notApplicable: "- None reported.",
217
+ };
218
+ }
219
+ return {
220
+ addressed: `- ${summary}`,
221
+ deferred: "- None reported.",
222
+ notApplicable: "- None reported.",
223
+ };
224
+ }
165
225
  export function buildRunFailureActivity(runType, reason) {
166
226
  const label = formatRunTypeLabel(runType);
167
227
  return {
@@ -266,10 +266,17 @@ function buildStructuredReviewContext(context) {
266
266
  const reviewCommitId = typeof context?.reviewCommitId === "string" ? context.reviewCommitId : undefined;
267
267
  const reviewUrl = typeof context?.reviewUrl === "string" ? context.reviewUrl : undefined;
268
268
  const reviewComments = readReviewFixComments(context);
269
- if (!reviewId && !reviewCommitId && !reviewUrl && reviewComments.length === 0) {
269
+ const degraded = context?.reviewContextDegraded === true;
270
+ if (!degraded && !reviewId && !reviewCommitId && !reviewUrl && reviewComments.length === 0) {
270
271
  return undefined;
271
272
  }
272
273
  const lines = ["## Structured Review Context", ""];
274
+ if (degraded) {
275
+ const reason = typeof context?.reviewContextDegradedReason === "string" && context.reviewContextDegradedReason.trim()
276
+ ? context.reviewContextDegradedReason.trim()
277
+ : "GitHub requested-changes context could not be refreshed before launch.";
278
+ lines.push("GitHub review context refresh: degraded", reason, "Do not assume cached review details are current. Re-read the PR review in GitHub before making review-fix changes.");
279
+ }
273
280
  if (reviewId !== undefined)
274
281
  lines.push(`Review ID: ${reviewId}`);
275
282
  if (reviewCommitId)
@@ -436,6 +443,22 @@ function buildFollowUpContextLines(issue, runType, context) {
436
443
  lines.push("", "Recent updates:");
437
444
  followUpLines.forEach((line) => lines.push(`- ${line}`));
438
445
  }
446
+ if (context?.replacementPrRequired === true) {
447
+ lines.push("", "Previous PR facts:");
448
+ if (typeof context.previousPrNumber === "number") {
449
+ lines.push(`Previous PR: #${context.previousPrNumber} (replacement PR needed)`);
450
+ }
451
+ if (typeof context.previousPrUrl === "string") {
452
+ lines.push(`Previous PR URL: ${context.previousPrUrl}`);
453
+ }
454
+ if (typeof context.previousPrState === "string") {
455
+ lines.push(`Previous PR state: ${context.previousPrState}`);
456
+ }
457
+ if (typeof context.previousPrHeadSha === "string") {
458
+ lines.push(`Previous PR head SHA: ${context.previousPrHeadSha}`);
459
+ }
460
+ lines.push("Create a fresh replacement PR for the new requested changes; do not mutate or republish the completed PR.");
461
+ }
439
462
  if (issue.prNumber || issue.prHeadSha || issue.prReviewState || context?.mergeStateStatus) {
440
463
  const prHeading = prContext.kind === "closed_historical_pr"
441
464
  || prContext.kind === "closed_replacement_pending"
@@ -663,8 +686,8 @@ export function mergePromptCustomizationLayers(base, override) {
663
686
  ? { extraInstructions: base.extraInstructions }
664
687
  : {}),
665
688
  replaceSections: {
666
- ...(base?.replaceSections ?? {}),
667
- ...(override?.replaceSections ?? {}),
689
+ ...base?.replaceSections,
690
+ ...override?.replaceSections,
668
691
  },
669
692
  };
670
693
  }
@@ -289,19 +289,24 @@ export class ReactiveRunPolicy {
289
289
  return updated;
290
290
  }
291
291
  async hydrateRequestedChangesContext(projectId, prNumber, repoFullName, headSha, context) {
292
- const merged = {
293
- ...(context ?? {}),
294
- ...(headSha ? { headSha } : {}),
295
- };
296
- if (hasStructuredReviewContext(merged)) {
297
- return merged;
292
+ const merged = { ...context };
293
+ if (headSha) {
294
+ merged.headSha = headSha;
295
+ merged.currentPrHeadSha = headSha;
298
296
  }
299
297
  const liveReview = await readLatestRequestedChangesReviewContext(repoFullName, prNumber);
300
298
  if (!liveReview) {
301
- return Object.keys(merged).length > 0 ? merged : context;
299
+ return {
300
+ ...merged,
301
+ reviewContextStatus: "degraded",
302
+ reviewContextDegraded: true,
303
+ reviewContextDegradedReason: "GitHub requested-changes review context could not be fetched before launch.",
304
+ };
302
305
  }
303
306
  return {
304
307
  ...merged,
308
+ reviewContextStatus: "fresh",
309
+ reviewContextDegraded: false,
305
310
  ...(liveReview.reviewId !== undefined ? { reviewId: liveReview.reviewId } : {}),
306
311
  ...(liveReview.reviewCommitId ? { reviewCommitId: liveReview.reviewCommitId } : {}),
307
312
  ...(liveReview.reviewUrl ? { reviewUrl: liveReview.reviewUrl } : {}),
@@ -327,21 +332,3 @@ function isReactiveScopeRiskPath(filePath) {
327
332
  return REACTIVE_SCOPE_RISK_EXACT_PATHS.has(filePath)
328
333
  || REACTIVE_SCOPE_RISK_PREFIXES.some((prefix) => filePath.startsWith(prefix));
329
334
  }
330
- function hasStructuredReviewContext(context) {
331
- if (!context)
332
- return false;
333
- const reviewBody = typeof context.reviewBody === "string" ? context.reviewBody.trim() : "";
334
- const isOperatorRetryPlaceholder = typeof context.source === "string"
335
- && context.source === "operator_retry"
336
- && /^operator requested retry of review-fix work\.?$/i.test(reviewBody);
337
- if (isOperatorRetryPlaceholder) {
338
- return false;
339
- }
340
- if (reviewBody)
341
- return true;
342
- if (typeof context.reviewUrl === "string" && context.reviewUrl.trim())
343
- return true;
344
- if (typeof context.reviewerName === "string" && context.reviewerName.trim())
345
- return true;
346
- return Array.isArray(context.reviewComments) && context.reviewComments.length > 0;
347
- }
@@ -20,6 +20,24 @@ function buildRunSummaryJson(report, publicationRecapSummary) {
20
20
  publicationRecapSummary: publicationRecapSummary ?? null,
21
21
  });
22
22
  }
23
+ function summarizePromptDeliveryEvents(events, run) {
24
+ let delivered = 0;
25
+ let failed = 0;
26
+ for (const event of events) {
27
+ if (event.eventType !== "prompt_delivered")
28
+ continue;
29
+ const payload = parseEventJson(event.eventJson);
30
+ if (payload?.runId !== run.id)
31
+ continue;
32
+ if (payload.status === "delivered") {
33
+ delivered += 1;
34
+ }
35
+ else if (payload.status === "delivery_failed") {
36
+ failed += 1;
37
+ }
38
+ }
39
+ return { delivered, failed };
40
+ }
23
41
  function shouldGeneratePublicationRecap(runType) {
24
42
  return runType === "implementation"
25
43
  || runType === "review_fix"
@@ -417,11 +435,16 @@ export class RunFinalizer {
417
435
  const completionSummary = publicationRecapSummary
418
436
  ?? report.assistantMessages.at(-1)?.slice(0, 300)
419
437
  ?? `${run.runType} completed.`;
438
+ const steeringSummary = summarizePromptDeliveryEvents(this.db.issueSessions.listIssueSessionEvents(run.projectId, run.linearIssueId), run);
420
439
  const linearActivity = buildRunCompletedActivity({
421
440
  runType: run.runType,
422
441
  completionSummary,
423
442
  postRunState: updatedIssue.factoryState,
424
443
  ...(updatedIssue.prNumber !== undefined ? { prNumber: updatedIssue.prNumber } : {}),
444
+ ...(run.runType === "review_fix" ? { reviewRound: Math.max(1, updatedIssue.reviewFixAttempts) } : {}),
445
+ ...(run.runType === "review_fix" && updatedIssue.prHeadSha ? { resultHeadSha: updatedIssue.prHeadSha } : {}),
446
+ ...(steeringSummary.delivered > 0 ? { steeringDeliveredCount: steeringSummary.delivered } : {}),
447
+ ...(steeringSummary.failed > 0 ? { steeringFailedCount: steeringSummary.failed } : {}),
425
448
  });
426
449
  if (linearActivity) {
427
450
  void this.linearSync.emitActivity(updatedIssue, linearActivity);
@@ -1,5 +1,5 @@
1
1
  import { summarizeCurrentThread } from "./run-reporting.js";
2
- import { buildRunStartedActivity, } from "./linear-session-reporting.js";
2
+ import { buildReviewRoundStartedActivity, buildRunStartedActivity, } from "./linear-session-reporting.js";
3
3
  import { CompletionCheckService } from "./completion-check.js";
4
4
  import { PublicationRecapService } from "./publication-recap.js";
5
5
  import { WorktreeManager } from "./worktree-manager.js";
@@ -411,7 +411,16 @@ export class RunOrchestrator {
411
411
  this.logger.info({ issueKey: issue.issueKey, runType, threadId, turnId }, `Started ${runType} run`);
412
412
  // Emit Linear activity + plan
413
413
  const freshIssue = this.db.issues.getIssue(item.projectId, item.issueId) ?? issue;
414
- void this.linearSync.emitActivity(freshIssue, buildRunStartedActivity(runType));
414
+ const reviewComments = Array.isArray(effectiveContext?.reviewComments) ? effectiveContext.reviewComments : undefined;
415
+ const reviewRoundActivity = runType === "review_fix"
416
+ ? buildReviewRoundStartedActivity({
417
+ round: Math.max(1, freshIssue.reviewFixAttempts),
418
+ ...(typeof effectiveContext?.reviewerName === "string" ? { reviewerName: effectiveContext.reviewerName } : {}),
419
+ ...(reviewComments ? { commentCount: reviewComments.length } : {}),
420
+ ...(typeof sourceHeadSha === "string" ? { headSha: sourceHeadSha } : {}),
421
+ })
422
+ : undefined;
423
+ void this.linearSync.emitActivity(freshIssue, reviewRoundActivity ?? buildRunStartedActivity(runType));
415
424
  void this.linearSync.syncSession(freshIssue, { activeRunType: runType });
416
425
  }
417
426
  async resetWorktreeToTrackedBranch(worktreePath, branchName, issue) {
@@ -12,6 +12,8 @@ import { IssueRemovalHandler } from "./webhooks/issue-removal-handler.js";
12
12
  import { safeJsonParse, sanitizeDiagnosticText } from "./utils.js";
13
13
  import { extractLatestAssistantSummary } from "./issue-session-events.js";
14
14
  import { WakeDispatcher } from "./wake-dispatcher.js";
15
+ import { CodexFollowupIntentClassifier } from "./followup-intent.js";
16
+ import { CodexConversationAdapter } from "./codex-conversation-adapter.js";
15
17
  export class WebhookHandler {
16
18
  config;
17
19
  db;
@@ -28,7 +30,7 @@ export class WebhookHandler {
28
30
  dependencyReadinessHandler;
29
31
  linearSync;
30
32
  wakeDispatcher;
31
- constructor(config, db, linearProvider, codex, wakeDispatcherOrEnqueueIssue, logger, feed) {
33
+ constructor(config, db, linearProvider, codex, wakeDispatcherOrEnqueueIssue, logger, feed, followupClassifier) {
32
34
  this.config = config;
33
35
  this.db = db;
34
36
  this.linearProvider = linearProvider;
@@ -44,11 +46,13 @@ export class WebhookHandler {
44
46
  : new WakeDispatcher(db, wakeDispatcherOrEnqueueIssue, () => undefined, logger, feed);
45
47
  this.installationHandler = new InstallationWebhookHandler(config, { linearInstallations: db.linearInstallations }, logger, feed);
46
48
  this.issueRemovalHandler = new IssueRemovalHandler(db, feed);
47
- this.commentWakeHandler = new CommentWakeHandler(db, codex, this.wakeDispatcher, logger, feed);
48
- this.agentSessionHandler = new AgentSessionHandler(config, db, linearProvider, codex, this.wakeDispatcher, logger, feed);
49
+ this.linearSync = new LinearSessionSync(config, db, linearProvider, logger, feed);
50
+ const intentClassifier = followupClassifier ?? new CodexFollowupIntentClassifier(codex, logger);
51
+ const conversationAdapter = new CodexConversationAdapter(db, codex, this.wakeDispatcher, logger, feed, intentClassifier);
52
+ this.commentWakeHandler = new CommentWakeHandler(db, this.wakeDispatcher, feed, conversationAdapter, (issue, content, options) => this.linearSync.emitActivity(issue, content, options));
53
+ this.agentSessionHandler = new AgentSessionHandler(config, db, linearProvider, codex, this.wakeDispatcher, logger, feed, conversationAdapter);
49
54
  this.desiredStageRecorder = new DesiredStageRecorder(db, linearProvider, this.wakeDispatcher, feed);
50
55
  this.contextLoader = new WebhookContextLoader(config, linearProvider);
51
- this.linearSync = new LinearSessionSync(config, db, linearProvider, logger, feed);
52
56
  this.dependencyReadinessHandler = new DependencyReadinessHandler(db, this.wakeDispatcher, (projectId, issueId) => this.peekPendingSessionWakeRunType(projectId, issueId));
53
57
  }
54
58
  async processWebhookEvent(webhookEventId) {
@@ -1,10 +1,7 @@
1
1
  import { buildAgentSessionPlanForIssue, } from "../agent-session-plan.js";
2
2
  import { buildAgentSessionExternalUrls } from "../agent-session-presentation.js";
3
- import { classifyFollowupIntent, followupIntentIsNonActionable } from "../followup-intent.js";
4
- import { extractLatestAssistantSummary } from "../issue-session-events.js";
5
- import { buildAlreadyRunningThought, buildAgentSessionAcknowledgementThought, buildBlockedDelegationActivity, buildDelegationThought, buildFollowupStatusActivity, buildNonActionableFollowupActivity, buildPromptDeliveredThought, buildStopConfirmationActivity, } from "../linear-session-reporting.js";
3
+ import { buildAlreadyRunningThought, buildAgentSessionAcknowledgementThought, buildBlockedDelegationActivity, buildDelegationThought, buildStopConfirmationActivity, } from "../linear-session-reporting.js";
6
4
  import { resolveProject, triggerEventAllowed } from "../project-resolution.js";
7
- import { deriveIssueStatusNote } from "../status-note.js";
8
5
  const PATCHRELAY_AGENT_ACTIVITY_TYPES = new Set([
9
6
  "action",
10
7
  "elicitation",
@@ -20,7 +17,8 @@ export class AgentSessionHandler {
20
17
  wakeDispatcher;
21
18
  logger;
22
19
  feed;
23
- constructor(config, db, linearProvider, codex, wakeDispatcher, logger, feed) {
20
+ conversationAdapter;
21
+ constructor(config, db, linearProvider, codex, wakeDispatcher, logger, feed, conversationAdapter) {
24
22
  this.config = config;
25
23
  this.db = db;
26
24
  this.linearProvider = linearProvider;
@@ -28,6 +26,7 @@ export class AgentSessionHandler {
28
26
  this.wakeDispatcher = wakeDispatcher;
29
27
  this.logger = logger;
30
28
  this.feed = feed;
29
+ this.conversationAdapter = conversationAdapter;
31
30
  }
32
31
  async acknowledgeCreated(normalized) {
33
32
  if (normalized.triggerEvent !== "agentSessionCreated" || !normalized.agentSession?.id || !normalized.issue) {
@@ -65,7 +64,6 @@ export class AgentSessionHandler {
65
64
  return;
66
65
  const existingIssue = this.db.issues.getIssue(project.id, normalized.issue.id);
67
66
  const activeRun = existingIssue?.activeRunId ? this.db.runs.getRunById(existingIssue.activeRunId) : undefined;
68
- const automationEnabled = delegated || existingIssue?.delegatedToPatchRelay === true;
69
67
  if (normalized.triggerEvent === "agentSessionCreated") {
70
68
  if (!delegated) {
71
69
  const latestIssue = this.db.issues.getIssue(project.id, normalized.issue.id);
@@ -128,78 +126,23 @@ export class AgentSessionHandler {
128
126
  }
129
127
  const promptBody = normalized.agentSession.promptBody?.trim();
130
128
  const directReply = promptBody && existingIssue ? params.isDirectReplyToOutstandingQuestion(existingIssue) : false;
131
- const promptIntent = promptBody ? classifyFollowupIntent(promptBody) : undefined;
132
- if (promptBody && existingIssue && promptIntent === "stop") {
133
- await this.handleStopSignal({
134
- normalized,
129
+ if (promptBody && existingIssue && this.conversationAdapter) {
130
+ const result = await this.conversationAdapter.deliverAgentInput({
135
131
  project,
136
- trackedIssue,
137
- activeRun,
138
- linear,
139
- syncAgentSession: (agentSessionId, issue, options) => this.syncAgentSession(linear, agentSessionId, issue, params.peekPendingSessionWakeRunType, options),
140
- });
141
- return;
142
- }
143
- if (promptBody && existingIssue && promptIntent === "status" && !directReply) {
144
- await this.publishAgentActivity(linear, normalized.agentSession.id, this.buildStatusActivity(existingIssue, activeRun, params.peekPendingSessionWakeRunType));
145
- await this.syncAgentSession(linear, normalized.agentSession.id, existingIssue ?? trackedIssue, params.peekPendingSessionWakeRunType, activeRun ? { activeRunType: activeRun.runType } : undefined);
146
- return;
147
- }
148
- if (promptBody && promptIntent && followupIntentIsNonActionable(promptIntent) && !directReply) {
149
- await this.publishAgentActivity(linear, normalized.agentSession.id, buildNonActionableFollowupActivity(promptIntent));
150
- return;
151
- }
152
- if (!automationEnabled && promptBody && existingIssue) {
153
- await this.publishAgentActivity(linear, normalized.agentSession.id, {
154
- type: "thought",
155
- body: "PatchRelay is paused because the issue is undelegated.",
156
- }, { ephemeral: true });
157
- return;
158
- }
159
- if (activeRun && promptBody && activeRun.threadId && activeRun.turnId) {
160
- const input = `New Linear agent prompt received while you are working.\n\n${promptBody}`;
161
- try {
162
- await this.codex.steerTurn({ threadId: activeRun.threadId, turnId: activeRun.turnId, input });
163
- this.feed?.publish({
164
- level: "info",
165
- kind: "agent",
166
- projectId: project.id,
167
- issueKey: trackedIssue?.issueKey,
168
- stage: activeRun.runType,
169
- status: "delivered",
170
- summary: `Delivered follow-up prompt to active ${activeRun.runType} workflow`,
171
- });
172
- }
173
- catch (error) {
174
- this.logger.warn({ issueKey: trackedIssue?.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to deliver follow-up prompt");
175
- this.feed?.publish({
176
- level: "warn",
177
- kind: "agent",
178
- projectId: project.id,
179
- issueKey: trackedIssue?.issueKey,
180
- stage: activeRun.runType,
181
- status: "delivery_failed",
182
- summary: `Could not deliver follow-up prompt to active ${activeRun.runType} workflow`,
183
- });
184
- }
185
- await this.publishAgentActivity(linear, normalized.agentSession.id, buildPromptDeliveredThought(activeRun.runType), { ephemeral: true });
186
- return;
187
- }
188
- if (promptBody && existingIssue && automationEnabled) {
189
- if (!directReply && promptIntent && followupIntentIsNonActionable(promptIntent)) {
190
- await this.publishAgentActivity(linear, normalized.agentSession.id, buildNonActionableFollowupActivity(promptIntent));
191
- return;
192
- }
193
- const queuedRunType = this.wakeDispatcher.recordEventAndDispatch(project.id, normalized.issue.id, {
194
- eventType: directReply ? "direct_reply" : "followup_prompt",
195
- eventJson: JSON.stringify({
196
- text: promptBody,
197
- source: "linear_agent_prompt",
198
- }),
132
+ issue: existingIssue,
133
+ source: "agent_session_prompt",
134
+ body: promptBody,
135
+ directReply,
136
+ emitActivity: (content, options) => this.publishAgentActivity(linear, normalized.agentSession.id, content, options),
137
+ peekPendingSessionWakeRunType: params.peekPendingSessionWakeRunType,
199
138
  });
200
139
  const latestIssue = this.db.issues.getIssue(project.id, normalized.issue.id);
201
- await this.syncAgentSession(linear, normalized.agentSession.id, latestIssue ?? trackedIssue, params.peekPendingSessionWakeRunType, { pendingRunType: queuedRunType ?? wakeRunType ?? (existingIssue.prReviewState === "changes_requested" ? "review_fix" : "implementation") });
202
- await this.publishAgentActivity(linear, normalized.agentSession.id, buildPromptDeliveredThought(queuedRunType ?? wakeRunType ?? "implementation"), { ephemeral: true });
140
+ const syncOptions = result.activeRunType
141
+ ? { activeRunType: result.activeRunType }
142
+ : result.queuedRunType ? { pendingRunType: result.queuedRunType }
143
+ : wakeRunType ? { pendingRunType: wakeRunType }
144
+ : undefined;
145
+ await this.syncAgentSession(linear, normalized.agentSession.id, latestIssue ?? trackedIssue, params.peekPendingSessionWakeRunType, syncOptions);
203
146
  return;
204
147
  }
205
148
  if (wakeRunType) {
@@ -208,24 +151,6 @@ export class AgentSessionHandler {
208
151
  await this.publishAgentActivity(linear, normalized.agentSession.id, buildDelegationThought(wakeRunType, "prompt"), { ephemeral: true });
209
152
  }
210
153
  }
211
- buildStatusActivity(issue, activeRun, peekPendingSessionWakeRunType) {
212
- const latestRun = activeRun ?? this.db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
213
- const latestEvent = this.db.issueSessions.listIssueSessionEvents(issue.projectId, issue.linearIssueId).at(-1);
214
- const statusNote = deriveIssueStatusNote({
215
- issue,
216
- latestRun,
217
- latestEvent,
218
- sessionSummary: extractLatestAssistantSummary(latestRun),
219
- waitingReason: undefined,
220
- });
221
- const pendingRunType = peekPendingSessionWakeRunType(issue.projectId, issue.linearIssueId);
222
- return buildFollowupStatusActivity({
223
- issue,
224
- ...(statusNote ? { statusNote } : {}),
225
- ...(activeRun?.runType ? { activeRunType: activeRun.runType } : {}),
226
- ...(pendingRunType ? { pendingRunType } : {}),
227
- });
228
- }
229
154
  async handleStopSignal(params) {
230
155
  const issueId = params.normalized.issue.id;
231
156
  const sessionId = params.normalized.agentSession.id;