patchrelay 0.36.13 → 0.36.14

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.36.13",
4
- "commit": "e015f208d2a5",
5
- "builtAt": "2026-04-10T03:05:43.066Z"
3
+ "version": "0.36.14",
4
+ "commit": "cf7280bff401",
5
+ "builtAt": "2026-04-10T04:01:33.969Z"
6
6
  }
@@ -2,8 +2,10 @@ import { buildAgentSessionPlanForIssue } from "./agent-session-plan.js";
2
2
  import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
3
3
  import { deriveIssueStatusNote } from "./status-note.js";
4
4
  import { derivePatchRelayWaitingReason } from "./waiting-reason.js";
5
- import { resolvePreferredReviewLinearState, resolvePreferredStartedLinearState } from "./linear-workflow.js";
5
+ import { resolvePreferredDeployingLinearState, resolvePreferredHumanNeededLinearState, resolvePreferredImplementingLinearState, resolvePreferredReviewLinearState, resolvePreferredReviewingLinearState, } from "./linear-workflow.js";
6
+ import { sanitizeOperatorFacingCommand, sanitizeOperatorFacingText } from "./presentation-text.js";
6
7
  const PROGRESS_THROTTLE_MS = 5_000;
8
+ const MAX_PROGRESS_TEXT_LENGTH = 220;
7
9
  export class LinearSessionSync {
8
10
  config;
9
11
  db;
@@ -11,6 +13,9 @@ export class LinearSessionSync {
11
13
  logger;
12
14
  feed;
13
15
  progressThrottle = new Map();
16
+ workingOnPublishedRuns = new Set();
17
+ agentMessageBuffers = new Map();
18
+ agentMessageProgressPublished = new Set();
14
19
  constructor(config, db, linearProvider, logger, feed) {
15
20
  this.config = config;
16
21
  this.db = db;
@@ -166,21 +171,103 @@ export class LinearSessionSync {
166
171
  }
167
172
  }
168
173
  maybeEmitProgress(notification, run) {
169
- const activity = resolveProgressActivity(notification);
170
- if (!activity)
174
+ const issue = this.db.issues.getIssue(run.projectId, run.linearIssueId);
175
+ if (!issue)
171
176
  return;
172
- const now = Date.now();
173
- const lastEmit = this.progressThrottle.get(run.id) ?? 0;
174
- if (now - lastEmit < PROGRESS_THROTTLE_MS)
177
+ const agentSentence = this.consumeAgentMessageSentence(notification, run);
178
+ const workingOn = this.resolveWorkingOnActivity(notification, agentSentence?.sentence);
179
+ if (workingOn && !this.workingOnPublishedRuns.has(run.id)) {
180
+ this.workingOnPublishedRuns.add(run.id);
181
+ void this.emitActivity(issue, workingOn);
182
+ }
183
+ const progress = this.resolveEphemeralProgressActivity(notification, agentSentence?.sentence);
184
+ if (!progress)
175
185
  return;
176
- this.progressThrottle.set(run.id, now);
177
- const issue = this.db.issues.getIssue(run.projectId, run.linearIssueId);
178
- if (issue) {
179
- void this.emitActivity(issue, activity, { ephemeral: true });
186
+ if (!progress.bypassThrottle) {
187
+ const now = Date.now();
188
+ const lastEmit = this.progressThrottle.get(run.id) ?? 0;
189
+ if (now - lastEmit < PROGRESS_THROTTLE_MS)
190
+ return;
191
+ this.progressThrottle.set(run.id, now);
180
192
  }
193
+ void this.emitActivity(issue, progress.activity, { ephemeral: true });
181
194
  }
182
195
  clearProgress(runId) {
183
196
  this.progressThrottle.delete(runId);
197
+ this.workingOnPublishedRuns.delete(runId);
198
+ for (const key of this.agentMessageBuffers.keys()) {
199
+ if (key.startsWith(`${runId}:`)) {
200
+ this.agentMessageBuffers.delete(key);
201
+ }
202
+ }
203
+ for (const key of this.agentMessageProgressPublished) {
204
+ if (key.startsWith(`${runId}:`)) {
205
+ this.agentMessageProgressPublished.delete(key);
206
+ }
207
+ }
208
+ }
209
+ resolveWorkingOnActivity(notification, agentSentence) {
210
+ const summary = resolveWorkingOnSummary(notification) ?? agentSentence;
211
+ if (!summary)
212
+ return undefined;
213
+ return { type: "response", body: `Working on: ${summary}` };
214
+ }
215
+ resolveEphemeralProgressActivity(notification, agentSentence) {
216
+ if (notification.method === "item/started") {
217
+ const item = notification.params.item;
218
+ if (!item)
219
+ return undefined;
220
+ const type = typeof item.type === "string" ? item.type : undefined;
221
+ if (type === "commandExecution") {
222
+ const cmd = item.command;
223
+ const cmdStr = Array.isArray(cmd)
224
+ ? sanitizeOperatorFacingCommand(cmd.map((part) => String(part)).join(" "))
225
+ : sanitizeOperatorFacingCommand(typeof cmd === "string" ? cmd : undefined);
226
+ return { activity: { type: "action", action: "Running", parameter: truncateProgressText(cmdStr ?? "command", 120) } };
227
+ }
228
+ if (type === "mcpToolCall") {
229
+ const server = typeof item.server === "string" ? item.server : "";
230
+ const tool = typeof item.tool === "string" ? item.tool : "";
231
+ return { activity: { type: "action", action: "Using", parameter: `${server}/${tool}` } };
232
+ }
233
+ if (type === "dynamicToolCall") {
234
+ const tool = typeof item.tool === "string" ? item.tool : "tool";
235
+ return { activity: { type: "action", action: "Using", parameter: tool } };
236
+ }
237
+ }
238
+ if (agentSentence) {
239
+ return {
240
+ activity: { type: "thought", body: agentSentence },
241
+ bypassThrottle: true,
242
+ };
243
+ }
244
+ return undefined;
245
+ }
246
+ consumeAgentMessageSentence(notification, run) {
247
+ const messageKey = resolveAgentMessageKey(notification, run);
248
+ if (!messageKey)
249
+ return undefined;
250
+ if (this.agentMessageProgressPublished.has(messageKey))
251
+ return undefined;
252
+ const delta = resolveAgentMessageDelta(notification);
253
+ if (delta) {
254
+ const previous = this.agentMessageBuffers.get(messageKey) ?? "";
255
+ const next = `${previous}${delta}`;
256
+ this.agentMessageBuffers.set(messageKey, next);
257
+ const sentence = extractFirstSentence(next);
258
+ if (!sentence)
259
+ return undefined;
260
+ this.agentMessageProgressPublished.add(messageKey);
261
+ return { sentence };
262
+ }
263
+ const completedText = resolveCompletedAgentMessageText(notification);
264
+ if (!completedText)
265
+ return undefined;
266
+ const sentence = extractFirstSentence(completedText);
267
+ if (!sentence)
268
+ return undefined;
269
+ this.agentMessageProgressPublished.add(messageKey);
270
+ return { sentence };
184
271
  }
185
272
  async syncStatusComment(issue, linear, options) {
186
273
  try {
@@ -205,29 +292,6 @@ export class LinearSessionSync {
205
292
  }
206
293
  }
207
294
  }
208
- function resolveProgressActivity(notification) {
209
- if (notification.method === "item/started") {
210
- const item = notification.params.item;
211
- if (!item)
212
- return undefined;
213
- const type = typeof item.type === "string" ? item.type : undefined;
214
- if (type === "commandExecution") {
215
- const cmd = item.command;
216
- const cmdStr = Array.isArray(cmd) ? cmd.join(" ") : typeof cmd === "string" ? cmd : undefined;
217
- return { type: "action", action: "Running", parameter: cmdStr?.slice(0, 120) ?? "command" };
218
- }
219
- if (type === "mcpToolCall") {
220
- const server = typeof item.server === "string" ? item.server : "";
221
- const tool = typeof item.tool === "string" ? item.tool : "";
222
- return { type: "action", action: "Using", parameter: `${server}/${tool}` };
223
- }
224
- if (type === "dynamicToolCall") {
225
- const tool = typeof item.tool === "string" ? item.tool : "tool";
226
- return { type: "action", action: "Using", parameter: tool };
227
- }
228
- }
229
- return undefined;
230
- }
231
295
  function renderStatusComment(db, issue, trackedIssue, options) {
232
296
  const activeRun = issue.activeRunId ? db.runs.getRunById(issue.activeRunId) : undefined;
233
297
  const latestRun = db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
@@ -343,32 +407,127 @@ function humanize(value) {
343
407
  }
344
408
  function shouldAutoAdvanceLinearState(issue) {
345
409
  const normalizedType = issue.currentLinearStateType?.trim().toLowerCase();
346
- if (normalizedType === "backlog" || normalizedType === "unstarted") {
347
- return true;
410
+ if (normalizedType === "completed" || normalizedType === "canceled" || normalizedType === "cancelled") {
411
+ return false;
348
412
  }
349
413
  const normalizedName = issue.currentLinearState?.trim().toLowerCase();
350
- return normalizedName === "backlog" || normalizedName === "todo" || normalizedName === "to do" || normalizedName === "triage";
414
+ return normalizedName !== "done" && normalizedName !== "completed" && normalizedName !== "complete";
351
415
  }
352
416
  function resolveDesiredActiveWorkflowState(issue, trackedIssue, options, liveIssue) {
417
+ if (issue.factoryState === "awaiting_input" || issue.factoryState === "failed" || issue.factoryState === "escalated"
418
+ || trackedIssue?.sessionState === "waiting_input" || trackedIssue?.sessionState === "failed") {
419
+ return resolvePreferredHumanNeededLinearState(liveIssue);
420
+ }
421
+ const activelyWorking = issue.activeRunId !== undefined
422
+ || options?.activeRunType !== undefined
423
+ || trackedIssue?.sessionState === "running"
424
+ || issue.factoryState === "delegated"
425
+ || issue.factoryState === "implementing"
426
+ || issue.factoryState === "changes_requested"
427
+ || issue.factoryState === "repairing_ci"
428
+ || issue.factoryState === "repairing_queue";
429
+ if (activelyWorking) {
430
+ return resolvePreferredImplementingLinearState(liveIssue);
431
+ }
432
+ if (issue.factoryState === "awaiting_queue"
433
+ || issue.prReviewState === "approved"
434
+ || isApprovedAndGreen(issue.prReviewState, issue.prCheckStatus)) {
435
+ return resolvePreferredDeployingLinearState(liveIssue);
436
+ }
437
+ const reviewQuillActive = hasPendingReviewQuillVerdict(issue.lastGitHubCiSnapshotJson);
438
+ if (reviewQuillActive) {
439
+ return resolvePreferredReviewingLinearState(liveIssue);
440
+ }
353
441
  const reviewBound = issue.prNumber !== undefined
354
442
  || Boolean(issue.prUrl)
355
443
  || issue.factoryState === "pr_open"
356
- || issue.factoryState === "awaiting_queue"
357
- || issue.factoryState === "changes_requested"
358
- || issue.factoryState === "repairing_ci"
359
- || issue.factoryState === "repairing_queue"
360
444
  || issue.prReviewState !== undefined
361
445
  || issue.prCheckStatus !== undefined;
362
446
  if (reviewBound) {
363
447
  return resolvePreferredReviewLinearState(liveIssue);
364
448
  }
365
- const activelyWorking = issue.activeRunId !== undefined
366
- || options?.activeRunType !== undefined
367
- || trackedIssue?.sessionState === "running"
368
- || issue.factoryState === "delegated"
369
- || issue.factoryState === "implementing";
370
- if (activelyWorking) {
371
- return resolvePreferredStartedLinearState(liveIssue);
449
+ return undefined;
450
+ }
451
+ function isApprovedAndGreen(prReviewState, prCheckStatus) {
452
+ const normalizedReview = prReviewState?.trim().toLowerCase();
453
+ const normalizedChecks = prCheckStatus?.trim().toLowerCase();
454
+ return normalizedReview === "approved" && (normalizedChecks === "success" || normalizedChecks === "passed");
455
+ }
456
+ function hasPendingReviewQuillVerdict(snapshotJson) {
457
+ if (!snapshotJson)
458
+ return false;
459
+ try {
460
+ const parsed = JSON.parse(snapshotJson);
461
+ return Array.isArray(parsed.checks) && parsed.checks.some((check) => typeof check.name === "string"
462
+ && check.name === "review-quill/verdict"
463
+ && typeof check.status === "string"
464
+ && check.status.toLowerCase() === "pending");
465
+ }
466
+ catch {
467
+ return false;
468
+ }
469
+ }
470
+ function resolveWorkingOnSummary(notification) {
471
+ if (notification.method !== "turn/plan/updated") {
472
+ return undefined;
473
+ }
474
+ const plan = notification.params.plan;
475
+ if (!Array.isArray(plan))
476
+ return undefined;
477
+ const ranked = plan
478
+ .map((entry) => entry)
479
+ .filter((entry) => typeof entry.step === "string" && entry.step.trim().length > 0)
480
+ .sort((a, b) => rankPlanStatus(a.status) - rankPlanStatus(b.status));
481
+ const first = ranked[0];
482
+ return summarizeProgressSentence(typeof first?.step === "string" ? first.step : undefined);
483
+ }
484
+ function rankPlanStatus(status) {
485
+ return status === "inProgress" ? 0
486
+ : status === "pending" ? 1
487
+ : status === "completed" ? 2
488
+ : 3;
489
+ }
490
+ function resolveAgentMessageKey(notification, run) {
491
+ if (notification.method === "item/agentMessage/delta") {
492
+ const itemId = typeof notification.params.itemId === "string" ? notification.params.itemId : undefined;
493
+ return itemId ? `${run.id}:${itemId}` : undefined;
494
+ }
495
+ if (notification.method === "item/completed") {
496
+ const item = notification.params.item;
497
+ const itemId = typeof item?.id === "string" ? item.id : undefined;
498
+ const itemType = typeof item?.type === "string" ? item.type : undefined;
499
+ return itemId && itemType === "agentMessage" ? `${run.id}:${itemId}` : undefined;
372
500
  }
373
501
  return undefined;
374
502
  }
503
+ function resolveAgentMessageDelta(notification) {
504
+ if (notification.method !== "item/agentMessage/delta") {
505
+ return undefined;
506
+ }
507
+ return typeof notification.params.delta === "string" ? notification.params.delta : undefined;
508
+ }
509
+ function resolveCompletedAgentMessageText(notification) {
510
+ if (notification.method !== "item/completed") {
511
+ return undefined;
512
+ }
513
+ const item = notification.params.item;
514
+ if (!item || item.type !== "agentMessage")
515
+ return undefined;
516
+ return typeof item.text === "string" ? item.text : undefined;
517
+ }
518
+ function extractFirstSentence(text) {
519
+ const sanitized = sanitizeOperatorFacingText(text)?.replace(/\s+/g, " ").trim();
520
+ if (!sanitized)
521
+ return undefined;
522
+ const match = sanitized.match(/^(.+?[.!?])(?:\s|$)/);
523
+ return truncateProgressText((match?.[1] ?? sanitized).trim(), MAX_PROGRESS_TEXT_LENGTH);
524
+ }
525
+ function summarizeProgressSentence(text) {
526
+ const summary = extractFirstSentence(text);
527
+ if (!summary)
528
+ return undefined;
529
+ return summary.endsWith(".") || summary.endsWith("!") || summary.endsWith("?") ? summary : `${summary}.`;
530
+ }
531
+ function truncateProgressText(text, maxLength) {
532
+ return text.length <= maxLength ? text : `${text.slice(0, maxLength - 3).trimEnd()}...`;
533
+ }
@@ -2,6 +2,20 @@ function normalizeLinearState(value) {
2
2
  const trimmed = value?.trim();
3
3
  return trimmed ? trimmed.toLowerCase() : undefined;
4
4
  }
5
+ function includesAny(normalized, candidates) {
6
+ return Boolean(normalized && candidates.includes(normalized));
7
+ }
8
+ function resolvePreferredLinearState(issue, params) {
9
+ const match = issue.workflowStates.find((state) => {
10
+ const normalizedType = normalizeLinearState(state.type);
11
+ const normalizedName = normalizeLinearState(state.name);
12
+ if (params.types && !params.types.includes(normalizedType ?? "")) {
13
+ return false;
14
+ }
15
+ return includesAny(normalizedName, params.names);
16
+ });
17
+ return match?.name ?? params.fallback;
18
+ }
5
19
  export function resolvePreferredStartedLinearState(issue) {
6
20
  const startedStates = issue.workflowStates.filter((state) => normalizeLinearState(state.type) === "started");
7
21
  const preferred = startedStates.find((state) => {
@@ -10,14 +24,50 @@ export function resolvePreferredStartedLinearState(issue) {
10
24
  });
11
25
  return preferred?.name ?? startedStates[0]?.name;
12
26
  }
27
+ export function resolvePreferredImplementingLinearState(issue) {
28
+ return resolvePreferredLinearState(issue, {
29
+ names: ["implementing", "in progress", "in-progress", "started", "doing"],
30
+ types: ["started"],
31
+ fallback: resolvePreferredStartedLinearState(issue),
32
+ });
33
+ }
13
34
  export function resolvePreferredReviewLinearState(issue) {
14
- const reviewState = issue.workflowStates.find((state) => {
15
- if (normalizeLinearState(state.type) !== "started")
16
- return false;
17
- const normalized = normalizeLinearState(state.name);
18
- return normalized === "in review" || normalized === "review";
35
+ return resolvePreferredLinearState(issue, {
36
+ names: ["review", "awaiting review"],
37
+ types: ["unstarted"],
38
+ fallback: resolvePreferredLinearState(issue, {
39
+ names: ["reviewing", "in review", "review"],
40
+ types: ["started"],
41
+ fallback: resolvePreferredStartedLinearState(issue),
42
+ }),
43
+ });
44
+ }
45
+ export function resolvePreferredReviewingLinearState(issue) {
46
+ return resolvePreferredLinearState(issue, {
47
+ names: ["reviewing", "in review", "review"],
48
+ types: ["started"],
49
+ fallback: resolvePreferredReviewLinearState(issue),
50
+ });
51
+ }
52
+ export function resolvePreferredDeployLinearState(issue) {
53
+ return resolvePreferredLinearState(issue, {
54
+ names: ["deploy", "ready to deploy", "ready for deploy", "merge"],
55
+ types: ["unstarted"],
56
+ fallback: resolvePreferredReviewLinearState(issue),
57
+ });
58
+ }
59
+ export function resolvePreferredDeployingLinearState(issue) {
60
+ return resolvePreferredLinearState(issue, {
61
+ names: ["deploying", "merging", "shipping"],
62
+ types: ["started"],
63
+ fallback: resolvePreferredDeployLinearState(issue),
64
+ });
65
+ }
66
+ export function resolvePreferredHumanNeededLinearState(issue) {
67
+ return resolvePreferredLinearState(issue, {
68
+ names: ["human needed", "needs human", "help needed", "operator needed", "blocked"],
69
+ fallback: undefined,
19
70
  });
20
- return reviewState?.name ?? resolvePreferredStartedLinearState(issue);
21
71
  }
22
72
  export function resolvePreferredCompletedLinearState(issue) {
23
73
  const completed = issue.workflowStates.find((state) => normalizeLinearState(state.type) === "completed");
@@ -1,7 +1,16 @@
1
1
  function unwrapShellWrappedCommand(text) {
2
2
  return text
3
3
  .replace(/`(?:\/bin\/bash|bash|\/bin\/sh|sh)\s+-lc\s+'([^`\n]+)'`/g, "`$1`")
4
- .replace(/`(?:\/bin\/bash|bash|\/bin\/sh|sh)\s+-lc\s+"([^`\n]+)"`/g, "`$1`");
4
+ .replace(/`(?:\/bin\/bash|bash|\/bin\/sh|sh)\s+-lc\s+"([^`\n]+)"`/g, "`$1`")
5
+ .replace(/^(?:\/bin\/bash|bash|\/bin\/sh|sh)\s+-lc\s+'([^`\n]+)'$/g, "$1")
6
+ .replace(/^(?:\/bin\/bash|bash|\/bin\/sh|sh)\s+-lc\s+"([^`\n]+)"$/g, "$1");
7
+ }
8
+ export function sanitizeOperatorFacingCommand(command) {
9
+ const trimmed = command?.trim();
10
+ if (!trimmed) {
11
+ return undefined;
12
+ }
13
+ return unwrapShellWrappedCommand(trimmed);
5
14
  }
6
15
  export function sanitizeOperatorFacingText(text) {
7
16
  const trimmed = text?.trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.36.13",
3
+ "version": "0.36.14",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {