patchrelay 0.12.9 → 0.13.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.
@@ -0,0 +1,160 @@
1
+ import { useEffect, useRef } from "react";
2
+ export function useDetailStream(options) {
3
+ const optionsRef = useRef(options);
4
+ optionsRef.current = options;
5
+ useEffect(() => {
6
+ const { issueKey } = optionsRef.current;
7
+ if (!issueKey)
8
+ return;
9
+ const abortController = new AbortController();
10
+ const { baseUrl, bearerToken, dispatch } = optionsRef.current;
11
+ const headers = { accept: "application/json" };
12
+ if (bearerToken) {
13
+ headers.authorization = `Bearer ${bearerToken}`;
14
+ }
15
+ // Rehydrate from thread/read via /api/issues/:key/live
16
+ void rehydrate(baseUrl, issueKey, headers, abortController.signal, dispatch);
17
+ // Stream codex notifications via filtered SSE
18
+ void streamCodexEvents(baseUrl, issueKey, headers, abortController.signal, dispatch);
19
+ return () => {
20
+ abortController.abort();
21
+ };
22
+ }, [options.issueKey]);
23
+ }
24
+ async function rehydrate(baseUrl, issueKey, headers, signal, dispatch) {
25
+ try {
26
+ const url = new URL(`/api/issues/${encodeURIComponent(issueKey)}/live`, baseUrl);
27
+ const response = await fetch(url, { headers, signal });
28
+ if (!response.ok)
29
+ return;
30
+ const data = await response.json();
31
+ const threadData = data.thread;
32
+ if (!threadData)
33
+ return;
34
+ const thread = materializeThread(threadData);
35
+ dispatch({ type: "thread-snapshot", thread });
36
+ }
37
+ catch {
38
+ // Rehydration is best-effort — SSE stream will provide updates
39
+ }
40
+ }
41
+ async function streamCodexEvents(baseUrl, issueKey, baseHeaders, signal, dispatch) {
42
+ try {
43
+ const url = new URL("/api/watch", baseUrl);
44
+ url.searchParams.set("issue", issueKey);
45
+ const headers = { ...baseHeaders, accept: "text/event-stream" };
46
+ const response = await fetch(url, { headers, signal });
47
+ if (!response.ok || !response.body)
48
+ return;
49
+ const reader = response.body.getReader();
50
+ const decoder = new TextDecoder();
51
+ let buffer = "";
52
+ let eventType = "";
53
+ let dataLines = [];
54
+ while (true) {
55
+ const { done, value } = await reader.read();
56
+ if (done)
57
+ break;
58
+ buffer += decoder.decode(value, { stream: true });
59
+ let newlineIndex = buffer.indexOf("\n");
60
+ while (newlineIndex !== -1) {
61
+ const rawLine = buffer.slice(0, newlineIndex);
62
+ buffer = buffer.slice(newlineIndex + 1);
63
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
64
+ if (!line) {
65
+ if (dataLines.length > 0) {
66
+ processDetailEvent(dispatch, eventType, dataLines.join("\n"));
67
+ dataLines = [];
68
+ eventType = "";
69
+ }
70
+ newlineIndex = buffer.indexOf("\n");
71
+ continue;
72
+ }
73
+ if (line.startsWith(":")) {
74
+ newlineIndex = buffer.indexOf("\n");
75
+ continue;
76
+ }
77
+ if (line.startsWith("event:")) {
78
+ eventType = line.slice(6).trim();
79
+ }
80
+ else if (line.startsWith("data:")) {
81
+ dataLines.push(line.slice(5).trimStart());
82
+ }
83
+ newlineIndex = buffer.indexOf("\n");
84
+ }
85
+ }
86
+ }
87
+ catch {
88
+ // Stream ended or aborted
89
+ }
90
+ }
91
+ function processDetailEvent(dispatch, eventType, data) {
92
+ try {
93
+ if (eventType === "codex") {
94
+ const parsed = JSON.parse(data);
95
+ dispatch({ type: "codex-notification", method: parsed.method, params: parsed.params });
96
+ }
97
+ // Feed events are already handled by the main watch stream
98
+ }
99
+ catch {
100
+ // Ignore parse errors
101
+ }
102
+ }
103
+ // ─── Thread Materialization from thread/read ──────────────────────
104
+ function materializeThread(summary) {
105
+ return {
106
+ threadId: summary.id,
107
+ status: summary.status,
108
+ turns: summary.turns.map(materializeTurn),
109
+ };
110
+ }
111
+ function materializeTurn(turn) {
112
+ return {
113
+ id: turn.id,
114
+ status: turn.status,
115
+ items: turn.items.map(materializeItem),
116
+ };
117
+ }
118
+ function materializeItem(item) {
119
+ // CodexThreadItem has an index-signature catch-all that defeats narrowing.
120
+ // Access fields via Record<string, unknown> and coerce explicitly.
121
+ const r = item;
122
+ const id = String(r.id ?? "unknown");
123
+ const type = String(r.type ?? "unknown");
124
+ const base = { id, type, status: "completed" };
125
+ switch (type) {
126
+ case "agentMessage":
127
+ return { ...base, text: String(r.text ?? "") };
128
+ case "commandExecution":
129
+ return {
130
+ ...base,
131
+ command: String(r.command ?? ""),
132
+ status: String(r.status ?? "completed"),
133
+ ...(typeof r.exitCode === "number" ? { exitCode: r.exitCode } : {}),
134
+ ...(typeof r.durationMs === "number" ? { durationMs: r.durationMs } : {}),
135
+ ...(typeof r.aggregatedOutput === "string" ? { output: r.aggregatedOutput } : {}),
136
+ };
137
+ case "fileChange":
138
+ return { ...base, status: String(r.status ?? "completed"), changes: Array.isArray(r.changes) ? r.changes : [] };
139
+ case "mcpToolCall":
140
+ return {
141
+ ...base,
142
+ status: String(r.status ?? "completed"),
143
+ toolName: `${String(r.server ?? "")}/${String(r.tool ?? "")}`,
144
+ ...(typeof r.durationMs === "number" ? { durationMs: r.durationMs } : {}),
145
+ };
146
+ case "dynamicToolCall":
147
+ return {
148
+ ...base,
149
+ status: String(r.status ?? "completed"),
150
+ toolName: String(r.tool ?? ""),
151
+ ...(typeof r.durationMs === "number" ? { durationMs: r.durationMs } : {}),
152
+ };
153
+ case "plan":
154
+ return { ...base, text: String(r.text ?? "") };
155
+ case "reasoning":
156
+ return { ...base, text: Array.isArray(r.summary) ? r.summary.join("\n") : "" };
157
+ default:
158
+ return base;
159
+ }
160
+ }
@@ -0,0 +1,102 @@
1
+ import { useEffect, useRef } from "react";
2
+ export function useWatchStream(options) {
3
+ const optionsRef = useRef(options);
4
+ optionsRef.current = options;
5
+ useEffect(() => {
6
+ let abortController = new AbortController();
7
+ let reconnectTimeout;
8
+ let attempt = 0;
9
+ const connect = () => {
10
+ abortController = new AbortController();
11
+ const { baseUrl, bearerToken, issueFilter, dispatch } = optionsRef.current;
12
+ const url = new URL("/api/watch", baseUrl);
13
+ if (issueFilter) {
14
+ url.searchParams.set("issue", issueFilter);
15
+ }
16
+ const headers = { accept: "text/event-stream" };
17
+ if (bearerToken) {
18
+ headers.authorization = `Bearer ${bearerToken}`;
19
+ }
20
+ void fetch(url, { headers, signal: abortController.signal })
21
+ .then(async (response) => {
22
+ if (!response.ok || !response.body) {
23
+ throw new Error(`Watch stream failed: ${response.status}`);
24
+ }
25
+ dispatch({ type: "connected" });
26
+ attempt = 0;
27
+ const reader = response.body.getReader();
28
+ const decoder = new TextDecoder();
29
+ let buffer = "";
30
+ let eventType = "";
31
+ let dataLines = [];
32
+ while (true) {
33
+ const { done, value } = await reader.read();
34
+ if (done)
35
+ break;
36
+ buffer += decoder.decode(value, { stream: true });
37
+ let newlineIndex = buffer.indexOf("\n");
38
+ while (newlineIndex !== -1) {
39
+ const rawLine = buffer.slice(0, newlineIndex);
40
+ buffer = buffer.slice(newlineIndex + 1);
41
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
42
+ if (!line) {
43
+ if (dataLines.length > 0) {
44
+ processEvent(dispatch, eventType, dataLines.join("\n"));
45
+ dataLines = [];
46
+ eventType = "";
47
+ }
48
+ newlineIndex = buffer.indexOf("\n");
49
+ continue;
50
+ }
51
+ if (line.startsWith(":")) {
52
+ newlineIndex = buffer.indexOf("\n");
53
+ continue;
54
+ }
55
+ if (line.startsWith("event:")) {
56
+ eventType = line.slice(6).trim();
57
+ }
58
+ else if (line.startsWith("data:")) {
59
+ dataLines.push(line.slice(5).trimStart());
60
+ }
61
+ newlineIndex = buffer.indexOf("\n");
62
+ }
63
+ }
64
+ })
65
+ .catch((error) => {
66
+ if (abortController.signal.aborted)
67
+ return;
68
+ const _msg = error instanceof Error ? error.message : String(error);
69
+ })
70
+ .finally(() => {
71
+ if (abortController.signal.aborted)
72
+ return;
73
+ dispatch({ type: "disconnected" });
74
+ attempt = Math.min(attempt + 1, 5);
75
+ const delay = Math.min(2000 * Math.pow(2, attempt), 30000);
76
+ reconnectTimeout = setTimeout(connect, delay);
77
+ });
78
+ };
79
+ connect();
80
+ return () => {
81
+ abortController.abort();
82
+ if (reconnectTimeout !== undefined) {
83
+ clearTimeout(reconnectTimeout);
84
+ }
85
+ };
86
+ }, []);
87
+ }
88
+ function processEvent(dispatch, eventType, data) {
89
+ try {
90
+ if (eventType === "issues") {
91
+ const issues = JSON.parse(data);
92
+ dispatch({ type: "issues-snapshot", issues });
93
+ }
94
+ else if (eventType === "feed") {
95
+ const event = JSON.parse(data);
96
+ dispatch({ type: "feed-event", event });
97
+ }
98
+ }
99
+ catch {
100
+ // Ignore parse errors from malformed events
101
+ }
102
+ }
@@ -0,0 +1,261 @@
1
+ export const initialWatchState = {
2
+ connected: false,
3
+ issues: [],
4
+ selectedIndex: 0,
5
+ view: "list",
6
+ activeDetailKey: null,
7
+ thread: null,
8
+ };
9
+ export function watchReducer(state, action) {
10
+ switch (action.type) {
11
+ case "connected":
12
+ return { ...state, connected: true };
13
+ case "disconnected":
14
+ return { ...state, connected: false };
15
+ case "issues-snapshot":
16
+ return {
17
+ ...state,
18
+ issues: action.issues,
19
+ selectedIndex: Math.min(state.selectedIndex, Math.max(0, action.issues.length - 1)),
20
+ };
21
+ case "feed-event":
22
+ return applyFeedEvent(state, action.event);
23
+ case "select":
24
+ return {
25
+ ...state,
26
+ selectedIndex: Math.max(0, Math.min(action.index, state.issues.length - 1)),
27
+ };
28
+ case "enter-detail":
29
+ return { ...state, view: "detail", activeDetailKey: action.issueKey, thread: null };
30
+ case "exit-detail":
31
+ return { ...state, view: "list", activeDetailKey: null, thread: null };
32
+ case "thread-snapshot":
33
+ return { ...state, thread: action.thread };
34
+ case "codex-notification":
35
+ return applyCodexNotification(state, action.method, action.params);
36
+ }
37
+ }
38
+ // ─── Feed Event Application ───────────────────────────────────────
39
+ function applyFeedEvent(state, event) {
40
+ if (!event.issueKey) {
41
+ return state;
42
+ }
43
+ const index = state.issues.findIndex((issue) => issue.issueKey === event.issueKey);
44
+ if (index === -1) {
45
+ return state;
46
+ }
47
+ const updated = [...state.issues];
48
+ const issue = { ...updated[index] };
49
+ if (event.kind === "stage" && event.stage) {
50
+ issue.factoryState = event.stage;
51
+ }
52
+ if (event.kind === "stage" && event.status === "starting" && event.stage) {
53
+ issue.activeRunType = event.stage;
54
+ }
55
+ if (event.kind === "turn") {
56
+ if (event.status === "completed" || event.status === "failed") {
57
+ issue.activeRunType = undefined;
58
+ issue.latestRunStatus = event.status;
59
+ }
60
+ }
61
+ if (event.kind === "github" && event.status) {
62
+ if (event.status === "check_passed" || event.status === "check_failed") {
63
+ issue.prCheckStatus = event.status === "check_passed" ? "passed" : "failed";
64
+ }
65
+ }
66
+ issue.updatedAt = event.at;
67
+ updated[index] = issue;
68
+ return { ...state, issues: updated };
69
+ }
70
+ // ─── Codex Notification Application ───────────────────────────────
71
+ function applyCodexNotification(state, method, params) {
72
+ if (!state.thread) {
73
+ // No thread loaded yet — only turn/started can bootstrap one
74
+ if (method === "turn/started") {
75
+ return bootstrapThreadFromTurnStarted(state, params);
76
+ }
77
+ return state;
78
+ }
79
+ switch (method) {
80
+ case "turn/started":
81
+ return withThread(state, addTurn(state.thread, params));
82
+ case "turn/completed":
83
+ return withThread(state, completeTurn(state.thread, params));
84
+ case "turn/plan/updated":
85
+ return withThread(state, updatePlan(state.thread, params));
86
+ case "turn/diff/updated":
87
+ return withThread(state, updateDiff(state.thread, params));
88
+ case "item/started":
89
+ return withThread(state, addItem(state.thread, params));
90
+ case "item/completed":
91
+ return withThread(state, completeItem(state.thread, params));
92
+ case "item/agentMessage/delta":
93
+ return withThread(state, appendItemText(state.thread, params));
94
+ case "item/commandExecution/outputDelta":
95
+ return withThread(state, appendItemOutput(state.thread, params));
96
+ case "item/plan/delta":
97
+ return withThread(state, appendItemText(state.thread, params));
98
+ case "item/reasoning/summaryTextDelta":
99
+ return withThread(state, appendItemText(state.thread, params));
100
+ case "thread/status/changed":
101
+ return withThread(state, updateThreadStatus(state.thread, params));
102
+ default:
103
+ return state;
104
+ }
105
+ }
106
+ function withThread(state, thread) {
107
+ return { ...state, thread };
108
+ }
109
+ function bootstrapThreadFromTurnStarted(state, params) {
110
+ const turnObj = params.turn;
111
+ const threadId = typeof params.threadId === "string" ? params.threadId : "unknown";
112
+ const turnId = typeof turnObj?.id === "string" ? turnObj.id : "unknown";
113
+ return {
114
+ ...state,
115
+ thread: {
116
+ threadId,
117
+ status: "active",
118
+ turns: [{ id: turnId, status: "inProgress", items: [] }],
119
+ },
120
+ };
121
+ }
122
+ // ─── Turn Handlers ────────────────────────────────────────────────
123
+ function addTurn(thread, params) {
124
+ const turnObj = params.turn;
125
+ const turnId = typeof turnObj?.id === "string" ? turnObj.id : "unknown";
126
+ const existing = thread.turns.find((t) => t.id === turnId);
127
+ if (existing) {
128
+ return thread;
129
+ }
130
+ return {
131
+ ...thread,
132
+ status: "active",
133
+ turns: [...thread.turns, { id: turnId, status: "inProgress", items: [] }],
134
+ };
135
+ }
136
+ function completeTurn(thread, params) {
137
+ const turnObj = params.turn;
138
+ const turnId = typeof turnObj?.id === "string" ? turnObj.id : undefined;
139
+ const status = typeof turnObj?.status === "string" ? turnObj.status : "completed";
140
+ if (!turnId)
141
+ return thread;
142
+ return {
143
+ ...thread,
144
+ turns: thread.turns.map((t) => t.id === turnId ? { ...t, status } : t),
145
+ };
146
+ }
147
+ function updatePlan(thread, params) {
148
+ const plan = params.plan;
149
+ if (!Array.isArray(plan))
150
+ return thread;
151
+ return {
152
+ ...thread,
153
+ plan: plan.map((entry) => {
154
+ const e = entry;
155
+ return {
156
+ step: typeof e.step === "string" ? e.step : String(e.step ?? ""),
157
+ status: typeof e.status === "string" ? e.status : "pending",
158
+ };
159
+ }),
160
+ };
161
+ }
162
+ function updateDiff(thread, params) {
163
+ const diff = typeof params.diff === "string" ? params.diff : undefined;
164
+ return { ...thread, diff };
165
+ }
166
+ function updateThreadStatus(thread, params) {
167
+ const statusObj = params.status;
168
+ const statusType = typeof statusObj?.type === "string" ? statusObj.type : undefined;
169
+ if (!statusType)
170
+ return thread;
171
+ return { ...thread, status: statusType };
172
+ }
173
+ // ─── Item Handlers ────────────────────────────────────────────────
174
+ function getLatestTurn(thread) {
175
+ return thread.turns[thread.turns.length - 1];
176
+ }
177
+ function updateLatestTurn(thread, updater) {
178
+ const last = getLatestTurn(thread);
179
+ if (!last)
180
+ return thread;
181
+ return {
182
+ ...thread,
183
+ turns: [...thread.turns.slice(0, -1), updater(last)],
184
+ };
185
+ }
186
+ function addItem(thread, params) {
187
+ const itemObj = params.item;
188
+ if (!itemObj)
189
+ return thread;
190
+ const id = typeof itemObj.id === "string" ? itemObj.id : "unknown";
191
+ const type = typeof itemObj.type === "string" ? itemObj.type : "unknown";
192
+ const status = typeof itemObj.status === "string" ? itemObj.status : "inProgress";
193
+ const item = { id, type, status };
194
+ if (type === "agentMessage" && typeof itemObj.text === "string") {
195
+ item.text = itemObj.text;
196
+ }
197
+ if (type === "commandExecution") {
198
+ const cmd = itemObj.command;
199
+ item.command = Array.isArray(cmd) ? cmd.join(" ") : typeof cmd === "string" ? cmd : undefined;
200
+ }
201
+ if (type === "mcpToolCall") {
202
+ const server = typeof itemObj.server === "string" ? itemObj.server : "";
203
+ const tool = typeof itemObj.tool === "string" ? itemObj.tool : "";
204
+ item.toolName = `${server}/${tool}`;
205
+ }
206
+ if (type === "dynamicToolCall") {
207
+ item.toolName = typeof itemObj.tool === "string" ? itemObj.tool : undefined;
208
+ }
209
+ return updateLatestTurn(thread, (turn) => ({
210
+ ...turn,
211
+ items: [...turn.items, item],
212
+ }));
213
+ }
214
+ function completeItem(thread, params) {
215
+ const itemObj = params.item;
216
+ if (!itemObj)
217
+ return thread;
218
+ const id = typeof itemObj.id === "string" ? itemObj.id : undefined;
219
+ if (!id)
220
+ return thread;
221
+ const status = typeof itemObj.status === "string" ? itemObj.status : "completed";
222
+ const exitCode = typeof itemObj.exitCode === "number" ? itemObj.exitCode : undefined;
223
+ const durationMs = typeof itemObj.durationMs === "number" ? itemObj.durationMs : undefined;
224
+ const text = typeof itemObj.text === "string" ? itemObj.text : undefined;
225
+ const changes = Array.isArray(itemObj.changes) ? itemObj.changes : undefined;
226
+ return updateLatestTurn(thread, (turn) => ({
227
+ ...turn,
228
+ items: turn.items.map((item) => {
229
+ if (item.id !== id)
230
+ return item;
231
+ return {
232
+ ...item,
233
+ status,
234
+ ...(exitCode !== undefined ? { exitCode } : {}),
235
+ ...(durationMs !== undefined ? { durationMs } : {}),
236
+ ...(text !== undefined ? { text } : {}),
237
+ ...(changes !== undefined ? { changes } : {}),
238
+ };
239
+ }),
240
+ }));
241
+ }
242
+ function appendItemText(thread, params) {
243
+ const itemId = typeof params.itemId === "string" ? params.itemId : undefined;
244
+ const delta = typeof params.delta === "string" ? params.delta : undefined;
245
+ if (!itemId || !delta)
246
+ return thread;
247
+ return updateLatestTurn(thread, (turn) => ({
248
+ ...turn,
249
+ items: turn.items.map((item) => item.id === itemId ? { ...item, text: (item.text ?? "") + delta } : item),
250
+ }));
251
+ }
252
+ function appendItemOutput(thread, params) {
253
+ const itemId = typeof params.itemId === "string" ? params.itemId : undefined;
254
+ const delta = typeof params.delta === "string" ? params.delta : undefined;
255
+ if (!itemId || !delta)
256
+ return thread;
257
+ return updateLatestTurn(thread, (turn) => ({
258
+ ...turn,
259
+ items: turn.items.map((item) => item.id === itemId ? { ...item, output: (item.output ?? "") + delta } : item),
260
+ }));
261
+ }
@@ -141,11 +141,8 @@ export class CodexAppServerClient extends EventEmitter {
141
141
  modelProvider: this.config.modelProvider ?? null,
142
142
  baseInstructions: this.config.baseInstructions ?? null,
143
143
  developerInstructions: this.config.developerInstructions ?? null,
144
- experimentalRawEvents: false,
144
+ experimentalRawEvents: this.config.experimentalRawEvents ?? false,
145
145
  };
146
- if (this.config.persistExtendedHistory) {
147
- this.logger.warn("persistExtendedHistory is requested but not enabled in the active app-server capability handshake; ignoring");
148
- }
149
146
  const response = (await this.sendRequest("thread/start", params));
150
147
  return this.mapThread(response.thread);
151
148
  }
package/dist/config.js CHANGED
@@ -95,6 +95,7 @@ const configSchema = z.object({
95
95
  approval_policy: z.enum(["never", "on-request", "on-failure", "untrusted"]).default("never"),
96
96
  sandbox_mode: z.enum(["danger-full-access", "workspace-write", "read-only"]).default("danger-full-access"),
97
97
  persist_extended_history: z.boolean().default(false),
98
+ experimental_raw_events: z.boolean().default(false),
98
99
  }),
99
100
  }),
100
101
  projects: z.array(projectSchema).default([]),
@@ -369,6 +370,7 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
369
370
  approvalPolicy: parsed.runner.codex.approval_policy,
370
371
  sandboxMode: parsed.runner.codex.sandbox_mode,
371
372
  persistExtendedHistory: parsed.runner.codex.persist_extended_history,
373
+ experimentalRawEvents: parsed.runner.codex.experimental_raw_events,
372
374
  },
373
375
  },
374
376
  projects: parsed.projects.map((project) => {
@@ -1,5 +1,8 @@
1
1
  import { resolveFactoryStateFromGitHub } from "./factory-state.js";
2
2
  import { normalizeGitHubWebhook, verifyGitHubWebhookSignature } from "./github-webhooks.js";
3
+ import { buildAgentSessionPlanForIssue } from "./agent-session-plan.js";
4
+ import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
5
+ import { buildGitHubStateActivity } from "./linear-session-reporting.js";
3
6
  import { resolveSecret } from "./resolve-secret.js";
4
7
  import { safeJsonParse } from "./utils.js";
5
8
  /**
@@ -137,8 +140,9 @@ export class GitHubWebhookHandler {
137
140
  factoryState: newState,
138
141
  });
139
142
  this.logger.info({ issueKey: issue.issueKey, from: afterMetadata.factoryState, to: newState, trigger: event.triggerEvent }, "Factory state transition from GitHub event");
140
- // Emit Linear activity for significant state changes
141
- void this.emitLinearActivity(issue, newState, event);
143
+ const transitionedIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
144
+ void this.emitLinearActivity(transitionedIssue, newState, event);
145
+ void this.syncLinearSession(transitionedIssue);
142
146
  // Schedule merge prep when entering awaiting_queue
143
147
  if (newState === "awaiting_queue") {
144
148
  this.db.upsertIssue({
@@ -230,24 +234,14 @@ export class GitHubWebhookHandler {
230
234
  const linear = await this.linearProvider.forProject(issue.projectId);
231
235
  if (!linear?.createAgentActivity)
232
236
  return;
233
- const messages = {
234
- pr_open: `PR #${event.prNumber ?? ""} opened.${event.prUrl ? ` ${event.prUrl}` : ""}`,
235
- awaiting_queue: "PR approved. Preparing merge.",
236
- changes_requested: `Review requested changes.${event.reviewerName ? ` Reviewer: ${event.reviewerName}` : ""}`,
237
- repairing_ci: `CI check failed${event.checkName ? `: ${event.checkName}` : ""}. Starting repair.`,
238
- repairing_queue: "Merge conflict with base branch. Starting repair.",
239
- done: `PR merged and deployed.${event.prNumber ? ` PR #${event.prNumber}` : ""}`,
240
- failed: "PR was closed without merging.",
241
- };
242
- const body = messages[newState];
243
- if (!body)
237
+ const content = buildGitHubStateActivity(issue.factoryState, event);
238
+ if (!content)
244
239
  return;
245
- const type = newState === "failed" || newState === "repairing_ci" || newState === "repairing_queue"
246
- ? "error"
247
- : "response";
240
+ const allowEphemeral = content.type === "thought" || content.type === "action";
248
241
  await linear.createAgentActivity({
249
242
  agentSessionId: issue.agentSessionId,
250
- content: { type, body },
243
+ content,
244
+ ...(allowEphemeral ? { ephemeral: false } : {}),
251
245
  });
252
246
  }
253
247
  catch (error) {
@@ -263,4 +257,26 @@ export class GitHubWebhookHandler {
263
257
  });
264
258
  }
265
259
  }
260
+ async syncLinearSession(issue) {
261
+ if (!issue.agentSessionId)
262
+ return;
263
+ try {
264
+ const linear = await this.linearProvider.forProject(issue.projectId);
265
+ if (!linear?.updateAgentSession)
266
+ return;
267
+ const externalUrls = buildAgentSessionExternalUrls(this.config, {
268
+ ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
269
+ ...(issue.prUrl ? { prUrl: issue.prUrl } : {}),
270
+ });
271
+ await linear.updateAgentSession({
272
+ agentSessionId: issue.agentSessionId,
273
+ plan: buildAgentSessionPlanForIssue(issue),
274
+ ...(externalUrls ? { externalUrls } : {}),
275
+ });
276
+ }
277
+ catch (error) {
278
+ const msg = error instanceof Error ? error.message : String(error);
279
+ this.logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to sync Linear session from GitHub webhook");
280
+ }
281
+ }
266
282
  }