patchrelay 0.16.0 → 0.17.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.
@@ -93,7 +93,6 @@ export function buildAgentSessionPlan(params) {
93
93
  case "implementing":
94
94
  return setStatuses(planForRunType("implementation", params), ["completed", "inProgress", "pending", "pending"]);
95
95
  case "pr_open":
96
- case "awaiting_review":
97
96
  return setStatuses(implementationPlan(), ["completed", "completed", "inProgress", "pending"]);
98
97
  case "changes_requested":
99
98
  return setStatuses(reviewFixPlan(), ["completed", "inProgress", "pending", "pending"]);
@@ -164,7 +163,7 @@ export function buildCompletedSessionPlan(runType) {
164
163
  if (runType === "ci_repair" || runType === "queue_repair") {
165
164
  return buildAgentSessionPlan({ factoryState: "awaiting_queue" });
166
165
  }
167
- return buildAgentSessionPlan({ factoryState: "awaiting_review" });
166
+ return buildAgentSessionPlan({ factoryState: "pr_open" });
168
167
  }
169
168
  export function buildAwaitingHandoffSessionPlan(runType) {
170
169
  return buildCompletedSessionPlan(runType);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.16.0",
4
- "commit": "396ce21398c4",
5
- "builtAt": "2026-03-25T12:29:18.014Z"
3
+ "version": "0.17.1",
4
+ "commit": "ff6a8d2fcbef",
5
+ "builtAt": "2026-03-25T14:45:25.511Z"
6
6
  }
@@ -64,5 +64,5 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
64
64
  }
65
65
  }
66
66
  });
67
- return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, selectedIndex: state.selectedIndex, connected: state.connected, filter: state.filter, totalCount: state.issues.length })) : (_jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), thread: state.thread, report: state.report, follow: state.follow, feedEntries: state.detailFeed })) }));
67
+ return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, selectedIndex: state.selectedIndex, connected: state.connected, filter: state.filter, totalCount: state.issues.length })) : (_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 })) }));
68
68
  }
@@ -1,12 +1,8 @@
1
- import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useReducer } from "react";
2
3
  import { Box, Text } from "ink";
3
- import { ThreadView } from "./ThreadView.js";
4
- import { FeedTimeline } from "./FeedTimeline.js";
4
+ import { Timeline } from "./Timeline.js";
5
5
  import { HelpBar } from "./HelpBar.js";
6
- function truncate(text, max) {
7
- const line = text.replace(/\n/g, " ").trim();
8
- return line.length > max ? `${line.slice(0, max - 3)}...` : line;
9
- }
10
6
  function formatTokens(n) {
11
7
  if (n >= 1_000_000)
12
8
  return `${(n / 1_000_000).toFixed(1)}M`;
@@ -14,16 +10,35 @@ function formatTokens(n) {
14
10
  return `${(n / 1_000).toFixed(1)}k`;
15
11
  return String(n);
16
12
  }
17
- function ThreadStatusBar({ thread, follow }) {
18
- return (_jsxs(Box, { gap: 2, children: [thread.tokenUsage && (_jsxs(Text, { dimColor: true, children: ["tokens: ", formatTokens(thread.tokenUsage.inputTokens), " in / ", formatTokens(thread.tokenUsage.outputTokens), " out"] })), thread.diffSummary && thread.diffSummary.filesChanged > 0 && (_jsxs(Text, { dimColor: true, children: ["diff: ", thread.diffSummary.filesChanged, " file", thread.diffSummary.filesChanged !== 1 ? "s" : "", " ", "+", thread.diffSummary.linesAdded, " -", thread.diffSummary.linesRemoved] })), follow && _jsx(Text, { color: "yellow", children: "follow" })] }));
13
+ function ElapsedTime({ startedAt }) {
14
+ const [, tick] = useReducer((c) => c + 1, 0);
15
+ useEffect(() => {
16
+ const id = setInterval(tick, 1000);
17
+ return () => clearInterval(id);
18
+ }, []);
19
+ const elapsed = Math.max(0, Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000));
20
+ const minutes = Math.floor(elapsed / 60);
21
+ const seconds = elapsed % 60;
22
+ return _jsxs(Text, { dimColor: true, children: [minutes, "m ", String(seconds).padStart(2, "0"), "s"] });
23
+ }
24
+ function planStepSymbol(status) {
25
+ if (status === "completed")
26
+ return "\u2713";
27
+ if (status === "inProgress")
28
+ return "\u25b8";
29
+ return " ";
19
30
  }
20
- function ReportView({ report }) {
21
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "Latest run:" }), _jsx(Text, { bold: true, children: report.runType }), _jsx(Text, { color: report.status === "completed" ? "green" : "red", children: report.status })] }), report.summary && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Summary:" }), _jsx(Text, { wrap: "wrap", children: truncate(report.summary, 300) })] })), report.commands.length > 0 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { bold: true, children: ["Commands (", report.commands.length, "):"] }), report.commands.slice(-10).map((cmd, i) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: cmd.exitCode === 0 ? "green" : cmd.exitCode !== undefined ? "red" : "white", children: cmd.exitCode === 0 ? "\u2713" : cmd.exitCode !== undefined ? "\u2717" : " " }), _jsx(Text, { dimColor: true, children: "$ " }), _jsx(Text, { children: truncate(cmd.command, 60) }), cmd.durationMs !== undefined && _jsxs(Text, { dimColor: true, children: [" ", (cmd.durationMs / 1000).toFixed(1), "s"] })] }, `cmd-${i}`)))] })), _jsxs(Box, { marginTop: 1, gap: 2, children: [report.fileChanges > 0 && _jsxs(Text, { dimColor: true, children: [report.fileChanges, " file change", report.fileChanges !== 1 ? "s" : ""] }), report.toolCalls > 0 && _jsxs(Text, { dimColor: true, children: [report.toolCalls, " tool call", report.toolCalls !== 1 ? "s" : ""] }), report.assistantMessages.length > 0 && _jsxs(Text, { dimColor: true, children: [report.assistantMessages.length, " message", report.assistantMessages.length !== 1 ? "s" : ""] })] })] }));
31
+ function planStepColor(status) {
32
+ if (status === "completed")
33
+ return "green";
34
+ if (status === "inProgress")
35
+ return "yellow";
36
+ return "white";
22
37
  }
23
- export function IssueDetailView({ issue, thread, report, follow, feedEntries }) {
38
+ export function IssueDetailView({ issue, timeline, follow, activeRunStartedAt, tokenUsage, diffSummary, plan, }) {
24
39
  if (!issue) {
25
40
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "red", children: "Issue not found." }), _jsx(HelpBar, { view: "detail", follow: follow })] }));
26
41
  }
27
42
  const key = issue.issueKey ?? issue.projectId;
28
- return (_jsxs(Box, { flexDirection: "column", 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] })] }), issue.title && _jsx(Text, { dimColor: true, children: issue.title }), thread && _jsx(ThreadStatusBar, { thread: thread, follow: follow }), _jsx(Text, { dimColor: true, children: "".repeat(72) }), thread ? (_jsx(ThreadView, { thread: thread, follow: follow })) : report ? (_jsx(ReportView, { report: report })) : (_jsx(Text, { dimColor: true, children: "Loading..." })), feedEntries.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "".repeat(72) }), _jsx(Text, { bold: true, dimColor: true, children: "Events:" }), _jsx(FeedTimeline, { entries: feedEntries, maxEntries: follow ? 5 : undefined })] })), _jsx(Text, { dimColor: true, children: "─".repeat(72) }), _jsx(HelpBar, { view: "detail", follow: follow })] }));
43
+ return (_jsxs(Box, { flexDirection: "column", 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" })] }), 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: "".repeat(72) }), _jsx(Timeline, { entries: timeline, follow: follow }), _jsx(Text, { dimColor: true, children: "─".repeat(72) }), _jsx(HelpBar, { view: "detail", follow: follow })] }));
29
44
  }
@@ -5,7 +5,6 @@ const STATE_COLORS = {
5
5
  preparing: "blue",
6
6
  implementing: "yellow",
7
7
  pr_open: "cyan",
8
- awaiting_review: "cyan",
9
8
  changes_requested: "magenta",
10
9
  repairing_ci: "magenta",
11
10
  awaiting_queue: "green",
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { TimelineRow } from "./TimelineRow.js";
4
+ const FOLLOW_TAIL_SIZE = 20;
5
+ export function Timeline({ entries, follow }) {
6
+ const visible = follow && entries.length > FOLLOW_TAIL_SIZE
7
+ ? entries.slice(-FOLLOW_TAIL_SIZE)
8
+ : entries;
9
+ const skipped = entries.length - visible.length;
10
+ if (entries.length === 0) {
11
+ return _jsx(Text, { dimColor: true, children: "No timeline events yet." });
12
+ }
13
+ return (_jsxs(Box, { flexDirection: "column", children: [skipped > 0 && _jsxs(Text, { dimColor: true, children: [" ... ", skipped, " earlier events"] }), visible.map((entry) => (_jsx(TimelineRow, { entry: entry }, entry.id)))] }));
14
+ }
@@ -0,0 +1,62 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { ItemLine } from "./ItemLine.js";
4
+ function formatTime(iso) {
5
+ return new Date(iso).toLocaleTimeString("en-GB", { hour12: false });
6
+ }
7
+ function formatDuration(startedAt, endedAt) {
8
+ const ms = new Date(endedAt).getTime() - new Date(startedAt).getTime();
9
+ const seconds = Math.floor(ms / 1000);
10
+ if (seconds < 60)
11
+ return `${seconds}s`;
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",
25
+ };
26
+ function FeedRow({ entry }) {
27
+ 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
+ function RunStartRow({ entry }) {
32
+ const run = entry.run;
33
+ return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { bold: true, color: "yellow", children: run.runType.padEnd(16) }), _jsx(Text, { bold: true, children: "run started" })] }));
34
+ }
35
+ function RunEndRow({ entry }) {
36
+ const run = entry.run;
37
+ const color = run.status === "completed" ? "green" : "red";
38
+ const duration = run.endedAt ? formatDuration(run.startedAt, run.endedAt) : "";
39
+ return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { bold: true, color: color, children: run.runType.padEnd(16) }), _jsx(Text, { bold: true, color: color, children: run.status }), duration ? _jsxs(Text, { dimColor: true, children: ["(", duration, ")"] }) : null] }));
40
+ }
41
+ function ItemRow({ entry }) {
42
+ const item = entry.item;
43
+ return (_jsx(Box, { paddingLeft: 2, children: _jsx(ItemLine, { item: item, isLast: false }) }));
44
+ }
45
+ function CIChecksRow({ entry }) {
46
+ const ci = entry.ciChecks;
47
+ 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}`)))] }));
48
+ }
49
+ export function TimelineRow({ entry }) {
50
+ switch (entry.kind) {
51
+ case "feed":
52
+ return _jsx(FeedRow, { entry: entry });
53
+ case "run-start":
54
+ return _jsx(RunStartRow, { entry: entry });
55
+ case "run-end":
56
+ return _jsx(RunEndRow, { entry: entry });
57
+ case "item":
58
+ return _jsx(ItemRow, { entry: entry });
59
+ case "ci-checks":
60
+ return _jsx(CIChecksRow, { entry: entry });
61
+ }
62
+ }
@@ -0,0 +1,363 @@
1
+ // ─── Build Timeline from Rehydration Data ─────────────────────────
2
+ export function buildTimelineFromRehydration(runs, feedEvents, liveThread, activeRunId) {
3
+ const entries = [];
4
+ // 1. Add run boundaries and items from reports
5
+ for (const run of runs) {
6
+ entries.push({
7
+ id: `run-start-${run.id}`,
8
+ at: run.startedAt,
9
+ kind: "run-start",
10
+ runId: run.id,
11
+ run: { runType: run.runType, status: run.status, startedAt: run.startedAt, endedAt: run.endedAt },
12
+ });
13
+ if (run.endedAt) {
14
+ entries.push({
15
+ id: `run-end-${run.id}`,
16
+ at: run.endedAt,
17
+ kind: "run-end",
18
+ runId: run.id,
19
+ run: { runType: run.runType, status: run.status, startedAt: run.startedAt, endedAt: run.endedAt },
20
+ });
21
+ }
22
+ // Items from completed run reports
23
+ if (run.report && run.id !== activeRunId) {
24
+ entries.push(...itemsFromReport(run.id, run.report, run.startedAt, run.endedAt));
25
+ }
26
+ }
27
+ // 2. Items from live thread (active run)
28
+ if (liveThread && activeRunId) {
29
+ entries.push(...itemsFromThread(activeRunId, liveThread));
30
+ }
31
+ // 3. Feed events → feed entries + CI check aggregation
32
+ entries.push(...feedEventsToEntries(feedEvents));
33
+ // 4. Sort by timestamp, then by entry order for stability
34
+ entries.sort((a, b) => {
35
+ const cmp = a.at.localeCompare(b.at);
36
+ if (cmp !== 0)
37
+ return cmp;
38
+ // Within same timestamp: run-start before items, items before run-end
39
+ return kindOrder(a.kind) - kindOrder(b.kind);
40
+ });
41
+ return entries;
42
+ }
43
+ function kindOrder(kind) {
44
+ switch (kind) {
45
+ case "run-start": return 0;
46
+ case "feed": return 1;
47
+ case "ci-checks": return 2;
48
+ case "item": return 3;
49
+ case "run-end": return 4;
50
+ }
51
+ }
52
+ // ─── Items from Report ────────────────────────────────────────────
53
+ function itemsFromReport(runId, report, startedAt, endedAt) {
54
+ const entries = [];
55
+ const start = new Date(startedAt).getTime();
56
+ const end = endedAt ? new Date(endedAt).getTime() : start + 60000;
57
+ let idx = 0;
58
+ const total = report.commands.length + report.assistantMessages.length + report.toolCalls.length;
59
+ for (const msg of report.assistantMessages) {
60
+ entries.push({
61
+ id: `report-${runId}-msg-${idx}`,
62
+ at: syntheticTimestamp(start, end, idx, total),
63
+ kind: "item",
64
+ runId,
65
+ item: { id: `report-${runId}-msg-${idx}`, type: "agentMessage", status: "completed", text: msg },
66
+ });
67
+ idx++;
68
+ }
69
+ for (const cmd of report.commands) {
70
+ entries.push({
71
+ id: `report-${runId}-cmd-${idx}`,
72
+ at: syntheticTimestamp(start, end, idx, total),
73
+ kind: "item",
74
+ runId,
75
+ item: {
76
+ id: `report-${runId}-cmd-${idx}`,
77
+ type: "commandExecution",
78
+ status: "completed",
79
+ command: cmd.command,
80
+ ...(typeof cmd.exitCode === "number" ? { exitCode: cmd.exitCode } : {}),
81
+ ...(typeof cmd.durationMs === "number" ? { durationMs: cmd.durationMs } : {}),
82
+ },
83
+ });
84
+ idx++;
85
+ }
86
+ for (const tool of report.toolCalls) {
87
+ entries.push({
88
+ id: `report-${runId}-tool-${idx}`,
89
+ at: syntheticTimestamp(start, end, idx, total),
90
+ kind: "item",
91
+ runId,
92
+ item: {
93
+ id: `report-${runId}-tool-${idx}`,
94
+ type: tool.type === "mcp" ? "mcpToolCall" : "dynamicToolCall",
95
+ status: "completed",
96
+ toolName: tool.name,
97
+ ...(typeof tool.durationMs === "number" ? { durationMs: tool.durationMs } : {}),
98
+ },
99
+ });
100
+ idx++;
101
+ }
102
+ if (report.fileChanges.length > 0) {
103
+ entries.push({
104
+ id: `report-${runId}-files`,
105
+ at: syntheticTimestamp(start, end, idx, total),
106
+ kind: "item",
107
+ runId,
108
+ item: {
109
+ id: `report-${runId}-files`,
110
+ type: "fileChange",
111
+ status: "completed",
112
+ changes: report.fileChanges,
113
+ },
114
+ });
115
+ }
116
+ return entries;
117
+ }
118
+ function syntheticTimestamp(startMs, endMs, index, total) {
119
+ if (total <= 1)
120
+ return new Date(startMs).toISOString();
121
+ const fraction = index / (total - 1);
122
+ return new Date(startMs + fraction * (endMs - startMs)).toISOString();
123
+ }
124
+ // ─── Items from Live Thread ───────────────────────────────────────
125
+ function itemsFromThread(runId, thread) {
126
+ const entries = [];
127
+ for (const turn of thread.turns) {
128
+ for (const item of turn.items) {
129
+ entries.push({
130
+ id: `live-${item.id}`,
131
+ at: new Date().toISOString(), // live items don't have timestamps; they'll sort to the end
132
+ kind: "item",
133
+ runId,
134
+ item: materializeItem(item),
135
+ });
136
+ }
137
+ }
138
+ return entries;
139
+ }
140
+ function materializeItem(item) {
141
+ const r = item;
142
+ const id = String(r.id ?? "unknown");
143
+ const type = String(r.type ?? "unknown");
144
+ const base = { id, type, status: "completed" };
145
+ switch (type) {
146
+ case "agentMessage":
147
+ return { ...base, text: String(r.text ?? "") };
148
+ case "commandExecution":
149
+ return {
150
+ ...base,
151
+ command: String(r.command ?? ""),
152
+ status: String(r.status ?? "completed"),
153
+ ...(typeof r.exitCode === "number" ? { exitCode: r.exitCode } : {}),
154
+ ...(typeof r.durationMs === "number" ? { durationMs: r.durationMs } : {}),
155
+ ...(typeof r.aggregatedOutput === "string" ? { output: r.aggregatedOutput } : {}),
156
+ };
157
+ case "fileChange":
158
+ return { ...base, status: String(r.status ?? "completed"), changes: Array.isArray(r.changes) ? r.changes : [] };
159
+ case "mcpToolCall":
160
+ return {
161
+ ...base,
162
+ status: String(r.status ?? "completed"),
163
+ toolName: `${String(r.server ?? "")}/${String(r.tool ?? "")}`,
164
+ ...(typeof r.durationMs === "number" ? { durationMs: r.durationMs } : {}),
165
+ };
166
+ case "dynamicToolCall":
167
+ return {
168
+ ...base,
169
+ status: String(r.status ?? "completed"),
170
+ toolName: String(r.tool ?? ""),
171
+ ...(typeof r.durationMs === "number" ? { durationMs: r.durationMs } : {}),
172
+ };
173
+ case "plan":
174
+ return { ...base, text: String(r.text ?? "") };
175
+ case "reasoning":
176
+ return { ...base, text: Array.isArray(r.summary) ? r.summary.join("\n") : "" };
177
+ default:
178
+ return base;
179
+ }
180
+ }
181
+ // ─── Feed Events to Timeline Entries ──────────────────────────────
182
+ function feedEventsToEntries(feedEvents) {
183
+ const entries = [];
184
+ const ciAggregator = new CICheckAggregator();
185
+ for (const event of feedEvents) {
186
+ // GitHub check events get aggregated
187
+ if (event.kind === "github" && (event.status === "check_passed" || event.status === "check_failed") && event.detail) {
188
+ const ciEntry = ciAggregator.add(event);
189
+ if (ciEntry) {
190
+ // Replace the last ci-checks entry if it was updated
191
+ const lastIdx = entries.findLastIndex((e) => e.kind === "ci-checks" && e.id === ciEntry.id);
192
+ if (lastIdx >= 0) {
193
+ entries[lastIdx] = ciEntry;
194
+ }
195
+ else {
196
+ entries.push(ciEntry);
197
+ }
198
+ }
199
+ continue;
200
+ }
201
+ entries.push({
202
+ id: `feed-${event.id}`,
203
+ at: event.at,
204
+ kind: "feed",
205
+ feed: {
206
+ feedKind: event.kind,
207
+ ...(event.status ? { status: event.status } : {}),
208
+ summary: event.summary,
209
+ ...(event.detail ? { detail: event.detail } : {}),
210
+ },
211
+ });
212
+ }
213
+ return entries;
214
+ }
215
+ // ─── CI Check Aggregation ─────────────────────────────────────────
216
+ const CI_CHECK_WINDOW_MS = 60_000;
217
+ class CICheckAggregator {
218
+ currentGroup = null;
219
+ groupCounter = 0;
220
+ add(event) {
221
+ const name = event.detail ?? "unknown";
222
+ const status = event.status === "check_passed" ? "passed" : "failed";
223
+ const eventMs = new Date(event.at).getTime();
224
+ if (this.currentGroup && eventMs - this.currentGroup.windowStart < CI_CHECK_WINDOW_MS) {
225
+ this.currentGroup.checks.set(name, status);
226
+ return this.toEntry();
227
+ }
228
+ this.groupCounter++;
229
+ this.currentGroup = {
230
+ id: `ci-checks-${this.groupCounter}`,
231
+ at: event.at,
232
+ checks: new Map([[name, status]]),
233
+ windowStart: eventMs,
234
+ };
235
+ return this.toEntry();
236
+ }
237
+ toEntry() {
238
+ const group = this.currentGroup;
239
+ const checks = [...group.checks.entries()].map(([name, status]) => ({ name, status }));
240
+ const overall = checks.every((c) => c.status === "passed") ? "passed"
241
+ : checks.some((c) => c.status === "failed") ? "failed"
242
+ : "pending";
243
+ return {
244
+ id: group.id,
245
+ at: group.at,
246
+ kind: "ci-checks",
247
+ ciChecks: { checks, overall },
248
+ };
249
+ }
250
+ }
251
+ // ─── Live Append Helpers ──────────────────────────────────────────
252
+ export function appendFeedToTimeline(timeline, event) {
253
+ // GitHub check events: aggregate into existing ci-checks entry
254
+ if (event.kind === "github" && (event.status === "check_passed" || event.status === "check_failed") && event.detail) {
255
+ return aggregateCICheckIntoTimeline(timeline, event);
256
+ }
257
+ return [...timeline, {
258
+ id: `feed-${event.id}`,
259
+ at: event.at,
260
+ kind: "feed",
261
+ feed: {
262
+ feedKind: event.kind,
263
+ ...(event.status ? { status: event.status } : {}),
264
+ summary: event.summary,
265
+ ...(event.detail ? { detail: event.detail } : {}),
266
+ },
267
+ }];
268
+ }
269
+ function aggregateCICheckIntoTimeline(timeline, event) {
270
+ const name = event.detail ?? "unknown";
271
+ const status = event.status === "check_passed" ? "passed" : "failed";
272
+ const eventMs = new Date(event.at).getTime();
273
+ // Find the most recent ci-checks entry within the window
274
+ for (let i = timeline.length - 1; i >= 0; i--) {
275
+ const entry = timeline[i];
276
+ if (entry.kind === "ci-checks" && entry.ciChecks) {
277
+ const entryMs = new Date(entry.at).getTime();
278
+ if (eventMs - entryMs < CI_CHECK_WINDOW_MS) {
279
+ const updatedChecks = [...entry.ciChecks.checks.filter((c) => c.name !== name), { name, status }];
280
+ const overall = updatedChecks.every((c) => c.status === "passed") ? "passed"
281
+ : updatedChecks.some((c) => c.status === "failed") ? "failed"
282
+ : "pending";
283
+ const updated = [...timeline];
284
+ updated[i] = { ...entry, ciChecks: { checks: updatedChecks, overall } };
285
+ return updated;
286
+ }
287
+ break;
288
+ }
289
+ }
290
+ // No recent ci-checks entry; create new one
291
+ return [...timeline, {
292
+ id: `ci-checks-live-${event.id}`,
293
+ at: event.at,
294
+ kind: "ci-checks",
295
+ ciChecks: { checks: [{ name, status }], overall: status },
296
+ }];
297
+ }
298
+ export function appendCodexItemToTimeline(timeline, params, activeRunId) {
299
+ const itemObj = params.item;
300
+ if (!itemObj)
301
+ return timeline;
302
+ const id = typeof itemObj.id === "string" ? itemObj.id : "unknown";
303
+ const type = typeof itemObj.type === "string" ? itemObj.type : "unknown";
304
+ const status = typeof itemObj.status === "string" ? itemObj.status : "inProgress";
305
+ const item = { id, type, status };
306
+ if (type === "agentMessage" && typeof itemObj.text === "string")
307
+ item.text = itemObj.text;
308
+ if (type === "commandExecution") {
309
+ const cmd = itemObj.command;
310
+ item.command = Array.isArray(cmd) ? cmd.join(" ") : typeof cmd === "string" ? cmd : undefined;
311
+ }
312
+ if (type === "mcpToolCall") {
313
+ item.toolName = `${String(itemObj.server ?? "")}/${String(itemObj.tool ?? "")}`;
314
+ }
315
+ if (type === "dynamicToolCall") {
316
+ item.toolName = typeof itemObj.tool === "string" ? itemObj.tool : undefined;
317
+ }
318
+ return [...timeline, {
319
+ id: `live-${id}`,
320
+ at: new Date().toISOString(),
321
+ kind: "item",
322
+ runId: activeRunId ?? undefined,
323
+ item,
324
+ }];
325
+ }
326
+ export function completeCodexItemInTimeline(timeline, params) {
327
+ const itemObj = params.item;
328
+ if (!itemObj)
329
+ return timeline;
330
+ const id = typeof itemObj.id === "string" ? itemObj.id : undefined;
331
+ if (!id)
332
+ return timeline;
333
+ const status = typeof itemObj.status === "string" ? itemObj.status : "completed";
334
+ const exitCode = typeof itemObj.exitCode === "number" ? itemObj.exitCode : undefined;
335
+ const durationMs = typeof itemObj.durationMs === "number" ? itemObj.durationMs : undefined;
336
+ const text = typeof itemObj.text === "string" ? itemObj.text : undefined;
337
+ const changes = Array.isArray(itemObj.changes) ? itemObj.changes : undefined;
338
+ return timeline.map((entry) => {
339
+ if (entry.kind !== "item" || entry.item?.id !== id)
340
+ return entry;
341
+ return {
342
+ ...entry,
343
+ item: {
344
+ ...entry.item,
345
+ status,
346
+ ...(exitCode !== undefined ? { exitCode } : {}),
347
+ ...(durationMs !== undefined ? { durationMs } : {}),
348
+ ...(text !== undefined ? { text } : {}),
349
+ ...(changes !== undefined ? { changes } : {}),
350
+ },
351
+ };
352
+ });
353
+ }
354
+ export function appendDeltaToTimelineItem(timeline, itemId, field, delta) {
355
+ return timeline.map((entry) => {
356
+ if (entry.kind !== "item" || entry.item?.id !== itemId)
357
+ return entry;
358
+ return {
359
+ ...entry,
360
+ item: { ...entry.item, [field]: (entry.item[field] ?? "") + delta },
361
+ };
362
+ });
363
+ }