patchrelay 0.12.8 → 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.
package/dist/http.js CHANGED
@@ -330,6 +330,49 @@ export async function buildHttpServer(config, service, logger) {
330
330
  reply.raw.on("error", cleanup);
331
331
  request.raw.on("close", cleanup);
332
332
  });
333
+ app.get("/api/watch", async (request, reply) => {
334
+ reply.hijack();
335
+ reply.raw.writeHead(200, {
336
+ "content-type": "text/event-stream; charset=utf-8",
337
+ "cache-control": "no-cache, no-transform",
338
+ connection: "keep-alive",
339
+ "x-accel-buffering": "no",
340
+ });
341
+ const writeSse = (eventType, data) => {
342
+ reply.raw.write(`event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`);
343
+ };
344
+ // Send initial issue snapshot
345
+ writeSse("issues", service.listTrackedIssues());
346
+ // Stream operator feed events
347
+ const issueFilter = getQueryParam(request, "issue");
348
+ const unsubscribeFeed = service.subscribeOperatorFeed((event) => {
349
+ if (issueFilter && event.issueKey !== issueFilter) {
350
+ return;
351
+ }
352
+ writeSse("feed", event);
353
+ });
354
+ // When filtered to a specific issue, also stream codex notifications
355
+ const unsubscribeCodex = issueFilter
356
+ ? service.subscribeCodexNotifications((event) => {
357
+ if (event.issueKey !== issueFilter) {
358
+ return;
359
+ }
360
+ writeSse("codex", { method: event.method, params: event.params });
361
+ })
362
+ : undefined;
363
+ const cleanup = () => {
364
+ clearInterval(keepAlive);
365
+ unsubscribeFeed();
366
+ unsubscribeCodex?.();
367
+ if (!reply.raw.destroyed)
368
+ reply.raw.end();
369
+ };
370
+ const keepAlive = setInterval(() => {
371
+ reply.raw.write(": keepalive\n\n");
372
+ }, 15000);
373
+ reply.raw.on("error", cleanup);
374
+ request.raw.on("close", cleanup);
375
+ });
333
376
  app.get("/api/installations", async (_request, reply) => {
334
377
  return reply.send({ ok: true, installations: service.listLinearInstallations() });
335
378
  });
@@ -477,10 +520,25 @@ function renderAgentSessionStatusErrorPage(message) {
477
520
  function renderAgentSessionStatusPage(params) {
478
521
  const issueTitle = params.sessionStatus.issue.title ?? params.sessionStatus.issue.issueKey ?? params.issueKey;
479
522
  const issueUrl = params.sessionStatus.issue.issueUrl;
523
+ const prUrl = params.sessionStatus.issue.prUrl;
524
+ const prLabel = params.sessionStatus.issue.prNumber ? `#${params.sessionStatus.issue.prNumber}` : undefined;
480
525
  const activeStage = formatStageChip(params.sessionStatus.activeRun);
481
526
  const latestStage = formatStageChip(params.sessionStatus.latestRun);
482
527
  const threadInfo = formatThread(params.sessionStatus.liveThread);
483
528
  const stagesRows = params.sessionStatus.runs.slice(-8).map((entry) => formatStageRow(entry.run)).join("");
529
+ const latestAgentMessage = params.sessionStatus.liveThread?.latestAgentMessage ?? params.sessionStatus.latestReportSummary?.latestAssistantMessage ?? "No agent summary yet.";
530
+ const latestPlan = params.sessionStatus.liveThread?.latestPlan ?? "No live plan available.";
531
+ const activeCommand = params.sessionStatus.liveThread?.activeCommand ?? "idle";
532
+ const commandCount = params.sessionStatus.liveThread?.commandCount ?? params.sessionStatus.latestReportSummary?.commandCount ?? 0;
533
+ const fileChangeCount = params.sessionStatus.liveThread?.fileChangeCount ?? params.sessionStatus.latestReportSummary?.fileChangeCount ?? 0;
534
+ const toolCallCount = params.sessionStatus.liveThread?.toolCallCount ?? params.sessionStatus.latestReportSummary?.toolCallCount ?? 0;
535
+ const factoryState = params.sessionStatus.issue.factoryState ?? "unknown";
536
+ const linearState = params.sessionStatus.issue.currentLinearState ?? "unknown";
537
+ const prState = params.sessionStatus.issue.prState ?? "unknown";
538
+ const reviewState = params.sessionStatus.issue.prReviewState ?? "unknown";
539
+ const checkState = params.sessionStatus.issue.prCheckStatus ?? "unknown";
540
+ const ciAttempts = params.sessionStatus.issue.ciRepairAttempts ?? 0;
541
+ const queueAttempts = params.sessionStatus.issue.queueRepairAttempts ?? 0;
484
542
  return `<!doctype html>
485
543
  <html lang="en">
486
544
  <head>
@@ -538,11 +596,34 @@ function renderAgentSessionStatusPage(params) {
538
596
  <h1>${escapeHtml(issueTitle)}</h1>
539
597
  <p>PatchRelay read-only agent session status for <code>${escapeHtml(params.issueKey)}</code>.</p>
540
598
  ${issueUrl ? `<p><a href="${escapeHtml(issueUrl)}" target="_blank" rel="noopener noreferrer">Open issue in Linear</a></p>` : ""}
599
+ ${prUrl ? `<p><a href="${escapeHtml(prUrl)}" target="_blank" rel="noopener noreferrer">Open pull request ${escapeHtml(prLabel ?? "")}</a></p>` : ""}
541
600
  <div class="chips">
601
+ <span class="chip"><strong>Factory:</strong> <code>${escapeHtml(factoryState)}</code></span>
602
+ <span class="chip"><strong>Linear:</strong> <code>${escapeHtml(linearState)}</code></span>
542
603
  <span class="chip"><strong>Active:</strong> ${activeStage}</span>
543
604
  <span class="chip"><strong>Latest:</strong> ${latestStage}</span>
544
605
  <span class="chip"><strong>Thread:</strong> ${threadInfo}</span>
545
606
  </div>
607
+ <div class="section">
608
+ <h2>Current View</h2>
609
+ <table>
610
+ <tbody>
611
+ <tr><th>Pull request</th><td>${escapeHtml(prLabel ?? "none")} (${escapeHtml(prState)})</td></tr>
612
+ <tr><th>Review</th><td>${escapeHtml(reviewState)}</td></tr>
613
+ <tr><th>Checks</th><td>${escapeHtml(checkState)}</td></tr>
614
+ <tr><th>Latest plan</th><td>${escapeHtml(latestPlan)}</td></tr>
615
+ <tr><th>Active command</th><td><code>${escapeHtml(activeCommand)}</code></td></tr>
616
+ <tr><th>Latest summary</th><td>${escapeHtml(latestAgentMessage)}</td></tr>
617
+ </tbody>
618
+ </table>
619
+ </div>
620
+ <div class="chips">
621
+ <span class="chip"><strong>Commands:</strong> ${escapeHtml(String(commandCount))}</span>
622
+ <span class="chip"><strong>File changes:</strong> ${escapeHtml(String(fileChangeCount))}</span>
623
+ <span class="chip"><strong>Tool calls:</strong> ${escapeHtml(String(toolCallCount))}</span>
624
+ <span class="chip"><strong>CI repairs:</strong> ${escapeHtml(String(ciAttempts))}</span>
625
+ <span class="chip"><strong>Queue repairs:</strong> ${escapeHtml(String(queueAttempts))}</span>
626
+ </div>
546
627
  <div class="section">
547
628
  <h2>Recent Stages</h2>
548
629
  <table>
@@ -1,4 +1,4 @@
1
- import { summarizeCurrentThread } from "./run-reporting.js";
1
+ import { extractStageSummary, summarizeCurrentThread } from "./run-reporting.js";
2
2
  import { safeJsonParse } from "./utils.js";
3
3
  export class IssueQueryService {
4
4
  db;
@@ -65,12 +65,27 @@ export class IssueQueryService {
65
65
  const overview = await this.getIssueOverview(issueKey);
66
66
  if (!overview)
67
67
  return undefined;
68
+ const issueRecord = this.db.getIssueByKey(issueKey);
68
69
  const report = await this.getIssueReport(issueKey);
70
+ const latestRunReport = report?.runs.at(-1)?.report;
69
71
  return {
70
- issue: overview.issue,
72
+ issue: {
73
+ issueKey: overview.issue.issueKey,
74
+ title: overview.issue.title,
75
+ issueUrl: overview.issue.issueUrl,
76
+ currentLinearState: overview.issue.currentLinearState,
77
+ factoryState: overview.issue.factoryState,
78
+ ...(issueRecord?.prNumber !== undefined ? { prNumber: issueRecord.prNumber } : {}),
79
+ ...(issueRecord?.prUrl ? { prUrl: issueRecord.prUrl } : {}),
80
+ ...(issueRecord?.prState ? { prState: issueRecord.prState } : {}),
81
+ ...(issueRecord?.prReviewState ? { prReviewState: issueRecord.prReviewState } : {}),
82
+ ...(issueRecord?.prCheckStatus ? { prCheckStatus: issueRecord.prCheckStatus } : {}),
83
+ ...(issueRecord ? { ciRepairAttempts: issueRecord.ciRepairAttempts, queueRepairAttempts: issueRecord.queueRepairAttempts } : {}),
84
+ },
71
85
  ...(overview.activeRun ? { activeRun: overview.activeRun } : {}),
72
86
  ...(overview.latestRun ? { latestRun: overview.latestRun } : {}),
73
87
  ...(overview.liveThread ? { liveThread: overview.liveThread } : {}),
88
+ ...(latestRunReport ? { latestReportSummary: extractStageSummary(latestRunReport) } : {}),
74
89
  runs: report?.runs ?? [],
75
90
  generatedAt: new Date().toISOString(),
76
91
  };
@@ -0,0 +1,134 @@
1
+ import { formatRunTypeLabel } from "./agent-session-plan.js";
2
+ function lowerRunTypeLabel(runType) {
3
+ return formatRunTypeLabel(runType).toLowerCase();
4
+ }
5
+ function trimSummary(summary, maxLength = 300) {
6
+ const value = summary?.trim();
7
+ if (!value) {
8
+ return undefined;
9
+ }
10
+ return value.length <= maxLength ? value : `${value.slice(0, maxLength).trimEnd()}...`;
11
+ }
12
+ function describeNextState(state, prNumber) {
13
+ const prLabel = prNumber ? `PR #${prNumber}` : "the pull request";
14
+ switch (state) {
15
+ case "pr_open":
16
+ case "awaiting_review":
17
+ return `${prLabel} is ready for review.`;
18
+ case "awaiting_queue":
19
+ return `${prLabel} is approved and back in the merge flow.`;
20
+ case "done":
21
+ return `${prLabel} has merged.`;
22
+ case "awaiting_input":
23
+ return "PatchRelay is waiting for guidance before continuing.";
24
+ case "failed":
25
+ return "PatchRelay needs help to recover this workflow.";
26
+ default:
27
+ return undefined;
28
+ }
29
+ }
30
+ export function buildDelegationThought(runType, source = "delegation") {
31
+ const sourceText = source === "prompt" ? "latest instructions" : "delegation";
32
+ return {
33
+ type: "thought",
34
+ body: `PatchRelay received the ${sourceText} and is preparing the ${lowerRunTypeLabel(runType)} workflow.`,
35
+ };
36
+ }
37
+ export function buildAlreadyRunningThought(runType) {
38
+ return {
39
+ type: "thought",
40
+ body: `PatchRelay is already working on the ${lowerRunTypeLabel(runType)} workflow.`,
41
+ };
42
+ }
43
+ export function buildPromptDeliveredThought(runType) {
44
+ return {
45
+ type: "thought",
46
+ body: `PatchRelay routed your latest instructions into the active ${lowerRunTypeLabel(runType)} workflow.`,
47
+ };
48
+ }
49
+ export function buildRunStartedActivity(runType) {
50
+ switch (runType) {
51
+ case "review_fix":
52
+ return { type: "action", action: "Addressing", parameter: "review feedback" };
53
+ case "ci_repair":
54
+ return { type: "action", action: "Repairing", parameter: "failing CI checks" };
55
+ case "queue_repair":
56
+ return { type: "action", action: "Repairing", parameter: "merge queue failure" };
57
+ case "implementation":
58
+ default:
59
+ return { type: "action", action: "Implementing", parameter: "requested change" };
60
+ }
61
+ }
62
+ export function buildRunCompletedActivity(params) {
63
+ const label = formatRunTypeLabel(params.runType);
64
+ const nextState = describeNextState(params.postRunState, params.prNumber);
65
+ const summary = trimSummary(params.completionSummary);
66
+ const lines = [`${label} completed.`];
67
+ if (nextState) {
68
+ lines.push("", nextState);
69
+ }
70
+ if (summary) {
71
+ lines.push("", summary);
72
+ }
73
+ return {
74
+ type: "response",
75
+ body: lines.join("\n"),
76
+ };
77
+ }
78
+ export function buildRunFailureActivity(runType, reason) {
79
+ const label = formatRunTypeLabel(runType);
80
+ return {
81
+ type: "error",
82
+ body: reason ? `${label} failed.\n\n${reason}` : `${label} failed.`,
83
+ };
84
+ }
85
+ export function buildGitHubStateActivity(newState, event) {
86
+ switch (newState) {
87
+ case "pr_open": {
88
+ const parts = [`PR #${event.prNumber ?? "?"} is open and ready for review.`];
89
+ if (event.prUrl) {
90
+ parts.push("", event.prUrl);
91
+ }
92
+ return { type: "response", body: parts.join("\n") };
93
+ }
94
+ case "awaiting_queue":
95
+ return { type: "response", body: "Review approved. PatchRelay is moving the PR toward merge." };
96
+ case "changes_requested":
97
+ return {
98
+ type: "action",
99
+ action: "Addressing",
100
+ parameter: event.reviewerName ? `review feedback from ${event.reviewerName}` : "review feedback",
101
+ };
102
+ case "repairing_ci":
103
+ return {
104
+ type: "action",
105
+ action: "Repairing",
106
+ parameter: event.checkName ? `CI failure: ${event.checkName}` : "failing CI checks",
107
+ };
108
+ case "repairing_queue":
109
+ return {
110
+ type: "action",
111
+ action: "Repairing",
112
+ parameter: "merge queue validation",
113
+ };
114
+ case "done":
115
+ return { type: "response", body: `PR merged.${event.prNumber ? ` PR #${event.prNumber}` : ""}` };
116
+ case "failed":
117
+ return { type: "error", body: "The pull request was closed without merging." };
118
+ default:
119
+ return undefined;
120
+ }
121
+ }
122
+ export function summarizeIssueStateForLinear(issue) {
123
+ switch (issue.factoryState) {
124
+ case "awaiting_review":
125
+ case "pr_open":
126
+ return issue.prNumber ? `PR #${issue.prNumber} is awaiting review.` : "Awaiting review.";
127
+ case "awaiting_queue":
128
+ return issue.prNumber ? `PR #${issue.prNumber} is approved and awaiting merge.` : "Approved and awaiting merge.";
129
+ case "done":
130
+ return issue.prNumber ? `PR #${issue.prNumber} has merged.` : "Change merged.";
131
+ default:
132
+ return undefined;
133
+ }
134
+ }
@@ -2,8 +2,10 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { ACTIVE_RUN_STATES } from "./factory-state.js";
4
4
  import { buildHookEnv, runProjectHook } from "./hook-runner.js";
5
- import { buildRunningSessionPlan, buildCompletedSessionPlan, buildFailedSessionPlan, } from "./agent-session-plan.js";
5
+ import { buildAgentSessionPlanForIssue, } from "./agent-session-plan.js";
6
6
  import { buildStageReport, countEventMethods, extractTurnId, resolveRunCompletionStatus, summarizeCurrentThread, } from "./run-reporting.js";
7
+ import { buildRunCompletedActivity, buildRunFailureActivity, buildRunStartedActivity, } from "./linear-session-reporting.js";
8
+ import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
7
9
  import { WorktreeManager } from "./worktree-manager.js";
8
10
  import { resolveAuthoritativeLinearStopState } from "./linear-workflow.js";
9
11
  import { execCommand } from "./utils.js";
@@ -15,6 +17,9 @@ function slugify(value) {
15
17
  function sanitizePathSegment(value) {
16
18
  return value.replace(/[^a-zA-Z0-9._-]+/g, "-");
17
19
  }
20
+ function lowerCaseFirst(value) {
21
+ return value ? `${value.slice(0, 1).toLowerCase()}${value.slice(1)}` : value;
22
+ }
18
23
  const WORKFLOW_FILES = {
19
24
  implementation: "IMPLEMENTATION_WORKFLOW.md",
20
25
  review_fix: "REVIEW_WORKFLOW.md",
@@ -36,6 +41,14 @@ function buildRunPrompt(issue, runType, repoPath, context) {
36
41
  issue.prNumber ? `PR: #${issue.prNumber}` : undefined,
37
42
  "",
38
43
  ].filter(Boolean);
44
+ const promptContext = typeof context?.promptContext === "string" ? context.promptContext.trim() : "";
45
+ const latestPrompt = typeof context?.promptBody === "string" ? context.promptBody.trim() : "";
46
+ if (promptContext) {
47
+ lines.push("## Linear Session Context", "", promptContext, "");
48
+ }
49
+ if (latestPrompt) {
50
+ lines.push("## Latest Human Instruction", "", latestPrompt, "");
51
+ }
39
52
  // Add run-type-specific context for reactive runs
40
53
  switch (runType) {
41
54
  case "ci_repair":
@@ -210,16 +223,17 @@ export class RunOrchestrator {
210
223
  factoryState: "failed",
211
224
  });
212
225
  this.logger.error({ issueKey: issue.issueKey, runType, error: message }, `Failed to launch ${runType} run`);
213
- void this.emitLinearActivity(issue, "error", `Failed to start ${runType}: ${message}`);
214
- void this.updateLinearPlan(issue, buildFailedSessionPlan(runType));
226
+ const failedIssue = this.db.getIssue(item.projectId, item.issueId) ?? issue;
227
+ void this.emitLinearActivity(failedIssue, buildRunFailureActivity(runType, `Failed to start ${lowerCaseFirst(message)}`));
228
+ void this.syncLinearSession(failedIssue, { activeRunType: runType });
215
229
  throw error;
216
230
  }
217
231
  this.db.updateRunThread(run.id, { threadId, turnId });
218
232
  this.logger.info({ issueKey: issue.issueKey, runType, threadId, turnId }, `Started ${runType} run`);
219
233
  // Emit Linear activity + plan
220
234
  const freshIssue = this.db.getIssue(item.projectId, item.issueId) ?? issue;
221
- void this.emitLinearActivity(freshIssue, "thought", `Started ${runType} run.`, { ephemeral: true });
222
- void this.updateLinearPlan(freshIssue, buildRunningSessionPlan(runType));
235
+ void this.emitLinearActivity(freshIssue, buildRunStartedActivity(runType));
236
+ void this.syncLinearSession(freshIssue, { activeRunType: runType });
223
237
  }
224
238
  // ─── Notification handler ─────────────────────────────────────────
225
239
  async handleCodexNotification(notification) {
@@ -269,8 +283,9 @@ export class RunOrchestrator {
269
283
  status: "failed",
270
284
  summary: `Turn failed for ${run.runType}`,
271
285
  });
272
- void this.emitLinearActivity(issue, "error", `${run.runType} run failed.`);
273
- void this.updateLinearPlan(issue, buildFailedSessionPlan(run.runType, run));
286
+ const failedIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? issue;
287
+ void this.emitLinearActivity(failedIssue, buildRunFailureActivity(run.runType));
288
+ void this.syncLinearSession(failedIssue, { activeRunType: run.runType });
274
289
  return;
275
290
  }
276
291
  // Complete the run
@@ -323,10 +338,15 @@ export class RunOrchestrator {
323
338
  detail: summarizeCurrentThread(thread).latestAgentMessage,
324
339
  });
325
340
  // Emit Linear completion activity + plan
341
+ const updatedIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? issue;
326
342
  const completionSummary = report.assistantMessages.at(-1)?.slice(0, 300) ?? `${run.runType} completed.`;
327
- const prInfo = issue.prNumber ? ` PR #${issue.prNumber}` : "";
328
- void this.emitLinearActivity(issue, "response", `${run.runType} completed.${prInfo}\n\n${completionSummary}`);
329
- void this.updateLinearPlan(issue, buildCompletedSessionPlan(run.runType));
343
+ void this.emitLinearActivity(updatedIssue, buildRunCompletedActivity({
344
+ runType: run.runType,
345
+ completionSummary,
346
+ postRunState: updatedIssue.factoryState,
347
+ ...(updatedIssue.prNumber !== undefined ? { prNumber: updatedIssue.prNumber } : {}),
348
+ }));
349
+ void this.syncLinearSession(updatedIssue);
330
350
  }
331
351
  // ─── Active status for query ──────────────────────────────────────
332
352
  async getActiveRunStatus(issueKey) {
@@ -471,7 +491,9 @@ export class RunOrchestrator {
471
491
  if (latestTurn?.status === "interrupted") {
472
492
  this.logger.warn({ issueKey: issue.issueKey, runType: run.runType, threadId: run.threadId }, "Run has interrupted turn — marking as failed");
473
493
  this.failRunAndClear(run, "Codex turn was interrupted");
474
- void this.emitLinearActivity(issue, "error", `${run.runType} run was interrupted.`);
494
+ const failedIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? issue;
495
+ void this.emitLinearActivity(failedIssue, buildRunFailureActivity(run.runType, "The Codex turn was interrupted."));
496
+ void this.syncLinearSession(failedIssue, { activeRunType: run.runType });
475
497
  return;
476
498
  }
477
499
  // Handle completed turn discovered during reconciliation
@@ -513,6 +535,12 @@ export class RunOrchestrator {
513
535
  status: "escalated",
514
536
  summary: `Escalated: ${reason}`,
515
537
  });
538
+ const escalatedIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
539
+ void this.emitLinearActivity(escalatedIssue, {
540
+ type: "error",
541
+ body: `PatchRelay needs human help to continue.\n\n${reason}`,
542
+ });
543
+ void this.syncLinearSession(escalatedIssue);
516
544
  }
517
545
  failRunAndClear(run, message) {
518
546
  this.db.transaction(() => {
@@ -525,34 +553,53 @@ export class RunOrchestrator {
525
553
  });
526
554
  });
527
555
  }
528
- async emitLinearActivity(issue, type, body, options) {
556
+ async emitLinearActivity(issue, content, options) {
529
557
  if (!issue.agentSessionId)
530
558
  return;
531
559
  try {
532
560
  const linear = await this.linearProvider.forProject(issue.projectId);
533
561
  if (!linear)
534
562
  return;
563
+ const allowEphemeral = content.type === "thought" || content.type === "action";
535
564
  await linear.createAgentActivity({
536
565
  agentSessionId: issue.agentSessionId,
537
- content: { type, body },
538
- ...(options?.ephemeral ? { ephemeral: true } : {}),
566
+ content,
567
+ ...(options?.ephemeral && allowEphemeral ? { ephemeral: true } : {}),
539
568
  });
540
569
  }
541
570
  catch (error) {
542
- this.logger.debug({ issueKey: issue.issueKey, type, error: error instanceof Error ? error.message : String(error) }, "Failed to emit Linear activity (non-blocking)");
571
+ const msg = error instanceof Error ? error.message : String(error);
572
+ this.logger.warn({ issueKey: issue.issueKey, type: content.type, error: msg }, "Failed to emit Linear activity");
573
+ this.feed?.publish({
574
+ level: "warn",
575
+ kind: "linear",
576
+ issueKey: issue.issueKey,
577
+ projectId: issue.projectId,
578
+ status: "linear_error",
579
+ summary: `Linear activity failed: ${msg}`,
580
+ });
543
581
  }
544
582
  }
545
- async updateLinearPlan(issue, plan) {
583
+ async syncLinearSession(issue, options) {
546
584
  if (!issue.agentSessionId)
547
585
  return;
548
586
  try {
549
587
  const linear = await this.linearProvider.forProject(issue.projectId);
550
588
  if (!linear?.updateAgentSession)
551
589
  return;
552
- await linear.updateAgentSession({ agentSessionId: issue.agentSessionId, plan });
590
+ const externalUrls = buildAgentSessionExternalUrls(this.config, {
591
+ ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
592
+ ...(issue.prUrl ? { prUrl: issue.prUrl } : {}),
593
+ });
594
+ await linear.updateAgentSession({
595
+ agentSessionId: issue.agentSessionId,
596
+ plan: buildAgentSessionPlanForIssue(issue, options),
597
+ ...(externalUrls ? { externalUrls } : {}),
598
+ });
553
599
  }
554
600
  catch (error) {
555
- this.logger.debug({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to update Linear plan (non-blocking)");
601
+ const msg = error instanceof Error ? error.message : String(error);
602
+ this.logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to update Linear plan");
556
603
  }
557
604
  }
558
605
  async readThreadWithRetry(threadId, maxRetries = 3) {
@@ -12,11 +12,42 @@ export function summarizeCurrentThread(thread) {
12
12
  const latestAgentMessage = latestTurn?.items
13
13
  .filter((item) => item.type === "agentMessage")
14
14
  .at(-1)?.text;
15
+ const latestPlan = latestTurn?.items
16
+ .filter((item) => item.type === "plan")
17
+ .at(-1)?.text;
18
+ const activeCommand = latestTurn?.items
19
+ .filter((item) => item.type === "commandExecution")
20
+ .filter((item) => item.status === "inProgress" || item.status === "running")
21
+ .at(-1)?.command
22
+ ?? latestTurn?.items
23
+ .filter((item) => item.type === "commandExecution")
24
+ .at(-1)?.command;
25
+ let commandCount = 0;
26
+ let fileChangeCount = 0;
27
+ let toolCallCount = 0;
28
+ for (const turn of thread.turns) {
29
+ for (const item of turn.items) {
30
+ if (item.type === "commandExecution") {
31
+ commandCount += 1;
32
+ }
33
+ else if (item.type === "fileChange" && Array.isArray(item.changes)) {
34
+ fileChangeCount += item.changes.length;
35
+ }
36
+ else if (item.type === "mcpToolCall" || item.type === "dynamicToolCall") {
37
+ toolCallCount += 1;
38
+ }
39
+ }
40
+ }
15
41
  return {
16
42
  threadId: thread.id,
17
43
  threadStatus: thread.status,
44
+ commandCount,
45
+ fileChangeCount,
46
+ toolCallCount,
18
47
  ...(latestTurn ? { latestTurnId: latestTurn.id, latestTurnStatus: latestTurn.status } : {}),
19
48
  ...(latestAgentMessage ? { latestAgentMessage } : {}),
49
+ ...(latestPlan ? { latestPlan } : {}),
50
+ ...(activeCommand ? { activeCommand } : {}),
20
51
  };
21
52
  }
22
53
  export function buildStageReport(run, issue, thread, eventCounts) {
package/dist/service.js CHANGED
@@ -138,6 +138,66 @@ export class PatchRelayService {
138
138
  getReadiness() {
139
139
  return this.runtime.getReadiness();
140
140
  }
141
+ listTrackedIssues() {
142
+ const rows = this.db.connection
143
+ .prepare(`SELECT
144
+ i.project_id, i.linear_issue_id, i.issue_key, i.title,
145
+ i.current_linear_state, i.factory_state, i.updated_at,
146
+ i.pr_number, i.pr_review_state, i.pr_check_status,
147
+ active_run.run_type AS active_run_type,
148
+ latest_run.run_type AS latest_run_type,
149
+ latest_run.status AS latest_run_status
150
+ FROM issues i
151
+ LEFT JOIN runs active_run ON active_run.id = i.active_run_id
152
+ LEFT JOIN runs latest_run ON latest_run.id = (
153
+ SELECT r.id FROM runs r
154
+ WHERE r.project_id = i.project_id AND r.linear_issue_id = i.linear_issue_id
155
+ ORDER BY r.id DESC LIMIT 1
156
+ )
157
+ ORDER BY i.updated_at DESC, i.issue_key ASC`)
158
+ .all();
159
+ return rows.map((row) => ({
160
+ ...(row.issue_key !== null ? { issueKey: String(row.issue_key) } : {}),
161
+ ...(row.title !== null ? { title: String(row.title) } : {}),
162
+ projectId: String(row.project_id),
163
+ factoryState: String(row.factory_state ?? "delegated"),
164
+ ...(row.current_linear_state !== null ? { currentLinearState: String(row.current_linear_state) } : {}),
165
+ ...(row.active_run_type !== null ? { activeRunType: String(row.active_run_type) } : {}),
166
+ ...(row.latest_run_type !== null ? { latestRunType: String(row.latest_run_type) } : {}),
167
+ ...(row.latest_run_status !== null ? { latestRunStatus: String(row.latest_run_status) } : {}),
168
+ ...(row.pr_number !== null ? { prNumber: Number(row.pr_number) } : {}),
169
+ ...(row.pr_review_state !== null ? { prReviewState: String(row.pr_review_state) } : {}),
170
+ ...(row.pr_check_status !== null ? { prCheckStatus: String(row.pr_check_status) } : {}),
171
+ updatedAt: String(row.updated_at),
172
+ }));
173
+ }
174
+ subscribeCodexNotifications(listener) {
175
+ const handler = (notification) => {
176
+ const threadId = typeof notification.params.threadId === "string"
177
+ ? notification.params.threadId
178
+ : typeof notification.params.thread === "object" && notification.params.thread !== null && "id" in notification.params.thread
179
+ ? String(notification.params.thread.id)
180
+ : undefined;
181
+ let issueKey;
182
+ let runId;
183
+ if (threadId) {
184
+ const run = this.db.getRunByThreadId(threadId);
185
+ if (run) {
186
+ runId = run.id;
187
+ const issue = this.db.getIssue(run.projectId, run.linearIssueId);
188
+ issueKey = issue?.issueKey ?? undefined;
189
+ }
190
+ }
191
+ listener({
192
+ method: notification.method,
193
+ params: notification.params,
194
+ ...(issueKey ? { issueKey } : {}),
195
+ ...(runId !== undefined ? { runId } : {}),
196
+ });
197
+ };
198
+ this.codex.on("notification", handler);
199
+ return () => { this.codex.off("notification", handler); };
200
+ }
141
201
  listOperatorFeed(options) {
142
202
  return this.feed.list(options);
143
203
  }