patchrelay 0.43.0 → 0.45.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,5 +1,12 @@
1
1
  {
2
2
  "server": {
3
3
  "public_base_url": "https://patchrelay.example.com"
4
+ },
5
+ "runner": {
6
+ "git_bin": "git",
7
+ "codex": {
8
+ "model": "gpt-5.3-codex-spark",
9
+ "reasoning_effort": "high"
10
+ }
4
11
  }
5
12
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.43.0",
4
- "commit": "cfb77c67aa5e",
5
- "builtAt": "2026-04-15T08:38:14.720Z"
3
+ "version": "0.45.0",
4
+ "commit": "5af50d4caf99",
5
+ "builtAt": "2026-04-16T16:13:04.169Z"
6
6
  }
@@ -73,6 +73,7 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
73
73
  const filtered = useMemo(() => filterIssues(state.issues, state.filter), [state.issues, state.filter]);
74
74
  const [frozen, setFrozen] = useState(false);
75
75
  const width = Math.max(20, stdout?.columns ?? 80);
76
+ const compact = width < 90;
76
77
  useWatchStream({ baseUrl, bearerToken, dispatch, active: !frozen });
77
78
  useDetailStream({ baseUrl, bearerToken, issueKey: state.activeDetailKey, dispatch, active: !frozen });
78
79
  const [promptMode, setPromptMode] = useState(false);
@@ -400,7 +401,7 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
400
401
  : promptStatus
401
402
  ? measureRenderedTextRows(promptStatus, width)
402
403
  : 0);
403
- return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, allIssues: state.issues, selectedIndex: state.selectedIndex, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt, filter: state.filter, totalCount: state.issues.length, frozen: frozen })) : state.view === "detail" ? (_jsxs(Box, { flexDirection: "column", children: [state.activeDetailKey && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Issues" }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { bold: true, children: state.activeDetailKey }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { dimColor: true, children: state.detailTab === "timeline" ? "Timeline" : "History" })] })), _jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), timeline: state.timeline, follow: state.follow, scrollOffset: state.detailScrollOffset, unreadBelow: state.detailUnreadBelow, activeRunStartedAt: state.activeRunStartedAt, activeRunId: state.activeRunId, tokenUsage: state.tokenUsage, diffSummary: state.diffSummary, plan: state.plan, issueContext: state.issueContext, detailTab: state.detailTab, rawRuns: state.rawRuns, rawFeedEvents: state.rawFeedEvents, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt, reservedRows: reservedRows, onLayoutChange: (viewportRows, contentRows) => {
404
+ return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, allIssues: state.issues, selectedIndex: state.selectedIndex, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt, filter: state.filter, totalCount: state.issues.length, frozen: frozen, compact: compact })) : state.view === "detail" ? (_jsxs(Box, { flexDirection: "column", children: [state.activeDetailKey && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Issues" }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { bold: true, children: state.activeDetailKey }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { dimColor: true, children: state.detailTab === "timeline" ? "Timeline" : "History" })] })), _jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), timeline: state.timeline, follow: state.follow, scrollOffset: state.detailScrollOffset, unreadBelow: state.detailUnreadBelow, activeRunStartedAt: state.activeRunStartedAt, activeRunId: state.activeRunId, tokenUsage: state.tokenUsage, diffSummary: state.diffSummary, plan: state.plan, issueContext: state.issueContext, detailTab: state.detailTab, rawRuns: state.rawRuns, rawFeedEvents: state.rawFeedEvents, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt, compact: compact, reservedRows: reservedRows, onLayoutChange: (viewportRows, contentRows) => {
404
405
  dispatch({ type: "detail-layout-updated", viewportRows, contentRows });
405
406
  } }), promptMode && (_jsx(PromptComposer, { buffer: promptBuffer, cursor: promptCursor })), promptStatus && !promptMode && (_jsx(Text, { dimColor: true, children: promptStatus }))] })) : null }));
406
407
  }
@@ -1,8 +1,11 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- export function buildHelpBarText(view, follow, detailTab) {
3
+ export function buildHelpBarText(view, follow, detailTab, compact = false) {
4
4
  if (view === "detail") {
5
5
  const tabHint = detailTab === "history" ? "t: timeline" : "h: history";
6
+ if (compact) {
7
+ return "j/k: scroll f: live p: prompt y/c/o: copy r: retry s: stop esc: list q: quit";
8
+ }
6
9
  return [
7
10
  tabHint,
8
11
  "j/k: scroll",
@@ -19,9 +22,12 @@ export function buildHelpBarText(view, follow, detailTab) {
19
22
  .filter(Boolean)
20
23
  .join(" ");
21
24
  }
25
+ if (compact) {
26
+ return "Enter: detail Tab: filter x: pause q: quit";
27
+ }
22
28
  return "Enter: detail Tab: filter";
23
29
  }
24
- export function HelpBar({ view, follow, detailTab }) {
25
- const text = buildHelpBarText(view, follow, detailTab);
30
+ export function HelpBar({ view, follow, detailTab, compact = false }) {
31
+ const text = buildHelpBarText(view, follow, detailTab, compact);
26
32
  return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: text }) }));
27
33
  }
@@ -5,7 +5,7 @@ import { HelpBar, buildHelpBarText } from "./HelpBar.js";
5
5
  import { buildDetailLines } from "./detail-rows.js";
6
6
  import { buildDetailStatusSegments, buildDetailStatusText } from "./detail-status.js";
7
7
  import { measureRenderedTextRows } from "./layout-measure.js";
8
- export function IssueDetailView({ issue, timeline, follow, scrollOffset, unreadBelow, activeRunStartedAt, activeRunId, tokenUsage, diffSummary, plan, issueContext, detailTab, rawRuns, rawFeedEvents, connected, lastServerMessageAt, reservedRows = 0, onLayoutChange, }) {
8
+ export function IssueDetailView({ issue, timeline, follow, scrollOffset, unreadBelow, activeRunStartedAt, activeRunId, tokenUsage, diffSummary, plan, issueContext, detailTab, rawRuns, rawFeedEvents, connected, lastServerMessageAt, reservedRows = 0, compact = false, onLayoutChange, }) {
9
9
  const { stdout } = useStdout();
10
10
  const width = Math.max(20, stdout?.columns ?? 80);
11
11
  const totalRows = stdout?.rows ?? 24;
@@ -17,9 +17,9 @@ export function IssueDetailView({ issue, timeline, follow, scrollOffset, unreadB
17
17
  connected,
18
18
  lastServerMessageAt,
19
19
  }), width);
20
- const helpRows = measureRenderedTextRows(buildHelpBarText("detail", follow, detailTab), width);
20
+ const helpRows = measureRenderedTextRows(buildHelpBarText("detail", follow, detailTab, compact), width);
21
21
  return statusRows + helpRows;
22
- }, [activeRunStartedAt, connected, detailTab, follow, lastServerMessageAt, unreadBelow, width]);
22
+ }, [activeRunStartedAt, connected, detailTab, follow, lastServerMessageAt, unreadBelow, width, compact]);
23
23
  const viewportRows = Math.max(4, totalRows - reservedRows - footerRows);
24
24
  const lines = useMemo(() => {
25
25
  if (!issue) {
@@ -60,7 +60,7 @@ export function IssueDetailView({ issue, timeline, follow, scrollOffset, unreadB
60
60
  const start = Math.min(scrollOffset, maxOffset);
61
61
  const visibleLines = lines.slice(start, start + viewportRows);
62
62
  const fillerCount = Math.max(0, viewportRows - visibleLines.length);
63
- return (_jsxs(Box, { flexDirection: "column", children: [visibleLines.map((line) => (_jsx(RenderedLine, { line: line }, line.key))), Array.from({ length: fillerCount }, (_, index) => (_jsx(Text, { children: " " }, `detail-fill-${index}`))), _jsx(DetailStatusStrip, { follow: follow, unreadBelow: unreadBelow, activeRunStartedAt: activeRunStartedAt, connected: connected, lastServerMessageAt: lastServerMessageAt }), _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab })] }));
63
+ return (_jsxs(Box, { flexDirection: "column", children: [visibleLines.map((line) => (_jsx(RenderedLine, { line: line }, line.key))), Array.from({ length: fillerCount }, (_, index) => (_jsx(Text, { children: " " }, `detail-fill-${index}`))), _jsx(DetailStatusStrip, { follow: follow, unreadBelow: unreadBelow, activeRunStartedAt: activeRunStartedAt, connected: connected, lastServerMessageAt: lastServerMessageAt }), _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab, compact: compact })] }));
64
64
  }
65
65
  function RenderedLine({ line }) {
66
66
  if (line.segments.length === 0) {
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useReducer } from "react";
3
3
  import { Box, Text, useStdout } from "ink";
4
4
  import { IssueRow, estimateIssueRowHeight } from "./IssueRow.js";
@@ -6,11 +6,11 @@ import { StatusBar } from "./StatusBar.js";
6
6
  import { HelpBar } from "./HelpBar.js";
7
7
  const FIXED_COLS = 8;
8
8
  const CHROME_ROWS = 4;
9
- export function computeVisibleWindow(issues, selectedIndex, maxRows, cols, titleWidth) {
9
+ export function computeVisibleWindow(issues, selectedIndex, maxRows, cols, titleWidth, compact) {
10
10
  if (issues.length === 0)
11
11
  return { start: 0, end: 0 };
12
12
  const clampedSelected = Math.max(0, Math.min(selectedIndex, issues.length - 1));
13
- const heights = issues.map((issue, index) => estimateIssueRowHeight(issue, index === clampedSelected, cols, titleWidth));
13
+ const heights = issues.map((issue, index) => estimateIssueRowHeight(issue, index === clampedSelected, cols, titleWidth, compact));
14
14
  let start = clampedSelected;
15
15
  let end = clampedSelected + 1;
16
16
  let usedRows = heights[clampedSelected] ?? 1;
@@ -34,12 +34,12 @@ export function computeVisibleWindow(issues, selectedIndex, maxRows, cols, title
34
34
  }
35
35
  return { start, end };
36
36
  }
37
- export function IssueListView({ issues, allIssues, selectedIndex, connected, lastServerMessageAt, filter, totalCount, frozen, }) {
37
+ export function IssueListView({ issues, allIssues, selectedIndex, connected, lastServerMessageAt, filter, totalCount, frozen, compact = false, }) {
38
38
  const { stdout } = useStdout();
39
39
  const cols = stdout?.columns ?? 80;
40
40
  const rows = stdout?.rows ?? 24;
41
- const titleWidth = Math.max(0, cols - FIXED_COLS);
42
- const maxVisibleRows = Math.max(1, rows - CHROME_ROWS);
41
+ const titleWidth = Math.max(0, cols - (compact ? 24 : FIXED_COLS));
42
+ const maxVisibleRows = Math.max(1, rows - (compact ? 3 : CHROME_ROWS));
43
43
  // Periodic refresh for elapsed times
44
44
  const [, tick] = useReducer((c) => c + 1, 0);
45
45
  useEffect(() => {
@@ -48,9 +48,9 @@ export function IssueListView({ issues, allIssues, selectedIndex, connected, las
48
48
  const id = setInterval(tick, 5000);
49
49
  return () => clearInterval(id);
50
50
  }, [frozen]);
51
- const { start: startIndex, end: endIndex } = computeVisibleWindow(issues, selectedIndex, maxVisibleRows, cols, titleWidth);
51
+ const { start: startIndex, end: endIndex } = computeVisibleWindow(issues, selectedIndex, maxVisibleRows, cols, titleWidth, compact);
52
52
  const visible = issues.slice(startIndex, endIndex);
53
53
  const hiddenAbove = startIndex;
54
54
  const hiddenBelow = Math.max(0, issues.length - endIndex);
55
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { issues: issues, totalCount: totalCount, filter: filter, connected: connected, lastServerMessageAt: lastServerMessageAt, allIssues: allIssues, frozen: frozen ?? false }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: issues.length === 0 ? (_jsx(Text, { dimColor: true, children: "No issues match the current filter." })) : (_jsxs(_Fragment, { children: [hiddenAbove > 0 && _jsxs(Text, { dimColor: true, children: [" ", hiddenAbove, " more above"] }), visible.map((issue, i) => (_jsx(IssueRow, { issue: issue, selected: startIndex + i === selectedIndex, titleWidth: titleWidth }, issue.issueKey ?? `${issue.projectId}-${startIndex + i}`))), hiddenBelow > 0 && _jsxs(Text, { dimColor: true, children: [" ", hiddenBelow, " more below"] })] })) }), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "list" }) })] }));
55
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { issues: issues, totalCount: totalCount, filter: filter, connected: connected, lastServerMessageAt: lastServerMessageAt, allIssues: allIssues, frozen: frozen ?? false, compact: compact }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: issues.length === 0 ? (_jsx(Text, { dimColor: true, children: "No issues match the current filter." })) : (_jsxs(_Fragment, { children: [hiddenAbove > 0 && _jsx(Text, { dimColor: true, children: compact ? `↑${hiddenAbove}` : ` ${hiddenAbove} more above` }), visible.map((issue, i) => (_jsx(IssueRow, { issue: issue, selected: startIndex + i === selectedIndex, titleWidth: titleWidth, compact: compact }, issue.issueKey ?? `${issue.projectId}-${startIndex + i}`))), hiddenBelow > 0 && _jsx(Text, { dimColor: true, children: compact ? `↓${hiddenBelow}` : ` ${hiddenBelow} more below` })] })) }), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "list", compact: compact }) })] }));
56
56
  }
@@ -67,7 +67,7 @@ function stageLabel(issue) {
67
67
  }
68
68
  }
69
69
  // ─── Context facts (what matters right now) ─────────────────────
70
- function buildFacts(issue, selected) {
70
+ function buildFacts(issue, selected, compact = false) {
71
71
  const facts = [];
72
72
  const rereviewNeeded = isRereviewNeeded(issue);
73
73
  // PR number
@@ -112,7 +112,7 @@ function buildFacts(issue, selected) {
112
112
  if (issue.blockedByCount > 0) {
113
113
  facts.push({ text: `waiting on ${issue.blockedByKeys.join(", ")}`, color: "yellow" });
114
114
  }
115
- return facts;
115
+ return compact ? facts.slice(0, 3) : facts;
116
116
  }
117
117
  // ─── What's blocking progress ───────────────────────────────────
118
118
  function blockerText(issue) {
@@ -153,34 +153,40 @@ function blockerText(issue) {
153
153
  return null;
154
154
  }
155
155
  // ─── Render ─────────────────────────────────────────────────────
156
- export function IssueRow({ issue, selected, titleWidth }) {
156
+ export function IssueRow({ issue, selected, titleWidth, compact = false, }) {
157
157
  const key = issue.issueKey ?? issue.projectId;
158
158
  const tw = titleWidth ?? 60;
159
159
  const title = issue.title ? truncate(issue.title, tw) : "";
160
- const detail = selected ? summarizeIssueStatusNote(issue.statusNote) : undefined;
161
160
  const session = sessionDisplay(issue);
162
- const facts = buildFacts(issue, selected);
163
- const blocker = selected ? blockerText(issue) : null;
161
+ const facts = buildFacts(issue, selected, compact);
162
+ const blocker = compact || !selected ? null : blockerText(issue);
163
+ const detail = compact || !selected ? undefined : summarizeIssueStatusNote(issue.statusNote);
164
164
  const isTerminal = TERMINAL_STATES.has(effectiveState(issue));
165
165
  // Terminal issues: compact single line
166
166
  if (isTerminal && !selected) {
167
167
  return (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { dimColor: true, children: ` ${key}` }), _jsx(Text, { dimColor: true, children: ` ${relativeTime(issue.updatedAt).padStart(4)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: session.color, children: session.label })] }));
168
168
  }
169
+ if (compact) {
170
+ return (_jsxs(Box, { children: [_jsx(Text, { color: selected ? "blueBright" : "gray", children: selected ? "\u25b8" : " " }), _jsx(Text, { bold: true, children: ` ${key}` }), _jsx(Text, { dimColor: true, children: ` ${relativeTime(issue.updatedAt).padStart(4)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: session.color, children: session.label }), facts.length > 0 && _jsx(Text, { dimColor: true, children: ` · ` }), facts.map((fact, i) => (_jsxs(Text, { children: [i > 0 ? _jsx(Text, { dimColor: true, children: ` ` }) : null, _jsx(Text, { color: fact.color ?? "white", dimColor: !fact.color, children: fact.text })] }, i)))] }));
171
+ }
169
172
  return (_jsxs(Box, { flexDirection: "column", marginBottom: detail ? 1 : 0, children: [_jsxs(Box, { children: [_jsx(Text, { color: selected ? "blueBright" : "gray", children: selected ? "\u25b8" : " " }), _jsx(Text, { bold: true, children: ` ${key}` }), _jsx(Text, { dimColor: true, children: ` ${relativeTime(issue.updatedAt).padStart(4)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: session.color, children: session.label }), facts.length > 0 && (_jsx(Text, { dimColor: true, children: ` \u00b7 ` })), facts.map((fact, i) => (_jsxs(Text, { children: [i > 0 ? _jsx(Text, { dimColor: true, children: ` \u00b7 ` }) : null, _jsx(Text, { color: fact.color ?? "white", dimColor: !fact.color, children: fact.text })] }, i)))] }), title ? (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: title }) })) : null, blocker ? (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: "yellow", children: blocker }) })) : null, detail ? (_jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, wrap: "wrap", children: detail }) })) : null, selected && issue.factoryState && issue.sessionState ? (_jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, children: `Debug stage: ${stageLabel(issue)}` }) })) : null] }));
170
173
  }
171
- export function estimateIssueRowHeight(issue, selected, cols, titleWidth) {
174
+ export function estimateIssueRowHeight(issue, selected, cols, titleWidth, compact = false) {
172
175
  const width = Math.max(20, cols);
173
176
  const key = issue.issueKey ?? issue.projectId;
174
177
  const tw = titleWidth ?? 60;
175
178
  const title = issue.title ? truncate(issue.title, tw) : "";
176
- const detail = selected ? summarizeIssueStatusNote(issue.statusNote) : undefined;
179
+ const detail = compact || !selected ? undefined : summarizeIssueStatusNote(issue.statusNote);
177
180
  const session = sessionDisplay(issue);
178
- const facts = buildFacts(issue, selected);
179
- const blocker = selected ? blockerText(issue) : null;
181
+ const facts = buildFacts(issue, selected, compact);
182
+ const blocker = compact || !selected ? null : blockerText(issue);
180
183
  const isTerminal = TERMINAL_STATES.has(effectiveState(issue));
181
184
  if (isTerminal && !selected) {
182
185
  return 1;
183
186
  }
187
+ if (compact) {
188
+ return 1;
189
+ }
184
190
  const line1Parts = [
185
191
  `${selected ? "\u25b8" : " "} ${key}`,
186
192
  relativeTime(issue.updatedAt).padStart(4),
@@ -8,7 +8,7 @@ const FILTER_LABELS = {
8
8
  "active": "active",
9
9
  "non-done": "in progress",
10
10
  };
11
- export function StatusBar({ issues, totalCount, filter, connected, lastServerMessageAt, allIssues, frozen, }) {
11
+ export function StatusBar({ issues, totalCount, filter, connected, lastServerMessageAt, allIssues, frozen, compact = false, }) {
12
12
  const showing = filter === "all" ? `${totalCount} issues` : `${issues.length}/${totalCount} issues`;
13
13
  const aggregateSource = filter === "all" ? allIssues : issues;
14
14
  const agg = computeAggregates(aggregateSource);
@@ -17,5 +17,19 @@ export function StatusBar({ issues, totalCount, filter, connected, lastServerMes
17
17
  const intervention = aggregateSource.filter((i) => i.sessionState === "failed" || i.factoryState === "failed" || i.factoryState === "escalated").length;
18
18
  const running = aggregateSource.filter((i) => i.sessionState === "running").length;
19
19
  const idle = aggregateSource.filter((i) => i.sessionState === "idle").length;
20
+ if (compact) {
21
+ const compactParts = [
22
+ withPr > 0 ? `p${withPr}` : null,
23
+ running > 0 ? `r${running}` : null,
24
+ waitingInput > 0 ? `w${waitingInput}` : null,
25
+ intervention > 0 ? `x${intervention}` : null,
26
+ agg.blocked > 0 ? `b${agg.blocked}` : null,
27
+ agg.ready > 0 ? `q${agg.ready}` : null,
28
+ agg.failed > 0 ? `f${agg.failed}` : null,
29
+ agg.done > 0 ? `d${agg.done}` : null,
30
+ frozen ? "frozen" : null,
31
+ ].filter(Boolean);
32
+ return (_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { bold: true, children: "patchrelay" }), _jsx(Text, { dimColor: true, children: showing }), _jsxs(Text, { dimColor: true, children: ["[", FILTER_LABELS[filter][0], "]"] }), compactParts.length > 0 ? _jsx(Text, { dimColor: true, children: compactParts.join(" ") }) : null] }), _jsx(FreshnessBadge, { connected: connected, lastServerMessageAt: lastServerMessageAt })] }));
33
+ }
20
34
  return (_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { bold: true, children: showing }), _jsxs(Text, { dimColor: true, children: ["[", FILTER_LABELS[filter], "]"] }), _jsx(Text, { dimColor: true, children: "|" }), running > 0 && _jsxs(Text, { color: "cyan", children: [running, " running"] }), idle > 0 && _jsxs(Text, { color: "blueBright", children: [idle, " idle"] }), agg.ready > 0 && _jsxs(Text, { color: "blueBright", children: [agg.ready, " ready"] }), agg.blocked > 0 && _jsxs(Text, { color: "yellow", children: [agg.blocked, " blocked"] }), withPr > 0 && _jsxs(Text, { dimColor: true, children: [withPr, " PRs"] }), waitingInput > 0 && _jsxs(Text, { color: "yellow", children: [waitingInput, " needs input"] }), intervention > 0 && _jsxs(Text, { color: "red", children: [intervention, " needs help"] }), agg.done > 0 && _jsxs(Text, { color: "green", children: [agg.done, " done"] }), agg.failed > 0 && _jsxs(Text, { color: "red", children: [agg.failed, " failed"] }), frozen && _jsx(Text, { color: "magenta", children: "frozen" })] }), _jsx(FreshnessBadge, { connected: connected, lastServerMessageAt: lastServerMessageAt })] }));
21
35
  }
@@ -342,7 +342,7 @@ function renderHistoryRunLines(run, index, width, gutter) {
342
342
  run.commandCount ? `${run.commandCount} cmds` : null,
343
343
  run.fileChangeCount ? `${run.fileChangeCount} files` : null,
344
344
  ].filter((value) => Boolean(value));
345
- const lines = renderTextLines(`${gutter}${run.status === "completed" ? "✓" : run.status === "failed" ? "✗" : run.status === "running" ? "" : "•"} #${index + 1} (${RUN_LABELS[run.runType] ?? run.runType})${duration ? ` ${duration}` : ""}${stats.length ? ` ${stats.join(", ")}` : ""}`, {
345
+ const lines = renderTextLines(`${gutter}${run.status === "completed" ? "✓" : run.status === "failed" ? "✗" : run.status === "running" ? "" : "•"} #${index + 1} (${RUN_LABELS[run.runType] ?? run.runType})${duration ? ` ${duration}` : ""}${stats.length ? ` ${stats.join(", ")}` : ""}`, {
346
346
  key: `history-run-${run.id}`,
347
347
  width,
348
348
  style: { color: statusColor },
@@ -43,6 +43,16 @@ export class CodexAppServerClient extends EventEmitter {
43
43
  this.logger = logger;
44
44
  this.spawnProcess = spawnProcess;
45
45
  }
46
+ /**
47
+ * Update runtime codex settings used by future thread/thread-fork calls.
48
+ * This allows service config changes to take effect without restarting.
49
+ */
50
+ setRuntimeConfig(config) {
51
+ this.config = {
52
+ ...this.config,
53
+ ...config,
54
+ };
55
+ }
46
56
  isStarted() {
47
57
  return this.started;
48
58
  }
@@ -149,6 +159,7 @@ export class CodexAppServerClient extends EventEmitter {
149
159
  serviceName: this.config.serviceName ?? "patchrelay",
150
160
  model: this.config.model ?? null,
151
161
  modelProvider: this.config.modelProvider ?? null,
162
+ reasoningEffort: this.config.reasoningEffort ?? null,
152
163
  baseInstructions: this.config.baseInstructions ?? null,
153
164
  developerInstructions: this.config.developerInstructions ?? null,
154
165
  experimentalRawEvents: this.config.experimentalRawEvents ?? false,
@@ -164,6 +175,7 @@ export class CodexAppServerClient extends EventEmitter {
164
175
  sandbox: this.config.sandboxMode,
165
176
  model: this.config.model ?? null,
166
177
  modelProvider: this.config.modelProvider ?? null,
178
+ reasoningEffort: this.config.reasoningEffort ?? null,
167
179
  baseInstructions: this.config.baseInstructions ?? null,
168
180
  developerInstructions: this.config.developerInstructions ?? null,
169
181
  };
@@ -181,6 +193,7 @@ export class CodexAppServerClient extends EventEmitter {
181
193
  sandbox: overrides?.sandboxMode ?? this.config.sandboxMode,
182
194
  model: overrides?.model ?? this.config.model ?? null,
183
195
  modelProvider: overrides?.modelProvider ?? this.config.modelProvider ?? null,
196
+ reasoningEffort: overrides?.reasoningEffort ?? this.config.reasoningEffort ?? null,
184
197
  baseInstructions: overrides?.baseInstructions ?? this.config.baseInstructions ?? null,
185
198
  developerInstructions: overrides?.developerInstructions ?? this.config.developerInstructions ?? null,
186
199
  };
package/dist/config.js CHANGED
@@ -138,6 +138,7 @@ const configSchema = z.object({
138
138
  request_timeout_ms: z.number().int().positive().default(30000),
139
139
  model: z.string().optional(),
140
140
  model_provider: z.string().optional(),
141
+ reasoning_effort: z.enum(["low", "medium", "high"]).optional(),
141
142
  service_name: z.string().default("patchrelay"),
142
143
  base_instructions: z.string().optional(),
143
144
  developer_instructions: z.string().optional(),
@@ -544,6 +545,7 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
544
545
  requestTimeoutMs: parsed.runner.codex.request_timeout_ms,
545
546
  ...(parsed.runner.codex.model ? { model: parsed.runner.codex.model } : {}),
546
547
  ...(parsed.runner.codex.model_provider ? { modelProvider: parsed.runner.codex.model_provider } : {}),
548
+ ...(parsed.runner.codex.reasoning_effort ? { reasoningEffort: parsed.runner.codex.reasoning_effort } : {}),
547
549
  ...(parsed.runner.codex.service_name ? { serviceName: parsed.runner.codex.service_name } : {}),
548
550
  ...(parsed.runner.codex.base_instructions ? { baseInstructions: parsed.runner.codex.base_instructions } : {}),
549
551
  ...(parsed.runner.codex.developer_instructions
package/dist/index.js CHANGED
@@ -38,7 +38,7 @@ async function main() {
38
38
  db.runMigrations();
39
39
  const codex = new CodexAppServerClient(config.runner.codex, logger);
40
40
  const linearProvider = new DatabaseBackedLinearClientProvider(config, db, logger);
41
- const service = new PatchRelayService(config, db, codex, linearProvider, logger);
41
+ const service = new PatchRelayService(config, db, codex, linearProvider, logger, configPath);
42
42
  await service.start();
43
43
  const app = await buildHttpServer(config, service, logger);
44
44
  try {
@@ -16,6 +16,7 @@ import { RunReconciler } from "./run-reconciler.js";
16
16
  import { RunRecoveryService } from "./run-recovery-service.js";
17
17
  import { RunWakePlanner } from "./run-wake-planner.js";
18
18
  import { getRemainingZombieRecoveryDelayMs } from "./zombie-recovery.js";
19
+ import { loadConfig } from "./config.js";
19
20
  function lowerCaseFirst(value) {
20
21
  return value ? `${value.slice(0, 1).toLowerCase()}${value.slice(1)}` : value;
21
22
  }
@@ -37,6 +38,7 @@ export class RunOrchestrator {
37
38
  enqueueIssue;
38
39
  logger;
39
40
  feed;
41
+ configPath;
40
42
  worktreeManager;
41
43
  /** Tracks last probe-failure feed event per issue to avoid spamming the operator feed. */
42
44
  queueHealthMonitor;
@@ -54,6 +56,7 @@ export class RunOrchestrator {
54
56
  runNotificationHandler;
55
57
  runReconciler;
56
58
  mergedLinearCompletionReconciler;
59
+ codexRuntimeConfig;
57
60
  threadPorts = {
58
61
  readThreadWithRetry: (threadId, maxRetries) => this.readThreadWithRetry(threadId, maxRetries),
59
62
  };
@@ -70,7 +73,7 @@ export class RunOrchestrator {
70
73
  };
71
74
  activeSessionLeases;
72
75
  botIdentity;
73
- constructor(config, db, codex, linearProvider, enqueueIssue, logger, feed) {
76
+ constructor(config, db, codex, linearProvider, enqueueIssue, logger, feed, configPath) {
74
77
  this.config = config;
75
78
  this.db = db;
76
79
  this.codex = codex;
@@ -78,7 +81,9 @@ export class RunOrchestrator {
78
81
  this.enqueueIssue = enqueueIssue;
79
82
  this.logger = logger;
80
83
  this.feed = feed;
84
+ this.configPath = configPath;
81
85
  this.worktreeManager = new WorktreeManager(config);
86
+ this.codexRuntimeConfig = config.runner.codex;
82
87
  this.linearSync = new LinearSessionSync(config, db, linearProvider, logger, feed);
83
88
  this.leaseService = new IssueSessionLeaseService(db, logger, this.workerId, this.threadPorts.readThreadWithRetry);
84
89
  this.activeSessionLeases = this.leaseService.activeSessionLeases;
@@ -100,6 +105,27 @@ export class RunOrchestrator {
100
105
  enqueueIssue: (projectId, issueId) => this.enqueueIssue(projectId, issueId),
101
106
  }, logger, feed);
102
107
  }
108
+ async refreshCodexRuntimeConfig() {
109
+ if (!this.configPath) {
110
+ return;
111
+ }
112
+ try {
113
+ const freshConfig = loadConfig(this.configPath, { profile: "service" });
114
+ if (this.codexRuntimeConfig.model === freshConfig.runner.codex.model &&
115
+ this.codexRuntimeConfig.modelProvider === freshConfig.runner.codex.modelProvider &&
116
+ this.codexRuntimeConfig.reasoningEffort === freshConfig.runner.codex.reasoningEffort) {
117
+ return;
118
+ }
119
+ this.codexRuntimeConfig = freshConfig.runner.codex;
120
+ this.codex.setRuntimeConfig(this.codexRuntimeConfig);
121
+ }
122
+ catch (error) {
123
+ this.logger.warn({
124
+ error: error instanceof Error ? error.message : String(error),
125
+ configPath: this.configPath,
126
+ }, "Failed to reload patchrelay runtime config before run; using previous codex configuration");
127
+ }
128
+ }
103
129
  resolveRunWake(issue) {
104
130
  return this.runWakePlanner.resolveRunWake(issue);
105
131
  }
@@ -111,6 +137,7 @@ export class RunOrchestrator {
111
137
  }
112
138
  // ─── Run ────────────────────────────────────────────────────────
113
139
  async run(item) {
140
+ await this.refreshCodexRuntimeConfig();
114
141
  const project = this.config.projects.find((p) => p.id === item.projectId);
115
142
  if (!project)
116
143
  return;
package/dist/service.js CHANGED
@@ -17,6 +17,7 @@ export class PatchRelayService {
17
17
  db;
18
18
  codex;
19
19
  logger;
20
+ configPath;
20
21
  linearProvider;
21
22
  orchestrator;
22
23
  githubAppTokenManager;
@@ -29,17 +30,18 @@ export class PatchRelayService {
29
30
  issueActions;
30
31
  startupRecovery;
31
32
  trackedIssueListQuery;
32
- constructor(config, db, codex, linearProvider, logger) {
33
+ constructor(config, db, codex, linearProvider, logger, configPath) {
33
34
  this.config = config;
34
35
  this.db = db;
35
36
  this.codex = codex;
36
37
  this.logger = logger;
38
+ this.configPath = configPath;
37
39
  this.linearProvider = toLinearClientProvider(linearProvider);
38
40
  this.feed = new OperatorEventFeed(db.operatorFeed);
39
41
  let enqueueIssue = () => {
40
42
  throw new Error("Service runtime enqueueIssue is not initialized");
41
43
  };
42
- this.orchestrator = new RunOrchestrator(config, db, codex, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed);
44
+ this.orchestrator = new RunOrchestrator(config, db, codex, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed, this.configPath);
43
45
  this.webhookHandler = new WebhookHandler(config, db, this.linearProvider, codex, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed);
44
46
  this.githubWebhookHandler = new GitHubWebhookHandler(config, db, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, codex, this.feed);
45
47
  const runtime = new ServiceRuntime(codex, logger, this.orchestrator, { listIssuesReadyForExecution: () => db.listIssuesReadyForExecution() }, this.webhookHandler, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.43.0",
3
+ "version": "0.45.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {