patchrelay 0.20.0 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-info.json +3 -3
- package/dist/cli/watch/App.js +38 -9
- package/dist/cli/watch/ItemLine.js +3 -0
- package/dist/cli/watch/format-utils.js +43 -0
- package/dist/cli/watch/sse-parser.js +43 -0
- package/dist/cli/watch/theme.js +55 -0
- package/dist/cli/watch/timeline-builder.js +1 -1
- package/dist/http.js +1 -1
- package/dist/service.js +43 -7
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
package/dist/cli/watch/App.js
CHANGED
|
@@ -12,12 +12,18 @@ async function postPrompt(baseUrl, issueKey, text, bearerToken) {
|
|
|
12
12
|
const headers = { "content-type": "application/json" };
|
|
13
13
|
if (bearerToken)
|
|
14
14
|
headers.authorization = `Bearer ${bearerToken}`;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
try {
|
|
16
|
+
const response = await fetch(new URL(`/api/issues/${encodeURIComponent(issueKey)}/prompt`, baseUrl), {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers,
|
|
19
|
+
body: JSON.stringify({ text }),
|
|
20
|
+
signal: AbortSignal.timeout(5000),
|
|
21
|
+
});
|
|
22
|
+
return await response.json();
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return { reason: "Request failed" };
|
|
26
|
+
}
|
|
21
27
|
}
|
|
22
28
|
async function postRetry(baseUrl, issueKey, bearerToken) {
|
|
23
29
|
const headers = { "content-type": "application/json" };
|
|
@@ -46,12 +52,35 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
|
|
|
46
52
|
void postRetry(baseUrl, state.activeDetailKey, bearerToken);
|
|
47
53
|
}
|
|
48
54
|
}, [baseUrl, bearerToken, state.activeDetailKey]);
|
|
55
|
+
const [promptStatus, setPromptStatus] = useState(null);
|
|
49
56
|
const handlePromptSubmit = useCallback(() => {
|
|
50
|
-
|
|
51
|
-
|
|
57
|
+
const text = promptBuffer.trim();
|
|
58
|
+
if (!state.activeDetailKey || !text) {
|
|
59
|
+
setPromptMode(false);
|
|
60
|
+
setPromptBuffer("");
|
|
61
|
+
return;
|
|
52
62
|
}
|
|
63
|
+
// Add synthetic userMessage to timeline immediately
|
|
64
|
+
dispatch({
|
|
65
|
+
type: "codex-notification",
|
|
66
|
+
method: "item/started",
|
|
67
|
+
params: { item: { id: `prompt-${Date.now()}`, type: "userMessage", status: "completed", text } },
|
|
68
|
+
});
|
|
53
69
|
setPromptMode(false);
|
|
54
70
|
setPromptBuffer("");
|
|
71
|
+
setPromptStatus("sending...");
|
|
72
|
+
void postPrompt(baseUrl, state.activeDetailKey, text, bearerToken).then((result) => {
|
|
73
|
+
if (result.delivered) {
|
|
74
|
+
setPromptStatus("delivered");
|
|
75
|
+
}
|
|
76
|
+
else if (result.queued) {
|
|
77
|
+
setPromptStatus("queued for next run");
|
|
78
|
+
}
|
|
79
|
+
else if (result.reason) {
|
|
80
|
+
setPromptStatus(`failed: ${result.reason}`);
|
|
81
|
+
}
|
|
82
|
+
setTimeout(() => setPromptStatus(null), 3000);
|
|
83
|
+
});
|
|
55
84
|
}, [baseUrl, bearerToken, state.activeDetailKey, promptBuffer]);
|
|
56
85
|
useInput((input, key) => {
|
|
57
86
|
if (promptMode) {
|
|
@@ -120,5 +149,5 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
|
|
|
120
149
|
}
|
|
121
150
|
}
|
|
122
151
|
});
|
|
123
|
-
return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, allIssues: state.issues, selectedIndex: state.selectedIndex, connected: state.connected, filter: state.filter, totalCount: state.issues.length })) : state.view === "detail" ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), timeline: state.timeline, follow: state.follow, activeRunStartedAt: state.activeRunStartedAt, tokenUsage: state.tokenUsage, diffSummary: state.diffSummary, plan: state.plan, issueContext: state.issueContext, allIssues: filtered, activeDetailKey: state.activeDetailKey }), promptMode && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "prompt> " }), _jsx(Text, { children: promptBuffer }), _jsx(Text, { dimColor: true, children: "_" })] }))] })) : (_jsx(FeedView, { events: state.feedEvents, connected: state.connected })) }));
|
|
152
|
+
return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, allIssues: state.issues, selectedIndex: state.selectedIndex, connected: state.connected, filter: state.filter, totalCount: state.issues.length })) : state.view === "detail" ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), timeline: state.timeline, follow: state.follow, activeRunStartedAt: state.activeRunStartedAt, tokenUsage: state.tokenUsage, diffSummary: state.diffSummary, plan: state.plan, issueContext: state.issueContext, allIssues: filtered, activeDetailKey: state.activeDetailKey }), promptMode && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "prompt> " }), _jsx(Text, { children: promptBuffer }), _jsx(Text, { dimColor: true, children: "_" })] })), promptStatus && !promptMode && (_jsx(Text, { dimColor: true, children: promptStatus }))] })) : (_jsx(FeedView, { events: state.feedEvents, connected: state.connected })) }));
|
|
124
153
|
}
|
|
@@ -64,6 +64,9 @@ export function ItemLine({ item, isLast }) {
|
|
|
64
64
|
case "plan":
|
|
65
65
|
content = renderPlan(item);
|
|
66
66
|
break;
|
|
67
|
+
case "userMessage":
|
|
68
|
+
content = (_jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "you: " }), _jsx(Text, { children: truncate(item.text ?? "", 120) })] }));
|
|
69
|
+
break;
|
|
67
70
|
default:
|
|
68
71
|
content = renderDefault(item);
|
|
69
72
|
break;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Format ISO timestamp as HH:MM:SS (24h, en-GB). */
|
|
2
|
+
export function formatTime(iso) {
|
|
3
|
+
return new Date(iso).toLocaleTimeString("en-GB", { hour12: false });
|
|
4
|
+
}
|
|
5
|
+
/** Format ISO timestamp as compact relative time: "3s", "12m", "2h", "5d". */
|
|
6
|
+
export function relativeTime(iso) {
|
|
7
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
8
|
+
if (ms < 0)
|
|
9
|
+
return "now";
|
|
10
|
+
const seconds = Math.floor(ms / 1000);
|
|
11
|
+
if (seconds < 60)
|
|
12
|
+
return `${seconds}s`;
|
|
13
|
+
const minutes = Math.floor(seconds / 60);
|
|
14
|
+
if (minutes < 60)
|
|
15
|
+
return `${minutes}m`;
|
|
16
|
+
const hours = Math.floor(minutes / 60);
|
|
17
|
+
if (hours < 24)
|
|
18
|
+
return `${hours}h`;
|
|
19
|
+
const days = Math.floor(hours / 24);
|
|
20
|
+
return `${days}d`;
|
|
21
|
+
}
|
|
22
|
+
/** Format millisecond duration as "2m 30s" or "45s". */
|
|
23
|
+
export function formatDuration(ms) {
|
|
24
|
+
const seconds = Math.floor(ms / 1000);
|
|
25
|
+
if (seconds < 60)
|
|
26
|
+
return `${seconds}s`;
|
|
27
|
+
const minutes = Math.floor(seconds / 60);
|
|
28
|
+
const remainingSeconds = seconds % 60;
|
|
29
|
+
return `${minutes}m ${remainingSeconds}s`;
|
|
30
|
+
}
|
|
31
|
+
/** Format token count with k/M suffix. */
|
|
32
|
+
export function formatTokens(n) {
|
|
33
|
+
if (n >= 1_000_000)
|
|
34
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
35
|
+
if (n >= 1_000)
|
|
36
|
+
return `${(n / 1_000).toFixed(1)}k`;
|
|
37
|
+
return String(n);
|
|
38
|
+
}
|
|
39
|
+
/** Truncate text to max length with ellipsis. Collapses newlines. */
|
|
40
|
+
export function truncate(text, max) {
|
|
41
|
+
const line = text.replace(/\n/g, " ").trim();
|
|
42
|
+
return line.length > max ? `${line.slice(0, max - 1)}\u2026` : line;
|
|
43
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared SSE (Server-Sent Events) stream parser.
|
|
3
|
+
* Extracts event type + data from a ReadableStream, calls onEvent for each complete event.
|
|
4
|
+
*/
|
|
5
|
+
export async function readSSEStream(body, onEvent) {
|
|
6
|
+
const reader = body.getReader();
|
|
7
|
+
const decoder = new TextDecoder();
|
|
8
|
+
let buffer = "";
|
|
9
|
+
let eventType = "";
|
|
10
|
+
let dataLines = [];
|
|
11
|
+
while (true) {
|
|
12
|
+
const { done, value } = await reader.read();
|
|
13
|
+
if (done)
|
|
14
|
+
break;
|
|
15
|
+
buffer += decoder.decode(value, { stream: true });
|
|
16
|
+
let newlineIndex = buffer.indexOf("\n");
|
|
17
|
+
while (newlineIndex !== -1) {
|
|
18
|
+
const rawLine = buffer.slice(0, newlineIndex);
|
|
19
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
20
|
+
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
21
|
+
if (!line) {
|
|
22
|
+
if (dataLines.length > 0) {
|
|
23
|
+
onEvent(eventType, dataLines.join("\n"));
|
|
24
|
+
dataLines = [];
|
|
25
|
+
eventType = "";
|
|
26
|
+
}
|
|
27
|
+
newlineIndex = buffer.indexOf("\n");
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (line.startsWith(":")) {
|
|
31
|
+
newlineIndex = buffer.indexOf("\n");
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (line.startsWith("event:")) {
|
|
35
|
+
eventType = line.slice(6).trim();
|
|
36
|
+
}
|
|
37
|
+
else if (line.startsWith("data:")) {
|
|
38
|
+
dataLines.push(line.slice(5).trimStart());
|
|
39
|
+
}
|
|
40
|
+
newlineIndex = buffer.indexOf("\n");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// ─── Factory State Colors ─────────────────────────────────────────
|
|
2
|
+
export const FACTORY_STATE_COLORS = {
|
|
3
|
+
delegated: "blue",
|
|
4
|
+
preparing: "blue",
|
|
5
|
+
implementing: "yellow",
|
|
6
|
+
awaiting_input: "yellow",
|
|
7
|
+
pr_open: "cyan",
|
|
8
|
+
changes_requested: "magenta",
|
|
9
|
+
repairing_ci: "magenta",
|
|
10
|
+
repairing_queue: "magenta",
|
|
11
|
+
awaiting_queue: "green",
|
|
12
|
+
done: "green",
|
|
13
|
+
failed: "red",
|
|
14
|
+
escalated: "red",
|
|
15
|
+
};
|
|
16
|
+
// ─── Item Status Symbols & Colors ─────────────────────────────────
|
|
17
|
+
export const ITEM_STATUS_SYMBOLS = {
|
|
18
|
+
completed: "\u2713",
|
|
19
|
+
failed: "\u2717",
|
|
20
|
+
declined: "\u2717",
|
|
21
|
+
inProgress: "\u25cf",
|
|
22
|
+
};
|
|
23
|
+
export const ITEM_STATUS_COLORS = {
|
|
24
|
+
completed: "green",
|
|
25
|
+
failed: "red",
|
|
26
|
+
declined: "red",
|
|
27
|
+
inProgress: "yellow",
|
|
28
|
+
};
|
|
29
|
+
// ─── CI Check Symbols & Colors ────────────────────────────────────
|
|
30
|
+
export const CHECK_SYMBOLS = {
|
|
31
|
+
passed: "\u2713",
|
|
32
|
+
failed: "\u2717",
|
|
33
|
+
pending: "\u25cf",
|
|
34
|
+
};
|
|
35
|
+
export const CHECK_COLORS = {
|
|
36
|
+
passed: "green",
|
|
37
|
+
failed: "red",
|
|
38
|
+
pending: "yellow",
|
|
39
|
+
};
|
|
40
|
+
// ─── Feed Event Colors ────────────────────────────────────────────
|
|
41
|
+
export const FEED_LEVEL_COLORS = {
|
|
42
|
+
info: "white",
|
|
43
|
+
warn: "yellow",
|
|
44
|
+
error: "red",
|
|
45
|
+
};
|
|
46
|
+
export const FEED_KIND_COLORS = {
|
|
47
|
+
stage: "cyan",
|
|
48
|
+
turn: "yellow",
|
|
49
|
+
github: "green",
|
|
50
|
+
webhook: "blue",
|
|
51
|
+
agent: "magenta",
|
|
52
|
+
service: "white",
|
|
53
|
+
workflow: "cyan",
|
|
54
|
+
linear: "blue",
|
|
55
|
+
};
|
|
@@ -303,7 +303,7 @@ export function appendCodexItemToTimeline(timeline, params, activeRunId) {
|
|
|
303
303
|
const type = typeof itemObj.type === "string" ? itemObj.type : "unknown";
|
|
304
304
|
const status = typeof itemObj.status === "string" ? itemObj.status : "inProgress";
|
|
305
305
|
const item = { id, type, status };
|
|
306
|
-
if (type === "agentMessage" && typeof itemObj.text === "string")
|
|
306
|
+
if ((type === "agentMessage" || type === "userMessage") && typeof itemObj.text === "string")
|
|
307
307
|
item.text = itemObj.text;
|
|
308
308
|
if (type === "commandExecution") {
|
|
309
309
|
const cmd = itemObj.command;
|
package/dist/http.js
CHANGED
|
@@ -323,7 +323,7 @@ export async function buildHttpServer(config, service, logger) {
|
|
|
323
323
|
if ("error" in result) {
|
|
324
324
|
return reply.code(409).send({ ok: false, reason: result.error });
|
|
325
325
|
}
|
|
326
|
-
return reply.send({ ok: true,
|
|
326
|
+
return reply.send({ ok: true, ...result });
|
|
327
327
|
});
|
|
328
328
|
app.get("/api/feed", async (request, reply) => {
|
|
329
329
|
const feedQuery = {
|
package/dist/service.js
CHANGED
|
@@ -218,22 +218,58 @@ export class PatchRelayService {
|
|
|
218
218
|
this.codex.on("notification", handler);
|
|
219
219
|
return () => { this.codex.off("notification", handler); };
|
|
220
220
|
}
|
|
221
|
-
async promptIssue(issueKey, text) {
|
|
221
|
+
async promptIssue(issueKey, text, source = "watch") {
|
|
222
222
|
const issue = this.db.getIssueByKey(issueKey);
|
|
223
223
|
if (!issue)
|
|
224
224
|
return undefined;
|
|
225
|
-
|
|
226
|
-
|
|
225
|
+
// Publish to operator feed so all clients see the prompt
|
|
226
|
+
this.feed.publish({
|
|
227
|
+
level: "info",
|
|
228
|
+
kind: "comment",
|
|
229
|
+
issueKey: issue.issueKey,
|
|
230
|
+
projectId: issue.projectId,
|
|
231
|
+
stage: issue.factoryState,
|
|
232
|
+
status: "operator_prompt",
|
|
233
|
+
summary: `Operator prompt (${source})`,
|
|
234
|
+
detail: text.slice(0, 200),
|
|
235
|
+
});
|
|
236
|
+
// If no active run, queue as pending context for the next run
|
|
237
|
+
if (!issue.activeRunId) {
|
|
238
|
+
const existing = issue.pendingRunContextJson
|
|
239
|
+
? JSON.parse(issue.pendingRunContextJson)
|
|
240
|
+
: {};
|
|
241
|
+
this.db.upsertIssue({
|
|
242
|
+
projectId: issue.projectId,
|
|
243
|
+
linearIssueId: issue.linearIssueId,
|
|
244
|
+
pendingRunContextJson: JSON.stringify({ ...existing, operatorPrompt: text }),
|
|
245
|
+
});
|
|
246
|
+
return { delivered: false, queued: true };
|
|
247
|
+
}
|
|
227
248
|
const run = this.db.getRun(issue.activeRunId);
|
|
228
|
-
if (!run?.threadId || !run.turnId)
|
|
229
|
-
return { error: "
|
|
249
|
+
if (!run?.threadId || !run.turnId) {
|
|
250
|
+
return { error: "Active run has no thread or turn yet" };
|
|
251
|
+
}
|
|
230
252
|
try {
|
|
231
|
-
await this.codex.steerTurn({
|
|
253
|
+
await this.codex.steerTurn({
|
|
254
|
+
threadId: run.threadId,
|
|
255
|
+
turnId: run.turnId,
|
|
256
|
+
input: `Operator prompt (${source}):\n\n${text}`,
|
|
257
|
+
});
|
|
232
258
|
return { delivered: true };
|
|
233
259
|
}
|
|
234
260
|
catch (error) {
|
|
261
|
+
// Turn may have completed between check and steer — queue for next run
|
|
235
262
|
const msg = error instanceof Error ? error.message : String(error);
|
|
236
|
-
|
|
263
|
+
this.logger.warn({ issueKey, error: msg }, "steerTurn failed, queuing prompt for next run");
|
|
264
|
+
const existing = issue.pendingRunContextJson
|
|
265
|
+
? JSON.parse(issue.pendingRunContextJson)
|
|
266
|
+
: {};
|
|
267
|
+
this.db.upsertIssue({
|
|
268
|
+
projectId: issue.projectId,
|
|
269
|
+
linearIssueId: issue.linearIssueId,
|
|
270
|
+
pendingRunContextJson: JSON.stringify({ ...existing, operatorPrompt: text }),
|
|
271
|
+
});
|
|
272
|
+
return { delivered: false, queued: true };
|
|
237
273
|
}
|
|
238
274
|
}
|
|
239
275
|
retryIssue(issueKey) {
|