patchrelay 0.15.0 → 0.17.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.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # PatchRelay
2
2
 
3
- PatchRelay is a self-hosted harness for Linear-driven Codex work on your own machine.
3
+ PatchRelay is a self-hosted harness for running a controlled coding loop per Linear issue on your own machine.
4
4
 
5
- It receives Linear webhooks, routes issues to the right local repository, prepares durable issue worktrees, runs Codex sessions through `codex app-server`, and keeps the whole run observable and resumable from the CLI. GitHub webhooks drive reactive loops for CI repair, review fixes, and merge queue failures.
5
+ It receives Linear webhooks, routes issues to the right local repository, prepares durable issue worktrees, runs Codex sessions through `codex app-server`, and keeps the whole issue loop observable and resumable from the CLI. GitHub webhooks drive reactive loops for CI repair, review fixes, and merge queue failures.
6
6
 
7
7
  PatchRelay is the system around the model:
8
8
 
@@ -10,7 +10,7 @@ PatchRelay is the system around the model:
10
10
  - Linear OAuth and workspace installations
11
11
  - issue-to-repo routing
12
12
  - issue worktree and branch lifecycle
13
- - run orchestration and thread continuity
13
+ - context packaging, run orchestration, and thread continuity
14
14
  - reactive CI repair, review fix, and merge queue repair loops
15
15
  - native Linear agent input forwarding into active runs
16
16
  - read-only inspection and run reporting
@@ -21,7 +21,7 @@ If you want Codex to work inside your real repos with your real tools, secrets,
21
21
 
22
22
  - Keep the agent in the real environment instead of rebuilding that environment in a hosted sandbox.
23
23
  - Use your existing machine, repos, secrets, SSH config, shell tools, and deployment access.
24
- - Keep deterministic workflow logic outside the model: routing, run orchestration, worktree ownership, and reporting.
24
+ - Keep deterministic workflow logic outside the model: context packaging, routing, run orchestration, worktree ownership, verification, and reporting.
25
25
  - Choose the Codex approval and sandbox settings that match your risk tolerance.
26
26
  - Let Linear drive the loop through delegation and native agent sessions.
27
27
  - Let GitHub drive reactive loops through PR reviews and CI check events.
@@ -33,6 +33,7 @@ PatchRelay does the deterministic harness work that you do not want to re-implem
33
33
 
34
34
  - verifies and deduplicates Linear and GitHub webhooks
35
35
  - maps issue events to the correct local project
36
+ - packages the right issue, repo, review, and failure context for each loop
36
37
  - creates and reuses one durable worktree and branch per issue lifecycle
37
38
  - starts Codex threads for implementation runs
38
39
  - triggers reactive runs for CI failures, review feedback, and merge queue failures
@@ -50,7 +51,7 @@ PatchRelay works best when read as five layers with clear ownership:
50
51
  - integration layer: Linear webhooks, GitHub webhooks, OAuth, project routing, and state sync
51
52
  - observability layer: CLI inspection, reports, event trails, and operator endpoints
52
53
 
53
- That separation is intentional. PatchRelay is not the policy itself and it is not the coding agent. It is the harness that keeps those pieces coordinated in a real repository with real operational state.
54
+ That separation is intentional. PatchRelay is not the policy itself and it is not the coding agent. It is the harness that keeps context, action, verification, and repair coordinated in a real repository with real operational state.
54
55
 
55
56
  ## Runtime Model
56
57
 
@@ -75,10 +76,10 @@ You will also need:
75
76
  ## How It Works
76
77
 
77
78
  1. A human delegates PatchRelay on an issue to start automation.
78
- 2. PatchRelay verifies the webhook and routes the issue to the right local project.
79
+ 2. PatchRelay verifies the webhook, routes the issue to the right local project, and packages the issue context for the first loop.
79
80
  3. Delegated issues create or reuse the issue worktree and launch an implementation run through `codex app-server`.
80
81
  4. PatchRelay persists thread ids, run state, and observations so the work stays inspectable and resumable.
81
- 5. GitHub webhooks drive reactive loops: CI repair on check failures, review fix on changes requested, and merge queue repair on queue failures.
82
+ 5. GitHub webhooks drive reactive verification and repair loops: CI repair on check failures, review fix on changes requested, and merge queue repair on queue failures.
82
83
  6. Native agent prompts and Linear comments can steer the active run. An operator can take over from the exact same worktree when needed.
83
84
 
84
85
  ## Factory State Machine
@@ -101,6 +102,8 @@ Run types:
101
102
  - `ci_repair` — fix failing CI checks
102
103
  - `queue_repair` — fix merge queue failures
103
104
 
105
+ PatchRelay treats these as distinct loop types with different context, entry conditions, and success criteria rather than as one generic "ask the agent again" workflow.
106
+
104
107
  ## Restart And Reconciliation
105
108
 
106
109
  PatchRelay treats restart safety as part of the harness contract, not as a best-effort extra.
@@ -122,7 +125,7 @@ PatchRelay uses repo-local workflow files as prompts for Codex runs:
122
125
  - `IMPLEMENTATION_WORKFLOW.md` — used for implementation, CI repair, and queue repair runs
123
126
  - `REVIEW_WORKFLOW.md` — used for review fix runs
124
127
 
125
- These files define how the agent should work in that repository. Keep them short and action-oriented.
128
+ These files define how the agent should work in that repository. Keep them short, action-oriented, and human-authored.
126
129
 
127
130
  ## Access Control
128
131
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.15.0",
4
- "commit": "55a384ed81ec",
5
- "builtAt": "2026-03-25T12:16:29.541Z"
3
+ "version": "0.17.0",
4
+ "commit": "972d51f6e0da",
5
+ "builtAt": "2026-03-25T12:55:22.635Z"
6
6
  }
@@ -1,11 +1,21 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useReducer, useMemo } from "react";
2
+ import { useReducer, useMemo, useCallback } from "react";
3
3
  import { Box, useApp, useInput } from "ink";
4
4
  import { watchReducer, initialWatchState, filterIssues } from "./watch-state.js";
5
5
  import { useWatchStream } from "./use-watch-stream.js";
6
6
  import { useDetailStream } from "./use-detail-stream.js";
7
7
  import { IssueListView } from "./IssueListView.js";
8
8
  import { IssueDetailView } from "./IssueDetailView.js";
9
+ async function postRetry(baseUrl, issueKey, bearerToken) {
10
+ const headers = { "content-type": "application/json" };
11
+ if (bearerToken)
12
+ headers.authorization = `Bearer ${bearerToken}`;
13
+ await fetch(new URL(`/api/issues/${encodeURIComponent(issueKey)}/retry`, baseUrl), {
14
+ method: "POST",
15
+ headers,
16
+ signal: AbortSignal.timeout(5000),
17
+ }).catch(() => { });
18
+ }
9
19
  export function App({ baseUrl, bearerToken, initialIssueKey }) {
10
20
  const { exit } = useApp();
11
21
  const [state, dispatch] = useReducer(watchReducer, {
@@ -15,6 +25,11 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
15
25
  const filtered = useMemo(() => filterIssues(state.issues, state.filter), [state.issues, state.filter]);
16
26
  useWatchStream({ baseUrl, bearerToken, dispatch });
17
27
  useDetailStream({ baseUrl, bearerToken, issueKey: state.activeDetailKey, dispatch });
28
+ const handleRetry = useCallback(() => {
29
+ if (state.activeDetailKey) {
30
+ void postRetry(baseUrl, state.activeDetailKey, bearerToken);
31
+ }
32
+ }, [baseUrl, bearerToken, state.activeDetailKey]);
18
33
  useInput((input, key) => {
19
34
  if (input === "q") {
20
35
  exit();
@@ -44,7 +59,10 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
44
59
  else if (input === "f") {
45
60
  dispatch({ type: "toggle-follow" });
46
61
  }
62
+ else if (input === "r") {
63
+ handleRetry();
64
+ }
47
65
  }
48
66
  });
49
- 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 })) }));
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 })) }));
50
68
  }
@@ -3,5 +3,5 @@ import { Box, Text } from "ink";
3
3
  export function HelpBar({ view, follow }) {
4
4
  return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: view === "list"
5
5
  ? "j/k: navigate Enter: detail Tab: filter q: quit"
6
- : `Esc: back f: follow ${follow ? "on" : "off"} q: quit` }) }));
6
+ : `Esc: back f: follow ${follow ? "on" : "off"} r: retry q: quit` }) }));
7
7
  }
@@ -1,11 +1,8 @@
1
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 { Timeline } from "./Timeline.js";
4
5
  import { HelpBar } from "./HelpBar.js";
5
- function truncate(text, max) {
6
- const line = text.replace(/\n/g, " ").trim();
7
- return line.length > max ? `${line.slice(0, max - 3)}...` : line;
8
- }
9
6
  function formatTokens(n) {
10
7
  if (n >= 1_000_000)
11
8
  return `${(n / 1_000_000).toFixed(1)}M`;
@@ -13,16 +10,35 @@ function formatTokens(n) {
13
10
  return `${(n / 1_000).toFixed(1)}k`;
14
11
  return String(n);
15
12
  }
16
- function ThreadStatusBar({ thread, follow }) {
17
- 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 " ";
18
30
  }
19
- function ReportView({ report }) {
20
- 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";
21
37
  }
22
- export function IssueDetailView({ issue, thread, report, follow }) {
38
+ export function IssueDetailView({ issue, timeline, follow, activeRunStartedAt, tokenUsage, diffSummary, plan, }) {
23
39
  if (!issue) {
24
40
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "red", children: "Issue not found." }), _jsx(HelpBar, { view: "detail", follow: follow })] }));
25
41
  }
26
42
  const key = issue.issueKey ?? issue.projectId;
27
- 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..." })), _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 })] }));
28
44
  }
@@ -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
+ }