patchrelay 0.7.5 → 0.7.7

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/README.md CHANGED
@@ -11,7 +11,7 @@ PatchRelay is the system around the model:
11
11
  - issue-to-repo routing
12
12
  - issue worktree and branch lifecycle
13
13
  - stage orchestration and thread continuity
14
- - comment forwarding into active runs
14
+ - native Linear agent input forwarding into active runs
15
15
  - read-only inspection and stage reporting
16
16
 
17
17
  If you want Codex to work inside your real repos with your real tools, secrets, SSH access, and deployment surface, PatchRelay is the harness that makes that loop reliable.
@@ -22,7 +22,7 @@ If you want Codex to work inside your real repos with your real tools, secrets,
22
22
  - Use your existing machine, repos, secrets, SSH config, shell tools, and deployment access.
23
23
  - Keep deterministic workflow logic outside the model: routing, staging, worktree ownership, and reporting.
24
24
  - Choose the Codex approval and sandbox settings that match your risk tolerance.
25
- - Let Linear drive the loop through delegation, workflow stages, and comments.
25
+ - Let Linear drive the loop through delegation, mentions, and workflow stages.
26
26
  - Drop into the exact issue worktree and resume control manually when needed.
27
27
 
28
28
  ## What PatchRelay Owns
@@ -34,7 +34,7 @@ PatchRelay does the deterministic harness work that you do not want to re-implem
34
34
  - creates and reuses one durable worktree and branch per issue lifecycle
35
35
  - starts or forks Codex threads for the workflows you bind to Linear states
36
36
  - persists enough state to correlate the Linear issue, local workspace, stage run, and Codex thread
37
- - reports progress back to Linear and forwards follow-up comments into active runs
37
+ - reports progress back to Linear and forwards follow-up agent input into active runs
38
38
  - exposes CLI and optional read-only inspection surfaces so operators can understand what happened
39
39
 
40
40
  ## System Layers
@@ -77,7 +77,7 @@ For the exact OAuth app settings and webhook categories, use the Linear onboardi
77
77
  2. PatchRelay verifies the webhook and routes the issue to the right local project.
78
78
  3. Delegated issues create or reuse the issue worktree and launch the matching workflow through `codex app-server`.
79
79
  4. PatchRelay persists thread ids, run state, and observations so the work stays inspectable and resumable.
80
- 5. Mentions stay conversational, while delegated sessions and issue comments can steer the active run. An operator can take over from the exact same worktree when needed.
80
+ 5. Mentions stay conversational, while delegated sessions and native agent prompts can steer the active run. An operator can take over from the exact same worktree when needed.
81
81
 
82
82
  ## Restart And Reconciliation
83
83
 
@@ -221,7 +221,7 @@ Important:
221
221
  1. Delegate a Linear issue to the PatchRelay app.
222
222
  2. PatchRelay reads the current Linear state like `Start`, `Ready for QA`, or `Deploy` to choose the matching workflow.
223
223
  3. Linear sends the delegation and agent-session webhooks to PatchRelay, which creates or reuses the issue worktree and launches the matching workflow.
224
- 4. Follow up in the issue comments to steer the active run or wake it with fresh input while it remains delegated.
224
+ 4. Follow up in the Linear agent session to steer the active run or wake it with fresh input while it remains delegated.
225
225
  5. Watch progress from the terminal or open the same worktree and take over manually.
226
226
 
227
227
  Useful commands:
@@ -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
+ { content: "Prepare workspace", status: statuses[0] },
15
+ { content: `Run ${stageLabel} workflow`, status: statuses[1] },
16
+ { content: "Review next Linear step", status: statuses[2] },
17
+ ];
18
+ }
19
+ export function buildPreparingSessionPlan(stage) {
20
+ return buildPlan(stage, ["inProgress", "pending", "pending"]);
21
+ }
22
+ export function buildRunningSessionPlan(stage) {
23
+ return buildPlan(stage, ["completed", "inProgress", "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", "inProgress"]);
30
+ }
31
+ export function buildFailedSessionPlan(stage, stageRun) {
32
+ const workflowStepStatus = stageRun?.threadId || stageRun?.turnId ? "completed" : "inProgress";
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.5",
4
- "commit": "884a7bfd9ef3",
5
- "builtAt": "2026-03-14T22:19:40.569Z"
3
+ "version": "0.7.7",
4
+ "commit": "b39ec3de1f37",
5
+ "builtAt": "2026-03-17T09:39:30.228Z"
6
6
  }
package/dist/config.js CHANGED
@@ -101,7 +101,7 @@ const configSchema = z.object({
101
101
  });
102
102
  function defaultTriggerEvents(actor) {
103
103
  if (actor === "app") {
104
- return ["issueCreated", "agentSessionCreated", "agentPrompted", "statusChanged"];
104
+ return ["agentSessionCreated", "agentPrompted"];
105
105
  }
106
106
  return ["statusChanged"];
107
107
  }
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 ?? []);
@@ -7,12 +7,6 @@ export function resolveProject(config, issue) {
7
7
  return undefined;
8
8
  }
9
9
  export function triggerEventAllowed(project, triggerEvent) {
10
- if (project.triggerEvents.includes(triggerEvent)) {
11
- return true;
12
- }
13
- if (triggerEvent === "agentSessionCreated") {
14
- return project.triggerEvents.includes("delegateChanged") || project.triggerEvents.includes("statusChanged");
15
- }
16
10
  return project.triggerEvents.includes(triggerEvent);
17
11
  }
18
12
  function normalizeTrustValue(value) {
@@ -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) {
@@ -74,27 +76,14 @@ export class WebhookDesiredStageRecorder {
74
76
  if (!normalizedIssue) {
75
77
  return undefined;
76
78
  }
77
- const stageAllowed = triggerEventAllowed(project, normalized.triggerEvent);
78
- let desiredStage;
79
- if (normalized.triggerEvent === "delegateChanged") {
80
- desiredStage = delegatedToPatchRelay ? resolveWorkflowStage(project, normalizedIssue.stateName) : undefined;
81
- if (!desiredStage) {
82
- return undefined;
83
- }
84
- if (!stageAllowed && !project.triggerEvents.includes("statusChanged")) {
85
- return undefined;
86
- }
87
- }
88
- else if (normalized.triggerEvent === "agentSessionCreated" || normalized.triggerEvent === "agentPrompted") {
89
- if (!delegatedToPatchRelay || !stageAllowed) {
90
- return undefined;
91
- }
92
- desiredStage = resolveWorkflowStage(project, normalizedIssue.stateName);
79
+ if (normalized.triggerEvent !== "agentSessionCreated" && normalized.triggerEvent !== "agentPrompted") {
80
+ return undefined;
93
81
  }
94
- else if (stageAllowed) {
95
- desiredStage = resolveWorkflowStage(project, normalizedIssue.stateName);
82
+ if (!delegatedToPatchRelay || !triggerEventAllowed(project, normalized.triggerEvent)) {
83
+ return undefined;
96
84
  }
97
- else {
85
+ const desiredStage = resolveWorkflowStage(project, normalizedIssue.stateName);
86
+ if (!desiredStage) {
98
87
  return undefined;
99
88
  }
100
89
  if (activeStageRun && desiredStage === activeStageRun.stage) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.7.5",
3
+ "version": "0.7.7",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {