patchrelay 0.36.18 → 0.37.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.
@@ -2,6 +2,7 @@ import { getThreadTurns } from "../../codex-thread-utils.js";
2
2
  // ─── Build Timeline from Rehydration Data ─────────────────────────
3
3
  export function buildTimelineFromRehydration(runs, feedEvents, liveThread, activeRunId) {
4
4
  const entries = [];
5
+ const activeRun = activeRunId ? runs.find((run) => run.id === activeRunId) : undefined;
5
6
  // 1. Add run boundaries and items from reports
6
7
  for (const run of runs) {
7
8
  entries.push({
@@ -32,22 +33,28 @@ export function buildTimelineFromRehydration(runs, feedEvents, liveThread, activ
32
33
  }
33
34
  // 2. Items from live thread (active run)
34
35
  if (liveThread && activeRunId) {
35
- entries.push(...itemsFromThread(activeRunId, liveThread));
36
+ entries.push(...itemsFromThread(activeRunId, liveThread, activeRun?.startedAt));
36
37
  }
37
38
  // 3. Feed events → feed entries + CI check aggregation
38
39
  entries.push(...feedEventsToEntries(feedEvents));
39
40
  // 4. Sort by timestamp, then by entry order for stability
40
- entries.sort((a, b) => {
41
- const cmp = a.at.localeCompare(b.at);
42
- if (cmp !== 0)
43
- return cmp;
44
- // Within same timestamp: run-start before items, items before run-end
45
- const kindCmp = kindOrder(a.kind) - kindOrder(b.kind);
46
- if (kindCmp !== 0)
47
- return kindCmp;
48
- return a.id.localeCompare(b.id);
41
+ return sortTimelineEntries(entries);
42
+ }
43
+ export function reconcileTimelineFromRehydration(previousTimeline, runs, feedEvents, liveThread, activeRunId) {
44
+ const rehydrated = buildTimelineFromRehydration(runs, feedEvents, liveThread, activeRunId);
45
+ if (previousTimeline.length === 0) {
46
+ return rehydrated;
47
+ }
48
+ const previousById = new Map(previousTimeline.map((entry) => [entry.id, entry]));
49
+ const rehydratedIds = new Set(rehydrated.map((entry) => entry.id));
50
+ const liveUserMessages = collectUserMessageCounts(rehydrated, activeRunId);
51
+ const merged = rehydrated.map((entry) => mergeTimelineEntry(previousById.get(entry.id), entry));
52
+ const carriedForward = previousTimeline.filter((entry) => {
53
+ if (rehydratedIds.has(entry.id))
54
+ return false;
55
+ return shouldCarryForwardEntry(entry, activeRunId, liveUserMessages);
49
56
  });
50
- return entries;
57
+ return sortTimelineEntries([...merged, ...carriedForward]);
51
58
  }
52
59
  function kindOrder(kind) {
53
60
  switch (kind) {
@@ -131,27 +138,37 @@ function syntheticTimestamp(startMs, endMs, index, total) {
131
138
  return new Date(startMs + fraction * (endMs - startMs)).toISOString();
132
139
  }
133
140
  // ─── Items from Live Thread ───────────────────────────────────────
134
- function itemsFromThread(runId, thread) {
141
+ function itemsFromThread(runId, thread, runStartedAt) {
135
142
  const entries = [];
143
+ let itemIndex = 0;
136
144
  for (const turn of getThreadTurns(thread)) {
137
145
  for (const item of turn.items) {
138
146
  entries.push({
139
147
  id: `live-${item.id}`,
140
- at: new Date().toISOString(), // live items don't have timestamps; they'll sort to the end
148
+ at: liveItemTimestamp(runStartedAt, itemIndex),
141
149
  kind: "item",
142
150
  runId,
143
151
  item: materializeItem(item),
144
152
  });
153
+ itemIndex += 1;
145
154
  }
146
155
  }
147
156
  return entries;
148
157
  }
158
+ const LIVE_ITEM_FALLBACK_START_MS = Date.UTC(9999, 0, 1, 0, 0, 0, 0);
159
+ function liveItemTimestamp(runStartedAt, itemIndex) {
160
+ const baseMs = runStartedAt ? Date.parse(runStartedAt) : LIVE_ITEM_FALLBACK_START_MS;
161
+ const stableBaseMs = Number.isFinite(baseMs) ? baseMs : LIVE_ITEM_FALLBACK_START_MS;
162
+ return new Date(stableBaseMs + itemIndex).toISOString();
163
+ }
149
164
  function materializeItem(item) {
150
165
  const r = item;
151
166
  const id = String(r.id ?? "unknown");
152
167
  const type = String(r.type ?? "unknown");
153
168
  const base = { id, type, status: "completed" };
154
169
  switch (type) {
170
+ case "userMessage":
171
+ return { ...base, text: extractUserMessageText(r.content) };
155
172
  case "agentMessage":
156
173
  return { ...base, text: String(r.text ?? "") };
157
174
  case "commandExecution":
@@ -340,16 +357,150 @@ function mergeDefinedItemFields(base, patch) {
340
357
  ...base,
341
358
  id: patch.id,
342
359
  type: patch.type,
343
- status: patch.status,
344
- ...(patch.text !== undefined ? { text: patch.text } : {}),
360
+ status: preferredItemStatus(base.status, patch.status),
361
+ ...(mergePreferredString(base.text, patch.text) !== undefined ? { text: mergePreferredString(base.text, patch.text) } : {}),
345
362
  ...(patch.command !== undefined ? { command: patch.command } : {}),
346
- ...(patch.output !== undefined ? { output: patch.output } : {}),
363
+ ...(mergePreferredString(base.output, patch.output) !== undefined ? { output: mergePreferredString(base.output, patch.output) } : {}),
347
364
  ...(patch.exitCode !== undefined ? { exitCode: patch.exitCode } : {}),
348
- ...(patch.durationMs !== undefined ? { durationMs: patch.durationMs } : {}),
349
- ...(patch.changes !== undefined ? { changes: patch.changes } : {}),
365
+ ...(patch.durationMs !== undefined || base.durationMs !== undefined
366
+ ? { durationMs: preferredNumber(base.durationMs, patch.durationMs) }
367
+ : {}),
368
+ ...(patch.changes !== undefined || base.changes !== undefined
369
+ ? { changes: preferredChanges(base.changes, patch.changes) }
370
+ : {}),
350
371
  ...(patch.toolName !== undefined ? { toolName: patch.toolName } : {}),
351
372
  };
352
373
  }
374
+ function mergeTimelineEntry(existing, incoming) {
375
+ if (!existing || existing.kind !== incoming.kind) {
376
+ return incoming;
377
+ }
378
+ switch (incoming.kind) {
379
+ case "item":
380
+ return {
381
+ ...incoming,
382
+ at: existing.at,
383
+ ...(existing.runId !== undefined && incoming.runId === undefined ? { runId: existing.runId } : {}),
384
+ item: incoming.item && existing.item ? mergeDefinedItemFields(existing.item, incoming.item) : incoming.item,
385
+ };
386
+ case "run-start":
387
+ case "run-end":
388
+ return {
389
+ ...incoming,
390
+ at: existing.at,
391
+ run: existing.run && incoming.run ? { ...existing.run, ...incoming.run } : incoming.run,
392
+ };
393
+ case "feed":
394
+ return {
395
+ ...incoming,
396
+ at: existing.at,
397
+ feed: existing.feed && incoming.feed ? { ...existing.feed, ...incoming.feed } : incoming.feed,
398
+ };
399
+ case "ci-checks":
400
+ return {
401
+ ...incoming,
402
+ at: existing.at,
403
+ ciChecks: incoming.ciChecks ?? existing.ciChecks,
404
+ };
405
+ }
406
+ }
407
+ function shouldCarryForwardEntry(entry, activeRunId, liveUserMessages) {
408
+ if (entry.kind !== "item" || entry.runId !== activeRunId) {
409
+ return false;
410
+ }
411
+ if (entry.item?.id.startsWith("prompt-") === true) {
412
+ const text = normalizePromptText(entry.item.text);
413
+ return !text || !consumeUserMessageMatch(liveUserMessages, text);
414
+ }
415
+ return entry.item?.status === "inProgress";
416
+ }
417
+ function sortTimelineEntries(entries) {
418
+ return [...entries].sort((a, b) => {
419
+ const cmp = a.at.localeCompare(b.at);
420
+ if (cmp !== 0)
421
+ return cmp;
422
+ // Within same timestamp: run-start before items, items before run-end
423
+ const kindCmp = kindOrder(a.kind) - kindOrder(b.kind);
424
+ if (kindCmp !== 0)
425
+ return kindCmp;
426
+ return a.id.localeCompare(b.id);
427
+ });
428
+ }
429
+ function preferredItemStatus(existing, incoming) {
430
+ return itemStatusRank(incoming) >= itemStatusRank(existing) ? incoming : existing;
431
+ }
432
+ function itemStatusRank(status) {
433
+ switch (status) {
434
+ case "failed":
435
+ case "completed":
436
+ case "declined":
437
+ return 2;
438
+ case "inProgress":
439
+ return 1;
440
+ default:
441
+ return 0;
442
+ }
443
+ }
444
+ function mergePreferredString(existing, incoming) {
445
+ if (incoming === undefined)
446
+ return existing;
447
+ if (existing === undefined)
448
+ return incoming;
449
+ return incoming.length >= existing.length ? incoming : existing;
450
+ }
451
+ function preferredNumber(existing, incoming) {
452
+ return incoming ?? existing;
453
+ }
454
+ function preferredChanges(existing, incoming) {
455
+ if (incoming === undefined)
456
+ return existing;
457
+ if (existing === undefined)
458
+ return incoming;
459
+ return incoming.length >= existing.length ? incoming : existing;
460
+ }
461
+ function collectUserMessageCounts(entries, activeRunId) {
462
+ const texts = new Map();
463
+ for (const entry of entries) {
464
+ if (entry.kind !== "item" || entry.runId !== activeRunId || entry.item?.type !== "userMessage") {
465
+ continue;
466
+ }
467
+ const text = normalizePromptText(entry.item.text);
468
+ if (text) {
469
+ texts.set(text, (texts.get(text) ?? 0) + 1);
470
+ }
471
+ }
472
+ return texts;
473
+ }
474
+ function consumeUserMessageMatch(messages, text) {
475
+ const count = messages.get(text) ?? 0;
476
+ if (count <= 0) {
477
+ return false;
478
+ }
479
+ if (count === 1) {
480
+ messages.delete(text);
481
+ }
482
+ else {
483
+ messages.set(text, count - 1);
484
+ }
485
+ return true;
486
+ }
487
+ function normalizePromptText(text) {
488
+ const normalized = text?.trim();
489
+ return normalized && normalized.length > 0 ? normalized : null;
490
+ }
491
+ function extractUserMessageText(content) {
492
+ if (!Array.isArray(content))
493
+ return "";
494
+ return content
495
+ .map((entry) => {
496
+ if (!entry || typeof entry !== "object")
497
+ return undefined;
498
+ const value = entry.text;
499
+ return typeof value === "string" ? value : undefined;
500
+ })
501
+ .filter((value) => Boolean(value))
502
+ .join("\n\n");
503
+ }
353
504
  // ─── Feed Events to Timeline Entries ──────────────────────────────
354
505
  function feedEventsToEntries(feedEvents) {
355
506
  const entries = [];
@@ -76,6 +76,7 @@ function buildCompactTimelineRows(entries) {
76
76
  }
77
77
  for (const run of runs.values()) {
78
78
  const status = resolveCompactRunStatus(run.run, run.items);
79
+ const verboseItems = status === "running" ? selectVerboseItems(run.items) : run.items;
79
80
  rows.push({
80
81
  id: run.id,
81
82
  kind: "run",
@@ -83,7 +84,7 @@ function buildCompactTimelineRows(entries) {
83
84
  finalized: status !== "running",
84
85
  run: { ...run.run, status, ...(run.endedAt ? { endedAt: run.endedAt } : {}) },
85
86
  details: summarizeRunDetails(run.items),
86
- items: run.items.map((item) => ({ at: run.at, item })),
87
+ items: verboseItems.map((item) => ({ at: run.at, item })),
87
88
  });
88
89
  }
89
90
  rows.sort((left, right) => {
@@ -167,6 +168,25 @@ function summarizeRunDetails(items) {
167
168
  }
168
169
  return dedupeDetails(details).slice(0, 3);
169
170
  }
171
+ function selectVerboseItems(items) {
172
+ const latestAssistantMessage = findLatest(items, (item) => item.type === "agentMessage" && Boolean(item.text?.trim()));
173
+ const latestUserMessage = !latestAssistantMessage
174
+ ? findLatest(items, (item) => item.type === "userMessage" && Boolean(item.text?.trim()))
175
+ : undefined;
176
+ const activeCommand = findLatest(items, (item) => item.type === "commandExecution" && item.status === "inProgress");
177
+ const latestCommandWithOutput = findLatest(items, (item) => item.type === "commandExecution" && Boolean(item.output?.trim()));
178
+ const latestCommand = activeCommand
179
+ ?? latestCommandWithOutput
180
+ ?? findLatest(items, (item) => item.type === "commandExecution" && Boolean(item.command?.trim()));
181
+ const latestFileChange = findLatest(items, (item) => item.type === "fileChange" && Array.isArray(item.changes) && item.changes.length > 0);
182
+ const latestToolCall = !latestFileChange
183
+ ? findLatest(items, (item) => item.type === "mcpToolCall" || item.type === "dynamicToolCall")
184
+ : undefined;
185
+ const selectedIds = new Set([latestUserMessage, latestAssistantMessage, latestCommand, latestFileChange, latestToolCall]
186
+ .filter((item) => Boolean(item))
187
+ .map((item) => item.id));
188
+ return items.filter((item) => selectedIds.has(item.id));
189
+ }
170
190
  function summarizeNarrative(input) {
171
191
  const normalized = input
172
192
  .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
@@ -0,0 +1,28 @@
1
+ export const defaultTimerApi = {
2
+ setTimeout(callback, delayMs) {
3
+ return setTimeout(callback, delayMs);
4
+ },
5
+ clearTimeout(timer) {
6
+ clearTimeout(timer);
7
+ },
8
+ };
9
+ export function showTransientStatus(controller, message, setStatus, timers, delayMs = 3_000) {
10
+ setStatus(message);
11
+ if (controller.timer !== null) {
12
+ timers.clearTimeout(controller.timer);
13
+ }
14
+ controller.timer = timers.setTimeout(() => {
15
+ controller.timer = null;
16
+ setStatus(null);
17
+ }, delayMs);
18
+ }
19
+ export function setPersistentStatus(controller, message, setStatus, timers) {
20
+ clearTransientStatus(controller, timers);
21
+ setStatus(message);
22
+ }
23
+ export function clearTransientStatus(controller, timers) {
24
+ if (controller.timer !== null) {
25
+ timers.clearTimeout(controller.timer);
26
+ controller.timer = null;
27
+ }
28
+ }
@@ -0,0 +1,76 @@
1
+ import { mkdtempSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { buildDetailLines } from "./detail-rows.js";
6
+ import { lineToPlainText } from "./render-rich-text.js";
7
+ export function findLastAssistantMessage(timeline) {
8
+ return findLastItemField(timeline, (entry) => entry.item?.type === "agentMessage", "text");
9
+ }
10
+ export function findLastCommand(timeline) {
11
+ return findLastItemField(timeline, (entry) => entry.item?.type === "commandExecution", "command");
12
+ }
13
+ export function findLastCommandOutput(timeline) {
14
+ return findLastItemField(timeline, (entry) => entry.item?.type === "commandExecution" && Boolean(entry.item?.output?.trim()), "output");
15
+ }
16
+ export function buildWatchDetailExportText(input) {
17
+ const lines = buildDetailLines({
18
+ ...input,
19
+ width: input.width ?? 100,
20
+ });
21
+ return `${lines.map(lineToPlainText).join("\n").trimEnd()}\n`;
22
+ }
23
+ export function writeTextToClipboard(text, stream = process.stderr) {
24
+ if (!stream.isTTY || text.length === 0) {
25
+ return false;
26
+ }
27
+ const encoded = Buffer.from(text, "utf8").toString("base64");
28
+ stream.write(`\u001b]52;c;${encoded}\u0007`);
29
+ return true;
30
+ }
31
+ export function exportWatchTextToTempFile(text, issueKey) {
32
+ const directory = mkdtempSync(join(tmpdir(), "patchrelay-watch-"));
33
+ const safeIssueKey = issueKey.replace(/[^a-zA-Z0-9._-]+/g, "-");
34
+ const filePath = join(directory, `${safeIssueKey || "issue"}-transcript.txt`);
35
+ writeFileSync(filePath, text, "utf8");
36
+ return filePath;
37
+ }
38
+ export function openTextInPager(text, stream = process.stderr) {
39
+ if (!stream.isTTY) {
40
+ return { ok: false, reason: "interactive TTY required" };
41
+ }
42
+ const streamWithFd = stream;
43
+ if (typeof streamWithFd.fd !== "number") {
44
+ return { ok: false, reason: "TTY stream fd unavailable" };
45
+ }
46
+ const pagerCommand = process.env.PAGER?.trim() || "less -R";
47
+ stream.write("\u001b[?1049l");
48
+ try {
49
+ const result = spawnSync("/bin/sh", ["-lc", pagerCommand], {
50
+ input: text,
51
+ stdio: ["pipe", streamWithFd.fd, streamWithFd.fd],
52
+ });
53
+ if (result.error) {
54
+ return { ok: false, reason: result.error.message };
55
+ }
56
+ if (typeof result.status === "number" && result.status !== 0) {
57
+ return { ok: false, reason: `${pagerCommand} exited with status ${result.status}` };
58
+ }
59
+ return { ok: true };
60
+ }
61
+ finally {
62
+ stream.write("\u001b[?1049h\u001b[2J\u001b[H");
63
+ }
64
+ }
65
+ function findLastItemField(timeline, predicate, field) {
66
+ for (let index = timeline.length - 1; index >= 0; index -= 1) {
67
+ const entry = timeline[index];
68
+ if (!predicate(entry))
69
+ continue;
70
+ const value = entry.item?.[field];
71
+ if (typeof value === "string" && value.trim().length > 0) {
72
+ return value;
73
+ }
74
+ }
75
+ return null;
76
+ }
@@ -1,8 +1,7 @@
1
- import { buildTimelineFromRehydration, appendFeedToTimeline, appendCodexItemToTimeline, completeCodexItemInTimeline, appendDeltaToTimelineItem, } from "./timeline-builder.js";
1
+ import { reconcileTimelineFromRehydration, appendFeedToTimeline, appendCodexItemToTimeline, completeCodexItemInTimeline, appendDeltaToTimelineItem, } from "./timeline-builder.js";
2
2
  // ─── Array size caps (prevent OOM) ───────────────────────────────
3
3
  const MAX_TIMELINE_ENTRIES = 2000;
4
4
  const MAX_RAW_FEED_EVENTS = 2000;
5
- const MAX_FEED_EVENTS = 1000;
6
5
  function capArray(arr, max) {
7
6
  return arr.length > max ? arr.slice(arr.length - max) : arr;
8
7
  }
@@ -32,7 +31,6 @@ export const initialWatchState = {
32
31
  filter: "non-done",
33
32
  follow: true,
34
33
  ...DETAIL_INITIAL,
35
- feedEvents: [],
36
34
  };
37
35
  const TERMINAL_FACTORY_STATES = new Set(["done", "failed"]);
38
36
  function effectiveSessionState(issue) {
@@ -216,7 +214,7 @@ export function watchReducer(state, action) {
216
214
  };
217
215
  }
218
216
  case "timeline-rehydrate": {
219
- const timeline = buildTimelineFromRehydration(action.runs, action.feedEvents, action.liveThread, action.activeRunId);
217
+ const timeline = reconcileTimelineFromRehydration(state.timeline, action.runs, action.feedEvents, action.liveThread, action.activeRunId);
220
218
  const activeRun = action.runs.find((r) => r.id === action.activeRunId);
221
219
  return {
222
220
  ...state,
@@ -243,14 +241,6 @@ export function watchReducer(state, action) {
243
241
  ...state,
244
242
  ...detailStateForPosition(state, maxDetailScrollOffset(state.detailContentRows, state.detailViewportRows), true),
245
243
  };
246
- case "enter-feed":
247
- return { ...state, view: "feed", activeDetailKey: null, ...DETAIL_INITIAL };
248
- case "exit-feed":
249
- return { ...state, view: "list" };
250
- case "feed-snapshot":
251
- return { ...state, feedEvents: action.events };
252
- case "feed-new-event":
253
- return { ...state, feedEvents: capArray([...state.feedEvents, action.event], MAX_FEED_EVENTS) };
254
244
  case "switch-detail-tab":
255
245
  return { ...state, follow: true, ...DETAIL_INITIAL, detailTab: action.tab };
256
246
  default:
@@ -0,0 +1,109 @@
1
+ import { buildAgentSessionPlanForIssue } from "./agent-session-plan.js";
2
+ import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
3
+ export class LinearAgentSessionClient {
4
+ config;
5
+ db;
6
+ linearProvider;
7
+ logger;
8
+ feed;
9
+ constructor(config, db, linearProvider, logger, feed) {
10
+ this.config = config;
11
+ this.db = db;
12
+ this.linearProvider = linearProvider;
13
+ this.logger = logger;
14
+ this.feed = feed;
15
+ }
16
+ ensureAgentSessionIssue(issue) {
17
+ if (issue.agentSessionId) {
18
+ return issue;
19
+ }
20
+ const recoveredAgentSessionId = this.db.webhookEvents.findLatestAgentSessionIdForIssue(issue.linearIssueId);
21
+ if (!recoveredAgentSessionId)
22
+ return issue;
23
+ this.logger.info({ issueKey: issue.issueKey, agentSessionId: recoveredAgentSessionId }, "Recovered missing Linear agent session id from webhook history");
24
+ return this.db.issues.upsertIssue({
25
+ projectId: issue.projectId,
26
+ linearIssueId: issue.linearIssueId,
27
+ agentSessionId: recoveredAgentSessionId,
28
+ });
29
+ }
30
+ async emitActivity(issue, content, options) {
31
+ const syncedIssue = this.ensureAgentSessionIssue(issue);
32
+ if (!syncedIssue.agentSessionId)
33
+ return;
34
+ try {
35
+ const linear = await this.linearProvider.forProject(syncedIssue.projectId);
36
+ if (!linear)
37
+ return;
38
+ const allowEphemeral = content.type === "thought" || content.type === "action";
39
+ await linear.createAgentActivity({
40
+ agentSessionId: syncedIssue.agentSessionId,
41
+ content,
42
+ ...(options?.ephemeral && allowEphemeral ? { ephemeral: true } : {}),
43
+ });
44
+ }
45
+ catch (error) {
46
+ const msg = error instanceof Error ? error.message : String(error);
47
+ this.logger.warn({ issueKey: syncedIssue.issueKey, type: content.type, error: msg }, "Failed to emit Linear activity");
48
+ this.feed?.publish({
49
+ level: "warn",
50
+ kind: "linear",
51
+ issueKey: syncedIssue.issueKey,
52
+ projectId: syncedIssue.projectId,
53
+ status: "linear_error",
54
+ summary: `Linear activity failed: ${msg}`,
55
+ });
56
+ }
57
+ }
58
+ async syncSessionPlan(issue, linear, options) {
59
+ if (!issue.agentSessionId || !linear.updateAgentSession) {
60
+ return;
61
+ }
62
+ const externalUrls = buildAgentSessionExternalUrls(this.config, {
63
+ ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
64
+ ...(issue.prUrl ? { prUrl: issue.prUrl } : {}),
65
+ });
66
+ await linear.updateAgentSession({
67
+ agentSessionId: issue.agentSessionId,
68
+ plan: buildAgentSessionPlanForIssue(issue, options),
69
+ ...(externalUrls ? { externalUrls } : {}),
70
+ });
71
+ }
72
+ async syncCodexPlan(issue, params) {
73
+ const syncedIssue = this.ensureAgentSessionIssue(issue);
74
+ if (!syncedIssue.agentSessionId)
75
+ return;
76
+ const plan = params.plan;
77
+ if (!Array.isArray(plan))
78
+ return;
79
+ const STATUS_MAP = {
80
+ pending: "pending",
81
+ inProgress: "inProgress",
82
+ completed: "completed",
83
+ };
84
+ const steps = plan.map((entry) => {
85
+ const e = entry;
86
+ const step = typeof e.step === "string" ? e.step : String(e.step ?? "");
87
+ const status = typeof e.status === "string" ? (STATUS_MAP[e.status] ?? "pending") : "pending";
88
+ return { content: step, status };
89
+ });
90
+ const fullPlan = [
91
+ { content: "Prepare workspace", status: "completed" },
92
+ ...steps,
93
+ { content: "Merge", status: "pending" },
94
+ ];
95
+ try {
96
+ const linear = await this.linearProvider.forProject(syncedIssue.projectId);
97
+ if (!linear?.updateAgentSession)
98
+ return;
99
+ await linear.updateAgentSession({
100
+ agentSessionId: syncedIssue.agentSessionId,
101
+ plan: fullPlan,
102
+ });
103
+ }
104
+ catch (error) {
105
+ const msg = error instanceof Error ? error.message : String(error);
106
+ this.logger.warn({ issueKey: syncedIssue.issueKey, error: msg }, "Failed to sync codex plan to Linear");
107
+ }
108
+ }
109
+ }