patchrelay 0.19.0 → 0.20.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.19.0",
4
- "commit": "0172365b885d",
5
- "builtAt": "2026-03-25T20:06:12.646Z"
3
+ "version": "0.20.1",
4
+ "commit": "d3e7ac8f36d2",
5
+ "builtAt": "2026-03-25T21:27:48.361Z"
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,23 @@ 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
+ try {
16
+ const response = await fetch(new URL(`/api/issues/${encodeURIComponent(issueKey)}/prompt`, baseUrl), {
17
+ method: "POST",
18
+ headers,
19
+ body: JSON.stringify({ text }),
20
+ signal: AbortSignal.timeout(5000),
21
+ });
22
+ return await response.json();
23
+ }
24
+ catch {
25
+ return { reason: "Request failed" };
26
+ }
27
+ }
11
28
  async function postRetry(baseUrl, issueKey, bearerToken) {
12
29
  const headers = { "content-type": "application/json" };
13
30
  if (bearerToken)
@@ -28,12 +45,60 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
28
45
  useWatchStream({ baseUrl, bearerToken, dispatch });
29
46
  useDetailStream({ baseUrl, bearerToken, issueKey: state.activeDetailKey, dispatch });
30
47
  useFeedStream({ baseUrl, bearerToken, active: state.view === "feed", dispatch });
48
+ const [promptMode, setPromptMode] = useState(false);
49
+ const [promptBuffer, setPromptBuffer] = useState("");
31
50
  const handleRetry = useCallback(() => {
32
51
  if (state.activeDetailKey) {
33
52
  void postRetry(baseUrl, state.activeDetailKey, bearerToken);
34
53
  }
35
54
  }, [baseUrl, bearerToken, state.activeDetailKey]);
55
+ const [promptStatus, setPromptStatus] = useState(null);
56
+ const handlePromptSubmit = useCallback(() => {
57
+ const text = promptBuffer.trim();
58
+ if (!state.activeDetailKey || !text) {
59
+ setPromptMode(false);
60
+ setPromptBuffer("");
61
+ return;
62
+ }
63
+ // Add synthetic userMessage to timeline immediately
64
+ dispatch({
65
+ type: "codex-notification",
66
+ method: "item/started",
67
+ params: { item: { id: `prompt-${Date.now()}`, type: "userMessage", status: "completed", text } },
68
+ });
69
+ setPromptMode(false);
70
+ setPromptBuffer("");
71
+ setPromptStatus("sending...");
72
+ void postPrompt(baseUrl, state.activeDetailKey, text, bearerToken).then((result) => {
73
+ if (result.delivered) {
74
+ setPromptStatus("delivered");
75
+ }
76
+ else if (result.queued) {
77
+ setPromptStatus("queued for next run");
78
+ }
79
+ else if (result.reason) {
80
+ setPromptStatus(`failed: ${result.reason}`);
81
+ }
82
+ setTimeout(() => setPromptStatus(null), 3000);
83
+ });
84
+ }, [baseUrl, bearerToken, state.activeDetailKey, promptBuffer]);
36
85
  useInput((input, key) => {
86
+ if (promptMode) {
87
+ if (key.escape) {
88
+ setPromptMode(false);
89
+ setPromptBuffer("");
90
+ }
91
+ else if (key.return) {
92
+ handlePromptSubmit();
93
+ }
94
+ else if (key.backspace || key.delete) {
95
+ setPromptBuffer((b) => b.slice(0, -1));
96
+ }
97
+ else if (input && !key.ctrl && !key.meta) {
98
+ setPromptBuffer((b) => b + input);
99
+ }
100
+ return;
101
+ }
37
102
  if (input === "q") {
38
103
  exit();
39
104
  return;
@@ -68,6 +133,9 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
68
133
  else if (input === "r") {
69
134
  handleRetry();
70
135
  }
136
+ else if (input === "p") {
137
+ setPromptMode(true);
138
+ }
71
139
  else if (input === "j" || key.downArrow) {
72
140
  dispatch({ type: "detail-navigate", direction: "next", filtered });
73
141
  }
@@ -81,5 +149,5 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
81
149
  }
82
150
  }
83
151
  });
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 })) }));
152
+ 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: "_" })] })), promptStatus && !promptMode && (_jsx(Text, { dimColor: true, children: promptStatus }))] })) : (_jsx(FeedView, { events: state.feedEvents, connected: state.connected })) }));
85
153
  }
@@ -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
  }
@@ -64,6 +64,9 @@ export function ItemLine({ item, isLast }) {
64
64
  case "plan":
65
65
  content = renderPlan(item);
66
66
  break;
67
+ case "userMessage":
68
+ content = (_jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "you: " }), _jsx(Text, { children: truncate(item.text ?? "", 120) })] }));
69
+ break;
67
70
  default:
68
71
  content = renderDefault(item);
69
72
  break;
@@ -0,0 +1,43 @@
1
+ /** Format ISO timestamp as HH:MM:SS (24h, en-GB). */
2
+ export function formatTime(iso) {
3
+ return new Date(iso).toLocaleTimeString("en-GB", { hour12: false });
4
+ }
5
+ /** Format ISO timestamp as compact relative time: "3s", "12m", "2h", "5d". */
6
+ export function relativeTime(iso) {
7
+ const ms = Date.now() - new Date(iso).getTime();
8
+ if (ms < 0)
9
+ return "now";
10
+ const seconds = Math.floor(ms / 1000);
11
+ if (seconds < 60)
12
+ return `${seconds}s`;
13
+ const minutes = Math.floor(seconds / 60);
14
+ if (minutes < 60)
15
+ return `${minutes}m`;
16
+ const hours = Math.floor(minutes / 60);
17
+ if (hours < 24)
18
+ return `${hours}h`;
19
+ const days = Math.floor(hours / 24);
20
+ return `${days}d`;
21
+ }
22
+ /** Format millisecond duration as "2m 30s" or "45s". */
23
+ export function formatDuration(ms) {
24
+ const seconds = Math.floor(ms / 1000);
25
+ if (seconds < 60)
26
+ return `${seconds}s`;
27
+ const minutes = Math.floor(seconds / 60);
28
+ const remainingSeconds = seconds % 60;
29
+ return `${minutes}m ${remainingSeconds}s`;
30
+ }
31
+ /** Format token count with k/M suffix. */
32
+ export function formatTokens(n) {
33
+ if (n >= 1_000_000)
34
+ return `${(n / 1_000_000).toFixed(1)}M`;
35
+ if (n >= 1_000)
36
+ return `${(n / 1_000).toFixed(1)}k`;
37
+ return String(n);
38
+ }
39
+ /** Truncate text to max length with ellipsis. Collapses newlines. */
40
+ export function truncate(text, max) {
41
+ const line = text.replace(/\n/g, " ").trim();
42
+ return line.length > max ? `${line.slice(0, max - 1)}\u2026` : line;
43
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Shared SSE (Server-Sent Events) stream parser.
3
+ * Extracts event type + data from a ReadableStream, calls onEvent for each complete event.
4
+ */
5
+ export async function readSSEStream(body, onEvent) {
6
+ const reader = body.getReader();
7
+ const decoder = new TextDecoder();
8
+ let buffer = "";
9
+ let eventType = "";
10
+ let dataLines = [];
11
+ while (true) {
12
+ const { done, value } = await reader.read();
13
+ if (done)
14
+ break;
15
+ buffer += decoder.decode(value, { stream: true });
16
+ let newlineIndex = buffer.indexOf("\n");
17
+ while (newlineIndex !== -1) {
18
+ const rawLine = buffer.slice(0, newlineIndex);
19
+ buffer = buffer.slice(newlineIndex + 1);
20
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
21
+ if (!line) {
22
+ if (dataLines.length > 0) {
23
+ onEvent(eventType, dataLines.join("\n"));
24
+ dataLines = [];
25
+ eventType = "";
26
+ }
27
+ newlineIndex = buffer.indexOf("\n");
28
+ continue;
29
+ }
30
+ if (line.startsWith(":")) {
31
+ newlineIndex = buffer.indexOf("\n");
32
+ continue;
33
+ }
34
+ if (line.startsWith("event:")) {
35
+ eventType = line.slice(6).trim();
36
+ }
37
+ else if (line.startsWith("data:")) {
38
+ dataLines.push(line.slice(5).trimStart());
39
+ }
40
+ newlineIndex = buffer.indexOf("\n");
41
+ }
42
+ }
43
+ }
@@ -0,0 +1,55 @@
1
+ // ─── Factory State Colors ─────────────────────────────────────────
2
+ export const FACTORY_STATE_COLORS = {
3
+ delegated: "blue",
4
+ preparing: "blue",
5
+ implementing: "yellow",
6
+ awaiting_input: "yellow",
7
+ pr_open: "cyan",
8
+ changes_requested: "magenta",
9
+ repairing_ci: "magenta",
10
+ repairing_queue: "magenta",
11
+ awaiting_queue: "green",
12
+ done: "green",
13
+ failed: "red",
14
+ escalated: "red",
15
+ };
16
+ // ─── Item Status Symbols & Colors ─────────────────────────────────
17
+ export const ITEM_STATUS_SYMBOLS = {
18
+ completed: "\u2713",
19
+ failed: "\u2717",
20
+ declined: "\u2717",
21
+ inProgress: "\u25cf",
22
+ };
23
+ export const ITEM_STATUS_COLORS = {
24
+ completed: "green",
25
+ failed: "red",
26
+ declined: "red",
27
+ inProgress: "yellow",
28
+ };
29
+ // ─── CI Check Symbols & Colors ────────────────────────────────────
30
+ export const CHECK_SYMBOLS = {
31
+ passed: "\u2713",
32
+ failed: "\u2717",
33
+ pending: "\u25cf",
34
+ };
35
+ export const CHECK_COLORS = {
36
+ passed: "green",
37
+ failed: "red",
38
+ pending: "yellow",
39
+ };
40
+ // ─── Feed Event Colors ────────────────────────────────────────────
41
+ export const FEED_LEVEL_COLORS = {
42
+ info: "white",
43
+ warn: "yellow",
44
+ error: "red",
45
+ };
46
+ export const FEED_KIND_COLORS = {
47
+ stage: "cyan",
48
+ turn: "yellow",
49
+ github: "green",
50
+ webhook: "blue",
51
+ agent: "magenta",
52
+ service: "white",
53
+ workflow: "cyan",
54
+ linear: "blue",
55
+ };
@@ -303,7 +303,7 @@ export function appendCodexItemToTimeline(timeline, params, activeRunId) {
303
303
  const type = typeof itemObj.type === "string" ? itemObj.type : "unknown";
304
304
  const status = typeof itemObj.status === "string" ? itemObj.status : "inProgress";
305
305
  const item = { id, type, status };
306
- if (type === "agentMessage" && typeof itemObj.text === "string")
306
+ if ((type === "agentMessage" || type === "userMessage") && typeof itemObj.text === "string")
307
307
  item.text = itemObj.text;
308
308
  if (type === "commandExecution") {
309
309
  const cmd = itemObj.command;
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, ...result });
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,60 @@ 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, source = "watch") {
222
+ const issue = this.db.getIssueByKey(issueKey);
223
+ if (!issue)
224
+ return undefined;
225
+ // Publish to operator feed so all clients see the prompt
226
+ this.feed.publish({
227
+ level: "info",
228
+ kind: "comment",
229
+ issueKey: issue.issueKey,
230
+ projectId: issue.projectId,
231
+ stage: issue.factoryState,
232
+ status: "operator_prompt",
233
+ summary: `Operator prompt (${source})`,
234
+ detail: text.slice(0, 200),
235
+ });
236
+ // If no active run, queue as pending context for the next run
237
+ if (!issue.activeRunId) {
238
+ const existing = issue.pendingRunContextJson
239
+ ? JSON.parse(issue.pendingRunContextJson)
240
+ : {};
241
+ this.db.upsertIssue({
242
+ projectId: issue.projectId,
243
+ linearIssueId: issue.linearIssueId,
244
+ pendingRunContextJson: JSON.stringify({ ...existing, operatorPrompt: text }),
245
+ });
246
+ return { delivered: false, queued: true };
247
+ }
248
+ const run = this.db.getRun(issue.activeRunId);
249
+ if (!run?.threadId || !run.turnId) {
250
+ return { error: "Active run has no thread or turn yet" };
251
+ }
252
+ try {
253
+ await this.codex.steerTurn({
254
+ threadId: run.threadId,
255
+ turnId: run.turnId,
256
+ input: `Operator prompt (${source}):\n\n${text}`,
257
+ });
258
+ return { delivered: true };
259
+ }
260
+ catch (error) {
261
+ // Turn may have completed between check and steer — queue for next run
262
+ const msg = error instanceof Error ? error.message : String(error);
263
+ this.logger.warn({ issueKey, error: msg }, "steerTurn failed, queuing prompt for next run");
264
+ const existing = issue.pendingRunContextJson
265
+ ? JSON.parse(issue.pendingRunContextJson)
266
+ : {};
267
+ this.db.upsertIssue({
268
+ projectId: issue.projectId,
269
+ linearIssueId: issue.linearIssueId,
270
+ pendingRunContextJson: JSON.stringify({ ...existing, operatorPrompt: text }),
271
+ });
272
+ return { delivered: false, queued: true };
273
+ }
274
+ }
221
275
  retryIssue(issueKey) {
222
276
  const issue = this.db.getIssueByKey(issueKey);
223
277
  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.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {