patchrelay 0.39.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.39.0",
4
- "commit": "80d285a7e9bd",
5
- "builtAt": "2026-04-11T20:04:29.313Z"
3
+ "version": "0.40.0",
4
+ "commit": "c1cef920f5e3",
5
+ "builtAt": "2026-04-11T22:19:02.944Z"
6
6
  }
@@ -2,7 +2,7 @@ import { setTimeout as delay } from "node:timers/promises";
2
2
  import { getRunTypeFlag } from "../args.js";
3
3
  import { CliUsageError } from "../errors.js";
4
4
  import { formatJson } from "../formatters/json.js";
5
- import { formatClose, formatInspect, formatList, formatLive, formatOpen, formatRetry, formatSessionHistory, formatWorktree } from "../formatters/text.js";
5
+ import { formatClose, formatInspect, formatList, formatLive, formatOpen, formatRetry, formatSessionHistory, formatTranscriptSource, formatWorktree } from "../formatters/text.js";
6
6
  import { buildOpenCommand } from "../interactive.js";
7
7
  import { writeOutput } from "../output.js";
8
8
  export async function handleIssueCommand(params) {
@@ -36,6 +36,8 @@ export async function handleIssueCommand(params) {
36
36
  return await handleOpenCommand(nested);
37
37
  case "sessions":
38
38
  return await handleSessionsCommand(nested);
39
+ case "transcript-source":
40
+ return await handleTranscriptSourceCommand(nested);
39
41
  case "retry":
40
42
  return await handleRetryCommand(nested);
41
43
  case "close":
@@ -116,6 +118,30 @@ export async function handleOpenCommand(params) {
116
118
  const openCommand = buildOpenCommand(params.config, result.worktreePath, result.resumeThreadId);
117
119
  return await params.runInteractive(openCommand.command, openCommand.args);
118
120
  }
121
+ export async function handleTranscriptSourceCommand(params) {
122
+ const issueKey = params.commandArgs[0];
123
+ if (!issueKey) {
124
+ throw new Error("transcript-source requires <issueKey>.");
125
+ }
126
+ const runFlag = params.parsed.flags.get("run");
127
+ let runId;
128
+ if (typeof runFlag === "string") {
129
+ const parsedRunId = Number(runFlag);
130
+ if (!Number.isSafeInteger(parsedRunId) || parsedRunId <= 0) {
131
+ throw new Error("--run must be a positive integer.");
132
+ }
133
+ runId = parsedRunId;
134
+ }
135
+ const result = params.data.transcriptSource(issueKey, runId);
136
+ if (!result) {
137
+ throw new Error(`Issue not found: ${issueKey}`);
138
+ }
139
+ if (runId !== undefined && result.runId !== runId) {
140
+ throw new Error(`Run not found for ${issueKey}: ${runId}`);
141
+ }
142
+ writeOutput(params.stdout, params.json ? formatJson(result) : formatTranscriptSource(result));
143
+ return 0;
144
+ }
119
145
  export async function handleSessionsCommand(params) {
120
146
  const issueKey = params.commandArgs[0];
121
147
  if (!issueKey) {
package/dist/cli/data.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import pino from "pino";
3
3
  import { CodexAppServerClient } from "../codex-app-server.js";
4
+ import { resolveCodexSessionSource } from "../codex-session-source.js";
4
5
  import { extractCompletionCheck } from "../completion-check.js";
5
6
  import { getThreadTurns } from "../codex-thread-utils.js";
6
7
  import { PatchRelayDatabase } from "../db.js";
@@ -272,6 +273,25 @@ export class CliDataAccess extends CliOperatorApiClient {
272
273
  ...(run ? { releasedRunId: run.id } : {}),
273
274
  };
274
275
  }
276
+ transcriptSource(issueKey, runId) {
277
+ const issue = this.db.getTrackedIssueByKey(issueKey);
278
+ if (!issue)
279
+ return undefined;
280
+ const dbIssue = this.db.issues.getIssueByKey(issueKey);
281
+ const runs = this.db.runs.listRunsForIssue(issue.projectId, issue.linearIssueId);
282
+ const selectedRun = runId !== undefined
283
+ ? runs.find((run) => run.id === runId)
284
+ : runs.slice().reverse().find((run) => run.threadId);
285
+ const threadId = selectedRun?.threadId ?? dbIssue.threadId;
286
+ return {
287
+ issue,
288
+ ...(selectedRun ? { runId: selectedRun.id, runType: selectedRun.runType, runStatus: selectedRun.status } : {}),
289
+ ...(threadId ? { threadId } : {}),
290
+ ...(selectedRun?.turnId ? { turnId: selectedRun.turnId } : {}),
291
+ ...(dbIssue.worktreePath ? { worktreePath: dbIssue.worktreePath } : {}),
292
+ ...(threadId ? { sessionSource: resolveCodexSessionSource(threadId) } : {}),
293
+ };
294
+ }
275
295
  sessions(issueKey) {
276
296
  const issue = this.db.getTrackedIssueByKey(issueKey);
277
297
  if (!issue)
@@ -284,6 +304,7 @@ export class CliDataAccess extends CliOperatorApiClient {
284
304
  .map((run) => {
285
305
  const summary = summarizeRun(run);
286
306
  const eventCount = this.db.runs.listThreadEvents(run.id).length;
307
+ const sessionSource = run.threadId ? resolveCodexSessionSource(run.threadId) : undefined;
287
308
  return {
288
309
  runId: run.id,
289
310
  runType: run.runType,
@@ -293,6 +314,7 @@ export class CliDataAccess extends CliOperatorApiClient {
293
314
  ...(run.parentThreadId ? { parentThreadId: run.parentThreadId } : {}),
294
315
  ...(summary ? { summary } : {}),
295
316
  ...(run.failureReason ? { failureReason: run.failureReason } : {}),
317
+ ...(sessionSource ? { sessionSource } : {}),
296
318
  eventCount,
297
319
  eventCountAvailable: this.config.runner.codex.persistExtendedHistory || eventCount > 0,
298
320
  startedAt: run.startedAt,
@@ -97,6 +97,29 @@ export function formatClose(result) {
97
97
  function formatTimestampRange(startedAt, endedAt) {
98
98
  return endedAt ? `${startedAt} -> ${endedAt}` : `${startedAt} -> running`;
99
99
  }
100
+ function formatSessionSource(sessionSource) {
101
+ if (!sessionSource)
102
+ return undefined;
103
+ if (sessionSource.exists)
104
+ return sessionSource.path;
105
+ return sessionSource.error ?? "not found";
106
+ }
107
+ export function formatTranscriptSource(result) {
108
+ const lines = [
109
+ value("Issue", result.issue.issueKey ?? result.issue.linearIssueId),
110
+ value("Worktree", result.worktreePath),
111
+ value("Run", result.runId !== undefined
112
+ ? `#${result.runId}${result.runType ? ` ${result.runType}` : ""}${result.runStatus ? ` (${result.runStatus})` : ""}`
113
+ : undefined),
114
+ value("Thread", result.threadId),
115
+ value("Turn", result.turnId),
116
+ value("Session source", formatSessionSource(result.sessionSource)),
117
+ result.sessionSource?.startedAt ? value("Started", result.sessionSource.startedAt) : undefined,
118
+ result.sessionSource?.originator ? value("Originator", result.sessionSource.originator) : undefined,
119
+ result.sessionSource?.cwd ? value("Working directory", result.sessionSource.cwd) : undefined,
120
+ ].filter(Boolean);
121
+ return `${lines.join("\n")}\n`;
122
+ }
100
123
  export function formatSessionHistory(result, buildOpenForThread) {
101
124
  const lines = [
102
125
  `${result.issue.issueKey ?? result.issue.linearIssueId}${result.issue.currentLinearState ? ` ${result.issue.currentLinearState}` : ""}`,
@@ -134,6 +157,18 @@ export function formatSessionHistory(result, buildOpenForThread) {
134
157
  else if (session.failureReason) {
135
158
  lines.push(value("Failure", truncateLine(session.failureReason)));
136
159
  }
160
+ if (session.sessionSource) {
161
+ lines.push(value("Session source", formatSessionSource(session.sessionSource)));
162
+ if (session.sessionSource.startedAt) {
163
+ lines.push(value("Started", session.sessionSource.startedAt));
164
+ }
165
+ if (session.sessionSource.originator) {
166
+ lines.push(value("Originator", session.sessionSource.originator));
167
+ }
168
+ if (session.sessionSource.cwd) {
169
+ lines.push(value("Working directory", session.sessionSource.cwd));
170
+ }
171
+ }
137
172
  if (session.threadId && result.worktreePath && buildOpenForThread) {
138
173
  const command = buildOpenForThread(session.threadId);
139
174
  lines.push(value("Open", formatCommand(command.command, command.args)));
package/dist/cli/help.js CHANGED
@@ -37,6 +37,7 @@ export function rootHelpText() {
37
37
  " issue watch <issueKey> [--json] Follow the active run until it settles",
38
38
  " issue open <issueKey> [--print] [--json] Open Codex in the issue worktree",
39
39
  " issue sessions <issueKey> [--json] Show recorded Codex app-server sessions for one issue",
40
+ " issue transcript-source <issueKey> [--run <id>] [--json] Show the raw Codex session file for one issue run",
40
41
  " issue close <issueKey> [--failed] [--reason <text>] [--json]",
41
42
  " Force-close one issue and release any active run",
42
43
  " service status [--json] Show systemd state and local health",
@@ -150,6 +151,7 @@ export function issueHelpText() {
150
151
  " path <issueKey> Print the issue worktree path",
151
152
  " open <issueKey> Open Codex in the issue worktree",
152
153
  " sessions <issueKey> Show recorded Codex app-server sessions",
154
+ " transcript-source <issueKey> Show the raw Codex session file for one issue run",
153
155
  " retry <issueKey> Requeue a run",
154
156
  " close <issueKey> Force-close a stuck issue",
155
157
  "",
@@ -158,6 +160,7 @@ export function issueHelpText() {
158
160
  " patchrelay issue show USE-54",
159
161
  " patchrelay issue watch USE-54",
160
162
  " patchrelay issue sessions USE-54",
163
+ " patchrelay issue transcript-source USE-54",
161
164
  " patchrelay close USE-54 --reason \"already handled manually\"",
162
165
  ].join("\n");
163
166
  }
package/dist/cli/index.js CHANGED
@@ -64,6 +64,9 @@ function validateFlags(command, commandArgs, parsed) {
64
64
  case "sessions":
65
65
  assertKnownFlags(parsed, "issue", ["json"]);
66
66
  return;
67
+ case "transcript-source":
68
+ assertKnownFlags(parsed, "issue", ["run", "json"]);
69
+ return;
67
70
  case "retry":
68
71
  assertKnownFlags(parsed, "issue", ["run-type", "reason", "json"]);
69
72
  return;
@@ -0,0 +1,85 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ function resolveCodexHome(explicit) {
5
+ return explicit
6
+ ?? process.env.CODEX_HOME
7
+ ?? path.join(homedir(), ".codex");
8
+ }
9
+ function walkSessionFiles(directory, threadId, matches) {
10
+ const entries = readdirSync(directory, { withFileTypes: true });
11
+ for (const entry of entries) {
12
+ const fullPath = path.join(directory, entry.name);
13
+ if (entry.isDirectory()) {
14
+ walkSessionFiles(fullPath, threadId, matches);
15
+ continue;
16
+ }
17
+ if (!entry.isFile() || !entry.name.endsWith(".jsonl")) {
18
+ continue;
19
+ }
20
+ if (entry.name.includes(threadId)) {
21
+ matches.push(fullPath);
22
+ }
23
+ }
24
+ }
25
+ function readSessionMeta(filePath) {
26
+ try {
27
+ const firstLine = readFileSync(filePath, "utf8").split(/\r?\n/, 1)[0];
28
+ if (!firstLine) {
29
+ return { error: "session file is empty" };
30
+ }
31
+ const parsed = JSON.parse(firstLine);
32
+ if (parsed.type !== "session_meta" || !parsed.payload || typeof parsed.payload !== "object" || Array.isArray(parsed.payload)) {
33
+ return { error: "session file does not start with session_meta" };
34
+ }
35
+ return { payload: parsed.payload };
36
+ }
37
+ catch (error) {
38
+ return {
39
+ error: error instanceof Error ? error.message : String(error),
40
+ };
41
+ }
42
+ }
43
+ export function resolveCodexSessionSource(threadId, options) {
44
+ const codexHome = resolveCodexHome(options?.codexHome);
45
+ const sessionsDir = path.join(codexHome, "sessions");
46
+ if (!existsSync(sessionsDir)) {
47
+ return {
48
+ threadId,
49
+ codexHome,
50
+ sessionsDir,
51
+ exists: false,
52
+ error: `sessions directory not found: ${sessionsDir}`,
53
+ };
54
+ }
55
+ const matches = [];
56
+ walkSessionFiles(sessionsDir, threadId, matches);
57
+ matches.sort().reverse();
58
+ let firstError;
59
+ for (const candidate of matches) {
60
+ const { payload, error } = readSessionMeta(candidate);
61
+ if (payload && payload.id === threadId) {
62
+ return {
63
+ threadId,
64
+ codexHome,
65
+ sessionsDir,
66
+ exists: true,
67
+ path: candidate,
68
+ ...(typeof payload.id === "string" ? { sessionId: payload.id } : {}),
69
+ ...(typeof payload.timestamp === "string" ? { startedAt: payload.timestamp } : {}),
70
+ ...(typeof payload.cwd === "string" ? { cwd: payload.cwd } : {}),
71
+ ...(typeof payload.originator === "string" ? { originator: payload.originator } : {}),
72
+ };
73
+ }
74
+ if (!firstError && error) {
75
+ firstError = `${candidate}: ${error}`;
76
+ }
77
+ }
78
+ return {
79
+ threadId,
80
+ codexHome,
81
+ sessionsDir,
82
+ exists: false,
83
+ ...(firstError ? { error: firstError } : { error: `session file not found for thread ${threadId}` }),
84
+ };
85
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {