patchrelay 0.36.19 → 0.37.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.
Files changed (36) hide show
  1. package/dist/build-info.json +3 -3
  2. package/dist/cli/cluster-health.js +25 -22
  3. package/dist/cli/data.js +1 -0
  4. package/dist/cli/formatters/text.js +5 -1
  5. package/dist/cli/watch/App.js +226 -27
  6. package/dist/cli/watch/HelpBar.js +18 -9
  7. package/dist/cli/watch/IssueDetailView.js +32 -14
  8. package/dist/cli/watch/IssueRow.js +4 -3
  9. package/dist/cli/watch/StatusBar.js +2 -1
  10. package/dist/cli/watch/detail-rows.js +5 -25
  11. package/dist/cli/watch/detail-status.js +38 -0
  12. package/dist/cli/watch/layout-measure.js +7 -0
  13. package/dist/cli/watch/pr-status.js +2 -1
  14. package/dist/cli/watch/prompt-layout.js +14 -0
  15. package/dist/cli/watch/state-visualization.js +5 -1
  16. package/dist/cli/watch/timeline-builder.js +169 -18
  17. package/dist/cli/watch/timeline-presentation.js +21 -1
  18. package/dist/cli/watch/transient-status.js +28 -0
  19. package/dist/cli/watch/watch-actions.js +76 -0
  20. package/dist/cli/watch/watch-state.js +2 -12
  21. package/dist/factory-state.js +1 -1
  22. package/dist/github-webhook-handler.js +26 -4
  23. package/dist/idle-reconciliation.js +19 -2
  24. package/dist/implementation-outcome-policy.js +3 -1
  25. package/dist/issue-overview-query.js +5 -0
  26. package/dist/linear-session-reporting.js +15 -6
  27. package/dist/linear-status-comment-sync.js +13 -1
  28. package/dist/pr-state.js +49 -0
  29. package/dist/service-issue-actions.js +5 -4
  30. package/dist/tracked-issue-list-query.js +3 -1
  31. package/dist/tracked-issue-projector.js +5 -0
  32. package/dist/waiting-reason.js +3 -2
  33. package/package.json +1 -1
  34. package/dist/cli/watch/ItemLine.js +0 -80
  35. package/dist/cli/watch/Timeline.js +0 -22
  36. package/dist/cli/watch/TimelineRow.js +0 -77
@@ -1,8 +1,8 @@
1
+ import { hasOpenPr } from "../../pr-state.js";
1
2
  import { buildStateHistory } from "./history-builder.js";
2
3
  import { buildTimelineRows } from "./timeline-presentation.js";
3
4
  import { planStepColor, planStepSymbol } from "./plan-helpers.js";
4
5
  import { progressBar } from "./format-utils.js";
5
- import { describePatchRelayFreshness } from "./freshness.js";
6
6
  import { hasDisplayPrBlocker, isApprovedReviewState, isAwaitingReviewState, isChangesRequestedReviewState, isRereviewNeeded, prChecksFact, } from "./pr-status.js";
7
7
  import { renderRichTextLines, renderTextLines } from "./render-rich-text.js";
8
8
  const SESSION_DISPLAY = {
@@ -85,20 +85,10 @@ function buildHeaderLines(input, width) {
85
85
  headerSegments.push({ text: " ", dimColor: true });
86
86
  headerSegments.push(...joinFactSegments(facts));
87
87
  }
88
- if (input.activeRunStartedAt) {
89
- headerSegments.push({ text: " ", dimColor: true });
90
- headerSegments.push({ text: elapsedLabel(input.activeRunStartedAt), dimColor: true });
91
- }
92
88
  if (meta.length > 0) {
93
89
  headerSegments.push({ text: " ", dimColor: true });
94
90
  headerSegments.push({ text: meta.join(" "), dimColor: true });
95
91
  }
96
- headerSegments.push({ text: " ", dimColor: true });
97
- headerSegments.push(...freshnessSegments(input.connected, input.lastServerMessageAt));
98
- if (input.follow) {
99
- headerSegments.push({ text: " " });
100
- headerSegments.push({ text: "live", color: "yellow", bold: true });
101
- }
102
92
  const lines = renderTextLines(segmentsToText(headerSegments), {
103
93
  key: "detail-header",
104
94
  width,
@@ -255,7 +245,7 @@ function renderTimelineItemLines(key, item, width, indent) {
255
245
  ? { color: "white" }
256
246
  : { dimColor: item.type !== "commandExecution" },
257
247
  });
258
- if (item.output && item.status === "inProgress") {
248
+ if (item.output && item.type === "commandExecution") {
259
249
  lines.push(...renderTextLines(lastNonEmptyLine(item.output), {
260
250
  key: `${key}-output`,
261
251
  width,
@@ -407,7 +397,7 @@ function buildFactSegments(issue, issueContext) {
407
397
  facts.push([{ text: "re-review needed", color: "yellow" }]);
408
398
  else if (isChangesRequestedReviewState(issue.prReviewState))
409
399
  facts.push([{ text: "changes requested", color: "yellow" }]);
410
- else if (issue.prNumber !== undefined
400
+ else if (hasOpenPr(issue.prNumber, issue.prState)
411
401
  && (isAwaitingReviewState(issue.prReviewState) || (!issue.prReviewState && issue.factoryState === "pr_open")))
412
402
  facts.push([{ text: "awaiting review", color: "yellow" }]);
413
403
  if (issue.factoryState === "awaiting_queue")
@@ -462,12 +452,6 @@ function formatDuration(startedAt, endedAt) {
462
452
  function formatTime(iso) {
463
453
  return new Date(iso).toLocaleTimeString("en-GB", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
464
454
  }
465
- function elapsedLabel(startedAt) {
466
- const elapsed = Math.max(0, Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000));
467
- const minutes = Math.floor(elapsed / 60);
468
- const seconds = elapsed % 60;
469
- return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
470
- }
471
455
  function itemSummary(item) {
472
456
  switch (item.type) {
473
457
  case "commandExecution": {
@@ -520,7 +504,7 @@ function effectiveState(issue) {
520
504
  return "blocked";
521
505
  if (issue.sessionState === "waiting_input")
522
506
  return "awaiting_input";
523
- if (issue.prNumber !== undefined)
507
+ if (hasOpenPr(issue.prNumber, issue.prState))
524
508
  return issue.factoryState;
525
509
  if (issue.readyForExecution && !issue.activeRunType && !hasDisplayPrBlocker(issue))
526
510
  return "ready";
@@ -563,7 +547,7 @@ function blockerText(issue, issueContext) {
563
547
  return "Awaiting re-review after requested changes";
564
548
  if (isChangesRequestedReviewState(issue.prReviewState))
565
549
  return "Review changes requested";
566
- if (issue.prNumber !== undefined && (isAwaitingReviewState(issue.prReviewState) || (!issue.prReviewState && effectiveState(issue) !== "done"))) {
550
+ if (hasOpenPr(issue.prNumber, issue.prState) && (isAwaitingReviewState(issue.prReviewState) || (!issue.prReviewState && effectiveState(issue) !== "done"))) {
567
551
  return "Awaiting review";
568
552
  }
569
553
  return null;
@@ -598,10 +582,6 @@ function feedGlyph(status) {
598
582
  return "✓";
599
583
  return "●";
600
584
  }
601
- function freshnessSegments(connected, lastServerMessageAt) {
602
- const freshness = describePatchRelayFreshness(connected, lastServerMessageAt);
603
- return [{ text: freshness.label, color: freshness.color, bold: true }];
604
- }
605
585
  function blankLine(key) {
606
586
  return { key, segments: [] };
607
587
  }
@@ -0,0 +1,38 @@
1
+ import { describePatchRelayFreshness } from "./freshness.js";
2
+ export function buildDetailStatusSegments(input, now = Date.now()) {
3
+ const groups = [];
4
+ groups.push(input.follow
5
+ ? [{ text: "live edge", color: "green", bold: true }]
6
+ : [{ text: "anchored review", color: "yellow", bold: true }]);
7
+ if (input.unreadBelow > 0) {
8
+ groups.push([{ text: `${input.unreadBelow} new below`, color: "yellow", bold: true }]);
9
+ }
10
+ if (input.activeRunStartedAt) {
11
+ groups.push([{ text: `run ${formatElapsed(input.activeRunStartedAt, now)}`, dimColor: true }]);
12
+ }
13
+ const freshness = describePatchRelayFreshness(input.connected, input.lastServerMessageAt, now);
14
+ groups.push([{ text: freshness.label, color: freshness.color, bold: true }]);
15
+ return joinGroups(groups);
16
+ }
17
+ export function buildDetailStatusText(input, now = Date.now()) {
18
+ return buildDetailStatusSegments(input, now).map((segment) => segment.text).join("");
19
+ }
20
+ function formatElapsed(startedAt, now) {
21
+ const startedMs = Date.parse(startedAt);
22
+ if (!Number.isFinite(startedMs))
23
+ return "0m 00s";
24
+ const elapsed = Math.max(0, Math.floor((now - startedMs) / 1000));
25
+ const minutes = Math.floor(elapsed / 60);
26
+ const seconds = elapsed % 60;
27
+ return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
28
+ }
29
+ function joinGroups(groups) {
30
+ const segments = [];
31
+ for (const [index, group] of groups.entries()) {
32
+ if (index > 0) {
33
+ segments.push({ text: " ", dimColor: true });
34
+ }
35
+ segments.push(...group);
36
+ }
37
+ return segments;
38
+ }
@@ -0,0 +1,7 @@
1
+ import { renderTextLines } from "./render-rich-text.js";
2
+ export function measureRenderedTextRows(text, width) {
3
+ return renderTextLines(text, {
4
+ key: "measure",
5
+ width,
6
+ }).length;
7
+ }
@@ -1,3 +1,4 @@
1
+ import { hasOpenPr } from "../../pr-state.js";
1
2
  function isPassingCheckStatus(status) {
2
3
  return status === "passed" || status === "success";
3
4
  }
@@ -72,7 +73,7 @@ export function prChecksFact(issue) {
72
73
  return undefined;
73
74
  }
74
75
  export function hasDisplayPrBlocker(issue) {
75
- if (issue.prNumber === undefined || issue.activeRunType) {
76
+ if (!hasOpenPr(issue.prNumber, issue.prState) || issue.activeRunType) {
76
77
  return false;
77
78
  }
78
79
  if (issue.factoryState === "pr_open" || issue.factoryState === "awaiting_queue" || issue.factoryState === "repairing_queue") {
@@ -0,0 +1,14 @@
1
+ import { measureRenderedTextRows } from "./layout-measure.js";
2
+ export const PROMPT_COMPOSER_HINT = "Enter: send Ctrl-N: newline Up/Down: history Esc: cancel";
3
+ export function buildPromptComposerDisplayLines(buffer, cursor) {
4
+ const withCursor = `${buffer.slice(0, cursor)}|${buffer.slice(cursor)}`;
5
+ const contentLines = withCursor.split("\n");
6
+ return [
7
+ ...contentLines.map((line, index) => `${index === 0 ? "prompt> " : " "}${line}`),
8
+ PROMPT_COMPOSER_HINT,
9
+ ];
10
+ }
11
+ export function measurePromptComposerRows(buffer, cursor, width) {
12
+ return buildPromptComposerDisplayLines(buffer, cursor)
13
+ .reduce((count, line) => count + measureRenderedTextRows(line, width), 0);
14
+ }
@@ -1,3 +1,4 @@
1
+ import { hasOpenPr } from "../../pr-state.js";
1
2
  const STATE_LABELS = {
2
3
  delegated: "delegated",
3
4
  implementing: "implementing",
@@ -163,9 +164,12 @@ export function buildPatchRelayQueueObservations(issue, feedEvents) {
163
164
  });
164
165
  }
165
166
  if (issue.prNumber !== undefined) {
167
+ const prLabel = hasOpenPr(issue.prNumber, issue.prState)
168
+ ? `Tracked PR: #${issue.prNumber}`
169
+ : `Tracked PR: #${issue.prNumber}${issue.prState ? ` (${issue.prState})` : ""}`;
166
170
  observations.push({
167
171
  tone: "info",
168
- text: `Tracked PR: #${issue.prNumber}${issue.prReviewState ? ` (${issue.prReviewState})` : ""}`,
172
+ text: `${prLabel}${issue.prReviewState ? ` (${issue.prReviewState})` : ""}`,
169
173
  });
170
174
  }
171
175
  return observations.slice(0, 3);
@@ -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:
@@ -30,7 +30,7 @@ const TRANSITION_RULES = [
30
30
  // pr_closed during an active run is suppressed — Codex may reopen.
31
31
  // Without a guard match, the event produces no transition (undefined).
32
32
  { event: "pr_closed",
33
- guard: (_, ctx) => ctx.activeRunId === undefined,
33
+ guard: (s, ctx) => ctx.activeRunId === undefined && !TERMINAL_STATES.has(s),
34
34
  to: "failed" },
35
35
  // ── PR lifecycle ───────────────────────────────────────────────
36
36
  { event: "pr_opened",