patchrelay 0.7.4 → 0.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ function formatStageLabel(stage) {
2
+ return stage.replace(/[-_]+/g, " ");
3
+ }
4
+ function titleCase(value) {
5
+ return value
6
+ .split(/\s+/)
7
+ .filter(Boolean)
8
+ .map((word) => word[0].toUpperCase() + word.slice(1))
9
+ .join(" ");
10
+ }
11
+ function buildPlan(stage, statuses) {
12
+ const stageLabel = titleCase(formatStageLabel(stage));
13
+ return [
14
+ { label: "Prepare workspace", status: statuses[0] },
15
+ { label: `Run ${stageLabel} workflow`, status: statuses[1] },
16
+ { label: "Review next Linear step", status: statuses[2] },
17
+ ];
18
+ }
19
+ export function buildPreparingSessionPlan(stage) {
20
+ return buildPlan(stage, ["in_progress", "pending", "pending"]);
21
+ }
22
+ export function buildRunningSessionPlan(stage) {
23
+ return buildPlan(stage, ["completed", "in_progress", "pending"]);
24
+ }
25
+ export function buildCompletedSessionPlan(stage) {
26
+ return buildPlan(stage, ["completed", "completed", "completed"]);
27
+ }
28
+ export function buildAwaitingHandoffSessionPlan(stage) {
29
+ return buildPlan(stage, ["completed", "completed", "in_progress"]);
30
+ }
31
+ export function buildFailedSessionPlan(stage, stageRun) {
32
+ const workflowStepStatus = stageRun?.threadId || stageRun?.turnId ? "completed" : "in_progress";
33
+ return buildPlan(stage, ["completed", workflowStepStatus, "pending"]);
34
+ }
@@ -0,0 +1,22 @@
1
+ import { buildSessionStatusUrl, createSessionStatusToken, deriveSessionStatusSigningSecret, } from "./public-agent-session-status.js";
2
+ const SESSION_STATUS_TTL_SECONDS = 60 * 60 * 24 * 7;
3
+ export function buildAgentSessionExternalUrls(config, issueKey) {
4
+ if (!issueKey || !config.server.publicBaseUrl) {
5
+ return undefined;
6
+ }
7
+ const token = createSessionStatusToken({
8
+ issueKey,
9
+ secret: deriveSessionStatusSigningSecret(config.linear.tokenEncryptionKey),
10
+ ttlSeconds: SESSION_STATUS_TTL_SECONDS,
11
+ });
12
+ return [
13
+ {
14
+ label: "PatchRelay status",
15
+ url: buildSessionStatusUrl({
16
+ publicBaseUrl: config.server.publicBaseUrl,
17
+ issueKey,
18
+ token: token.token,
19
+ }),
20
+ },
21
+ ];
22
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.7.4",
4
- "commit": "3a0fe6ac72e2",
5
- "builtAt": "2026-03-14T16:59:43.147Z"
3
+ "version": "0.7.6",
4
+ "commit": "cd9193b4a7b1",
5
+ "builtAt": "2026-03-17T08:05:09.587Z"
6
6
  }
package/dist/http.js CHANGED
@@ -177,6 +177,36 @@ export async function buildHttpServer(config, service, logger) {
177
177
  });
178
178
  return reply.code(result.status).send(result.body);
179
179
  });
180
+ app.get("/agent/session/:issueKey", async (request, reply) => {
181
+ const issueKey = request.params.issueKey;
182
+ const token = getQueryParam(request, "token");
183
+ if (!token) {
184
+ return reply
185
+ .code(401)
186
+ .type("text/html; charset=utf-8")
187
+ .send(renderAgentSessionStatusErrorPage("Missing access token."));
188
+ }
189
+ const status = await service.getPublicAgentSessionStatus({ issueKey, token });
190
+ if (status.status === "invalid_token") {
191
+ return reply
192
+ .code(401)
193
+ .type("text/html; charset=utf-8")
194
+ .send(renderAgentSessionStatusErrorPage("The access token is invalid or expired."));
195
+ }
196
+ if (status.status === "issue_not_found") {
197
+ return reply
198
+ .code(404)
199
+ .type("text/html; charset=utf-8")
200
+ .send(renderAgentSessionStatusErrorPage("Issue status is not available."));
201
+ }
202
+ return reply
203
+ .type("text/html; charset=utf-8")
204
+ .send(renderAgentSessionStatusPage({
205
+ issueKey,
206
+ expiresAt: status.expiresAt,
207
+ sessionStatus: status.sessionStatus,
208
+ }));
209
+ });
180
210
  if (config.operatorApi.enabled) {
181
211
  app.addHook("onRequest", async (request, reply) => {
182
212
  if (!request.url.startsWith("/api/")) {
@@ -218,6 +248,19 @@ export async function buildHttpServer(config, service, logger) {
218
248
  }
219
249
  return reply.send({ ok: true, ...result });
220
250
  });
251
+ app.get("/api/issues/:issueKey/session-url", async (request, reply) => {
252
+ const issueKey = request.params.issueKey;
253
+ const ttlSeconds = getPositiveIntegerQueryParam(request, "ttlSeconds");
254
+ const issue = await service.getIssueOverview(issueKey);
255
+ if (!issue) {
256
+ return reply.code(404).send({ ok: false, reason: "issue_not_found" });
257
+ }
258
+ const link = service.createPublicAgentSessionStatusLink(issueKey, ttlSeconds ? { ttlSeconds } : undefined);
259
+ if (!link) {
260
+ return reply.code(503).send({ ok: false, reason: "public_base_url_not_configured" });
261
+ }
262
+ return reply.send({ ok: true, ...link });
263
+ });
221
264
  }
222
265
  if (managementRoutesEnabled) {
223
266
  app.get("/api/feed", async (request, reply) => {
@@ -371,3 +414,140 @@ function renderOAuthResult(message) {
371
414
  </body>
372
415
  </html>`;
373
416
  }
417
+ function renderAgentSessionStatusErrorPage(message) {
418
+ return `<!doctype html>
419
+ <html lang="en">
420
+ <head>
421
+ <meta charset="utf-8">
422
+ <meta name="viewport" content="width=device-width, initial-scale=1">
423
+ <title>PatchRelay Agent Status</title>
424
+ <style>
425
+ body { font-family: Georgia, "Times New Roman", serif; background: #f7f4ef; color: #1f1d1a; margin: 0; padding: 32px; }
426
+ main { max-width: 720px; margin: 10vh auto; background: rgba(255,255,255,0.86); border: 1px solid rgba(31,29,26,0.12); border-radius: 20px; padding: 32px; box-shadow: 0 28px 80px rgba(49,42,30,0.10); }
427
+ p { font-size: 18px; line-height: 1.6; color: #4d483f; }
428
+ </style>
429
+ </head>
430
+ <body>
431
+ <main>
432
+ <h1>PatchRelay Agent Session</h1>
433
+ <p>${escapeHtml(message)}</p>
434
+ </main>
435
+ </body>
436
+ </html>`;
437
+ }
438
+ function renderAgentSessionStatusPage(params) {
439
+ const issueTitle = params.sessionStatus.issue.title ?? params.sessionStatus.issue.issueKey ?? params.issueKey;
440
+ const issueUrl = params.sessionStatus.issue.issueUrl;
441
+ const activeStage = formatStageChip(params.sessionStatus.activeStageRun);
442
+ const latestStage = formatStageChip(params.sessionStatus.latestStageRun);
443
+ const threadInfo = formatThread(params.sessionStatus.liveThread);
444
+ const stagesRows = params.sessionStatus.stages.slice(-8).map((entry) => formatStageRow(entry.stageRun)).join("");
445
+ return `<!doctype html>
446
+ <html lang="en">
447
+ <head>
448
+ <meta charset="utf-8">
449
+ <meta name="viewport" content="width=device-width, initial-scale=1">
450
+ <title>PatchRelay Agent Session ${escapeHtml(params.issueKey)}</title>
451
+ <style>
452
+ :root {
453
+ color-scheme: light;
454
+ --bg: #f5f1e8;
455
+ --panel: rgba(255,255,255,0.84);
456
+ --ink: #1f1d1a;
457
+ --muted: #5b554b;
458
+ --accent: #1f6d57;
459
+ --line: rgba(31,29,26,0.15);
460
+ }
461
+ * { box-sizing: border-box; }
462
+ body {
463
+ margin: 0;
464
+ min-height: 100vh;
465
+ font-family: Georgia, "Times New Roman", serif;
466
+ color: var(--ink);
467
+ background:
468
+ radial-gradient(circle at top left, rgba(184, 139, 68, 0.22), transparent 36%),
469
+ radial-gradient(circle at bottom right, rgba(31, 109, 87, 0.16), transparent 30%),
470
+ linear-gradient(150deg, #eee3cf 0%, var(--bg) 52%, #f8f6f1 100%);
471
+ padding: 24px;
472
+ }
473
+ main {
474
+ width: min(920px, 100%);
475
+ margin: 0 auto;
476
+ background: var(--panel);
477
+ border: 1px solid var(--line);
478
+ border-radius: 22px;
479
+ padding: 32px;
480
+ box-shadow: 0 30px 86px rgba(49,42,30,0.12);
481
+ }
482
+ h1 { margin: 0; font-size: clamp(34px, 7vw, 52px); line-height: 1.05; }
483
+ p { color: var(--muted); font-size: 17px; line-height: 1.6; margin: 12px 0 0; }
484
+ a { color: var(--accent); text-decoration: none; }
485
+ a:hover { text-decoration: underline; }
486
+ .chips { display: flex; flex-wrap: wrap; gap: 10px; margin: 20px 0 6px; }
487
+ .chip { border: 1px solid var(--line); border-radius: 999px; padding: 9px 14px; background: rgba(255,255,255,0.74); font-size: 14px; }
488
+ .section { margin-top: 24px; padding-top: 18px; border-top: 1px solid var(--line); }
489
+ .section h2 { margin: 0; font-size: 22px; }
490
+ table { width: 100%; border-collapse: collapse; margin-top: 14px; }
491
+ th, td { text-align: left; border-bottom: 1px solid var(--line); padding: 10px 8px; vertical-align: top; }
492
+ th { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: #5f594e; }
493
+ td { font-size: 15px; color: #2a2622; }
494
+ code { font-family: "SFMono-Regular", "Cascadia Code", "Fira Code", monospace; font-size: 0.95em; }
495
+ </style>
496
+ </head>
497
+ <body>
498
+ <main>
499
+ <h1>${escapeHtml(issueTitle)}</h1>
500
+ <p>PatchRelay read-only agent session status for <code>${escapeHtml(params.issueKey)}</code>.</p>
501
+ ${issueUrl ? `<p><a href="${escapeHtml(issueUrl)}" target="_blank" rel="noopener noreferrer">Open issue in Linear</a></p>` : ""}
502
+ <div class="chips">
503
+ <span class="chip"><strong>Active:</strong> ${activeStage}</span>
504
+ <span class="chip"><strong>Latest:</strong> ${latestStage}</span>
505
+ <span class="chip"><strong>Thread:</strong> ${threadInfo}</span>
506
+ </div>
507
+ <div class="section">
508
+ <h2>Recent Stages</h2>
509
+ <table>
510
+ <thead>
511
+ <tr>
512
+ <th>Stage</th>
513
+ <th>Status</th>
514
+ <th>Started</th>
515
+ <th>Ended</th>
516
+ </tr>
517
+ </thead>
518
+ <tbody>
519
+ ${stagesRows || '<tr><td colspan="4">No completed stage runs yet.</td></tr>'}
520
+ </tbody>
521
+ </table>
522
+ </div>
523
+ <p>Snapshot generated at <code>${escapeHtml(params.sessionStatus.generatedAt)}</code>. Link valid until <code>${escapeHtml(params.expiresAt)}</code>.</p>
524
+ </main>
525
+ </body>
526
+ </html>`;
527
+ }
528
+ function formatStageChip(stageRun) {
529
+ if (!stageRun) {
530
+ return "none";
531
+ }
532
+ const stage = stageRun.stage ?? "unknown";
533
+ const status = stageRun.status ?? "unknown";
534
+ return `<code>${escapeHtml(stage)}</code> (${escapeHtml(status)})`;
535
+ }
536
+ function formatThread(liveThread) {
537
+ if (!liveThread) {
538
+ return "idle";
539
+ }
540
+ const threadId = liveThread.threadId ?? "unknown";
541
+ const status = liveThread.threadStatus ?? "unknown";
542
+ return `<code>${escapeHtml(threadId)}</code> (${escapeHtml(status)})`;
543
+ }
544
+ function formatStageRow(stageRun) {
545
+ if (!stageRun) {
546
+ return '<tr><td colspan="4">Unknown stage record</td></tr>';
547
+ }
548
+ const stage = stageRun.stage ?? "unknown";
549
+ const status = stageRun.status ?? "unknown";
550
+ const startedAt = stageRun.startedAt ?? "-";
551
+ const endedAt = stageRun.endedAt ?? "-";
552
+ return `<tr><td><code>${escapeHtml(stage)}</code></td><td>${escapeHtml(status)}</td><td><code>${escapeHtml(startedAt)}</code></td><td><code>${escapeHtml(endedAt)}</code></td></tr>`;
553
+ }
@@ -65,4 +65,19 @@ export class IssueQueryService {
65
65
  async getActiveStageStatus(issueKey) {
66
66
  return await this.stageFinalizer.getActiveStageStatus(issueKey);
67
67
  }
68
+ async getPublicAgentSessionStatus(issueKey) {
69
+ const overview = await this.getIssueOverview(issueKey);
70
+ if (!overview) {
71
+ return undefined;
72
+ }
73
+ const report = await this.getIssueReport(issueKey);
74
+ return {
75
+ issue: overview.issue,
76
+ ...(overview.activeStageRun ? { activeStageRun: overview.activeStageRun } : {}),
77
+ ...(overview.latestStageRun ? { latestStageRun: overview.latestStageRun } : {}),
78
+ ...(overview.liveThread ? { liveThread: overview.liveThread } : {}),
79
+ stages: report?.stages ?? [],
80
+ generatedAt: new Date().toISOString(),
81
+ };
82
+ }
68
83
  }
@@ -156,6 +156,32 @@ export class LinearGraphqlClient {
156
156
  }
157
157
  return response.agentActivityCreate.agentActivity;
158
158
  }
159
+ async updateAgentSession(params) {
160
+ const input = {};
161
+ if ("externalUrls" in params) {
162
+ input.externalUrls = params.externalUrls;
163
+ }
164
+ if ("plan" in params) {
165
+ input.plan = params.plan;
166
+ }
167
+ const response = await this.request(`
168
+ mutation PatchRelayUpdateAgentSession($id: String!, $input: AgentSessionUpdateInput!) {
169
+ agentSessionUpdate(id: $id, input: $input) {
170
+ success
171
+ agentSession {
172
+ id
173
+ }
174
+ }
175
+ }
176
+ `, {
177
+ id: params.agentSessionId,
178
+ input,
179
+ });
180
+ if (!response.agentSessionUpdate.success || !response.agentSessionUpdate.agentSession) {
181
+ throw new Error(`Linear rejected agent session update for session ${params.agentSessionId}`);
182
+ }
183
+ return response.agentSessionUpdate.agentSession;
184
+ }
159
185
  async updateIssueLabels(params) {
160
186
  const issue = await this.getIssue(params.issueId);
161
187
  const addIds = this.resolveLabelIds(issue, params.addNames ?? []);
@@ -0,0 +1,77 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ const TOKEN_VERSION = 1;
3
+ const DEFAULT_TTL_SECONDS = 60 * 60 * 24;
4
+ const PURPOSE = "patchrelay-agent-session-status";
5
+ export function createSessionStatusToken(params) {
6
+ const nowSeconds = Math.floor((params.nowMs ?? Date.now()) / 1000);
7
+ const ttlSeconds = Number.isFinite(params.ttlSeconds) ? Math.max(60, Math.floor(params.ttlSeconds ?? DEFAULT_TTL_SECONDS)) : DEFAULT_TTL_SECONDS;
8
+ const payload = {
9
+ v: TOKEN_VERSION,
10
+ i: params.issueKey,
11
+ exp: nowSeconds + ttlSeconds,
12
+ };
13
+ const payloadEncoded = encodeBase64Url(Buffer.from(JSON.stringify(payload), "utf8"));
14
+ const signature = signPayload(payloadEncoded, params.secret);
15
+ return {
16
+ token: `${payloadEncoded}.${signature}`,
17
+ issueKey: payload.i,
18
+ expiresAt: new Date(payload.exp * 1000).toISOString(),
19
+ };
20
+ }
21
+ export function verifySessionStatusToken(token, secret, nowMs) {
22
+ const [payloadEncoded, signatureEncoded] = token.split(".", 2);
23
+ if (!payloadEncoded || !signatureEncoded) {
24
+ return undefined;
25
+ }
26
+ const expectedSignature = signPayload(payloadEncoded, secret);
27
+ if (!timingSafeEqualUtf8(signatureEncoded, expectedSignature)) {
28
+ return undefined;
29
+ }
30
+ const payload = parsePayload(payloadEncoded);
31
+ if (!payload || payload.v !== TOKEN_VERSION || typeof payload.i !== "string" || typeof payload.exp !== "number") {
32
+ return undefined;
33
+ }
34
+ const nowSeconds = Math.floor((nowMs ?? Date.now()) / 1000);
35
+ if (payload.exp < nowSeconds) {
36
+ return undefined;
37
+ }
38
+ return {
39
+ issueKey: payload.i,
40
+ expiresAt: new Date(payload.exp * 1000).toISOString(),
41
+ };
42
+ }
43
+ export function buildSessionStatusUrl(params) {
44
+ const url = new URL(`/agent/session/${encodeURIComponent(params.issueKey)}`, params.publicBaseUrl);
45
+ url.searchParams.set("token", params.token);
46
+ return url.toString();
47
+ }
48
+ export function deriveSessionStatusSigningSecret(tokenEncryptionKey) {
49
+ return `${PURPOSE}:${tokenEncryptionKey}`;
50
+ }
51
+ function signPayload(payloadEncoded, secret) {
52
+ const digest = createHmac("sha256", secret).update(payloadEncoded).digest();
53
+ return encodeBase64Url(digest);
54
+ }
55
+ function parsePayload(payloadEncoded) {
56
+ try {
57
+ const decoded = decodeBase64Url(payloadEncoded).toString("utf8");
58
+ return JSON.parse(decoded);
59
+ }
60
+ catch {
61
+ return undefined;
62
+ }
63
+ }
64
+ function encodeBase64Url(value) {
65
+ return value.toString("base64url");
66
+ }
67
+ function decodeBase64Url(value) {
68
+ return Buffer.from(value, "base64url");
69
+ }
70
+ function timingSafeEqualUtf8(left, right) {
71
+ const leftBuffer = Buffer.from(left, "utf8");
72
+ const rightBuffer = Buffer.from(right, "utf8");
73
+ if (leftBuffer.length !== rightBuffer.length) {
74
+ return false;
75
+ }
76
+ return timingSafeEqual(leftBuffer, rightBuffer);
77
+ }
@@ -10,12 +10,17 @@ export class SerialWorkQueue {
10
10
  this.logger = logger;
11
11
  this.getKey = getKey;
12
12
  }
13
- enqueue(item) {
13
+ enqueue(item, options) {
14
14
  const key = this.getKey?.(item);
15
15
  if (key && this.queuedKeys.has(key)) {
16
16
  return;
17
17
  }
18
- this.items.push(item);
18
+ if (options?.priority) {
19
+ this.items.unshift(item);
20
+ }
21
+ else {
22
+ this.items.push(item);
23
+ }
19
24
  if (key) {
20
25
  this.queuedKeys.add(key);
21
26
  }
@@ -80,8 +80,8 @@ export class ServiceRuntime {
80
80
  this.ready = false;
81
81
  void this.codex.stop();
82
82
  }
83
- enqueueWebhookEvent(eventId) {
84
- this.webhookQueue.enqueue(eventId);
83
+ enqueueWebhookEvent(eventId, options) {
84
+ this.webhookQueue.enqueue(eventId, options);
85
85
  }
86
86
  enqueueIssue(projectId, issueId) {
87
87
  this.issueQueue.enqueue({ projectId, issueId });
@@ -133,8 +133,10 @@ export class ServiceStageRunner {
133
133
  failureMessage: "Failed to deliver queued Linear comment during stage startup",
134
134
  ...(claim.issue.issueKey ? { issueKey: claim.issue.issueKey } : {}),
135
135
  });
136
- await this.lifecyclePublisher.refreshRunningStatusComment(item.projectId, item.issueId, claim.stageRun.id, issue.issueKey);
137
- await this.lifecyclePublisher.publishStageStarted(claim.issue, claim.stageRun.stage);
136
+ const deliveredToSession = await this.lifecyclePublisher.publishStageStarted(claim.issue, claim.stageRun.stage);
137
+ if (!deliveredToSession) {
138
+ await this.lifecyclePublisher.refreshRunningStatusComment(item.projectId, item.issueId, claim.stageRun.id, issue.issueKey);
139
+ }
138
140
  this.logger.info({
139
141
  issueKey: issue.issueKey,
140
142
  stage: claim.stageRun.stage,
@@ -24,7 +24,7 @@ export class ServiceWebhookProcessor {
24
24
  this.logger = logger;
25
25
  this.feed = feed;
26
26
  const turnInputDispatcher = new StageTurnInputDispatcher(stores, codex, logger);
27
- const agentActivity = new StageAgentActivityPublisher(linearProvider, logger);
27
+ const agentActivity = new StageAgentActivityPublisher(config, linearProvider, logger);
28
28
  this.desiredStageRecorder = new WebhookDesiredStageRecorder(stores);
29
29
  this.agentSessionHandler = new AgentSessionWebhookHandler(stores, turnInputDispatcher, agentActivity, feed);
30
30
  this.commentHandler = new CommentWebhookHandler(stores, turnInputDispatcher, feed);
package/dist/service.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { IssueQueryService } from "./issue-query-service.js";
2
2
  import { LinearOAuthService } from "./linear-oauth-service.js";
3
3
  import { OperatorEventFeed } from "./operator-feed.js";
4
+ import { buildSessionStatusUrl, createSessionStatusToken, deriveSessionStatusSigningSecret, verifySessionStatusToken, } from "./public-agent-session-status.js";
4
5
  import { ServiceRuntime } from "./service-runtime.js";
5
6
  import { ServiceStageFinalizer } from "./service-stage-finalizer.js";
6
7
  import { ServiceStageRunner } from "./service-stage-runner.js";
@@ -111,7 +112,9 @@ export class PatchRelayService {
111
112
  rawBody: params.rawBody,
112
113
  });
113
114
  if (result.accepted) {
114
- this.runtime.enqueueWebhookEvent(result.accepted.id);
115
+ this.runtime.enqueueWebhookEvent(result.accepted.id, {
116
+ priority: result.accepted.normalized.triggerEvent === "agentSessionCreated",
117
+ });
115
118
  }
116
119
  return {
117
120
  status: result.status,
@@ -136,6 +139,44 @@ export class PatchRelayService {
136
139
  async getActiveStageStatus(issueKey) {
137
140
  return await this.queryService.getActiveStageStatus(issueKey);
138
141
  }
142
+ createPublicAgentSessionStatusLink(issueKey, options) {
143
+ if (!this.config.server.publicBaseUrl) {
144
+ return undefined;
145
+ }
146
+ const signingSecret = deriveSessionStatusSigningSecret(this.config.linear.tokenEncryptionKey);
147
+ const token = createSessionStatusToken({
148
+ issueKey,
149
+ secret: signingSecret,
150
+ ...(options?.nowMs !== undefined ? { nowMs: options.nowMs } : {}),
151
+ ...(options?.ttlSeconds !== undefined ? { ttlSeconds: options.ttlSeconds } : {}),
152
+ });
153
+ return {
154
+ url: buildSessionStatusUrl({
155
+ publicBaseUrl: this.config.server.publicBaseUrl,
156
+ issueKey,
157
+ token: token.token,
158
+ }),
159
+ issueKey: token.issueKey,
160
+ expiresAt: token.expiresAt,
161
+ };
162
+ }
163
+ async getPublicAgentSessionStatus(params) {
164
+ const signingSecret = deriveSessionStatusSigningSecret(this.config.linear.tokenEncryptionKey);
165
+ const parsed = verifySessionStatusToken(params.token, signingSecret, params.nowMs);
166
+ if (!parsed || parsed.issueKey.trim().toLowerCase() !== params.issueKey.trim().toLowerCase()) {
167
+ return { status: "invalid_token" };
168
+ }
169
+ const sessionStatus = await this.queryService.getPublicAgentSessionStatus(params.issueKey);
170
+ if (!sessionStatus) {
171
+ return { status: "issue_not_found" };
172
+ }
173
+ return {
174
+ status: "ok",
175
+ issueKey: params.issueKey,
176
+ expiresAt: parsed.expiresAt,
177
+ sessionStatus,
178
+ };
179
+ }
139
180
  }
140
181
  function toLinearClientProvider(linear) {
141
182
  if (linear && typeof linear.forProject === "function") {
@@ -1,7 +1,10 @@
1
+ import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
1
2
  export class StageAgentActivityPublisher {
3
+ config;
2
4
  linearProvider;
3
5
  logger;
4
- constructor(linearProvider, logger) {
6
+ constructor(config, linearProvider, logger) {
7
+ this.config = config;
5
8
  this.linearProvider = linearProvider;
6
9
  this.logger = logger;
7
10
  }
@@ -30,4 +33,27 @@ export class StageAgentActivityPublisher {
30
33
  }
31
34
  await this.publishForSession(issue.projectId, issue.activeAgentSessionId, content);
32
35
  }
36
+ async updateSession(params) {
37
+ const linear = await this.linearProvider.forProject(params.projectId);
38
+ if (!linear?.updateAgentSession) {
39
+ return;
40
+ }
41
+ const externalUrls = buildAgentSessionExternalUrls(this.config, params.issueKey);
42
+ if (!externalUrls && !params.plan) {
43
+ return;
44
+ }
45
+ try {
46
+ await linear.updateAgentSession({
47
+ agentSessionId: params.agentSessionId,
48
+ ...(externalUrls ? { externalUrls } : {}),
49
+ ...(params.plan ? { plan: params.plan } : {}),
50
+ });
51
+ }
52
+ catch (error) {
53
+ this.logger.warn({
54
+ agentSessionId: params.agentSessionId,
55
+ error: error instanceof Error ? error.message : String(error),
56
+ }, "Failed to update Linear agent session");
57
+ }
58
+ }
33
59
  }
@@ -1,3 +1,4 @@
1
+ import { buildFailedSessionPlan } from "./agent-session-plan.js";
1
2
  import { buildStageFailedComment, resolveActiveLinearState, resolveFallbackLinearState, resolveWorkflowLabelCleanup, } from "./linear-workflow.js";
2
3
  function normalizeStateName(value) {
3
4
  const trimmed = value?.trim();
@@ -48,31 +49,44 @@ export async function syncFailedStageToLinear(params) {
48
49
  lifecycleStatus: "failed",
49
50
  });
50
51
  }
51
- const result = await linear
52
- .upsertIssueComment({
53
- issueId: params.stageRun.linearIssueId,
54
- ...(params.issue.statusCommentId ? { commentId: params.issue.statusCommentId } : {}),
55
- body: buildStageFailedComment({
56
- issue: params.issue,
57
- stageRun: params.stageRun,
58
- message: params.message,
59
- ...(fallbackState ? { fallbackState } : {}),
60
- ...(params.mode ? { mode: params.mode } : {}),
61
- }),
62
- })
63
- .catch(() => undefined);
64
- if (result) {
65
- params.stores.workflowCoordinator.setIssueStatusComment(params.stageRun.projectId, params.stageRun.linearIssueId, result.id);
66
- }
52
+ let deliveredToSession = false;
67
53
  if (params.issue.activeAgentSessionId) {
68
- await linear
69
- .createAgentActivity({
70
- agentSessionId: params.issue.activeAgentSessionId,
71
- content: {
72
- type: "error",
73
- body: `PatchRelay could not complete the ${params.stageRun.stage} workflow: ${params.message}`,
74
- },
54
+ deliveredToSession =
55
+ (await linear
56
+ .updateAgentSession?.({
57
+ agentSessionId: params.issue.activeAgentSessionId,
58
+ plan: buildFailedSessionPlan(params.stageRun.stage, params.stageRun),
59
+ })
60
+ .then(() => true)
61
+ .catch(() => false)) ?? false;
62
+ deliveredToSession =
63
+ (await linear
64
+ .createAgentActivity({
65
+ agentSessionId: params.issue.activeAgentSessionId,
66
+ content: {
67
+ type: "error",
68
+ body: `PatchRelay could not complete the ${params.stageRun.stage} workflow: ${params.message}`,
69
+ },
70
+ })
71
+ .then(() => true)
72
+ .catch(() => false)) || deliveredToSession;
73
+ }
74
+ if (!deliveredToSession) {
75
+ const result = await linear
76
+ .upsertIssueComment({
77
+ issueId: params.stageRun.linearIssueId,
78
+ ...(params.issue.statusCommentId ? { commentId: params.issue.statusCommentId } : {}),
79
+ body: buildStageFailedComment({
80
+ issue: params.issue,
81
+ stageRun: params.stageRun,
82
+ message: params.message,
83
+ ...(fallbackState ? { fallbackState } : {}),
84
+ ...(params.mode ? { mode: params.mode } : {}),
85
+ }),
75
86
  })
76
87
  .catch(() => undefined);
88
+ if (result) {
89
+ params.stores.workflowCoordinator.setIssueStatusComment(params.stageRun.projectId, params.stageRun.linearIssueId, result.id);
90
+ }
77
91
  }
78
92
  }
@@ -1,3 +1,5 @@
1
+ import { buildAwaitingHandoffSessionPlan, buildCompletedSessionPlan, buildRunningSessionPlan, } from "./agent-session-plan.js";
2
+ import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
1
3
  import { buildAwaitingHandoffComment, buildRunningStatusComment, resolveActiveLinearState, resolveWorkflowLabelCleanup, resolveWorkflowLabelNames, } from "./linear-workflow.js";
2
4
  import { sanitizeDiagnosticText } from "./utils.js";
3
5
  export class StageLifecyclePublisher {
@@ -71,12 +73,13 @@ export class StageLifecyclePublisher {
71
73
  }
72
74
  async publishStageStarted(issue, stage) {
73
75
  if (!issue.activeAgentSessionId) {
74
- return;
76
+ return false;
75
77
  }
76
78
  const linear = await this.linearProvider.forProject(issue.projectId);
77
79
  if (!linear) {
78
- return;
80
+ return false;
79
81
  }
82
+ const sessionUpdated = await this.updateAgentSession(linear, issue, buildRunningSessionPlan(stage), stage);
80
83
  try {
81
84
  await linear.createAgentActivity({
82
85
  agentSessionId: issue.activeAgentSessionId,
@@ -88,6 +91,7 @@ export class StageLifecyclePublisher {
88
91
  },
89
92
  ephemeral: true,
90
93
  });
94
+ return true;
91
95
  }
92
96
  catch (error) {
93
97
  this.logger.warn({
@@ -96,6 +100,7 @@ export class StageLifecyclePublisher {
96
100
  agentSessionId: issue.activeAgentSessionId,
97
101
  error: error instanceof Error ? error.message : String(error),
98
102
  }, "Failed to publish Linear agent activity after stage startup");
103
+ return sessionUpdated;
99
104
  }
100
105
  }
101
106
  async publishStageCompletion(stageRun, enqueueIssue) {
@@ -134,16 +139,10 @@ export class StageLifecyclePublisher {
134
139
  }
135
140
  this.stores.workflowCoordinator.setIssueLifecycleStatus(stageRun.projectId, stageRun.linearIssueId, "paused");
136
141
  const finalStageRun = this.stores.issueWorkflows.getStageRun(stageRun.id) ?? stageRun;
137
- const result = await linear.upsertIssueComment({
138
- issueId: stageRun.linearIssueId,
139
- ...(refreshedIssue.statusCommentId ? { commentId: refreshedIssue.statusCommentId } : {}),
140
- body: buildAwaitingHandoffComment({
141
- issue: refreshedIssue,
142
- stageRun: finalStageRun,
143
- activeState,
144
- }),
145
- });
146
- this.stores.workflowCoordinator.setIssueStatusComment(stageRun.projectId, stageRun.linearIssueId, result.id);
142
+ let deliveredToSession = false;
143
+ if (refreshedIssue.activeAgentSessionId) {
144
+ deliveredToSession = await this.updateAgentSession(linear, refreshedIssue, buildAwaitingHandoffSessionPlan(stageRun.stage));
145
+ }
147
146
  this.feed?.publish({
148
147
  level: "info",
149
148
  kind: "stage",
@@ -154,10 +153,23 @@ export class StageLifecyclePublisher {
154
153
  summary: `Completed ${stageRun.stage} workflow`,
155
154
  detail: `Waiting for a Linear state change or follow-up input while the issue remains in ${activeState}.`,
156
155
  });
157
- await this.publishAgentCompletion(refreshedIssue, {
158
- type: "elicitation",
159
- body: `PatchRelay finished the ${stageRun.stage} workflow. Move the issue to its next workflow state or leave a follow-up prompt to continue.`,
160
- });
156
+ deliveredToSession =
157
+ (await this.publishAgentCompletion(refreshedIssue, {
158
+ type: "elicitation",
159
+ body: `PatchRelay finished the ${stageRun.stage} workflow. Move the issue to its next workflow state or leave a follow-up prompt to continue.`,
160
+ })) || deliveredToSession;
161
+ if (!deliveredToSession) {
162
+ const result = await linear.upsertIssueComment({
163
+ issueId: stageRun.linearIssueId,
164
+ ...(refreshedIssue.statusCommentId ? { commentId: refreshedIssue.statusCommentId } : {}),
165
+ body: buildAwaitingHandoffComment({
166
+ issue: refreshedIssue,
167
+ stageRun: finalStageRun,
168
+ activeState,
169
+ }),
170
+ });
171
+ this.stores.workflowCoordinator.setIssueStatusComment(stageRun.projectId, stageRun.linearIssueId, result.id);
172
+ }
161
173
  return;
162
174
  }
163
175
  const cleanup = resolveWorkflowLabelCleanup(project);
@@ -179,6 +191,9 @@ export class StageLifecyclePublisher {
179
191
  }
180
192
  }
181
193
  if (refreshedIssue) {
194
+ if (refreshedIssue.activeAgentSessionId && linear) {
195
+ await this.updateAgentSession(linear, refreshedIssue, buildCompletedSessionPlan(stageRun.stage));
196
+ }
182
197
  this.feed?.publish({
183
198
  level: "info",
184
199
  kind: "stage",
@@ -194,20 +209,46 @@ export class StageLifecyclePublisher {
194
209
  });
195
210
  }
196
211
  }
212
+ async updateAgentSession(linear, issue, plan, stage) {
213
+ if (!issue.activeAgentSessionId) {
214
+ return false;
215
+ }
216
+ try {
217
+ const externalUrls = buildAgentSessionExternalUrls(this.config, issue.issueKey);
218
+ await linear.updateAgentSession?.({
219
+ agentSessionId: issue.activeAgentSessionId,
220
+ ...(externalUrls ? { externalUrls } : {}),
221
+ plan,
222
+ });
223
+ return true;
224
+ }
225
+ catch (error) {
226
+ this.logger.warn({
227
+ issueKey: issue.issueKey,
228
+ issueId: issue.linearIssueId,
229
+ ...(stage ? { stage } : {}),
230
+ agentSessionId: issue.activeAgentSessionId,
231
+ error: sanitizeDiagnosticText(error instanceof Error ? error.message : String(error)),
232
+ }, "Failed to update Linear agent session");
233
+ return false;
234
+ }
235
+ }
197
236
  async publishAgentCompletion(issue, content) {
198
237
  if (!issue.activeAgentSessionId) {
199
- return;
238
+ return false;
200
239
  }
201
240
  const linear = await this.linearProvider.forProject(issue.projectId);
202
241
  if (!linear) {
203
- return;
242
+ return false;
204
243
  }
205
- await linear
206
- .createAgentActivity({
207
- agentSessionId: issue.activeAgentSessionId,
208
- content,
209
- })
210
- .catch((error) => {
244
+ try {
245
+ await linear.createAgentActivity({
246
+ agentSessionId: issue.activeAgentSessionId,
247
+ content,
248
+ });
249
+ return true;
250
+ }
251
+ catch (error) {
211
252
  this.logger.warn({
212
253
  issueKey: issue.issueKey,
213
254
  issueId: issue.linearIssueId,
@@ -215,6 +256,7 @@ export class StageLifecyclePublisher {
215
256
  activityType: content.type,
216
257
  error: sanitizeDiagnosticText(error instanceof Error ? error.message : String(error)),
217
258
  }, "Failed to publish Linear agent activity");
218
- });
259
+ return false;
260
+ }
219
261
  }
220
262
  }
@@ -1,10 +1,19 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { buildPreparingSessionPlan, buildRunningSessionPlan } from "./agent-session-plan.js";
2
3
  import { triggerEventAllowed } from "./project-resolution.js";
3
4
  import { listRunnableStates, resolveWorkflowStage } from "./workflow-policy.js";
4
5
  function trimPrompt(value) {
5
6
  const trimmed = value?.trim();
6
7
  return trimmed ? trimmed : undefined;
7
8
  }
9
+ function buildSessionUpdateParams(projectId, agentSessionId, issueKey, plan) {
10
+ return {
11
+ projectId,
12
+ agentSessionId,
13
+ ...(issueKey ? { issueKey } : {}),
14
+ plan,
15
+ };
16
+ }
8
17
  export class AgentSessionWebhookHandler {
9
18
  stores;
10
19
  turnInputDispatcher;
@@ -30,6 +39,7 @@ export class AgentSessionWebhookHandler {
30
39
  if (normalized.triggerEvent === "agentSessionCreated") {
31
40
  if (!delegatedToPatchRelay) {
32
41
  if (activeStage) {
42
+ await this.agentActivity.updateSession(buildSessionUpdateParams(project.id, normalized.agentSession.id, issue?.issueKey ?? normalized.issue?.identifier, buildRunningSessionPlan(activeStage)));
33
43
  await this.agentActivity.publishForSession(project.id, normalized.agentSession.id, {
34
44
  type: "thought",
35
45
  body: `PatchRelay is already running the ${activeStage} workflow for this issue. Delegate it to PatchRelay if you want automation to own the workflow, or keep replying here to steer the active run.`,
@@ -54,15 +64,17 @@ export class AgentSessionWebhookHandler {
54
64
  return;
55
65
  }
56
66
  if (desiredStage) {
67
+ await this.agentActivity.updateSession(buildSessionUpdateParams(project.id, normalized.agentSession.id, issue?.issueKey ?? normalized.issue?.identifier, buildPreparingSessionPlan(desiredStage)));
57
68
  await this.agentActivity.publishForSession(project.id, normalized.agentSession.id, {
58
- type: "thought",
59
- body: `PatchRelay received the delegation and is preparing the ${desiredStage} workflow.`,
69
+ type: "response",
70
+ body: `PatchRelay picked this up and is preparing the ${desiredStage} workflow.`,
60
71
  });
61
72
  return;
62
73
  }
63
74
  if (activeStage) {
75
+ await this.agentActivity.updateSession(buildSessionUpdateParams(project.id, normalized.agentSession.id, issue?.issueKey ?? normalized.issue?.identifier, buildRunningSessionPlan(activeStage)));
64
76
  await this.agentActivity.publishForSession(project.id, normalized.agentSession.id, {
65
- type: "thought",
77
+ type: "response",
66
78
  body: `PatchRelay is already running the ${activeStage} workflow for this issue.`,
67
79
  });
68
80
  }
@@ -123,8 +135,9 @@ export class AgentSessionWebhookHandler {
123
135
  return;
124
136
  }
125
137
  if (!activeRunLease && desiredStage) {
138
+ await this.agentActivity.updateSession(buildSessionUpdateParams(project.id, normalized.agentSession.id, issue?.issueKey ?? normalized.issue?.identifier, buildPreparingSessionPlan(desiredStage)));
126
139
  await this.agentActivity.publishForSession(project.id, normalized.agentSession.id, {
127
- type: "thought",
140
+ type: "response",
128
141
  body: `PatchRelay received your prompt and is preparing the ${desiredStage} workflow.`,
129
142
  });
130
143
  return;
@@ -27,6 +27,8 @@ export class WebhookDesiredStageRecorder {
27
27
  const stageAllowed = triggerEventAllowed(project, normalized.triggerEvent);
28
28
  const desiredStage = this.resolveDesiredStage(project, normalized, issue, activeStageRun, delegatedToPatchRelay);
29
29
  const launchInput = this.resolveLaunchInput(normalized.agentSession);
30
+ const activeAgentSessionId = normalized.agentSession?.id ??
31
+ (!activeStageRun && (desiredStage || (normalized.triggerEvent === "delegateChanged" && !delegatedToPatchRelay)) ? null : undefined);
30
32
  const refreshedIssue = this.stores.workflowCoordinator.recordDesiredStage({
31
33
  projectId: project.id,
32
34
  linearIssueId: normalizedIssue.id,
@@ -36,7 +38,7 @@ export class WebhookDesiredStageRecorder {
36
38
  ...(normalizedIssue.stateName ? { currentLinearState: normalizedIssue.stateName } : {}),
37
39
  ...(desiredStage ? { desiredStage } : {}),
38
40
  ...(options?.eventReceiptId !== undefined ? { desiredReceiptId: options.eventReceiptId } : {}),
39
- ...(normalized.agentSession?.id ? { activeAgentSessionId: normalized.agentSession.id } : {}),
41
+ ...(activeAgentSessionId !== undefined ? { activeAgentSessionId } : {}),
40
42
  lastWebhookAt: new Date().toISOString(),
41
43
  });
42
44
  if (launchInput && !activeStageRun && delegatedToPatchRelay && stageAllowed) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {