patchrelay 0.20.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.20.0",
4
- "commit": "e3ce50c81db1",
5
- "builtAt": "2026-03-25T20:18:22.070Z"
3
+ "version": "0.20.1",
4
+ "commit": "d3e7ac8f36d2",
5
+ "builtAt": "2026-03-25T21:27:48.361Z"
6
6
  }
@@ -12,12 +12,18 @@ async function postPrompt(baseUrl, issueKey, text, bearerToken) {
12
12
  const headers = { "content-type": "application/json" };
13
13
  if (bearerToken)
14
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(() => { });
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
+ }
21
27
  }
22
28
  async function postRetry(baseUrl, issueKey, bearerToken) {
23
29
  const headers = { "content-type": "application/json" };
@@ -46,12 +52,35 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
46
52
  void postRetry(baseUrl, state.activeDetailKey, bearerToken);
47
53
  }
48
54
  }, [baseUrl, bearerToken, state.activeDetailKey]);
55
+ const [promptStatus, setPromptStatus] = useState(null);
49
56
  const handlePromptSubmit = useCallback(() => {
50
- if (state.activeDetailKey && promptBuffer.trim()) {
51
- void postPrompt(baseUrl, state.activeDetailKey, promptBuffer.trim(), bearerToken);
57
+ const text = promptBuffer.trim();
58
+ if (!state.activeDetailKey || !text) {
59
+ setPromptMode(false);
60
+ setPromptBuffer("");
61
+ return;
52
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
+ });
53
69
  setPromptMode(false);
54
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
+ });
55
84
  }, [baseUrl, bearerToken, state.activeDetailKey, promptBuffer]);
56
85
  useInput((input, key) => {
57
86
  if (promptMode) {
@@ -120,5 +149,5 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
120
149
  }
121
150
  }
122
151
  });
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 })) }));
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 })) }));
124
153
  }
@@ -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/http.js CHANGED
@@ -323,7 +323,7 @@ export async function buildHttpServer(config, service, logger) {
323
323
  if ("error" in result) {
324
324
  return reply.code(409).send({ ok: false, reason: result.error });
325
325
  }
326
- return reply.send({ ok: true, delivered: true });
326
+ return reply.send({ ok: true, ...result });
327
327
  });
328
328
  app.get("/api/feed", async (request, reply) => {
329
329
  const feedQuery = {
package/dist/service.js CHANGED
@@ -218,22 +218,58 @@ 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) {
221
+ async promptIssue(issueKey, text, source = "watch") {
222
222
  const issue = this.db.getIssueByKey(issueKey);
223
223
  if (!issue)
224
224
  return undefined;
225
- if (!issue.activeRunId)
226
- return { error: "No active run" };
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
+ }
227
248
  const run = this.db.getRun(issue.activeRunId);
228
- if (!run?.threadId || !run.turnId)
229
- return { error: "No active thread or turn" };
249
+ if (!run?.threadId || !run.turnId) {
250
+ return { error: "Active run has no thread or turn yet" };
251
+ }
230
252
  try {
231
- await this.codex.steerTurn({ threadId: run.threadId, turnId: run.turnId, input: `Operator prompt from watch TUI:\n\n${text}` });
253
+ await this.codex.steerTurn({
254
+ threadId: run.threadId,
255
+ turnId: run.turnId,
256
+ input: `Operator prompt (${source}):\n\n${text}`,
257
+ });
232
258
  return { delivered: true };
233
259
  }
234
260
  catch (error) {
261
+ // Turn may have completed between check and steer — queue for next run
235
262
  const msg = error instanceof Error ? error.message : String(error);
236
- return { error: `Failed to deliver prompt: ${msg}` };
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 };
237
273
  }
238
274
  }
239
275
  retryIssue(issueKey) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.20.0",
3
+ "version": "0.20.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {