patchrelay 0.20.0 → 0.20.2

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.2",
4
+ "commit": "b04903921cbf",
5
+ "builtAt": "2026-03-25T21:41:41.991Z"
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
  }
@@ -1,12 +1,7 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { HelpBar } from "./HelpBar.js";
4
4
  const TAIL_SIZE = 30;
5
- const LEVEL_COLORS = {
6
- info: "white",
7
- warn: "yellow",
8
- error: "red",
9
- };
10
5
  const KIND_COLORS = {
11
6
  stage: "cyan",
12
7
  turn: "yellow",
@@ -16,17 +11,17 @@ const KIND_COLORS = {
16
11
  service: "white",
17
12
  workflow: "cyan",
18
13
  linear: "blue",
14
+ comment: "cyan",
19
15
  };
20
16
  function formatTime(iso) {
21
17
  return new Date(iso).toLocaleTimeString("en-GB", { hour12: false });
22
18
  }
23
19
  function FeedEventRow({ event }) {
24
20
  const kindColor = KIND_COLORS[event.kind] ?? "white";
25
- const levelColor = LEVEL_COLORS[event.level] ?? "white";
26
- return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(event.at) }), _jsx(Text, { color: kindColor, children: event.kind.padEnd(10) }), event.issueKey && _jsx(Text, { bold: true, children: event.issueKey.padEnd(10) }), event.stage && _jsx(Text, { color: "cyan", children: event.stage.padEnd(16) }), _jsx(Text, { color: levelColor, children: event.summary })] }));
21
+ return (_jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: [formatTime(event.at), " "] }), _jsx(Text, { color: kindColor, children: (event.status ?? event.kind).padEnd(14) }), event.issueKey && _jsx(Text, { bold: true, children: ` ${event.issueKey.padEnd(9)}` }), _jsxs(Text, { children: [" ", event.summary] })] }));
27
22
  }
28
23
  export function FeedView({ events, connected }) {
29
24
  const visible = events.length > TAIL_SIZE ? events.slice(-TAIL_SIZE) : events;
30
25
  const skipped = events.length - visible.length;
31
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, children: "Operator Feed" }), _jsx(Text, { color: connected ? "green" : "red", children: connected ? "\u25cf connected" : "\u25cb disconnected" })] }), _jsx(Text, { dimColor: true, children: "\u2500".repeat(72) }), events.length === 0 ? (_jsx(Text, { dimColor: true, children: "No feed events yet." })) : (_jsxs(Box, { flexDirection: "column", children: [skipped > 0 && _jsxs(Text, { dimColor: true, children: [" ... ", skipped, " earlier events"] }), visible.map((event) => (_jsx(FeedEventRow, { event: event }, event.id)))] })), _jsx(Text, { dimColor: true, children: "\u2500".repeat(72) }), _jsx(HelpBar, { view: "feed" })] }));
26
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, children: "Operator Feed" }), _jsx(Text, { color: connected ? "green" : "red", children: connected ? "\u25cf connected" : "\u25cb disconnected" })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: events.length === 0 ? (_jsx(Text, { dimColor: true, children: "No feed events yet." })) : (_jsxs(_Fragment, { children: [skipped > 0 && _jsxs(Text, { dimColor: true, children: [" ... ", skipped, " earlier"] }), visible.map((event) => (_jsx(FeedEventRow, { event: event }, event.id)))] })) }), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "feed" }) })] }));
32
27
  }
@@ -35,26 +35,23 @@ function planStepColor(status) {
35
35
  return "yellow";
36
36
  return "white";
37
37
  }
38
- // ─── Compact Issue Sidebar (#4 split-pane) ───────────────────────
39
38
  const SIDEBAR_STATE_COLORS = {
40
39
  delegated: "blue", preparing: "blue",
41
40
  implementing: "yellow", awaiting_input: "yellow",
42
- pr_open: "cyan", awaiting_review: "cyan",
41
+ pr_open: "cyan",
43
42
  changes_requested: "magenta", repairing_ci: "magenta", repairing_queue: "magenta",
44
43
  awaiting_queue: "green", done: "green",
45
44
  failed: "red", escalated: "red",
46
45
  };
47
46
  function CompactSidebar({ issues, activeKey }) {
48
- return (_jsxs(Box, { flexDirection: "column", width: 28, borderStyle: "single", borderColor: "gray", paddingLeft: 1, paddingRight: 1, children: [_jsx(Text, { bold: true, dimColor: true, children: "Issues" }), issues.map((issue) => {
49
- const key = issue.issueKey ?? issue.projectId;
50
- const isCurrent = key === activeKey;
51
- const stateColor = SIDEBAR_STATE_COLORS[issue.factoryState] ?? "white";
52
- return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: isCurrent ? "blueBright" : "white", bold: isCurrent, children: isCurrent ? "\u25b8" : " " }), _jsx(Text, { bold: isCurrent, children: key.padEnd(10) }), _jsx(Text, { color: stateColor, children: issue.factoryState.slice(0, 12) })] }, key));
53
- })] }));
47
+ return (_jsx(Box, { flexDirection: "column", width: 24, paddingRight: 1, children: issues.map((issue) => {
48
+ const key = issue.issueKey ?? issue.projectId;
49
+ const isCurrent = key === activeKey;
50
+ const sc = SIDEBAR_STATE_COLORS[issue.factoryState] ?? "white";
51
+ return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: isCurrent ? "blueBright" : "white", bold: isCurrent, children: isCurrent ? "\u25b8" : " " }), _jsx(Text, { bold: isCurrent, children: key.padEnd(9) }), _jsx(Text, { color: sc, children: issue.factoryState.slice(0, 10) })] }, key));
52
+ }) }));
54
53
  }
55
- // ─── Issue Context Panel (#5) ────────────────────────────────────
56
54
  const PRIORITY_LABELS = {
57
- 0: { label: "none", color: "" },
58
55
  1: { label: "urgent", color: "red" },
59
56
  2: { label: "high", color: "yellow" },
60
57
  3: { label: "medium", color: "cyan" },
@@ -63,48 +60,37 @@ const PRIORITY_LABELS = {
63
60
  function ContextPanel({ issue, ctx }) {
64
61
  const parts = [];
65
62
  if (ctx.priority != null && ctx.priority > 0) {
66
- const p = PRIORITY_LABELS[ctx.priority] ?? { label: String(ctx.priority), color: "" };
67
- parts.push({ label: "priority", value: p.label, color: p.color });
68
- }
69
- if (ctx.estimate != null) {
70
- parts.push({ label: "estimate", value: String(ctx.estimate), color: "" });
71
- }
72
- if (ctx.currentLinearState) {
73
- parts.push({ label: "linear", value: ctx.currentLinearState, color: "" });
63
+ const p = PRIORITY_LABELS[ctx.priority];
64
+ parts.push(p ? `${p.label}` : `p${ctx.priority}`);
74
65
  }
75
66
  if (issue.prNumber) {
76
- const prInfo = `#${issue.prNumber}${issue.prReviewState === "approved" ? " \u2713" : issue.prReviewState === "changes_requested" ? " \u2717" : ""}${issue.prCheckStatus ? ` ci:${issue.prCheckStatus}` : ""}`;
77
- const prColor = issue.prReviewState === "approved" ? "green" : issue.prReviewState === "changes_requested" ? "red" : "";
78
- parts.push({ label: "pr", value: prInfo, color: prColor });
79
- }
80
- if (ctx.runCount > 0) {
81
- parts.push({ label: "runs", value: String(ctx.runCount), color: "" });
67
+ let pr = `#${issue.prNumber}`;
68
+ if (issue.prReviewState === "approved")
69
+ pr += " \u2713";
70
+ else if (issue.prReviewState === "changes_requested")
71
+ pr += " \u2717";
72
+ parts.push(pr);
82
73
  }
74
+ if (ctx.runCount > 0)
75
+ parts.push(`${ctx.runCount} runs`);
83
76
  const retries = [
84
77
  ctx.ciRepairAttempts > 0 ? `ci:${ctx.ciRepairAttempts}` : "",
85
- ctx.queueRepairAttempts > 0 ? `queue:${ctx.queueRepairAttempts}` : "",
86
- ctx.reviewFixAttempts > 0 ? `review:${ctx.reviewFixAttempts}` : "",
78
+ ctx.queueRepairAttempts > 0 ? `q:${ctx.queueRepairAttempts}` : "",
79
+ ctx.reviewFixAttempts > 0 ? `rev:${ctx.reviewFixAttempts}` : "",
87
80
  ].filter(Boolean).join(" ");
88
- if (retries) {
89
- parts.push({ label: "retries", value: retries, color: "yellow" });
90
- }
91
- if (ctx.branchName) {
92
- parts.push({ label: "branch", value: ctx.branchName, color: "" });
93
- }
94
- const hasDescription = Boolean(ctx.description);
95
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { gap: 2, flexWrap: "wrap", children: parts.map((p) => (_jsxs(Text, { dimColor: true, children: [p.label, ": ", p.color ? _jsx(Text, { color: p.color, children: p.value }) : _jsx(Text, { dimColor: true, children: p.value })] }, p.label))) }), hasDescription && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [ctx.description.slice(0, 200), ctx.description.length > 200 ? "\u2026" : ""] }))] }));
81
+ if (retries)
82
+ parts.push(retries);
83
+ return (_jsxs(Box, { flexDirection: "column", children: [parts.length > 0 && _jsx(Text, { dimColor: true, children: parts.join(" ") }), ctx.description && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [ctx.description.slice(0, 160), ctx.description.length > 160 ? "\u2026" : ""] }))] }));
96
84
  }
97
- // ─── Detail Panel (right side of split) ──────────────────────────
98
85
  function DetailPanel({ issue, timeline, follow, activeRunStartedAt, tokenUsage, diffSummary, plan, issueContext, }) {
99
86
  if (!issue) {
100
87
  return _jsx(Text, { color: "red", children: "Issue not found." });
101
88
  }
102
89
  const key = issue.issueKey ?? issue.projectId;
103
- return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: key }), _jsx(Text, { color: "cyan", children: issue.factoryState }), issue.activeRunType && _jsx(Text, { color: "yellow", children: issue.activeRunType }), issue.prNumber !== undefined && _jsxs(Text, { dimColor: true, children: ["PR #", issue.prNumber] }), activeRunStartedAt && _jsx(ElapsedTime, { startedAt: activeRunStartedAt })] }), issue.title && _jsx(Text, { dimColor: true, children: issue.title }), _jsxs(Box, { gap: 2, children: [tokenUsage && (_jsxs(Text, { dimColor: true, children: ["tokens: ", formatTokens(tokenUsage.inputTokens), " in / ", formatTokens(tokenUsage.outputTokens), " out"] })), diffSummary && diffSummary.filesChanged > 0 && (_jsxs(Text, { dimColor: true, children: ["diff: ", diffSummary.filesChanged, " file", diffSummary.filesChanged !== 1 ? "s" : "", " ", "+", diffSummary.linesAdded, " -", diffSummary.linesRemoved] })), follow && _jsx(Text, { color: "yellow", children: "follow" })] }), issueContext && _jsx(ContextPanel, { issue: issue, ctx: issueContext }), plan && plan.length > 0 && (_jsx(Box, { flexDirection: "column", children: plan.map((entry, i) => (_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: planStepColor(entry.status), children: ["[", planStepSymbol(entry.status), "]"] }), _jsx(Text, { children: entry.step })] }, `plan-${i}`))) })), _jsx(Text, { dimColor: true, children: "\u2500".repeat(60) }), _jsx(Timeline, { entries: timeline, follow: follow })] }));
90
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: key }), _jsx(Text, { color: "cyan", children: issue.factoryState }), issue.activeRunType && _jsx(Text, { color: "yellow", children: issue.activeRunType }), issue.prNumber !== undefined && _jsxs(Text, { dimColor: true, children: ["#", issue.prNumber] }), activeRunStartedAt && _jsx(ElapsedTime, { startedAt: activeRunStartedAt })] }), issue.title && _jsx(Text, { children: issue.title }), (tokenUsage || (diffSummary && diffSummary.filesChanged > 0)) && (_jsxs(Box, { gap: 2, children: [tokenUsage && _jsxs(Text, { dimColor: true, children: [formatTokens(tokenUsage.inputTokens), " in / ", formatTokens(tokenUsage.outputTokens), " out"] }), diffSummary && diffSummary.filesChanged > 0 && (_jsxs(Text, { dimColor: true, children: [diffSummary.filesChanged, "f +", diffSummary.linesAdded, " -", diffSummary.linesRemoved] })), follow && _jsx(Text, { color: "yellow", children: "follow" })] })), issueContext && _jsx(ContextPanel, { issue: issue, ctx: issueContext }), plan && plan.length > 0 && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: plan.map((entry, i) => (_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: planStepColor(entry.status), children: ["[", planStepSymbol(entry.status), "]"] }), _jsx(Text, { children: entry.step })] }, `plan-${i}`))) })), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Timeline, { entries: timeline, follow: follow }) })] }));
104
91
  }
105
- // ─── Main Detail View (split layout) ─────────────────────────────
106
92
  export function IssueDetailView(props) {
107
93
  const { allIssues, activeDetailKey, follow, ...detailProps } = props;
108
94
  const showSidebar = allIssues.length > 1;
109
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", flexGrow: 1, children: [showSidebar && _jsx(CompactSidebar, { issues: allIssues, activeKey: activeDetailKey }), _jsx(DetailPanel, { ...detailProps, follow: follow })] }), _jsx(Text, { dimColor: true, children: "\u2500".repeat(72) }), _jsx(HelpBar, { view: "detail", follow: follow })] }));
95
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", flexGrow: 1, children: [showSidebar && _jsx(CompactSidebar, { issues: allIssues, activeKey: activeDetailKey }), _jsx(DetailPanel, { ...detailProps, follow: follow })] }), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "detail", follow: follow }) })] }));
110
96
  }
@@ -1,8 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
2
+ import { Box, Text, useStdout } from "ink";
3
3
  import { IssueRow } from "./IssueRow.js";
4
4
  import { StatusBar } from "./StatusBar.js";
5
5
  import { HelpBar } from "./HelpBar.js";
6
+ // Fixed columns: selector(2) + key(10) + state(11) + run(11) + pr(7) + ago(4) + gaps(6) = ~51
7
+ const FIXED_COLS = 51;
6
8
  export function IssueListView({ issues, allIssues, selectedIndex, connected, filter, totalCount }) {
7
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { issues: issues, totalCount: totalCount, filter: filter, connected: connected, allIssues: allIssues }), _jsx(Text, { dimColor: true, children: "\u2500".repeat(72) }), issues.length === 0 ? (_jsx(Text, { dimColor: true, children: "No issues match the current filter." })) : (_jsx(Box, { flexDirection: "column", children: issues.map((issue, index) => (_jsx(IssueRow, { issue: issue, selected: index === selectedIndex }, issue.issueKey ?? `${issue.projectId}-${index}`))) })), _jsx(Text, { dimColor: true, children: "\u2500".repeat(72) }), _jsx(HelpBar, { view: "list" })] }));
9
+ const { stdout } = useStdout();
10
+ const cols = stdout?.columns ?? 80;
11
+ const titleWidth = Math.max(0, cols - FIXED_COLS);
12
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { issues: issues, totalCount: totalCount, filter: filter, connected: connected, allIssues: allIssues }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: issues.length === 0 ? (_jsx(Text, { dimColor: true, children: "No issues match the current filter." })) : (issues.map((issue, index) => (_jsx(IssueRow, { issue: issue, selected: index === selectedIndex, titleWidth: titleWidth }, issue.issueKey ?? `${issue.projectId}-${index}`)))) }), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "list" }) })] }));
8
13
  }
@@ -14,9 +14,44 @@ const STATE_COLORS = {
14
14
  escalated: "red",
15
15
  awaiting_input: "yellow",
16
16
  };
17
+ const STATE_SHORT = {
18
+ delegated: "queued",
19
+ preparing: "prep",
20
+ implementing: "impl",
21
+ pr_open: "pr open",
22
+ changes_requested: "changes",
23
+ repairing_ci: "ci fix",
24
+ awaiting_queue: "merging",
25
+ repairing_queue: "merge fix",
26
+ done: "done",
27
+ failed: "failed",
28
+ escalated: "escalated",
29
+ awaiting_input: "input",
30
+ };
31
+ const RUN_SHORT = {
32
+ implementation: "impl",
33
+ ci_repair: "ci",
34
+ review_fix: "review",
35
+ queue_repair: "merge",
36
+ };
37
+ const STATUS_SHORT = {
38
+ running: "\u25b8",
39
+ completed: "\u2713",
40
+ failed: "\u2717",
41
+ released: "\u2013",
42
+ };
17
43
  function stateColor(state) {
18
44
  return STATE_COLORS[state] ?? "white";
19
45
  }
46
+ function formatRun(issue) {
47
+ const run = issue.activeRunType ?? issue.latestRunType;
48
+ if (!run)
49
+ return "";
50
+ const runLabel = RUN_SHORT[run] ?? run;
51
+ const status = issue.activeRunType ? "running" : issue.latestRunStatus;
52
+ const statusLabel = status ? STATUS_SHORT[status] ?? status : "";
53
+ return `${runLabel} ${statusLabel}`;
54
+ }
20
55
  function formatPr(issue) {
21
56
  if (!issue.prNumber)
22
57
  return "";
@@ -44,15 +79,17 @@ function relativeTime(iso) {
44
79
  return `${days}d`;
45
80
  }
46
81
  function truncate(text, max) {
82
+ if (max <= 0)
83
+ return "";
47
84
  return text.length > max ? `${text.slice(0, max - 1)}\u2026` : text;
48
85
  }
49
- export function IssueRow({ issue, selected }) {
86
+ export function IssueRow({ issue, selected, titleWidth }) {
50
87
  const key = issue.issueKey ?? issue.projectId;
51
- const state = issue.factoryState;
52
- const run = issue.activeRunType ?? issue.latestRunType;
53
- const runStatus = issue.activeRunType ? "running" : issue.latestRunStatus;
88
+ const state = STATE_SHORT[issue.factoryState] ?? issue.factoryState;
89
+ const run = formatRun(issue);
54
90
  const pr = formatPr(issue);
55
91
  const ago = relativeTime(issue.updatedAt);
56
- const title = issue.title ? truncate(issue.title, 40) : "";
57
- return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: selected ? "blueBright" : "white", bold: selected, children: selected ? "\u25b8" : " " }), _jsx(Text, { bold: true, children: key.padEnd(10) }), _jsx(Text, { color: stateColor(state), children: state.padEnd(18) }), _jsx(Text, { dimColor: true, children: run ? `${run}:${runStatus ?? "?"}`.padEnd(22) : "".padEnd(22) }), pr ? _jsx(Text, { dimColor: true, children: pr.padEnd(6) }) : _jsx(Text, { dimColor: true, children: "".padEnd(6) }), _jsx(Text, { dimColor: true, children: ago.padStart(4) }), title ? _jsxs(Text, { dimColor: true, children: [" ", title] }) : null] }));
92
+ const tw = titleWidth ?? 30;
93
+ const title = issue.title ? truncate(issue.title, tw) : "";
94
+ return (_jsxs(Box, { children: [_jsx(Text, { color: selected ? "blueBright" : "white", bold: selected, children: selected ? "\u25b8" : " " }), _jsx(Text, { bold: true, children: ` ${key.padEnd(9)}` }), _jsx(Text, { color: stateColor(issue.factoryState), children: ` ${state.padEnd(10)}` }), _jsx(Text, { dimColor: true, children: ` ${run.padEnd(10)}` }), _jsx(Text, { dimColor: true, children: ` ${pr.padEnd(6)}` }), _jsx(Text, { dimColor: true, children: ` ${ago.padStart(3)}` }), title ? _jsx(Text, { dimColor: true, children: ` ${title}` }) : null] }));
58
95
  }
@@ -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;
@@ -1,8 +1,8 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { ItemLine } from "./ItemLine.js";
4
4
  function formatTime(iso) {
5
- return new Date(iso).toLocaleTimeString("en-GB", { hour12: false });
5
+ return new Date(iso).toLocaleTimeString("en-GB", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
6
6
  }
7
7
  function formatDuration(startedAt, endedAt) {
8
8
  const ms = new Date(endedAt).getTime() - new Date(startedAt).getTime();
@@ -10,62 +10,45 @@ function formatDuration(startedAt, endedAt) {
10
10
  if (seconds < 60)
11
11
  return `${seconds}s`;
12
12
  const minutes = Math.floor(seconds / 60);
13
- const remainingSeconds = seconds % 60;
14
- return `${minutes}m ${remainingSeconds}s`;
15
- }
16
- const CHECK_SYMBOLS = {
17
- passed: "\u2713",
18
- failed: "\u2717",
19
- pending: "\u25cf",
20
- };
21
- const CHECK_COLORS = {
22
- passed: "green",
23
- failed: "red",
24
- pending: "yellow",
13
+ const s = seconds % 60;
14
+ return `${minutes}m${s > 0 ? ` ${s}s` : ""}`;
15
+ }
16
+ const CHECK_SYMBOLS = { passed: "\u2713", failed: "\u2717", pending: "\u25cf" };
17
+ const CHECK_COLORS = { passed: "green", failed: "red", pending: "yellow" };
18
+ const RUN_LABELS = {
19
+ implementation: "implement",
20
+ ci_repair: "ci fix",
21
+ review_fix: "review fix",
22
+ queue_repair: "merge fix",
25
23
  };
26
24
  function FeedRow({ entry }) {
27
25
  const feed = entry.feed;
28
- const statusLabel = feed.status ?? feed.feedKind;
29
- return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { color: "cyan", children: statusLabel.padEnd(16) }), _jsx(Text, { children: feed.summary })] }));
30
- }
31
- const RUN_TYPE_LABELS = {
32
- implementation: "implementing",
33
- ci_repair: "repairing checks",
34
- review_fix: "addressing feedback",
35
- queue_repair: "repairing merge",
36
- };
37
- function runLabel(runType) {
38
- return RUN_TYPE_LABELS[runType] ?? runType;
26
+ const label = feed.status ?? feed.feedKind;
27
+ return (_jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: [formatTime(entry.at), " "] }), _jsx(Text, { color: "cyan", children: label.padEnd(14) }), _jsxs(Text, { children: [" ", feed.summary] })] }));
39
28
  }
40
29
  function RunStartRow({ entry }) {
41
30
  const run = entry.run;
42
- return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { bold: true, color: "yellow", children: runLabel(run.runType).padEnd(20) }), _jsx(Text, { bold: true, children: "started" })] }));
31
+ return (_jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: [formatTime(entry.at), " "] }), _jsx(Text, { bold: true, color: "yellow", children: (RUN_LABELS[run.runType] ?? run.runType).padEnd(14) }), _jsx(Text, { bold: true, children: " started" })] }));
43
32
  }
44
33
  function RunEndRow({ entry }) {
45
34
  const run = entry.run;
46
35
  const color = run.status === "completed" ? "green" : "red";
47
- const duration = run.endedAt ? formatDuration(run.startedAt, run.endedAt) : "";
48
- return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { bold: true, color: color, children: runLabel(run.runType).padEnd(20) }), _jsx(Text, { bold: true, color: color, children: run.status }), duration ? _jsxs(Text, { dimColor: true, children: ["(", duration, ")"] }) : null] }));
36
+ const dur = run.endedAt ? ` ${formatDuration(run.startedAt, run.endedAt)}` : "";
37
+ return (_jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: [formatTime(entry.at), " "] }), _jsx(Text, { bold: true, color: color, children: (RUN_LABELS[run.runType] ?? run.runType).padEnd(14) }), _jsxs(Text, { bold: true, color: color, children: [" ", run.status] }), dur ? _jsx(Text, { dimColor: true, children: dur }) : null] }));
49
38
  }
50
39
  function ItemRow({ entry }) {
51
- const item = entry.item;
52
- return (_jsx(Box, { paddingLeft: 2, children: _jsx(ItemLine, { item: item, isLast: false }) }));
40
+ return (_jsx(Box, { paddingLeft: 2, children: _jsx(ItemLine, { item: entry.item, isLast: false }) }));
53
41
  }
54
42
  function CIChecksRow({ entry }) {
55
43
  const ci = entry.ciChecks;
56
- return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { color: CHECK_COLORS[ci.overall] ?? "white", children: "ci_checks".padEnd(16) }), ci.checks.map((check, i) => (_jsxs(Text, { children: [_jsx(Text, { color: CHECK_COLORS[check.status] ?? "white", children: CHECK_SYMBOLS[check.status] ?? " " }), _jsxs(Text, { dimColor: true, children: [" ", check.name, " "] })] }, `check-${i}`)))] }));
44
+ return (_jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: [formatTime(entry.at), " "] }), _jsx(Text, { color: CHECK_COLORS[ci.overall] ?? "white", children: "checks".padEnd(14) }), _jsx(Text, { children: " " }), ci.checks.map((check, i) => (_jsxs(Text, { children: [_jsx(Text, { color: CHECK_COLORS[check.status] ?? "white", children: CHECK_SYMBOLS[check.status] ?? " " }), _jsxs(Text, { dimColor: true, children: [check.name, " "] })] }, `c-${i}`)))] }));
57
45
  }
58
46
  export function TimelineRow({ entry }) {
59
47
  switch (entry.kind) {
60
- case "feed":
61
- return _jsx(FeedRow, { entry: entry });
62
- case "run-start":
63
- return _jsx(RunStartRow, { entry: entry });
64
- case "run-end":
65
- return _jsx(RunEndRow, { entry: entry });
66
- case "item":
67
- return _jsx(ItemRow, { entry: entry });
68
- case "ci-checks":
69
- return _jsx(CIChecksRow, { entry: entry });
48
+ case "feed": return _jsx(FeedRow, { entry: entry });
49
+ case "run-start": return _jsx(RunStartRow, { entry: entry });
50
+ case "run-end": return _jsx(RunEndRow, { entry: entry });
51
+ case "item": return _jsx(ItemRow, { entry: entry });
52
+ case "ci-checks": return _jsx(CIChecksRow, { entry: entry });
70
53
  }
71
54
  }
@@ -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.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {