patchrelay 0.55.2 → 0.56.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.55.2",
4
- "commit": "8e07dee509b9",
5
- "builtAt": "2026-05-01T14:20:04.619Z"
3
+ "version": "0.56.1",
4
+ "commit": "39a7bc21b981",
5
+ "builtAt": "2026-05-01T15:22:20.307Z"
6
6
  }
@@ -0,0 +1,56 @@
1
+ const IMPLEMENTATION_PATTERNS = [
2
+ /\b(add|address|adjust|change|create|delete|deploy|fix|implement|install|merge|open|polish|publish|push|remove|rename|set|ship|update)\b/i,
3
+ /\b(go on and|go ahead and|please|pls)\s+\b(add|address|adjust|change|create|delete|deploy|fix|implement|install|merge|open|polish|publish|push|remove|rename|set|ship|update|use)\b/i,
4
+ /\b(use|keep|switch|move)\b.+\b(instead|copy|api|contract|behavior|state|repo|branch|team|issue|pr)\b/i,
5
+ ];
6
+ const RETRY_PATTERNS = [
7
+ /\b(continue|go on|keep going|proceed|resume)\b/i,
8
+ /\b(retry|try again|rerun|run again|restart)\b/i,
9
+ /\b(next task|next issue)\b/i,
10
+ ];
11
+ const STOP_PATTERNS = [
12
+ /\b(stop|cancel|halt|abort)\b/i,
13
+ /\b(pause|hold)\s+(work|implementation|the run|patchrelay)\b/i,
14
+ /\bdo not\s+(continue|proceed|work|implement)\b/i,
15
+ ];
16
+ const STATUS_PATTERNS = [
17
+ /\b(status|progress)\b/i,
18
+ /\b(status update|progress update|any update|quick update)\b/i,
19
+ /\b(where are we|what'?s happening|what is happening|what'?s deployed|what is deployed)\b/i,
20
+ /\b(done so far|deployed so far|current work|current run)\b/i,
21
+ ];
22
+ const CLARIFICATION_PATTERNS = [
23
+ /\b(fyi|for context|heads up|to clarify|clarification|correction|actually|note that)\b/i,
24
+ /\b(i meant|what i meant|not asking you to|no action needed)\b/i,
25
+ ];
26
+ const QUESTION_PATTERNS = [
27
+ /\?$/,
28
+ /\b(can|could|do|does|did|is|are|was|were|why|how|what|when|where|which|who|should|would)\b/i,
29
+ ];
30
+ export function classifyFollowupIntent(input) {
31
+ const text = input.trim();
32
+ if (!text)
33
+ return "clarification";
34
+ if (matchesAny(text, STOP_PATTERNS))
35
+ return "stop";
36
+ if (matchesAny(text, RETRY_PATTERNS))
37
+ return "retry";
38
+ if (matchesAny(text, STATUS_PATTERNS))
39
+ return "status";
40
+ if (matchesAny(text, IMPLEMENTATION_PATTERNS))
41
+ return "implementation_request";
42
+ if (matchesAny(text, CLARIFICATION_PATTERNS))
43
+ return "clarification";
44
+ if (matchesAny(text, QUESTION_PATTERNS))
45
+ return "question";
46
+ return "clarification";
47
+ }
48
+ export function followupIntentQueuesWork(intent) {
49
+ return intent === "implementation_request" || intent === "retry";
50
+ }
51
+ export function followupIntentIsNonActionable(intent) {
52
+ return intent === "status" || intent === "question" || intent === "clarification";
53
+ }
54
+ function matchesAny(text, patterns) {
55
+ return patterns.some((pattern) => pattern.test(text));
56
+ }
@@ -42,6 +42,12 @@ export function buildAlreadyRunningThought(runType) {
42
42
  body: `PatchRelay is already working on the ${lowerRunTypeLabel(runType)} workflow.`,
43
43
  };
44
44
  }
45
+ export function buildAgentSessionAcknowledgementThought() {
46
+ return {
47
+ type: "thought",
48
+ body: "PatchRelay received this agent session and is checking the issue state.",
49
+ };
50
+ }
45
51
  export function buildBlockedDelegationActivity(blockedByKeys = []) {
46
52
  const blockers = blockedByKeys.filter((key) => key.trim().length > 0);
47
53
  const blockerText = blockers.length > 0
@@ -58,6 +64,24 @@ export function buildPromptDeliveredThought(runType) {
58
64
  body: `PatchRelay routed your latest instructions into the active ${lowerRunTypeLabel(runType)} workflow.`,
59
65
  };
60
66
  }
67
+ export function buildFollowupStatusActivity(params) {
68
+ const subject = params.issue.issueKey ? `${params.issue.issueKey}` : "this issue";
69
+ const runNote = params.activeRunType
70
+ ? ` Active workflow: ${lowerRunTypeLabel(params.activeRunType)}.`
71
+ : params.pendingRunType ? ` Queued workflow: ${lowerRunTypeLabel(params.pendingRunType)}.` : "";
72
+ const prNote = params.issue.prNumber ? ` PR #${params.issue.prNumber}.` : "";
73
+ const statusNote = params.statusNote ? ` ${params.statusNote}` : "";
74
+ return {
75
+ type: "response",
76
+ body: `PatchRelay status: ${subject} is ${formatFactoryState(params.issue.factoryState)}.${prNote}${runNote}${statusNote}`.trim(),
77
+ };
78
+ }
79
+ export function buildNonActionableFollowupActivity(intent) {
80
+ const body = intent === "status"
81
+ ? "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.";
83
+ return { type: "response", body };
84
+ }
61
85
  export function buildRunStartedActivity(runType) {
62
86
  switch (runType) {
63
87
  case "review_fix":
@@ -73,6 +97,9 @@ export function buildRunStartedActivity(runType) {
73
97
  return { type: "action", action: "Implementing", parameter: "requested change" };
74
98
  }
75
99
  }
100
+ function formatFactoryState(state) {
101
+ return state.replaceAll("_", " ");
102
+ }
76
103
  export function buildRunCompletedActivity(params) {
77
104
  const prLabel = params.prNumber ? `PR #${params.prNumber}` : "the pull request";
78
105
  const summary = trimSummary(params.completionSummary);
@@ -74,6 +74,7 @@ export class WebhookHandler {
74
74
  this.db.webhookEvents.markWebhookProcessed(webhookEventId, "processed");
75
75
  return;
76
76
  }
77
+ await this.agentSessionHandler.acknowledgeCreated(normalized);
77
78
  const routed = await this.contextLoader.load(normalized);
78
79
  const project = routed?.project;
79
80
  if (!project) {
@@ -1,7 +1,10 @@
1
1
  import { buildAgentSessionPlanForIssue, } from "../agent-session-plan.js";
2
2
  import { buildAgentSessionExternalUrls } from "../agent-session-presentation.js";
3
- import { buildAlreadyRunningThought, buildBlockedDelegationActivity, buildDelegationThought, buildPromptDeliveredThought, buildStopConfirmationActivity, } from "../linear-session-reporting.js";
4
- import { triggerEventAllowed } from "../project-resolution.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";
6
+ import { resolveProject, triggerEventAllowed } from "../project-resolution.js";
7
+ import { deriveIssueStatusNote } from "../status-note.js";
5
8
  const PATCHRELAY_AGENT_ACTIVITY_TYPES = new Set([
6
9
  "action",
7
10
  "elicitation",
@@ -24,6 +27,33 @@ export class AgentSessionHandler {
24
27
  this.logger = logger;
25
28
  this.feed = feed;
26
29
  }
30
+ async acknowledgeCreated(normalized) {
31
+ if (normalized.triggerEvent !== "agentSessionCreated" || !normalized.agentSession?.id || !normalized.issue) {
32
+ return;
33
+ }
34
+ const project = resolveProject(this.config, normalized.issue);
35
+ if (!project || !triggerEventAllowed(project, normalized.triggerEvent)) {
36
+ return;
37
+ }
38
+ const linear = await this.linearProvider.forProject(project.id);
39
+ if (!linear?.createAgentActivity) {
40
+ return;
41
+ }
42
+ try {
43
+ await linear.createAgentActivity({
44
+ agentSessionId: normalized.agentSession.id,
45
+ content: buildAgentSessionAcknowledgementThought(),
46
+ ephemeral: true,
47
+ });
48
+ }
49
+ catch (error) {
50
+ this.logger.warn({
51
+ agentSessionId: normalized.agentSession.id,
52
+ issueKey: normalized.issue.identifier,
53
+ error: error instanceof Error ? error.message : String(error),
54
+ }, "Failed to acknowledge Linear agent session creation");
55
+ }
56
+ }
27
57
  async handle(params) {
28
58
  const { normalized, project, trackedIssue, wakeRunType, delegated } = params;
29
59
  if (!normalized.agentSession?.id || !normalized.issue)
@@ -95,6 +125,28 @@ export class AgentSessionHandler {
95
125
  return;
96
126
  }
97
127
  const promptBody = normalized.agentSession.promptBody?.trim();
128
+ const directReply = promptBody && existingIssue ? params.isDirectReplyToOutstandingQuestion(existingIssue) : false;
129
+ const promptIntent = promptBody ? classifyFollowupIntent(promptBody) : undefined;
130
+ if (promptBody && existingIssue && promptIntent === "stop") {
131
+ await this.handleStopSignal({
132
+ normalized,
133
+ project,
134
+ trackedIssue,
135
+ activeRun,
136
+ linear,
137
+ syncAgentSession: (agentSessionId, issue, options) => this.syncAgentSession(linear, agentSessionId, issue, params.peekPendingSessionWakeRunType, options),
138
+ });
139
+ return;
140
+ }
141
+ if (promptBody && existingIssue && promptIntent === "status" && !directReply) {
142
+ await this.publishAgentActivity(linear, normalized.agentSession.id, this.buildStatusActivity(existingIssue, activeRun, params.peekPendingSessionWakeRunType));
143
+ await this.syncAgentSession(linear, normalized.agentSession.id, existingIssue ?? trackedIssue, params.peekPendingSessionWakeRunType, activeRun ? { activeRunType: activeRun.runType } : undefined);
144
+ return;
145
+ }
146
+ if (promptBody && promptIntent && followupIntentIsNonActionable(promptIntent) && !directReply) {
147
+ await this.publishAgentActivity(linear, normalized.agentSession.id, buildNonActionableFollowupActivity(promptIntent));
148
+ return;
149
+ }
98
150
  if (!automationEnabled && promptBody && existingIssue) {
99
151
  await this.publishAgentActivity(linear, normalized.agentSession.id, {
100
152
  type: "thought",
@@ -133,7 +185,10 @@ export class AgentSessionHandler {
133
185
  }
134
186
  if (promptBody && existingIssue && automationEnabled) {
135
187
  const hadPendingWake = this.db.issueSessions.peekIssueSessionWake(project.id, normalized.issue.id) !== undefined;
136
- const directReply = params.isDirectReplyToOutstandingQuestion(existingIssue);
188
+ if (!directReply && promptIntent && followupIntentIsNonActionable(promptIntent)) {
189
+ await this.publishAgentActivity(linear, normalized.agentSession.id, buildNonActionableFollowupActivity(promptIntent));
190
+ return;
191
+ }
137
192
  this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(project.id, normalized.issue.id, {
138
193
  projectId: project.id,
139
194
  linearIssueId: normalized.issue.id,
@@ -157,6 +212,24 @@ export class AgentSessionHandler {
157
212
  await this.publishAgentActivity(linear, normalized.agentSession.id, buildDelegationThought(wakeRunType, "prompt"), { ephemeral: true });
158
213
  }
159
214
  }
215
+ buildStatusActivity(issue, activeRun, peekPendingSessionWakeRunType) {
216
+ const latestRun = activeRun ?? this.db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
217
+ const latestEvent = this.db.issueSessions.listIssueSessionEvents(issue.projectId, issue.linearIssueId).at(-1);
218
+ const statusNote = deriveIssueStatusNote({
219
+ issue,
220
+ latestRun,
221
+ latestEvent,
222
+ sessionSummary: extractLatestAssistantSummary(latestRun),
223
+ waitingReason: undefined,
224
+ });
225
+ const pendingRunType = peekPendingSessionWakeRunType(issue.projectId, issue.linearIssueId);
226
+ return buildFollowupStatusActivity({
227
+ issue,
228
+ ...(statusNote ? { statusNote } : {}),
229
+ ...(activeRun?.runType ? { activeRunType: activeRun.runType } : {}),
230
+ ...(pendingRunType ? { pendingRunType } : {}),
231
+ });
232
+ }
160
233
  async handleStopSignal(params) {
161
234
  const issueId = params.normalized.issue.id;
162
235
  const sessionId = params.normalized.agentSession.id;
@@ -31,6 +31,8 @@ export function isPatchRelayGeneratedActivityComment(body) {
31
31
  || body.startsWith("PatchRelay is already working on ")
32
32
  || body.startsWith("PatchRelay received the ")
33
33
  || body.startsWith("PatchRelay routed your latest instructions into ")
34
+ || body.startsWith("PatchRelay status:")
35
+ || body.startsWith("PatchRelay did not start implementation ")
34
36
  || body.startsWith("PatchRelay has stopped work as requested.")
35
37
  || body.startsWith("Merge preparation failed ")
36
38
  || body === "This thread is for an agent session with patchrelay.";
@@ -1,3 +1,4 @@
1
+ import { classifyFollowupIntent, followupIntentIsNonActionable, followupIntentQueuesWork } from "../followup-intent.js";
1
2
  import { triggerEventAllowed } from "../project-resolution.js";
2
3
  import { hasExplicitPatchRelayWakeIntent, isInertPatchRelayComment, isPatchRelayManagedCommentAuthor, } from "./comment-policy.js";
3
4
  import { classifyIssue } from "../issue-class.js";
@@ -57,9 +58,10 @@ export class CommentWakeHandler {
57
58
  });
58
59
  return;
59
60
  }
61
+ const directReply = params.isDirectReplyToOutstandingQuestion(issue);
62
+ const intent = classifyFollowupIntent(trimmedBody);
60
63
  if (!issue.activeRunId) {
61
64
  if (ENQUEUEABLE_STATES.has(issue.factoryState)) {
62
- const directReply = params.isDirectReplyToOutstandingQuestion(issue);
63
65
  const wakeIntent = issueClass === "orchestration" || directReply || hasExplicitPatchRelayWakeIntent(trimmedBody);
64
66
  if (!wakeIntent) {
65
67
  this.feed?.publish({
@@ -73,6 +75,42 @@ export class CommentWakeHandler {
73
75
  });
74
76
  return;
75
77
  }
78
+ if (intent === "stop") {
79
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(project.id, normalized.issue.id, {
80
+ projectId: project.id,
81
+ linearIssueId: normalized.issue.id,
82
+ eventType: "stop_requested",
83
+ eventJson: JSON.stringify({
84
+ body: trimmedBody,
85
+ author: normalized.comment.userName,
86
+ }),
87
+ });
88
+ this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(project.id, normalized.issue.id);
89
+ this.feed?.publish({
90
+ level: "info",
91
+ kind: "comment",
92
+ projectId: project.id,
93
+ issueKey: trackedIssue?.issueKey,
94
+ status: "stopped",
95
+ summary: "Stop request recorded from Linear comment",
96
+ detail: trimmedBody.slice(0, 200),
97
+ });
98
+ return;
99
+ }
100
+ if (!directReply && !followupIntentQueuesWork(intent)) {
101
+ this.feed?.publish({
102
+ level: "info",
103
+ kind: "comment",
104
+ projectId: project.id,
105
+ issueKey: trackedIssue?.issueKey,
106
+ status: intent === "status" ? "status_requested" : "ignored",
107
+ summary: intent === "status"
108
+ ? "Ignored status comment without queueing work"
109
+ : "Ignored non-actionable follow-up comment",
110
+ detail: trimmedBody.slice(0, 200),
111
+ });
112
+ return;
113
+ }
76
114
  const runType = issue.prReviewState === "changes_requested" ? "review_fix" : "implementation";
77
115
  const hadPendingWake = this.db.issueSessions.peekIssueSessionWake(project.id, normalized.issue.id) !== undefined;
78
116
  this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(project.id, normalized.issue.id, {
@@ -102,6 +140,60 @@ export class CommentWakeHandler {
102
140
  const run = this.db.runs.getRunById(issue.activeRunId);
103
141
  if (!run?.threadId || !run.turnId)
104
142
  return;
143
+ if (intent === "stop") {
144
+ try {
145
+ await this.codex.steerTurn({
146
+ threadId: run.threadId,
147
+ turnId: run.turnId,
148
+ input: "STOP: The user has requested you stop working immediately. Do not make further changes. Wrap up and exit.",
149
+ });
150
+ }
151
+ catch (error) {
152
+ this.logger.warn({ issueKey: trackedIssue?.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to steer Codex turn for comment stop request");
153
+ }
154
+ this.db.runs.finishRun(run.id, { status: "released", threadId: run.threadId, turnId: run.turnId });
155
+ this.db.issueSessions.upsertIssueRespectingActiveLease(project.id, normalized.issue.id, {
156
+ projectId: project.id,
157
+ linearIssueId: normalized.issue.id,
158
+ activeRunId: null,
159
+ factoryState: "awaiting_input",
160
+ });
161
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(project.id, normalized.issue.id, {
162
+ projectId: project.id,
163
+ linearIssueId: normalized.issue.id,
164
+ eventType: "stop_requested",
165
+ eventJson: JSON.stringify({
166
+ body: trimmedBody,
167
+ author: normalized.comment.userName,
168
+ }),
169
+ });
170
+ this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(project.id, normalized.issue.id);
171
+ this.feed?.publish({
172
+ level: "info",
173
+ kind: "comment",
174
+ projectId: project.id,
175
+ issueKey: trackedIssue?.issueKey,
176
+ stage: run.runType,
177
+ status: "stopped",
178
+ summary: "Stop request delivered to active workflow",
179
+ });
180
+ return;
181
+ }
182
+ if (!directReply && followupIntentIsNonActionable(intent)) {
183
+ this.feed?.publish({
184
+ level: "info",
185
+ kind: "comment",
186
+ projectId: project.id,
187
+ issueKey: trackedIssue?.issueKey,
188
+ stage: run.runType,
189
+ status: intent === "status" ? "status_requested" : "ignored",
190
+ summary: intent === "status"
191
+ ? "Ignored status comment without steering active workflow"
192
+ : "Ignored non-actionable follow-up comment",
193
+ detail: trimmedBody.slice(0, 200),
194
+ });
195
+ return;
196
+ }
105
197
  const body = [
106
198
  "New Linear comment received while you are working.",
107
199
  normalized.comment.userName ? `Author: ${normalized.comment.userName}` : undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.55.2",
3
+ "version": "0.56.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {