patchrelay 0.20.8 → 0.21.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.20.8",
4
- "commit": "669292b8198c",
5
- "builtAt": "2026-03-26T09:30:17.898Z"
3
+ "version": "0.21.0",
4
+ "commit": "37cae5cfd0e6",
5
+ "builtAt": "2026-03-26T10:21:53.343Z"
6
6
  }
@@ -25,6 +25,22 @@ async function postPrompt(baseUrl, issueKey, text, bearerToken) {
25
25
  return { reason: "Request failed" };
26
26
  }
27
27
  }
28
+ async function postStop(baseUrl, issueKey, bearerToken) {
29
+ const headers = { "content-type": "application/json" };
30
+ if (bearerToken)
31
+ headers.authorization = `Bearer ${bearerToken}`;
32
+ try {
33
+ const response = await fetch(new URL(`/api/issues/${encodeURIComponent(issueKey)}/stop`, baseUrl), {
34
+ method: "POST",
35
+ headers,
36
+ signal: AbortSignal.timeout(5000),
37
+ });
38
+ return await response.json();
39
+ }
40
+ catch {
41
+ return { reason: "request failed" };
42
+ }
43
+ }
28
44
  async function postRetry(baseUrl, issueKey, bearerToken) {
29
45
  const headers = { "content-type": "application/json" };
30
46
  if (bearerToken)
@@ -146,6 +162,15 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
146
162
  else if (input === "p") {
147
163
  setPromptMode(true);
148
164
  }
165
+ else if (input === "s") {
166
+ if (state.activeDetailKey) {
167
+ setPromptStatus("stopping...");
168
+ void postStop(baseUrl, state.activeDetailKey, bearerToken).then((result) => {
169
+ setPromptStatus(result.ok ? "stop sent" : `stop failed: ${result.reason ?? "unknown"}`);
170
+ setTimeout(() => setPromptStatus(null), 3000);
171
+ });
172
+ }
173
+ }
149
174
  else if (input === "j" || key.downArrow) {
150
175
  dispatch({ type: "detail-navigate", direction: "next", filtered });
151
176
  }
@@ -7,7 +7,7 @@ const HELP_TEXT = {
7
7
  };
8
8
  export function HelpBar({ view, follow }) {
9
9
  const text = view === "detail"
10
- ? `j/k: prev/next Esc: list f: follow ${follow ? "on" : "off"} p: prompt r: retry q: quit`
10
+ ? `j/k: prev/next Esc: list f: follow ${follow ? "on" : "off"} p: prompt s: stop r: retry q: quit`
11
11
  : HELP_TEXT[view];
12
12
  return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: text }) }));
13
13
  }
package/dist/http.js CHANGED
@@ -325,6 +325,17 @@ export async function buildHttpServer(config, service, logger) {
325
325
  }
326
326
  return reply.send({ ok: true, ...result });
327
327
  });
328
+ app.post("/api/issues/:issueKey/stop", async (request, reply) => {
329
+ const issueKey = request.params.issueKey;
330
+ const result = await service.stopIssue(issueKey);
331
+ if (!result) {
332
+ return reply.code(404).send({ ok: false, reason: "issue_not_found" });
333
+ }
334
+ if ("error" in result) {
335
+ return reply.code(409).send({ ok: false, reason: result.error });
336
+ }
337
+ return reply.send({ ok: true, ...result });
338
+ });
328
339
  app.get("/api/feed", async (request, reply) => {
329
340
  const feedQuery = {
330
341
  limit: getPositiveIntegerQueryParam(request, "limit") ?? 50,
@@ -84,6 +84,7 @@ export class RunOrchestrator {
84
84
  feed;
85
85
  worktreeManager;
86
86
  progressThrottle = new Map();
87
+ activeThreadId;
87
88
  botIdentity;
88
89
  constructor(config, db, codex, linearProvider, enqueueIssue, logger, feed) {
89
90
  this.config = config;
@@ -247,9 +248,18 @@ export class RunOrchestrator {
247
248
  }
248
249
  // ─── Notification handler ─────────────────────────────────────────
249
250
  async handleCodexNotification(notification) {
250
- const threadId = typeof notification.params.threadId === "string" ? notification.params.threadId : undefined;
251
+ // threadId is present on turn-level notifications but NOT on item-level ones.
252
+ // Fall back to the tracked active thread for item/delta notifications.
253
+ let threadId = typeof notification.params.threadId === "string" ? notification.params.threadId : undefined;
254
+ if (!threadId) {
255
+ threadId = this.activeThreadId;
256
+ }
251
257
  if (!threadId)
252
258
  return;
259
+ // Track the active thread from turn/started so item notifications can find it
260
+ if (notification.method === "turn/started" && threadId) {
261
+ this.activeThreadId = threadId;
262
+ }
253
263
  const run = this.db.getRunByThreadId(threadId);
254
264
  if (!run)
255
265
  return;
@@ -299,6 +309,7 @@ export class RunOrchestrator {
299
309
  void this.emitLinearActivity(failedIssue, buildRunFailureActivity(run.runType));
300
310
  void this.syncLinearSession(failedIssue, { activeRunType: run.runType });
301
311
  this.progressThrottle.delete(run.id);
312
+ this.activeThreadId = undefined;
302
313
  return;
303
314
  }
304
315
  // Complete the run
@@ -348,6 +359,7 @@ export class RunOrchestrator {
348
359
  }));
349
360
  void this.syncLinearSession(updatedIssue);
350
361
  this.progressThrottle.delete(run.id);
362
+ this.activeThreadId = undefined;
351
363
  }
352
364
  // ─── In-flight progress ──────────────────────────────────────────
353
365
  maybeEmitProgressActivity(notification, run) {
package/dist/service.js CHANGED
@@ -192,12 +192,20 @@ export class PatchRelayService {
192
192
  }));
193
193
  }
194
194
  subscribeCodexNotifications(listener) {
195
+ let trackedThreadId;
195
196
  const handler = (notification) => {
196
- const threadId = typeof notification.params.threadId === "string"
197
+ let threadId = typeof notification.params.threadId === "string"
197
198
  ? notification.params.threadId
198
199
  : typeof notification.params.thread === "object" && notification.params.thread !== null && "id" in notification.params.thread
199
200
  ? String(notification.params.thread.id)
200
201
  : undefined;
202
+ // Item-level notifications lack threadId — use the tracked one from turn/started
203
+ if (!threadId)
204
+ threadId = trackedThreadId;
205
+ if (notification.method === "turn/started" && threadId)
206
+ trackedThreadId = threadId;
207
+ if (notification.method === "turn/completed")
208
+ trackedThreadId = undefined;
201
209
  let issueKey;
202
210
  let runId;
203
211
  if (threadId) {
@@ -272,6 +280,40 @@ export class PatchRelayService {
272
280
  return { delivered: false, queued: true };
273
281
  }
274
282
  }
283
+ async stopIssue(issueKey) {
284
+ const issue = this.db.getIssueByKey(issueKey);
285
+ if (!issue)
286
+ return undefined;
287
+ if (!issue.activeRunId)
288
+ return { error: "No active run to stop" };
289
+ const run = this.db.getRun(issue.activeRunId);
290
+ if (run?.threadId && run.turnId) {
291
+ try {
292
+ await this.codex.steerTurn({
293
+ threadId: run.threadId,
294
+ turnId: run.turnId,
295
+ input: "STOP: The operator has requested this run to halt immediately. Finish your current action, commit any partial progress, and stop.",
296
+ });
297
+ }
298
+ catch {
299
+ // Turn may already be done
300
+ }
301
+ }
302
+ this.db.upsertIssue({
303
+ projectId: issue.projectId,
304
+ linearIssueId: issue.linearIssueId,
305
+ factoryState: "awaiting_input",
306
+ });
307
+ this.feed.publish({
308
+ level: "warn",
309
+ kind: "workflow",
310
+ issueKey: issue.issueKey,
311
+ projectId: issue.projectId,
312
+ status: "stopped",
313
+ summary: "Operator stopped the run",
314
+ });
315
+ return { stopped: true };
316
+ }
275
317
  retryIssue(issueKey) {
276
318
  const issue = this.db.getIssueByKey(issueKey);
277
319
  if (!issue)
@@ -306,8 +306,32 @@ export class WebhookHandler {
306
306
  if (!triggerEventAllowed(project, normalized.triggerEvent))
307
307
  return;
308
308
  const issue = this.db.getIssue(project.id, normalized.issue.id);
309
- if (!issue?.activeRunId)
309
+ if (!issue)
310
310
  return;
311
+ // No active run — enqueue a run with the comment as context if appropriate
312
+ if (!issue.activeRunId) {
313
+ const ENQUEUEABLE_STATES = new Set(["pr_open", "changes_requested", "implementing", "delegated"]);
314
+ if (ENQUEUEABLE_STATES.has(issue.factoryState)) {
315
+ const runType = issue.prReviewState === "changes_requested" ? "review_fix" : "implementation";
316
+ this.db.upsertIssue({
317
+ projectId: project.id,
318
+ linearIssueId: normalized.issue.id,
319
+ pendingRunType: runType,
320
+ pendingRunContextJson: JSON.stringify({ userComment: normalized.comment.body.trim() }),
321
+ });
322
+ this.enqueueIssue(project.id, normalized.issue.id);
323
+ this.feed?.publish({
324
+ level: "info",
325
+ kind: "comment",
326
+ projectId: project.id,
327
+ issueKey: trackedIssue?.issueKey,
328
+ status: "enqueued",
329
+ summary: `Comment enqueued ${runType} run`,
330
+ detail: normalized.comment.body.slice(0, 200),
331
+ });
332
+ }
333
+ return;
334
+ }
311
335
  const run = this.db.getRun(issue.activeRunId);
312
336
  if (!run?.threadId || !run.turnId)
313
337
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.20.8",
3
+ "version": "0.21.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {