patchrelay 0.19.0 → 0.20.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.19.0",
4
- "commit": "0172365b885d",
5
- "builtAt": "2026-03-25T20:06:12.646Z"
3
+ "version": "0.20.0",
4
+ "commit": "e3ce50c81db1",
5
+ "builtAt": "2026-03-25T20:18:22.070Z"
6
6
  }
@@ -1,6 +1,6 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useReducer, useMemo, useCallback } from "react";
3
- import { Box, useApp, useInput } from "ink";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useReducer, useMemo, useCallback, useState } from "react";
3
+ import { Box, Text, useApp, useInput } from "ink";
4
4
  import { watchReducer, initialWatchState, filterIssues } from "./watch-state.js";
5
5
  import { useWatchStream } from "./use-watch-stream.js";
6
6
  import { useDetailStream } from "./use-detail-stream.js";
@@ -8,6 +8,17 @@ import { useFeedStream } from "./use-feed-stream.js";
8
8
  import { IssueListView } from "./IssueListView.js";
9
9
  import { IssueDetailView } from "./IssueDetailView.js";
10
10
  import { FeedView } from "./FeedView.js";
11
+ async function postPrompt(baseUrl, issueKey, text, bearerToken) {
12
+ const headers = { "content-type": "application/json" };
13
+ if (bearerToken)
14
+ headers.authorization = `Bearer ${bearerToken}`;
15
+ await fetch(new URL(`/api/issues/${encodeURIComponent(issueKey)}/prompt`, baseUrl), {
16
+ method: "POST",
17
+ headers,
18
+ body: JSON.stringify({ text }),
19
+ signal: AbortSignal.timeout(5000),
20
+ }).catch(() => { });
21
+ }
11
22
  async function postRetry(baseUrl, issueKey, bearerToken) {
12
23
  const headers = { "content-type": "application/json" };
13
24
  if (bearerToken)
@@ -28,12 +39,37 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
28
39
  useWatchStream({ baseUrl, bearerToken, dispatch });
29
40
  useDetailStream({ baseUrl, bearerToken, issueKey: state.activeDetailKey, dispatch });
30
41
  useFeedStream({ baseUrl, bearerToken, active: state.view === "feed", dispatch });
42
+ const [promptMode, setPromptMode] = useState(false);
43
+ const [promptBuffer, setPromptBuffer] = useState("");
31
44
  const handleRetry = useCallback(() => {
32
45
  if (state.activeDetailKey) {
33
46
  void postRetry(baseUrl, state.activeDetailKey, bearerToken);
34
47
  }
35
48
  }, [baseUrl, bearerToken, state.activeDetailKey]);
49
+ const handlePromptSubmit = useCallback(() => {
50
+ if (state.activeDetailKey && promptBuffer.trim()) {
51
+ void postPrompt(baseUrl, state.activeDetailKey, promptBuffer.trim(), bearerToken);
52
+ }
53
+ setPromptMode(false);
54
+ setPromptBuffer("");
55
+ }, [baseUrl, bearerToken, state.activeDetailKey, promptBuffer]);
36
56
  useInput((input, key) => {
57
+ if (promptMode) {
58
+ if (key.escape) {
59
+ setPromptMode(false);
60
+ setPromptBuffer("");
61
+ }
62
+ else if (key.return) {
63
+ handlePromptSubmit();
64
+ }
65
+ else if (key.backspace || key.delete) {
66
+ setPromptBuffer((b) => b.slice(0, -1));
67
+ }
68
+ else if (input && !key.ctrl && !key.meta) {
69
+ setPromptBuffer((b) => b + input);
70
+ }
71
+ return;
72
+ }
37
73
  if (input === "q") {
38
74
  exit();
39
75
  return;
@@ -68,6 +104,9 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
68
104
  else if (input === "r") {
69
105
  handleRetry();
70
106
  }
107
+ else if (input === "p") {
108
+ setPromptMode(true);
109
+ }
71
110
  else if (input === "j" || key.downArrow) {
72
111
  dispatch({ type: "detail-navigate", direction: "next", filtered });
73
112
  }
@@ -81,5 +120,5 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
81
120
  }
82
121
  }
83
122
  });
84
- return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, allIssues: state.issues, selectedIndex: state.selectedIndex, connected: state.connected, filter: state.filter, totalCount: state.issues.length })) : state.view === "detail" ? (_jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), timeline: state.timeline, follow: state.follow, activeRunStartedAt: state.activeRunStartedAt, tokenUsage: state.tokenUsage, diffSummary: state.diffSummary, plan: state.plan, issueContext: state.issueContext, allIssues: filtered, activeDetailKey: state.activeDetailKey })) : (_jsx(FeedView, { events: state.feedEvents, connected: state.connected })) }));
123
+ return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, allIssues: state.issues, selectedIndex: state.selectedIndex, connected: state.connected, filter: state.filter, totalCount: state.issues.length })) : state.view === "detail" ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), timeline: state.timeline, follow: state.follow, activeRunStartedAt: state.activeRunStartedAt, tokenUsage: state.tokenUsage, diffSummary: state.diffSummary, plan: state.plan, issueContext: state.issueContext, allIssues: filtered, activeDetailKey: state.activeDetailKey }), promptMode && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "prompt> " }), _jsx(Text, { children: promptBuffer }), _jsx(Text, { dimColor: true, children: "_" })] }))] })) : (_jsx(FeedView, { events: state.feedEvents, connected: state.connected })) }));
85
124
  }
@@ -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"} r: retry q: quit`
10
+ ? `j/k: prev/next Esc: list f: follow ${follow ? "on" : "off"} p: prompt 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/db.js CHANGED
@@ -233,6 +233,10 @@ export class PatchRelayDatabase {
233
233
  const row = this.connection.prepare("SELECT * FROM issues WHERE branch_name = ?").get(branchName);
234
234
  return row ? mapIssueRow(row) : undefined;
235
235
  }
236
+ getIssueByPrNumber(prNumber) {
237
+ const row = this.connection.prepare("SELECT * FROM issues WHERE pr_number = ?").get(prNumber);
238
+ return row ? mapIssueRow(row) : undefined;
239
+ }
236
240
  listIssuesReadyForExecution() {
237
241
  const rows = this.connection
238
242
  .prepare("SELECT project_id, linear_issue_id FROM issues WHERE (pending_run_type IS NOT NULL OR pending_merge_prep = 1) AND active_run_id IS NULL")
@@ -25,14 +25,16 @@ export class GitHubWebhookHandler {
25
25
  enqueueIssue;
26
26
  mergeQueue;
27
27
  logger;
28
+ codex;
28
29
  feed;
29
- constructor(config, db, linearProvider, enqueueIssue, mergeQueue, logger, feed) {
30
+ constructor(config, db, linearProvider, enqueueIssue, mergeQueue, logger, codex, feed) {
30
31
  this.config = config;
31
32
  this.db = db;
32
33
  this.linearProvider = linearProvider;
33
34
  this.enqueueIssue = enqueueIssue;
34
35
  this.mergeQueue = mergeQueue;
35
36
  this.logger = logger;
37
+ this.codex = codex;
36
38
  this.feed = feed;
37
39
  }
38
40
  async acceptGitHubWebhook(params) {
@@ -100,6 +102,10 @@ export class GitHubWebhookHandler {
100
102
  }
101
103
  return;
102
104
  }
105
+ if (params.eventType === "issue_comment") {
106
+ await this.handlePrComment(payload);
107
+ return;
108
+ }
103
109
  const event = normalizeGitHubWebhook({
104
110
  eventType: params.eventType,
105
111
  payload: payload,
@@ -282,6 +288,56 @@ export class GitHubWebhookHandler {
282
288
  this.logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to sync Linear session from GitHub webhook");
283
289
  }
284
290
  }
291
+ async handlePrComment(payload) {
292
+ if (payload.action !== "created")
293
+ return;
294
+ const issuePayload = payload.issue;
295
+ const comment = payload.comment;
296
+ if (!issuePayload || !comment)
297
+ return;
298
+ if (!issuePayload.pull_request)
299
+ return; // only PR comments
300
+ const body = typeof comment.body === "string" ? comment.body : "";
301
+ if (!body.trim())
302
+ return;
303
+ const user = comment.user;
304
+ const author = typeof user?.login === "string" ? user.login : "unknown";
305
+ if (typeof user?.type === "string" && user.type === "Bot")
306
+ return;
307
+ const prNumber = typeof issuePayload.number === "number" ? issuePayload.number : undefined;
308
+ if (!prNumber)
309
+ return;
310
+ const issue = this.db.getIssueByPrNumber(prNumber);
311
+ if (!issue)
312
+ return;
313
+ this.feed?.publish({
314
+ level: "info",
315
+ kind: "comment",
316
+ issueKey: issue.issueKey,
317
+ projectId: issue.projectId,
318
+ stage: issue.factoryState,
319
+ status: "pr_comment",
320
+ summary: `GitHub PR comment from ${author}`,
321
+ detail: body.slice(0, 200),
322
+ });
323
+ if (issue.activeRunId) {
324
+ const run = this.db.getRun(issue.activeRunId);
325
+ if (run?.threadId && run.turnId) {
326
+ try {
327
+ await this.codex.steerTurn({
328
+ threadId: run.threadId,
329
+ turnId: run.turnId,
330
+ input: `GitHub PR comment from ${author}:\n\n${body}`,
331
+ });
332
+ this.logger.info({ issueKey: issue.issueKey, author }, "Forwarded GitHub PR comment to active run");
333
+ }
334
+ catch (error) {
335
+ const msg = error instanceof Error ? error.message : String(error);
336
+ this.logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to forward GitHub PR comment");
337
+ }
338
+ }
339
+ }
340
+ }
285
341
  }
286
342
  function resolveCheckClass(checkName, project) {
287
343
  if (!checkName || !project)
package/dist/http.js CHANGED
@@ -309,6 +309,22 @@ export async function buildHttpServer(config, service, logger) {
309
309
  }
310
310
  return reply.send({ ok: true, ...result });
311
311
  });
312
+ app.post("/api/issues/:issueKey/prompt", async (request, reply) => {
313
+ const issueKey = request.params.issueKey;
314
+ const body = request.body;
315
+ const text = body?.text;
316
+ if (!text || typeof text !== "string") {
317
+ return reply.code(400).send({ ok: false, reason: "missing text field" });
318
+ }
319
+ const result = await service.promptIssue(issueKey, text);
320
+ if (!result) {
321
+ return reply.code(404).send({ ok: false, reason: "issue_not_found" });
322
+ }
323
+ if ("error" in result) {
324
+ return reply.code(409).send({ ok: false, reason: result.error });
325
+ }
326
+ return reply.send({ ok: true, delivered: true });
327
+ });
312
328
  app.get("/api/feed", async (request, reply) => {
313
329
  const feedQuery = {
314
330
  limit: getPositiveIntegerQueryParam(request, "limit") ?? 50,
package/dist/service.js CHANGED
@@ -57,7 +57,7 @@ export class PatchRelayService {
57
57
  })();
58
58
  });
59
59
  this.webhookHandler = new WebhookHandler(config, db, this.linearProvider, codex, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed);
60
- this.githubWebhookHandler = new GitHubWebhookHandler(config, db, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), this.mergeQueue, logger, this.feed);
60
+ this.githubWebhookHandler = new GitHubWebhookHandler(config, db, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), this.mergeQueue, logger, codex, this.feed);
61
61
  const runtime = new ServiceRuntime(codex, logger, this.orchestrator, { listIssuesReadyForExecution: () => db.listIssuesReadyForExecution() }, this.webhookHandler, {
62
62
  processIssue: async (item) => {
63
63
  const issue = db.getIssue(item.projectId, item.issueId);
@@ -218,6 +218,24 @@ export class PatchRelayService {
218
218
  this.codex.on("notification", handler);
219
219
  return () => { this.codex.off("notification", handler); };
220
220
  }
221
+ async promptIssue(issueKey, text) {
222
+ const issue = this.db.getIssueByKey(issueKey);
223
+ if (!issue)
224
+ return undefined;
225
+ if (!issue.activeRunId)
226
+ return { error: "No active run" };
227
+ const run = this.db.getRun(issue.activeRunId);
228
+ if (!run?.threadId || !run.turnId)
229
+ return { error: "No active thread or turn" };
230
+ try {
231
+ await this.codex.steerTurn({ threadId: run.threadId, turnId: run.turnId, input: `Operator prompt from watch TUI:\n\n${text}` });
232
+ return { delivered: true };
233
+ }
234
+ catch (error) {
235
+ const msg = error instanceof Error ? error.message : String(error);
236
+ return { error: `Failed to deliver prompt: ${msg}` };
237
+ }
238
+ }
221
239
  retryIssue(issueKey) {
222
240
  const issue = this.db.getIssueByKey(issueKey);
223
241
  if (!issue)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {