bosun 0.42.2 → 0.42.4

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.
Files changed (49) hide show
  1. package/.env.example +9 -0
  2. package/agent/agent-event-bus.mjs +10 -0
  3. package/agent/agent-supervisor.mjs +20 -0
  4. package/bosun-tui.mjs +107 -105
  5. package/cli.mjs +10 -0
  6. package/config/config.mjs +25 -0
  7. package/config/executor-config.mjs +124 -1
  8. package/infra/container-runner.mjs +565 -1
  9. package/infra/monitor.mjs +18 -0
  10. package/infra/tracing.mjs +544 -240
  11. package/infra/tui-bridge.mjs +13 -1
  12. package/kanban/kanban-adapter.mjs +128 -4
  13. package/lib/repo-map.mjs +114 -3
  14. package/package.json +11 -4
  15. package/server/ui-server.mjs +3 -0
  16. package/task/task-archiver.mjs +18 -6
  17. package/task/task-attachments.mjs +14 -10
  18. package/task/task-cli.mjs +24 -4
  19. package/task/task-executor.mjs +19 -0
  20. package/task/task-store.mjs +194 -37
  21. package/telegram/telegram-bot.mjs +4 -1
  22. package/tui/app.mjs +131 -171
  23. package/tui/components/status-header.mjs +178 -75
  24. package/tui/lib/header-config.mjs +68 -0
  25. package/tui/lib/ws-bridge.mjs +61 -9
  26. package/tui/screens/agents.mjs +127 -0
  27. package/tui/screens/tasks.mjs +1 -48
  28. package/ui/app.js +8 -5
  29. package/ui/components/kanban-board.js +65 -3
  30. package/ui/components/session-list.js +18 -32
  31. package/ui/demo-defaults.js +52 -2
  32. package/ui/modules/session-api.js +100 -0
  33. package/ui/modules/state.js +71 -15
  34. package/ui/tabs/workflows.js +25 -1
  35. package/ui/tui/App.js +298 -0
  36. package/ui/tui/TasksScreen.js +564 -0
  37. package/ui/tui/constants.js +55 -0
  38. package/ui/tui/tasks-screen-helpers.js +301 -0
  39. package/ui/tui/useTasks.js +61 -0
  40. package/ui/tui/useWebSocket.js +166 -0
  41. package/ui/tui/useWorkflows.js +30 -0
  42. package/workflow/workflow-engine.mjs +412 -7
  43. package/workflow/workflow-nodes.mjs +616 -75
  44. package/workflow-templates/agents.mjs +3 -0
  45. package/workflow-templates/planning.mjs +7 -0
  46. package/workflow-templates/sub-workflows.mjs +5 -0
  47. package/workflow-templates/task-execution.mjs +3 -0
  48. package/workspace/command-diagnostics.mjs +1 -1
  49. package/workspace/context-cache.mjs +182 -9
@@ -0,0 +1,68 @@
1
+ import { loadConfig } from "../../config/config.mjs";
2
+
3
+ function isTruthy(value) {
4
+ return ["1", "true", "yes", "on"].includes(String(value || "").trim().toLowerCase());
5
+ }
6
+
7
+ function hasValue(value) {
8
+ return String(value || "").trim().length > 0;
9
+ }
10
+
11
+ function resolveProjectLabel(config = {}) {
12
+ const candidates = [
13
+ config?.linearUrl,
14
+ config?.linear?.url,
15
+ config?.kanban?.url,
16
+ config?.kanban?.projectUrl,
17
+ config?.vkPublicUrl,
18
+ config?.vkEndpointUrl,
19
+ config?.projectUrl,
20
+ config?.kanban?.projectId,
21
+ config?.projectId,
22
+ ];
23
+ const label = candidates.find((candidate) => hasValue(candidate));
24
+ return label ? String(label).trim() : "No project";
25
+ }
26
+
27
+ function resolveConfiguredProviders(config = {}) {
28
+ const primaryAgent = String(config?.primaryAgent || "").trim().toLowerCase();
29
+ return {
30
+ claude: primaryAgent === "claude-sdk" || hasValue(process.env.ANTHROPIC_API_KEY),
31
+ codex:
32
+ primaryAgent === "codex-sdk" ||
33
+ config?.codexEnabled === true ||
34
+ hasValue(process.env.OPENAI_API_KEY) ||
35
+ hasValue(process.env.AZURE_OPENAI_API_KEY),
36
+ gemini:
37
+ primaryAgent === "gemini-sdk" ||
38
+ hasValue(process.env.GEMINI_API_KEY) ||
39
+ hasValue(process.env.GOOGLE_API_KEY),
40
+ copilot:
41
+ primaryAgent === "copilot-sdk" ||
42
+ (!isTruthy(process.env.COPILOT_SDK_DISABLED) && (hasValue(process.env.COPILOT_CLI_TOKEN) || hasValue(process.env.GITHUB_PAT))),
43
+ };
44
+ }
45
+
46
+ export function readTuiHeaderConfig(configDir) {
47
+ try {
48
+ const argv = ["node", "bosun-tui"];
49
+ if (configDir) {
50
+ argv.push("--config-dir", String(configDir));
51
+ }
52
+ const config = loadConfig(argv, { reloadEnv: false });
53
+ return {
54
+ configuredProviders: resolveConfiguredProviders(config),
55
+ projectLabel: resolveProjectLabel(config),
56
+ };
57
+ } catch {
58
+ return {
59
+ configuredProviders: {
60
+ claude: hasValue(process.env.ANTHROPIC_API_KEY),
61
+ codex: hasValue(process.env.OPENAI_API_KEY) || hasValue(process.env.AZURE_OPENAI_API_KEY),
62
+ gemini: hasValue(process.env.GEMINI_API_KEY) || hasValue(process.env.GOOGLE_API_KEY),
63
+ copilot: hasValue(process.env.COPILOT_CLI_TOKEN) || hasValue(process.env.GITHUB_PAT),
64
+ },
65
+ projectLabel: "No project",
66
+ };
67
+ }
68
+ }
@@ -1,3 +1,4 @@
1
+ import { existsSync, readFileSync } from "node:fs";
1
2
  import { resolve } from "node:path";
2
3
 
3
4
  import { resolveTuiAuthToken as resolveSharedTuiAuthToken } from "../../infra/tui-bridge.mjs";
@@ -17,6 +18,27 @@ function normalizeProtocol(protocol) {
17
18
  return value === "wss" ? "wss" : "ws";
18
19
  }
19
20
 
21
+ function readUiInstanceLock(configDir = defaultConfigDir()) {
22
+ try {
23
+ const lockPath = resolve(configDir, ".cache", "ui-server.instance.lock.json");
24
+ if (!existsSync(lockPath)) return null;
25
+ const parsed = JSON.parse(readFileSync(lockPath, "utf8"));
26
+ if (!parsed || typeof parsed !== "object") return null;
27
+ return parsed;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function resolveWebSocketProtocol({ protocol, configDir } = {}) {
34
+ const explicit = String(protocol || "").trim().toLowerCase();
35
+ if (explicit === "ws" || explicit === "wss") {
36
+ return explicit;
37
+ }
38
+ const instance = readUiInstanceLock(configDir);
39
+ return String(instance?.protocol || "").trim().toLowerCase() === "https" ? "wss" : "ws";
40
+ }
41
+
20
42
  function resolveTuiAuthToken(options = {}) {
21
43
  return resolveSharedTuiAuthToken({
22
44
  env: options.env || process.env,
@@ -54,10 +76,12 @@ class TuiWsBridge {
54
76
  this.reconnectDelay = 1000;
55
77
  this.reconnectTimer = null;
56
78
  this._connected = false;
79
+ this._connectionState = "offline";
80
+ this._manualDisconnect = false;
57
81
  this._url = buildTuiWebSocketUrl({
58
82
  host,
59
83
  port,
60
- protocol,
84
+ protocol: resolveWebSocketProtocol({ protocol, configDir: this.configDir }),
61
85
  token: resolveTuiAuthToken({ configDir: this.configDir }),
62
86
  });
63
87
  }
@@ -66,24 +90,26 @@ class TuiWsBridge {
66
90
  if (this.ws && this.ws.readyState === WebSocket.OPEN) {
67
91
  return;
68
92
  }
93
+ this._manualDisconnect = false;
69
94
 
70
95
  try {
71
96
  this._url = buildTuiWebSocketUrl({
72
97
  host: this.host,
73
98
  port: this.port,
74
- protocol: this.protocol,
99
+ protocol: resolveWebSocketProtocol({ protocol: this.protocol, configDir: this.configDir }),
75
100
  token: resolveTuiAuthToken({ configDir: this.configDir }),
76
101
  });
77
102
  this.ws = new WebSocket(this._url);
78
103
 
79
104
  this.ws.onopen = () => {
80
105
  this._connected = true;
106
+ this._connectionState = "connected";
81
107
  this.reconnectAttempts = 0;
82
108
  this.send("subscribe", {
83
109
  channels: ["monitor", "stats", "sessions", "tasks", "workflows", "tui"],
84
110
  });
111
+ this._emit("connection:state", { state: this._connectionState });
85
112
  this._emit("connect", {});
86
- console.log("[ws-bridge] Connected to UI server");
87
113
  };
88
114
 
89
115
  this.ws.onmessage = (event) => {
@@ -91,28 +117,35 @@ class TuiWsBridge {
91
117
  const data = JSON.parse(event.data);
92
118
  this._handleMessage(data);
93
119
  } catch (err) {
94
- console.warn("[ws-bridge] Failed to parse message:", err.message);
120
+ this._emit("error", { message: err?.message || "Failed to parse message" });
95
121
  }
96
122
  };
97
123
 
98
124
  this.ws.onclose = () => {
99
125
  this._connected = false;
126
+ this._connectionState = this._manualDisconnect ? "offline" : "reconnecting";
127
+ this._emit("connection:state", { state: this._connectionState });
100
128
  this._emit("disconnect", {});
101
129
  console.log("[ws-bridge] Disconnected from UI server");
102
- this._scheduleReconnect();
130
+ if (!this._manualDisconnect) {
131
+ this._scheduleReconnect();
132
+ }
103
133
  };
104
134
 
105
135
  this.ws.onerror = (err) => {
106
- console.error("[ws-bridge] WebSocket error:", err.message);
107
- this._emit("error", { message: err.message || "WebSocket error" });
136
+ const message = err?.message || err?.error?.message || "WebSocket error";
137
+ this._emit("error", { message });
138
+ this._emit("error", { message });
139
+ this._emit("error", { message });
108
140
  };
109
141
  } catch (err) {
110
- console.error("[ws-bridge] Failed to connect:", err.message);
142
+ this._emit("error", { message: err?.message || "Failed to connect" });
111
143
  this._scheduleReconnect();
112
144
  }
113
145
  }
114
146
 
115
147
  disconnect() {
148
+ this._manualDisconnect = true;
116
149
  if (this.reconnectTimer) {
117
150
  clearTimeout(this.reconnectTimer);
118
151
  this.reconnectTimer = null;
@@ -122,19 +155,32 @@ class TuiWsBridge {
122
155
  this.ws = null;
123
156
  }
124
157
  this._connected = false;
158
+ this._connectionState = "offline";
159
+ this._emit("connection:state", { state: this._connectionState });
125
160
  }
126
161
 
127
162
  _scheduleReconnect() {
128
163
  if (this.reconnectAttempts >= this.maxReconnectAttempts) {
164
+ this._connectionState = "offline";
165
+ this._emit("connection:state", { state: this._connectionState });
129
166
  this._emit("error", { message: "Max reconnection attempts reached" });
130
167
  return;
131
168
  }
132
169
 
133
170
  const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts);
134
171
  this.reconnectAttempts++;
172
+ this._connectionState = "reconnecting";
173
+ this._emit("connection:state", {
174
+ state: this._connectionState,
175
+ attempt: this.reconnectAttempts,
176
+ delayMs: delay,
177
+ });
178
+ this._emit("reconnecting", {
179
+ attempt: this.reconnectAttempts,
180
+ delayMs: delay,
181
+ });
135
182
 
136
183
  this.reconnectTimer = setTimeout(() => {
137
- console.log(`[ws-bridge] Reconnecting (attempt ${this.reconnectAttempts})...`);
138
184
  this.connect();
139
185
  }, delay);
140
186
  }
@@ -232,6 +278,10 @@ class TuiWsBridge {
232
278
  get isConnected() {
233
279
  return this._connected;
234
280
  }
281
+
282
+ get connectionState() {
283
+ return this._connectionState;
284
+ }
235
285
  }
236
286
 
237
287
  let _instance = null;
@@ -279,5 +329,7 @@ export {
279
329
  buildTuiWebSocketUrl,
280
330
  createWsBridge,
281
331
  defaultConfigDir,
332
+ resolveWebSocketProtocol,
282
333
  resolveTuiAuthToken,
283
334
  };
335
+
@@ -155,6 +155,8 @@ export default function AgentsScreen({ wsBridge, host = "127.0.0.1", port = 3080
155
155
  };
156
156
  }, [refreshData]);
157
157
 
158
+ const selectedSession = entries.find((entry) => entry.id === selectedId)?.session || entries[0]?.session || null;
159
+
158
160
  React.useEffect(() => {
159
161
  if (!wsBridge || typeof wsBridge.on !== "function") return undefined;
160
162
  const handlers = [
@@ -166,7 +168,80 @@ export default function AgentsScreen({ wsBridge, host = "127.0.0.1", port = 3080
166
168
  : [];
167
169
  applySessionSnapshot(sessions, Date.now());
168
170
  }),
171
+ wsBridge.on("session:event", (payload) => {
172
+ const session = payload?.session;
173
+ if (!session?.id) return;
174
+ const nextSessions = Array.isArray(liveSessionsRef.current)
175
+ ? [...liveSessionsRef.current]
176
+ : [];
177
+ const existingIndex = nextSessions.findIndex((candidate) => candidate.id === session.id);
178
+ if (existingIndex >= 0) nextSessions[existingIndex] = session;
179
+ else nextSessions.unshift(session);
180
+ applySessionSnapshot(nextSessions, Date.now());
181
+
182
+ if (selectedId === session.id) {
183
+ setDetailView(detailLines(payload));
184
+ const hasMessages = Array.isArray(payload?.session?.messages);
185
+ const isMessageEvent = payload?.event?.kind === "message";
186
+ if (hasMessages || isMessageEvent) {
187
+ setLogLines(sessionMessagesToLogLines(payload));
188
+ }
189
+ const isMessageEvent = payload?.event?.kind === "message";
190
+ if (hasMessages || isMessageEvent) {
191
+ setLogLines(sessionMessagesToLogLines(payload));
192
+ }
193
+ const isMessageEvent = payload?.event?.kind === "message";
194
+ if (hasMessages || isMessageEvent) {
195
+ setLogLines(sessionMessagesToLogLines(payload));
196
+ }
197
+ const isMessageEvent = payload?.event?.kind === "message";
198
+ if (hasMessages || isMessageEvent) {
199
+ setLogLines(sessionMessagesToLogLines(payload));
200
+ }
201
+ }
202
+ }),
203
+ wsBridge.on("retry:update", applyRetryQueue),
204
+ wsBridge.on("retry-queue-updated", applyRetryQueue),
205
+ ];
206
+
207
+ return () => {
208
+ handlers.forEach((handler) => {
209
+ if (typeof handler === "function") handler();
210
+ });
211
+ };
212
+ }, [applyRetryQueue, applySessionSnapshot, selectedId, wsBridge]);
213
+
214
+ useInput((input, key) => {
215
+ if (confirmKill) {
216
+ if ((input === "y" || input === "Y") && selectedSession?.id) {
217
+ void fetchJson(resolvedHost, resolvedPort, sessionActionPath(selectedSession.id, "stop"), {
218
+ method: "POST",
219
+ }).then(() => {
220
+ setStatusLine(`Stopped ${describeSelection(selectedSession)}`);
221
+ }).catch((error) => {
222
+ setStatusLine(error.message || String(error));
223
+ });
224
+ }
225
+ setConfirmKill(false);
226
+ return;
227
+ }
169
228
 
229
+ const selectableEntries = entries.filter((entry) => entry?.session);
230
+ const selectedIndex = selectableEntries.findIndex((entry) => entry.id === selectedId);
231
+
232
+ if (key.upArrow && selectedIndex > 0) {
233
+ setSelectedId(selectableEntries[selectedIndex - 1].id);
234
+ return;
235
+ }
236
+ if (key.downArrow && selectedIndex >= 0 && selectedIndex < selectableEntries.length - 1) {
237
+ setSelectedId(selectableEntries[selectedIndex + 1].id);
238
+ return;
239
+ }
240
+ if (key.return && selectedSession?.id) {
241
+ setDetailView(detailLines({ session: selectedSession }));
242
+ return;
243
+ }
244
+ if (input === "c" || input === "C") {
170
245
  if (selectedSession?.id) {
171
246
  stdout.write(buildOsc52CopySequence(selectedSession.id));
172
247
  setStatusLine(`Copied ${selectedSession.id}`);
@@ -177,6 +252,58 @@ export default function AgentsScreen({ wsBridge, host = "127.0.0.1", port = 3080
177
252
  setShowBackoff((current) => !current);
178
253
  return;
179
254
  }
255
+ if (input === "p" || input === "P") {
256
+ if (selectedSession?.id) {
257
+ void fetchJson(resolvedHost, resolvedPort, sessionActionPath(selectedSession.id, "pause"), {
258
+ method: "POST",
259
+ }).then(() => setStatusLine(`Paused ${describeSelection(selectedSession)}`)).catch((error) => {
260
+ setStatusLine(error.message || String(error));
261
+ });
262
+ }
263
+ return;
264
+ }
265
+ if (input === "r" || input === "R") {
266
+ if (selectedSession?.id) {
267
+ void fetchJson(resolvedHost, resolvedPort, sessionActionPath(selectedSession.id, "resume"), {
268
+ method: "POST",
269
+ }).then(() => setStatusLine(`Resumed ${describeSelection(selectedSession)}`)).catch((error) => {
270
+ setStatusLine(error.message || String(error));
271
+ });
272
+ }
273
+ return;
274
+ }
275
+ if (input === "k" || input === "K") {
276
+ if (selectedSession?.id) {
277
+ setConfirmKill(true);
278
+ }
279
+ return;
280
+ }
281
+ if (input === "l" || input === "L") {
282
+ if (selectedSession?.id) {
283
+ void fetchJson(resolvedHost, resolvedPort, `/api/sessions/${encodeURIComponent(selectedSession.id)}?workspace=all&full=1`)
284
+ .then((payload) => {
285
+ setLogLines(sessionMessagesToLogLines(payload));
286
+ setStatusLine(`Loaded logs for ${describeSelection(selectedSession)}`);
287
+ })
288
+ .catch((error) => {
289
+ setStatusLine(error.message || String(error));
290
+ });
291
+ }
292
+ return;
293
+ }
294
+ if (input === "d" || input === "D") {
295
+ if (selectedSession?.id) {
296
+ void fetchJson(resolvedHost, resolvedPort, sessionActionPath(selectedSession.id, "diff"))
297
+ .then((payload) => {
298
+ setDiffView(summarizeDiff(payload));
299
+ setStatusLine(`Loaded diff for ${describeSelection(selectedSession)}`);
300
+ })
301
+ .catch((error) => {
302
+ setStatusLine(error.message || String(error));
303
+ });
304
+ }
305
+ return;
306
+ }
180
307
  if (key.escape) {
181
308
  setDetailView(null);
182
309
  setLogLines([]);
@@ -1,48 +1 @@
1
- import React from "react";
2
- import htm from "htm";
3
- import { Box, Text } from "ink";
4
-
5
- const html = htm.bind(React.createElement);
6
-
7
- const COLUMNS = ["todo", "inprogress", "inreview", "blocked", "done"];
8
-
9
- function formatTask(task) {
10
- const id = String(task?.id || "").slice(0, 8) || "--------";
11
- const title = String(task?.title || "Untitled");
12
- return `${id} ${title}`;
13
- }
14
-
15
- export default function TasksScreen({ tasks }) {
16
- const buckets = new Map(COLUMNS.map((column) => [column, []]));
17
- for (const task of Array.isArray(tasks) ? tasks : []) {
18
- const status = String(task?.status || "todo").toLowerCase();
19
- if (!buckets.has(status)) buckets.set(status, []);
20
- buckets.get(status).push(task);
21
- }
22
-
23
- return html`
24
- <${Box} flexDirection="column" paddingY=${1}>
25
- <${Text} dimColor>Task board view is read-only in the terminal build.<//>
26
- <${Box} marginTop=${1}>
27
- ${COLUMNS.map((column) => html`
28
- <${Box}
29
- key=${column}
30
- flexDirection="column"
31
- borderStyle="single"
32
- paddingX=${1}
33
- marginRight=${1}
34
- width=${24}
35
- >
36
- <${Text} bold>${column} (${(buckets.get(column) || []).length})<//>
37
- ${(buckets.get(column) || []).slice(0, 8).map((task) => html`
38
- <${Text} key=${task.id} wrap="truncate-end">${formatTask(task)}<//>
39
- `)}
40
- ${(buckets.get(column) || []).length === 0 && html`
41
- <${Text} dimColor>No tasks<//>
42
- `}
43
- <//>
44
- `)}
45
- <//>
46
- <//>
47
- `;
48
- }
1
+ export { default } from "../../ui/tui/TasksScreen.js";
package/ui/app.js CHANGED
@@ -276,6 +276,7 @@ import {
276
276
  import { formatRelative } from "./modules/utils.js";
277
277
  import {
278
278
  buildSessionApiPath,
279
+ shouldFallbackToAllSessions,
279
280
  getSessionLifecycleState,
280
281
  getSessionRecencyTimestamp,
281
282
  getSessionRuntimeState,
@@ -1050,11 +1051,11 @@ function InspectorPanel({ onResizeStart, onResizeReset, showResizer }) {
1050
1051
  try {
1051
1052
  res = await apiFetch(fullSessionPath, { _silent: true });
1052
1053
  } catch (err) {
1053
- const errorText = String(err?.message || "").toLowerCase();
1054
- const shouldRetryAll =
1055
- Boolean(fallbackSessionPath) &&
1056
- fallbackSessionPath !== fullSessionPath &&
1057
- (errorText.includes("session not found") || errorText.includes("request failed (404)"));
1054
+ const shouldRetryAll = shouldFallbackToAllSessions(
1055
+ err,
1056
+ fullSessionPath,
1057
+ fallbackSessionPath,
1058
+ );
1058
1059
  if (!shouldRetryAll) throw err;
1059
1060
  res = await apiFetch(fallbackSessionPath, { _silent: true });
1060
1061
  }
@@ -2688,3 +2689,5 @@ const remountApp = () => {
2688
2689
  };
2689
2690
  globalThis.__veRemountApp = remountApp;
2690
2691
  mountApp();
2692
+
2693
+
@@ -14,6 +14,7 @@ import {
14
14
  loadTasks,
15
15
  normalizeTaskLifecycleStatus,
16
16
  classifyTaskLifecycleAction,
17
+ mergeTaskRecords,
17
18
  } from "../modules/state.js";
18
19
  import { apiFetch } from "../modules/api.js";
19
20
  import { haptic, showConfirm } from "../modules/telegram.js";
@@ -249,6 +250,56 @@ function getTaskBaseBranch(task) {
249
250
  );
250
251
  }
251
252
 
253
+ function getTaskPrLinkage(task) {
254
+ if (!task) return [];
255
+ const records = [];
256
+ const seen = new Set();
257
+ const append = (record) => {
258
+ if (!record || typeof record !== "object") return;
259
+ const branchName = String(record.branchName || "").trim();
260
+ const prUrl = String(record.prUrl || "").trim();
261
+ const prNumber = Number.parseInt(String(record.prNumber ?? ""), 10);
262
+ if (!branchName && !prUrl && !(Number.isFinite(prNumber) && prNumber > 0)) return;
263
+ const normalized = {
264
+ branchName,
265
+ prUrl,
266
+ prNumber: Number.isFinite(prNumber) && prNumber > 0 ? prNumber : null,
267
+ source: String(record.source || task?.meta?.prLinkageSource || "").trim(),
268
+ freshness: String(record.freshness || task?.meta?.prLinkageFreshness || "").trim(),
269
+ linkedAt: String(record.linkedAt || "").trim(),
270
+ updatedAt: String(record.updatedAt || task?.meta?.prLinkageUpdatedAt || "").trim(),
271
+ };
272
+ const key = [normalized.branchName.toLowerCase(), normalized.prNumber || "", normalized.prUrl.toLowerCase()].join("|");
273
+ if (seen.has(key)) return;
274
+ seen.add(key);
275
+ records.push(normalized);
276
+ };
277
+ (Array.isArray(task.prLinkage) ? task.prLinkage : []).forEach(append);
278
+ (Array.isArray(task?.meta?.prLinkage) ? task.meta.prLinkage : []).forEach(append);
279
+ append(task);
280
+ return records;
281
+ }
282
+
283
+ function getPrimaryTaskPrLinkage(task) {
284
+ return getTaskPrLinkage(task)[0] || null;
285
+ }
286
+
287
+ function formatPrLinkageFreshnessLabel(linkage) {
288
+ const freshness = String(linkage?.freshness || "").trim().toLowerCase();
289
+ const timestamp = String(linkage?.updatedAt || linkage?.linkedAt || "").trim();
290
+ const relative = timestamp ? formatRelative(timestamp) : "";
291
+ if (freshness === "stale") {
292
+ return relative ? `PR freshness: Stale (${relative})` : "PR freshness: Stale";
293
+ }
294
+ if (freshness === "fresh") {
295
+ return relative ? `PR freshness: Fresh (${relative})` : "PR freshness: Fresh";
296
+ }
297
+ if (relative) {
298
+ return `PR freshness: Updated ${relative}`;
299
+ }
300
+ return "PR freshness: Linked";
301
+ }
302
+
252
303
  function getTaskRuntimeSnapshot(task) {
253
304
  return task?.runtimeSnapshot || task?.meta?.runtimeSnapshot || null;
254
305
  }
@@ -444,7 +495,7 @@ async function executeBoardTransition(task, newStatus, columnLabel) {
444
495
  const merged = detail?.data || startRes?.data || null;
445
496
  if (merged) {
446
497
  tasksData.value = tasksData.value.map((t) =>
447
- matchTaskId(t.id, taskId) ? { ...t, ...merged } : t,
498
+ matchTaskId(t.id, taskId) ? mergeTaskRecords(t, merged) : t,
448
499
  );
449
500
  }
450
501
  return startRes;
@@ -462,7 +513,7 @@ async function executeBoardTransition(task, newStatus, columnLabel) {
462
513
  });
463
514
  if (res?.data) {
464
515
  tasksData.value = tasksData.value.map((t) =>
465
- matchTaskId(t.id, taskId) ? { ...t, ...res.data } : t,
516
+ matchTaskId(t.id, taskId) ? mergeTaskRecords(t, res.data) : t,
466
517
  );
467
518
  }
468
519
  return res;
@@ -621,6 +672,7 @@ function KanbanCard({ task, onOpen }) {
621
672
  const storyPoints = getTaskStoryPoints(task);
622
673
  const dueDate = getTaskDueDate(task);
623
674
  const blockedPreview = getTaskBlockedPreview(task);
675
+ const prLinkage = getPrimaryTaskPrLinkage(task);
624
676
  const repoName = task.repo || task.repository || "";
625
677
  const issueNum = task.issueNumber || task.issue_number || (typeof task.id === "string" && /^\d+$/.test(task.id) ? task.id : null);
626
678
  const hasAgent = Boolean(
@@ -693,6 +745,14 @@ function KanbanCard({ task, onOpen }) {
693
745
  ${dueDate && html`<${Chip} label=${`Due: ${truncate(dueDate, 18)}`} size="small" variant="outlined" color="warning" sx=${{ height: 20, fontSize: '0.65rem' }} />`}
694
746
  </${Stack}>
695
747
  `}
748
+ ${prLinkage && html`
749
+ <${Stack} direction="row" spacing=${0.5} flexWrap="wrap" sx=${{ mt: 0.75 }}>
750
+ ${prLinkage.prNumber && html`<${Chip} label=${`PR #${prLinkage.prNumber}`} size="small" variant="outlined" sx=${{ height: 20, fontSize: '0.65rem' }} />`}
751
+ ${prLinkage.branchName && html`<${Chip} label=${`Branch: ${truncate(prLinkage.branchName, 18)}`} size="small" variant="outlined" sx=${{ height: 20, fontSize: '0.65rem' }} />`}
752
+ ${prLinkage.source && html`<${Chip} label=${`PR source: ${truncate(prLinkage.source, 16)}`} size="small" variant="outlined" sx=${{ height: 20, fontSize: '0.65rem' }} />`}
753
+ ${(prLinkage.freshness || prLinkage.updatedAt || prLinkage.linkedAt) && html`<${Chip} label=${formatPrLinkageFreshnessLabel(prLinkage)} size="small" color=${prLinkage.freshness === "stale" ? "warning" : "success"} sx=${{ height: 20, fontSize: '0.65rem' }} />`}
754
+ </${Stack}>
755
+ `}
696
756
  ${baseBranch && html`
697
757
  <${Typography} variant="caption" color="text.secondary" sx=${{ display: 'block', mt: 0.5 }}>Base: ${truncate(baseBranch, 24)}</${Typography}>
698
758
  `}
@@ -845,7 +905,7 @@ function KanbanColumn({
845
905
  });
846
906
  if (res?.data) {
847
907
  tasksData.value = tasksData.value.map((t) =>
848
- matchTaskId(t.id, taskId) ? { ...t, ...res.data } : t,
908
+ matchTaskId(t.id, taskId) ? mergeTaskRecords(t, res.data) : t,
849
909
  );
850
910
  }
851
911
  return res;
@@ -1180,3 +1240,5 @@ export function KanbanBoard({ onOpenTask, hasMoreTasks = false, loadingMoreTasks
1180
1240
  </${Box}>
1181
1241
  `;
1182
1242
  }
1243
+
1244
+