bosun 0.42.0 → 0.42.2

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 (63) hide show
  1. package/.env.example +12 -0
  2. package/README.md +2 -0
  3. package/agent/agent-pool.mjs +34 -1
  4. package/agent/agent-work-report.mjs +89 -3
  5. package/agent/analyze-agent-work-helpers.mjs +14 -0
  6. package/agent/analyze-agent-work.mjs +23 -3
  7. package/agent/primary-agent.mjs +23 -1
  8. package/bosun-tui.mjs +4 -3
  9. package/bosun.schema.json +1 -1
  10. package/config/config.mjs +58 -0
  11. package/config/workspace-health.mjs +36 -6
  12. package/git/diff-stats.mjs +550 -124
  13. package/github/github-app-auth.mjs +9 -5
  14. package/infra/maintenance.mjs +13 -6
  15. package/infra/monitor.mjs +398 -10
  16. package/infra/runtime-accumulator.mjs +9 -1
  17. package/infra/session-tracker.mjs +163 -1
  18. package/infra/tui-bridge.mjs +415 -0
  19. package/infra/worktree-recovery-state.mjs +159 -0
  20. package/kanban/kanban-adapter.mjs +41 -8
  21. package/lib/repo-map.mjs +411 -0
  22. package/package.json +140 -137
  23. package/server/ui-server.mjs +953 -59
  24. package/shell/codex-config.mjs +34 -8
  25. package/task/task-cli.mjs +93 -19
  26. package/task/task-executor.mjs +397 -8
  27. package/task/task-store.mjs +194 -1
  28. package/telegram/telegram-bot.mjs +267 -18
  29. package/tools/vitest-runner.mjs +108 -0
  30. package/tui/app.mjs +252 -148
  31. package/tui/components/status-header.mjs +88 -131
  32. package/tui/lib/ws-bridge.mjs +125 -35
  33. package/tui/screens/agents-screen-helpers.mjs +219 -0
  34. package/tui/screens/agents.mjs +287 -270
  35. package/tui/screens/status.mjs +51 -189
  36. package/tui/screens/tasks.mjs +41 -253
  37. package/ui/app.js +52 -23
  38. package/ui/components/chat-view.js +263 -84
  39. package/ui/components/diff-viewer.js +324 -140
  40. package/ui/components/kanban-board.js +13 -9
  41. package/ui/components/session-list.js +111 -41
  42. package/ui/demo-defaults.js +481 -59
  43. package/ui/demo.html +32 -0
  44. package/ui/modules/session-api.js +320 -5
  45. package/ui/modules/stream-timeline.js +356 -0
  46. package/ui/modules/telegram.js +5 -2
  47. package/ui/modules/worktree-recovery.js +85 -0
  48. package/ui/styles.css +44 -0
  49. package/ui/tabs/chat.js +19 -4
  50. package/ui/tabs/dashboard.js +22 -0
  51. package/ui/tabs/infra.js +25 -0
  52. package/ui/tabs/tasks.js +119 -11
  53. package/voice/voice-auth-manager.mjs +10 -5
  54. package/workflow/workflow-engine.mjs +179 -1
  55. package/workflow/workflow-nodes.mjs +872 -16
  56. package/workflow/workflow-templates.mjs +4 -0
  57. package/workflow-templates/github.mjs +2 -1
  58. package/workflow-templates/planning.mjs +2 -1
  59. package/workflow-templates/sub-workflows.mjs +10 -0
  60. package/workflow-templates/task-batch.mjs +9 -8
  61. package/workflow-templates/task-execution.mjs +30 -12
  62. package/workflow-templates/task-lifecycle.mjs +59 -4
  63. package/workspace/shared-knowledge.mjs +409 -155
@@ -44,6 +44,7 @@ import {
44
44
  } from "../kanban/kanban-adapter.mjs";
45
45
 
46
46
  import {
47
+ addActiveSessionListener,
47
48
  getActiveThreads,
48
49
  launchEphemeralThread,
49
50
  launchOrResumeThread,
@@ -132,6 +133,10 @@ import {
132
133
  listActiveInstances,
133
134
  selectCoordinator,
134
135
  } from "../infra/presence.mjs";
136
+ import {
137
+ normalizeWorktreeRecoveryState,
138
+ readWorktreeRecoveryState,
139
+ } from "../infra/worktree-recovery-state.mjs";
135
140
  import {
136
141
  loadWorkspaceRegistry,
137
142
  getLocalWorkspace,
@@ -155,10 +160,12 @@ import {
155
160
  import {
156
161
  getSessionTracker,
157
162
  addSessionEventListener,
163
+ addSessionStateListener,
158
164
  } from "../infra/session-tracker.mjs";
159
165
  import { ensureTestRuntimeSandbox } from "../infra/test-runtime.mjs";
160
166
  import {
161
167
  addSessionAccumulationListener,
168
+ exportRuntimeData,
162
169
  getCompletedSessions,
163
170
  getRuntimeStats,
164
171
  getTaskLifetimeTotals,
@@ -206,6 +213,17 @@ import {
206
213
  mergeTaskAttachments,
207
214
  } from "../task/task-attachments.mjs";
208
215
  import { getVisionSessionState } from "../voice/vision-session-state.mjs";
216
+ import {
217
+ buildLogStreamPayload,
218
+ buildMonitorStatsPayload,
219
+ buildSessionEventPayload,
220
+ buildSessionsUpdatePayload,
221
+ buildTasksUpdatePayload,
222
+ buildWorkflowStatusPayload,
223
+ createTuiStatsEmitter,
224
+ persistCompatibleTuiAuthToken,
225
+ resolveTuiAuthToken,
226
+ } from "../infra/tui-bridge.mjs";
209
227
 
210
228
  const TASK_STORE_MODULE_PATH = "../task/task-store.mjs";
211
229
  const TASK_STORE_START_GUARD_EXPORTS = [
@@ -228,6 +246,10 @@ const TASK_STORE_DAG_EXPORTS = Object.freeze({
228
246
  });
229
247
  const TASK_STORE_GET_TASK_EXPORTS = ["getTaskById", "getTask"];
230
248
  const TASK_STORE_COMMENT_EXPORTS = ["getTaskComments", "listTaskComments"];
249
+ const TASK_STORE_RUN_EXPORTS = {
250
+ list: ["getTaskRuns", "listTaskRuns"],
251
+ append: ["appendTaskRun", "addTaskRun"],
252
+ };
231
253
  const TASK_STORE_DEPENDENCY_EXPORTS = {
232
254
  add: ["addTaskDependency"],
233
255
  remove: ["removeTaskDependency"],
@@ -2169,6 +2191,261 @@ function resolvePrimaryWorkspaceId() {
2169
2191
  }
2170
2192
  }
2171
2193
 
2194
+ function normalizeDiffTaskRef(value) {
2195
+ return String(value || "").trim();
2196
+ }
2197
+
2198
+ function collectTaskWorkflowRunEntries(task) {
2199
+ return [
2200
+ ...(Array.isArray(task?.workflowRuns) ? task.workflowRuns : []),
2201
+ ...(Array.isArray(task?.workflowHistory) ? task.workflowHistory : []),
2202
+ ...(Array.isArray(task?.workflows) ? task.workflows : []),
2203
+ ...(Array.isArray(task?.meta?.workflowRuns) ? task.meta.workflowRuns : []),
2204
+ ];
2205
+ }
2206
+
2207
+ function collectTaskDiffSessionIds(task) {
2208
+ const ids = new Set();
2209
+ const push = (value) => {
2210
+ const normalized = normalizeDiffTaskRef(value);
2211
+ if (normalized) ids.add(normalized);
2212
+ };
2213
+
2214
+ push(task?.sessionId);
2215
+ push(task?.primarySessionId);
2216
+ push(task?.meta?.sessionId);
2217
+ push(task?.meta?.primarySessionId);
2218
+
2219
+ for (const entry of collectTaskWorkflowRunEntries(task)) {
2220
+ push(entry?.sessionId);
2221
+ push(entry?.primarySessionId);
2222
+ push(entry?.threadId);
2223
+ push(entry?.agentSessionId);
2224
+ push(entry?.meta?.sessionId);
2225
+ push(entry?.meta?.threadId);
2226
+ }
2227
+
2228
+ return [...ids];
2229
+ }
2230
+
2231
+ function collectTaskDiffCommitRefs(task) {
2232
+ const refs = new Set();
2233
+ const push = (value) => {
2234
+ const normalized = normalizeDiffTaskRef(value);
2235
+ if (normalized) refs.add(normalized);
2236
+ };
2237
+
2238
+ push(task?.commitSha);
2239
+ push(task?.sha);
2240
+ push(task?.headSha);
2241
+ push(task?.mergeCommitSha);
2242
+ push(task?.meta?.commitSha);
2243
+ push(task?.meta?.sha);
2244
+ push(task?.meta?.headSha);
2245
+ push(task?.meta?.mergeCommitSha);
2246
+ push(task?.meta?.pr?.headSha);
2247
+ push(task?.meta?.pr?.mergeCommitSha);
2248
+
2249
+ return [...refs];
2250
+ }
2251
+
2252
+ function pickTaskDiffBranch(task) {
2253
+ return (
2254
+ normalizeDiffTaskRef(task?.branchName) ||
2255
+ normalizeDiffTaskRef(task?.branch) ||
2256
+ normalizeDiffTaskRef(task?.meta?.branchName) ||
2257
+ normalizeDiffTaskRef(task?.meta?.branch) ||
2258
+ normalizeDiffTaskRef(task?.meta?.pr?.headRefName) ||
2259
+ ""
2260
+ );
2261
+ }
2262
+
2263
+ function pickTaskDiffBaseBranch(task) {
2264
+ return (
2265
+ normalizeDiffTaskRef(task?.baseBranch) ||
2266
+ normalizeDiffTaskRef(task?.base_branch) ||
2267
+ normalizeDiffTaskRef(task?.meta?.baseBranch) ||
2268
+ normalizeDiffTaskRef(task?.meta?.base_branch) ||
2269
+ normalizeDiffTaskRef(task?.meta?.pr?.baseRefName) ||
2270
+ "origin/main"
2271
+ );
2272
+ }
2273
+
2274
+ function resolveTaskWorkspaceEntry(task, workspaceContext = {}) {
2275
+ const configDir = resolveUiConfigDir();
2276
+ if (!configDir) return null;
2277
+ const listed = listManagedWorkspaces(configDir, { repoRoot });
2278
+ const taskWorkspace = normalizeDiffTaskRef(task?.workspace || task?.meta?.workspace);
2279
+ const taskWorkspacePath = normalizeCandidatePath(taskWorkspace);
2280
+ const workspaceId = normalizeDiffTaskRef(workspaceContext?.workspaceId || workspaceContext?.workspaceFilter);
2281
+
2282
+ return (
2283
+ (taskWorkspace
2284
+ ? listed.find((entry) => {
2285
+ const id = normalizeDiffTaskRef(entry?.id);
2286
+ const entryPath = normalizeCandidatePath(entry?.path);
2287
+ return id === taskWorkspace || (taskWorkspacePath && entryPath && entryPath === taskWorkspacePath);
2288
+ })
2289
+ : null) ||
2290
+ (workspaceId
2291
+ ? listed.find((entry) => normalizeDiffTaskRef(entry?.id) === workspaceId)
2292
+ : null) ||
2293
+ getActiveManagedWorkspace(configDir) ||
2294
+ listed[0] ||
2295
+ null
2296
+ );
2297
+ }
2298
+
2299
+ function resolveTaskRepositoryDir(task, workspaceContext = {}) {
2300
+ const repositoryName = normalizeDiffTaskRef(task?.repository || task?.meta?.repository);
2301
+ const taskWorkspace = normalizeDiffTaskRef(task?.workspace || task?.meta?.workspace);
2302
+ const taskWorkspacePath = normalizeCandidatePath(taskWorkspace);
2303
+ const workspaceEntry = resolveTaskWorkspaceEntry(task, workspaceContext);
2304
+ const candidates = [];
2305
+
2306
+ const pushCandidate = (candidate) => {
2307
+ const normalized = normalizeCandidatePath(candidate);
2308
+ if (normalized && !candidates.includes(normalized)) candidates.push(normalized);
2309
+ };
2310
+
2311
+ if (taskWorkspacePath && repositoryName) pushCandidate(resolve(taskWorkspacePath, repositoryName));
2312
+ if (taskWorkspacePath) pushCandidate(taskWorkspacePath);
2313
+
2314
+ if (workspaceEntry) {
2315
+ const repos = Array.isArray(workspaceEntry.repos) ? workspaceEntry.repos : [];
2316
+ const matchedRepo = repositoryName
2317
+ ? repos.find((repo) => {
2318
+ const values = [
2319
+ normalizeDiffTaskRef(repo?.name),
2320
+ normalizeDiffTaskRef(repo?.slug),
2321
+ normalizeCandidatePath(repo?.path),
2322
+ ].filter(Boolean);
2323
+ return values.includes(repositoryName) || values.includes(normalizeCandidatePath(repositoryName));
2324
+ })
2325
+ : null;
2326
+ if (matchedRepo?.path) pushCandidate(matchedRepo.path);
2327
+ if (workspaceEntry.path && repositoryName) pushCandidate(resolve(workspaceEntry.path, repositoryName));
2328
+ pushCandidate(pickWorkspaceRepoDir(workspaceEntry));
2329
+ pushCandidate(workspaceEntry.path);
2330
+ }
2331
+
2332
+ if (workspaceContext?.workspaceDir && repositoryName) {
2333
+ pushCandidate(resolve(workspaceContext.workspaceDir, repositoryName));
2334
+ }
2335
+ pushCandidate(workspaceContext?.workspaceDir);
2336
+ pushCandidate(repoRoot);
2337
+
2338
+ for (const candidate of candidates) {
2339
+ if (!candidate || !existsSync(candidate)) continue;
2340
+ if (existsSync(resolve(candidate, ".git"))) return candidate;
2341
+ }
2342
+ return candidates.find((candidate) => candidate && existsSync(candidate)) || "";
2343
+ }
2344
+
2345
+ function emptyTaskDiffPayload(formatted, source = {}) {
2346
+ return {
2347
+ diff: {
2348
+ files: [],
2349
+ totalFiles: 0,
2350
+ totalAdditions: 0,
2351
+ totalDeletions: 0,
2352
+ formatted,
2353
+ },
2354
+ summary: formatted,
2355
+ commits: [],
2356
+ source,
2357
+ };
2358
+ }
2359
+
2360
+ async function buildTaskDiffPayload(task, workspaceContext = {}) {
2361
+ if (!task) {
2362
+ return emptyTaskDiffPayload("(task not found)", { kind: "task", detail: "Task could not be loaded." });
2363
+ }
2364
+
2365
+ const baseBranch = pickTaskDiffBaseBranch(task);
2366
+
2367
+ for (const sessionId of collectTaskDiffSessionIds(task)) {
2368
+ try {
2369
+ const session = tracker.getSession(sessionId);
2370
+ if (!session) continue;
2371
+ const worktreePath = await resolveSessionWorktreePath(session);
2372
+ if (!worktreePath || !existsSync(worktreePath)) continue;
2373
+ const diff = collectDiffStats(worktreePath, {
2374
+ baseBranch,
2375
+ includePatch: true,
2376
+ });
2377
+ if (diff.totalFiles > 0) {
2378
+ return {
2379
+ diff,
2380
+ summary: diff.formatted,
2381
+ commits: getRecentCommits(worktreePath),
2382
+ source: {
2383
+ kind: "session",
2384
+ label: diff.sourceRange || "session worktree",
2385
+ detail: worktreePath,
2386
+ },
2387
+ };
2388
+ }
2389
+ } catch {
2390
+ // Fall through to branch or commit resolution.
2391
+ }
2392
+ }
2393
+
2394
+ const repoPath = resolveTaskRepositoryDir(task, workspaceContext);
2395
+ if (!repoPath || !existsSync(repoPath)) {
2396
+ return emptyTaskDiffPayload("(no repository for task diff)", {
2397
+ kind: "task",
2398
+ detail: "No repository or preserved worktree is available for this task.",
2399
+ });
2400
+ }
2401
+
2402
+ const attempts = [];
2403
+ const branch = pickTaskDiffBranch(task);
2404
+ if (branch) {
2405
+ attempts.push({
2406
+ kind: "branch",
2407
+ label: `${baseBranch}...${branch}`,
2408
+ options: { baseBranch, targetRef: branch, includePatch: true },
2409
+ });
2410
+ }
2411
+ for (const commitRef of collectTaskDiffCommitRefs(task)) {
2412
+ attempts.push({
2413
+ kind: "commit",
2414
+ label: commitRef,
2415
+ options: { range: `${commitRef}^..${commitRef}`, includePatch: true },
2416
+ });
2417
+ }
2418
+ if (!attempts.length) {
2419
+ attempts.push({
2420
+ kind: "task",
2421
+ label: `${baseBranch}...HEAD`,
2422
+ options: { baseBranch, includePatch: true },
2423
+ });
2424
+ }
2425
+
2426
+ for (const attempt of attempts) {
2427
+ const diff = collectDiffStats(repoPath, attempt.options);
2428
+ if (diff.totalFiles > 0) {
2429
+ return {
2430
+ diff,
2431
+ summary: diff.formatted,
2432
+ commits: getRecentCommits(repoPath),
2433
+ source: {
2434
+ kind: attempt.kind,
2435
+ label: diff.sourceRange || attempt.label,
2436
+ detail: repoPath,
2437
+ },
2438
+ };
2439
+ }
2440
+ }
2441
+
2442
+ return emptyTaskDiffPayload("(no diff available)", {
2443
+ kind: branch ? "branch" : "task",
2444
+ label: branch ? `${baseBranch}...${branch}` : "",
2445
+ detail: repoPath,
2446
+ });
2447
+ }
2448
+
2172
2449
  function taskMatchesWorkspaceContext(task, workspaceContext) {
2173
2450
  const workspaceFilter = String(
2174
2451
  workspaceContext?.workspaceFilter || workspaceContext?.workspaceId || "",
@@ -4623,14 +4900,22 @@ let _browserOpened = false;
4623
4900
  const AUTO_OPEN_MARKER_FILE = "ui-auto-open.json";
4624
4901
  const UI_INSTANCE_LOCK_FILE = "ui-server.instance.lock.json";
4625
4902
  const UI_SESSION_TOKEN_FILE = "ui-session-token.json";
4903
+ const TUI_SESSION_TOKEN_FILE = "ui-token";
4626
4904
  const UI_LAST_PORT_FILE = "ui-last-port.json";
4627
4905
  const DEFAULT_AUTO_OPEN_COOLDOWN_MS = 12 * 60 * 60 * 1000; // 12h
4628
4906
  const DEFAULT_SESSION_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
4629
4907
  const wsClients = new Set();
4630
4908
  let sessionListenerAttached = false;
4909
+ let sessionStateListenerAttached = false;
4910
+ let activeSessionListenerAttached = false;
4631
4911
  let sessionAccumulatorListenerAttached = false;
4912
+ let removeSessionEventListener = null;
4913
+ let removeSessionStateListener = null;
4914
+ let removeActiveSessionListener = null;
4915
+ let removeSessionAccumulatorListener = null;
4632
4916
  /** @type {ReturnType<typeof setInterval>|null} */
4633
4917
  let wsHeartbeatTimer = null;
4918
+ let tuiStatsEmitter = null;
4634
4919
  const WORKFLOW_WS_BATCH_MS = 80;
4635
4920
  const workflowWsBatchByKey = new Map();
4636
4921
  const workflowEngineListenerCleanup = new WeakMap();
@@ -4688,6 +4973,35 @@ async function resolveVoiceRelay() {
4688
4973
  let _fallbackExecPrimaryPrompt = null;
4689
4974
  /** Track in-flight chat turns so /api/sessions/:id/stop can abort them. */
4690
4975
  const sessionRunAbortControllers = new Map();
4976
+ let _activeSessions = [];
4977
+
4978
+ function getLiveSessionSnapshot({ includeHidden = false } = {}) {
4979
+ const tracker = getSessionTracker();
4980
+ let sessions = tracker.listAllSessions();
4981
+ if (!includeHidden) {
4982
+ sessions = sessions.filter((session) => {
4983
+ const detailed = tracker.getSessionById(session.id) || session;
4984
+ return !shouldHideSessionFromDefaultList(detailed);
4985
+ });
4986
+ }
4987
+ return sessions;
4988
+ }
4989
+
4990
+ function broadcastSessionsSnapshot(sessions = getLiveSessionSnapshot()) {
4991
+ const normalized = Array.isArray(sessions) ? sessions : [];
4992
+ broadcastUiEvent(["sessions", "tui"], "sessions:update", {
4993
+ sessions: normalized,
4994
+ });
4995
+ }
4996
+
4997
+ function updateActiveSessions(sessions) {
4998
+ _activeSessions = Array.isArray(sessions) ? sessions : [];
4999
+ broadcastSessionsSnapshot(_activeSessions);
5000
+ for (const session of _activeSessions) {
5001
+ broadcastUiEvent(["sessions", "tui"], "session:update", session);
5002
+ }
5003
+ }
5004
+
4691
5005
  async function resolveExecPrimaryPrompt() {
4692
5006
  if (typeof uiDeps.execPrimaryPrompt === "function") return uiDeps.execPrimaryPrompt;
4693
5007
  if (_fallbackExecPrimaryPrompt) return _fallbackExecPrimaryPrompt;
@@ -4989,6 +5303,17 @@ function isValidSessionToken(token) {
4989
5303
  }
4990
5304
 
4991
5305
  function readPersistedSessionToken() {
5306
+ const envToken = resolveTuiAuthToken({ env: process.env, configDir: resolveUiConfigDir() });
5307
+ if (isValidSessionToken(envToken)) return envToken;
5308
+ try {
5309
+ const plainTokenPath = resolveUiCachePath(TUI_SESSION_TOKEN_FILE);
5310
+ if (existsSync(plainTokenPath)) {
5311
+ const plainToken = String(readFileSync(plainTokenPath, "utf8") || "").trim();
5312
+ if (isValidSessionToken(plainToken)) return plainToken;
5313
+ }
5314
+ } catch {
5315
+ // best effort
5316
+ }
4992
5317
  try {
4993
5318
  const tokenPath = resolveUiCachePath(UI_SESSION_TOKEN_FILE);
4994
5319
  if (!existsSync(tokenPath)) return "";
@@ -5006,6 +5331,7 @@ function readPersistedSessionToken() {
5006
5331
 
5007
5332
  function persistSessionToken(token) {
5008
5333
  if (!isValidSessionToken(token)) return;
5334
+ process.env.BOSUN_UI_TOKEN = token;
5009
5335
  try {
5010
5336
  const tokenPath = resolveUiCachePath(UI_SESSION_TOKEN_FILE);
5011
5337
  writeFileSync(
@@ -5021,6 +5347,7 @@ function persistSessionToken(token) {
5021
5347
  ),
5022
5348
  "utf8",
5023
5349
  );
5350
+ persistCompatibleTuiAuthToken(token, { configDir: resolveUiConfigDir() });
5024
5351
  } catch {
5025
5352
  // best effort
5026
5353
  }
@@ -5040,6 +5367,7 @@ function ensureSessionToken() {
5040
5367
  const persisted = readPersistedSessionToken();
5041
5368
  if (persisted) {
5042
5369
  sessionToken = persisted;
5370
+ persistSessionToken(sessionToken);
5043
5371
  return sessionToken;
5044
5372
  }
5045
5373
  sessionToken = randomBytes(32).toString("hex");
@@ -9095,6 +9423,87 @@ function sendWsMessage(socket, payload) {
9095
9423
  }
9096
9424
  }
9097
9425
 
9426
+ function broadcastCanonicalEvent(channels, type, payload = {}) {
9427
+ const required = new Set(Array.isArray(channels) ? channels : [channels]);
9428
+ const message = {
9429
+ type,
9430
+ channels: Array.from(required),
9431
+ payload,
9432
+ ts: Date.now(),
9433
+ };
9434
+ for (const socket of wsClients) {
9435
+ const subscribed = socket.__channels || new Set(["*"]);
9436
+ const shouldSend = subscribed.has("*") || Array.from(required).some((channel) => subscribed.has(channel));
9437
+ if (shouldSend) sendWsMessage(socket, message);
9438
+ }
9439
+ }
9440
+
9441
+ function getCurrentSessionSnapshot() {
9442
+ try {
9443
+ const tracker = getSessionTracker();
9444
+ return buildSessionsUpdatePayload(tracker?.listAllSessions?.() || []);
9445
+ } catch {
9446
+ return [];
9447
+ }
9448
+ }
9449
+
9450
+ function broadcastTuiSessionsSnapshot(reason = "updated", detail = {}) {
9451
+ broadcastCanonicalEvent(["sessions", "tui"], "sessions:update", getCurrentSessionSnapshot());
9452
+ const sessionEvent = buildSessionEventPayload({
9453
+ sessionId: detail?.sessionId || detail?.session?.id || detail?.threadId || detail?.taskKey || "",
9454
+ taskId: detail?.taskId || detail?.session?.taskId || detail?.taskKey || "",
9455
+ session: detail?.session || detail,
9456
+ event: {
9457
+ kind: "state",
9458
+ reason,
9459
+ ...(detail && typeof detail === "object" ? detail : {}),
9460
+ },
9461
+ });
9462
+ if (sessionEvent.sessionId && sessionEvent.taskId) {
9463
+ broadcastCanonicalEvent(["sessions", "tui"], "session:event", sessionEvent);
9464
+ }
9465
+ }
9466
+
9467
+ function buildCurrentTuiMonitorStats() {
9468
+ const executor = uiDeps.getInternalExecutor?.() || null;
9469
+ const injectedStats = uiDeps.getTuiMonitorStats?.() || {};
9470
+ const status = executor?.getStatus?.() || {};
9471
+ const slots = Array.isArray(status?.slots) ? status.slots : [];
9472
+ const runtimeStats = getRuntimeStats() || {};
9473
+ const runtimeSessions = Array.isArray(runtimeStats?.sessions) ? runtimeStats.sessions : [];
9474
+
9475
+ const pickNumericStat = (...candidates) => {
9476
+ for (const candidate of candidates) {
9477
+ if (candidate == null) continue;
9478
+ const numeric = Number(candidate);
9479
+ if (Number.isFinite(numeric)) {
9480
+ return numeric;
9481
+ }
9482
+ }
9483
+ return 0;
9484
+ };
9485
+ const tokensIn = pickNumericStat(injectedStats?.tokensIn, runtimeStats?.totalInputTokens);
9486
+ const tokensOut = pickNumericStat(injectedStats?.tokensOut, runtimeStats?.totalOutputTokens);
9487
+
9488
+ return buildMonitorStatsPayload({
9489
+ agentPool: {
9490
+ activeAgents: pickNumericStat(status?.activeSlots, slots.length, injectedStats?.activeAgents),
9491
+ maxAgents: pickNumericStat(status?.maxParallel, injectedStats?.maxAgents),
9492
+ tokensIn: pickNumericStat(injectedStats?.tokensIn, tokensIn),
9493
+ tokensOut: pickNumericStat(injectedStats?.tokensOut, tokensOut),
9494
+ throughputTps: injectedStats?.throughputTps,
9495
+ rateLimits: injectedStats?.rateLimits || {},
9496
+ },
9497
+ runtimeStats: {
9498
+ ...runtimeStats,
9499
+ sessions: runtimeSessions,
9500
+ totalInputTokens: tokensIn,
9501
+ totalOutputTokens: tokensOut,
9502
+ },
9503
+ uptimeMs: runtimeStats?.startedAt ? Date.now() - Number(runtimeStats.startedAt) : process.uptime() * 1000,
9504
+ });
9505
+ }
9506
+
9098
9507
  function normalizeWorkflowNodeStatus(status) {
9099
9508
  const normalized = String(status || "").trim().toLowerCase();
9100
9509
  if (normalized === "completed" || normalized === "success") return "success";
@@ -9138,6 +9547,106 @@ function summarizeOutputLines(value, maxLines = 3, maxChars = 140) {
9138
9547
  return lines;
9139
9548
  }
9140
9549
 
9550
+ function summarizeTrajectoryText(value, maxLength = 160) {
9551
+ const text = String(value ?? "")
9552
+ .replace(/\r\n/g, "\n")
9553
+ .replace(/\r/g, "\n")
9554
+ .split("\n")
9555
+ .map((line) => line.trim())
9556
+ .filter(Boolean)
9557
+ .join(" ")
9558
+ .replace(/\s{2,}/g, " ")
9559
+ .trim();
9560
+ if (!text) return null;
9561
+ if (text.length <= maxLength) return text;
9562
+ return `${text.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`;
9563
+ }
9564
+
9565
+ function classifyTrajectorySessionEvent(event = {}) {
9566
+ const payload = event && typeof event === "object" ? event : {};
9567
+ const role = String(payload?.role || payload?.message?.role || "").trim().toLowerCase();
9568
+ const type = String(payload?.type || payload?.message?.type || payload?.event?.kind || "").trim().toLowerCase();
9569
+ if (role === "user") return "user";
9570
+ if (role === "assistant") return "assistant";
9571
+ if (type.includes("tool") && (type.includes("result") || type.includes("output"))) return "tool_result";
9572
+ if (type.includes("tool")) return "tool_call";
9573
+ if (type.includes("reason") || type.includes("thinking")) return "reasoning";
9574
+ if (type === "error") return "status";
9575
+ return "event";
9576
+ }
9577
+
9578
+ function buildTrajectoryStepFromSessionEvent(event = {}) {
9579
+ const normalizedType = classifyTrajectorySessionEvent(event);
9580
+ const message = event?.message && typeof event.message === "object" ? event.message : event;
9581
+ const content = message?.content ?? event?.content ?? null;
9582
+ const summary = summarizeTrajectoryText(
9583
+ message?.summary || event?.summary || content || event?.label || event?.reason || event?.type || "Run event recorded.",
9584
+ ) || "Run event recorded.";
9585
+ return {
9586
+ id: String(event?.id || message?.id || `step-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`),
9587
+ at: String(event?.timestamp || message?.timestamp || event?.at || new Date().toISOString()),
9588
+ type: normalizedType,
9589
+ summary,
9590
+ payload: {
9591
+ role: message?.role || event?.role || null,
9592
+ type: message?.type || event?.type || null,
9593
+ content: typeof content === "string" ? content : null,
9594
+ reason: event?.reason || null,
9595
+ },
9596
+ event: event && typeof event === "object" ? { ...event } : null,
9597
+ };
9598
+ }
9599
+
9600
+ function buildTaskRunSummary(steps = [], fallback = "Run recorded.") {
9601
+ const ordered = Array.isArray(steps) ? steps : [];
9602
+ for (let index = ordered.length - 1; index >= 0; index -= 1) {
9603
+ const summary = summarizeTrajectoryText(ordered[index]?.summary || "");
9604
+ if (summary) return summary;
9605
+ }
9606
+ return fallback;
9607
+ }
9608
+
9609
+ async function buildReplayableTaskRuns(task, tracker = null, limit = 5) {
9610
+ const sessionIds = collectTaskDiffSessionIds(task);
9611
+ const { value: storedRunsRaw } = await callTaskStoreFunction(TASK_STORE_RUN_EXPORTS.list, [task?.id]);
9612
+ const storedRuns = storedRunsRaw;
9613
+ const runs = Array.isArray(storedRuns) ? storedRuns.map((run) => ({
9614
+ ...run,
9615
+ summary: run.summary || buildTaskRunSummary(run.steps, run.status === "failed" ? "Run failed." : "Run completed."),
9616
+ })) : [];
9617
+ const seenRunIds = new Set(runs.map((entry) => String(entry?.runId || "")).filter(Boolean));
9618
+ for (const sessionId of sessionIds) {
9619
+ const session = tracker?.getSession?.(sessionId);
9620
+ if (!session) continue;
9621
+ const events = Array.isArray(session?.events) ? session.events : [];
9622
+ const steps = events.map((event) => buildTrajectoryStepFromSessionEvent(event)).filter(Boolean);
9623
+ if (steps.length === 0) continue;
9624
+ const runId = String(sessionId || session?.id || "").trim();
9625
+ if (!runId || seenRunIds.has(runId)) continue;
9626
+ seenRunIds.add(runId);
9627
+ runs.push({
9628
+ runId,
9629
+ startedAt: String(session?.createdAt || steps[0]?.at || new Date().toISOString()),
9630
+ endedAt: session?.status === "active" ? null : String(session?.updatedAt || steps.at(-1)?.at || new Date().toISOString()),
9631
+ status: String(session?.status || "completed"),
9632
+ taskKey: String(task?.id || session?.taskId || "").trim() || null,
9633
+ sdk: String(session?.executor || session?.sdk || session?.metadata?.resolvedSdk || "").trim() || null,
9634
+ threadId: String(session?.threadId || session?.metadata?.threadId || "").trim() || null,
9635
+ resumeThreadId: String(session?.metadata?.resumeThreadId || session?.threadId || "").trim() || null,
9636
+ replayable: true,
9637
+ outcome: session?.status === "failed" ? "failed" : "completed",
9638
+ summary: buildTaskRunSummary(steps, session?.status === "failed" ? "Run failed." : "Run completed."),
9639
+ steps,
9640
+ meta: {
9641
+ sessionId: runId,
9642
+ source: "session-tracker",
9643
+ },
9644
+ });
9645
+ }
9646
+ runs.sort((left, right) => Date.parse(String(right?.startedAt || 0)) - Date.parse(String(left?.startedAt || 0)));
9647
+ return runs.slice(0, Math.max(1, Number(limit) || 5));
9648
+ }
9649
+
9141
9650
  function buildWorkflowNodeOutputPreview(nodeType, output = null) {
9142
9651
  if (output == null) return { lines: [] };
9143
9652
  const type = String(nodeType || "").trim().toLowerCase();
@@ -9278,6 +9787,13 @@ function attachWorkflowEngineLiveBridge(engine) {
9278
9787
  };
9279
9788
 
9280
9789
  listen("run:start", (payload) => {
9790
+ broadcastCanonicalEvent(["workflows", "tui"], "workflow:status", buildWorkflowStatusPayload({
9791
+ ...payload,
9792
+ workflowName: payload.name || payload.workflowName || null,
9793
+ eventType: "run:start",
9794
+ status: "running",
9795
+ timestamp: Date.now(),
9796
+ }));
9281
9797
  queueWorkflowWsEvent({
9282
9798
  kind: "run",
9283
9799
  workflowId: payload.workflowId,
@@ -9289,6 +9805,14 @@ function attachWorkflowEngineLiveBridge(engine) {
9289
9805
  });
9290
9806
  });
9291
9807
  listen("run:end", (payload) => {
9808
+ broadcastCanonicalEvent(["workflows", "tui"], "workflow:status", buildWorkflowStatusPayload({
9809
+ ...payload,
9810
+ workflowName: payload.workflowName || payload.name || null,
9811
+ eventType: "run:end",
9812
+ durationMs: Number(payload.duration) || null,
9813
+ status: String(payload.status || "").trim().toLowerCase() || "completed",
9814
+ timestamp: Date.now(),
9815
+ }));
9292
9816
  queueWorkflowWsEvent({
9293
9817
  kind: "run",
9294
9818
  workflowId: payload.workflowId,
@@ -9301,6 +9825,13 @@ function attachWorkflowEngineLiveBridge(engine) {
9301
9825
  });
9302
9826
  });
9303
9827
  listen("run:error", (payload) => {
9828
+ broadcastCanonicalEvent(["workflows", "tui"], "workflow:status", buildWorkflowStatusPayload({
9829
+ ...payload,
9830
+ workflowName: payload.workflowName || payload.name || null,
9831
+ eventType: "run:error",
9832
+ status: "failed",
9833
+ timestamp: Date.now(),
9834
+ }));
9304
9835
  queueWorkflowWsEvent({
9305
9836
  kind: "run",
9306
9837
  workflowId: payload.workflowId,
@@ -9338,6 +9869,13 @@ function attachWorkflowEngineLiveBridge(engine) {
9338
9869
  });
9339
9870
  });
9340
9871
  listen("node:complete", (payload) => {
9872
+ broadcastCanonicalEvent(["workflows", "tui"], "workflow:status", buildWorkflowStatusPayload({
9873
+ ...payload,
9874
+ workflowName: payload.workflowName || null,
9875
+ eventType: "node:complete",
9876
+ status: "success",
9877
+ timestamp: Date.now(),
9878
+ }));
9341
9879
  const preview = buildWorkflowNodeOutputPreview(payload.nodeType, payload.output);
9342
9880
  queueWorkflowWsEvent({
9343
9881
  kind: "node",
@@ -9408,21 +9946,13 @@ function attachWorkflowEngineLiveBridge(engine) {
9408
9946
  }
9409
9947
 
9410
9948
  function broadcastUiEvent(channels, type, payload = {}) {
9411
- const required = new Set(Array.isArray(channels) ? channels : [channels]);
9412
- const message = {
9413
- type,
9414
- channels: Array.from(required),
9415
- payload,
9416
- ts: Date.now(),
9417
- };
9418
- for (const socket of wsClients) {
9419
- const subscribed = socket.__channels || new Set(["*"]);
9420
- const shouldSend =
9421
- subscribed.has("*") ||
9422
- Array.from(required).some((channel) => subscribed.has(channel));
9423
- if (shouldSend) {
9424
- sendWsMessage(socket, message);
9425
- }
9949
+ const required = Array.isArray(channels) ? channels : [channels];
9950
+ broadcastCanonicalEvent(required, type, payload);
9951
+ if (required.includes("sessions") && type !== "sessions:update" && type !== "session:event") {
9952
+ broadcastTuiSessionsSnapshot(type, payload);
9953
+ }
9954
+ if (required.includes("tasks") && type !== "tasks:update") {
9955
+ broadcastCanonicalEvent(["tasks", "tui"], "tasks:update", buildTasksUpdatePayload(payload, { sourceEvent: type }));
9426
9956
  }
9427
9957
  }
9428
9958
 
@@ -9443,6 +9973,20 @@ function broadcastSessionMessage(payload) {
9443
9973
  sendWsMessage(socket, message);
9444
9974
  }
9445
9975
  }
9976
+
9977
+ const sessionEvent = buildSessionEventPayload({
9978
+ sessionId: payload?.sessionId || payload?.session?.id || payload?.taskId || "",
9979
+ taskId: payload?.taskId || payload?.session?.taskId || payload?.sessionId || "",
9980
+ session: payload?.session || {},
9981
+ event: {
9982
+ kind: "message",
9983
+ message: payload?.message ?? null,
9984
+ },
9985
+ });
9986
+ if (sessionEvent.sessionId && sessionEvent.taskId) {
9987
+ broadcastCanonicalEvent(["sessions", "tui"], "session:event", sessionEvent);
9988
+ }
9989
+ broadcastCanonicalEvent(["sessions", "tui"], "sessions:update", getCurrentSessionSnapshot());
9446
9990
  }
9447
9991
 
9448
9992
  async function collectUiStats() {
@@ -9698,7 +10242,6 @@ function startLogStream(socket, logType, query) {
9698
10242
  // File was truncated/rotated — reset
9699
10243
  streamState.offset = 0;
9700
10244
  }
9701
-
9702
10245
  if (size <= streamState.offset) return;
9703
10246
 
9704
10247
  // Read only new bytes
@@ -10490,7 +11033,13 @@ async function handleDeviceFlowPoll(req, res) {
10490
11033
  async function readStatusSnapshot() {
10491
11034
  try {
10492
11035
  const raw = await readFile(statusPath, "utf8");
10493
- return JSON.parse(raw);
11036
+ const parsed = JSON.parse(raw);
11037
+ if (parsed && typeof parsed === "object") {
11038
+ parsed.worktreeRecovery = normalizeWorktreeRecoveryState(
11039
+ parsed.worktreeRecovery || parsed.worktree_recovery || null,
11040
+ );
11041
+ }
11042
+ return parsed;
10494
11043
  } catch {
10495
11044
  return null;
10496
11045
  }
@@ -10918,9 +11467,9 @@ async function tailFile(filePath, lineCount, maxBytes = 1_000_000) {
10918
11467
  };
10919
11468
  }
10920
11469
 
10921
- async function readJsonlTail(filePath, maxLines = 2000) {
11470
+ async function readJsonlTail(filePath, maxLines = 2000, maxBytes = 1_000_000) {
10922
11471
  if (!existsSync(filePath)) return [];
10923
- const tail = await tailFile(filePath, maxLines);
11472
+ const tail = await tailFile(filePath, maxLines, maxBytes);
10924
11473
  return (tail.lines || [])
10925
11474
  .map((line) => {
10926
11475
  try {
@@ -10994,7 +11543,7 @@ async function readCompletedSessionEntries(maxLines = 100_000) {
10994
11543
  }
10995
11544
  } catch { /* stat failed, skip */ }
10996
11545
  }
10997
- const entries = await readJsonlTail(sessionLogPath, maxLines);
11546
+ const entries = await readJsonlTail(sessionLogPath, maxLines, 50_000_000);
10998
11547
  return {
10999
11548
  sessionLogPath,
11000
11549
  entries: entries.filter((entry) => String(entry?.type || "completed_session") === "completed_session"),
@@ -11138,7 +11687,7 @@ async function buildUsageAnalytics(days) {
11138
11687
  const streamPath = resolve(logDir, "agent-work-stream.jsonl");
11139
11688
  const [{ entries: completedSessions }, events] = await Promise.all([
11140
11689
  readCompletedSessionEntries(100_000),
11141
- readJsonlTail(streamPath, 100_000),
11690
+ readJsonlTail(streamPath, 100_000, 50_000_000),
11142
11691
  ]);
11143
11692
 
11144
11693
  const cutoff = days ? Date.now() - days * 24 * 60 * 60 * 1000 : 0;
@@ -11306,6 +11855,162 @@ function resolveAgentWorkLogDir() {
11306
11855
  return candidates[0];
11307
11856
  }
11308
11857
 
11858
+ function clampRunSummaryText(value, maxLength = 220) {
11859
+ const text = String(value || "").replace(/\s+/g, " ").trim();
11860
+ if (!text) return "";
11861
+ if (text.length <= maxLength) return text;
11862
+ return `${text.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
11863
+ }
11864
+
11865
+ function summarizeTrajectoryEvent(event = {}) {
11866
+ const type = String(event?.event_type || event?.type || "event").trim() || "event";
11867
+ const data = event?.data && typeof event.data === "object" ? event.data : {};
11868
+ if (type === "session_start") {
11869
+ return clampRunSummaryText(`Started ${String(event.executor || event.model || "agent").trim() || "agent"} run for ${String(event.task_title || event.taskId || "task").trim() || "task"}`);
11870
+ }
11871
+ if (type === "session_end") {
11872
+ const status = String(data?.completion_status || event?.status || "completed").trim() || "completed";
11873
+ return clampRunSummaryText(`Finished run with status ${status}`);
11874
+ }
11875
+ if (type === "tool_call") {
11876
+ const toolName = String(data?.tool_name || event?.tool_name || "tool").trim() || "tool";
11877
+ return clampRunSummaryText(`Called ${toolName}`);
11878
+ }
11879
+ if (type === "tool_result") {
11880
+ const toolName = String(data?.tool_name || event?.tool_name || "tool").trim() || "tool";
11881
+ const status = String(data?.status || event?.status || "completed").trim() || "completed";
11882
+ return clampRunSummaryText(`${toolName} returned ${status}`);
11883
+ }
11884
+ if (type === "error") {
11885
+ const message = String(data?.error_message || event?.error_message || event?.message || "error").trim() || "error";
11886
+ return clampRunSummaryText(`Error: ${message}`);
11887
+ }
11888
+ if (type === "usage") {
11889
+ const totalTokens = numberOrZero(data?.total_tokens || data?.tokens || event?.total_tokens);
11890
+ if (totalTokens > 0) return clampRunSummaryText(`Used ${totalTokens} tokens`);
11891
+ }
11892
+ if (type === "agent_output") {
11893
+ const output = String(data?.output || event?.output || "").trim();
11894
+ if (output) return clampRunSummaryText(output);
11895
+ }
11896
+ return clampRunSummaryText(type.replace(/_/g, " "));
11897
+ }
11898
+
11899
+ function toTrajectoryReplayEvent(event = {}, index = 0) {
11900
+ const timestamp = event?.timestamp || event?.recordedAt || null;
11901
+ const eventType = String(event?.event_type || event?.type || "event").trim() || "event";
11902
+ const data = event?.data && typeof event.data === "object" ? event.data : {};
11903
+ return {
11904
+ index,
11905
+ timestamp,
11906
+ type: eventType,
11907
+ summary: summarizeTrajectoryEvent(event),
11908
+ attemptId: String(event?.attempt_id || event?.attemptId || "").trim() || null,
11909
+ taskId: String(event?.taskId || "").trim() || null,
11910
+ taskTitle: String(event?.task_title || event?.taskTitle || "").trim() || null,
11911
+ executor: String(event?.executor || event?.model || "").trim() || null,
11912
+ data: makeJsonSafe(data, { maxDepth: 4 }),
11913
+ };
11914
+ }
11915
+
11916
+ function buildReplayOverview(events = []) {
11917
+ const totals = {
11918
+ events: events.length,
11919
+ toolCalls: 0,
11920
+ toolResults: 0,
11921
+ outputs: 0,
11922
+ errors: 0,
11923
+ usageEvents: 0,
11924
+ };
11925
+ const shortSteps = [];
11926
+ for (const event of events) {
11927
+ if (event.type === "tool_call") totals.toolCalls += 1;
11928
+ if (event.type === "tool_result") totals.toolResults += 1;
11929
+ if (event.type === "agent_output") totals.outputs += 1;
11930
+ if (event.type === "error") totals.errors += 1;
11931
+ if (event.type === "usage") totals.usageEvents += 1;
11932
+ if (shortSteps.length >= 12) continue;
11933
+ if (["session_start", "tool_call", "tool_result", "error", "session_end"].includes(event.type)) {
11934
+ shortSteps.push(event.summary);
11935
+ continue;
11936
+ }
11937
+ if (event.type === "agent_output" && shortSteps.length < 6) shortSteps.push(event.summary);
11938
+ }
11939
+ return { totals, shortSteps };
11940
+ }
11941
+
11942
+ async function listReplayableAgentRuns(options = {}) {
11943
+ const logDir = resolveAgentWorkLogDir();
11944
+ const sessionsDir = resolve(logDir, "agent-sessions");
11945
+ const limit = Math.max(1, Math.min(100, Number(options.limit) || 25));
11946
+ const taskIdFilter = String(options.taskId || "").trim();
11947
+ const files = await readdir(sessionsDir).catch(() => []);
11948
+ const runs = [];
11949
+
11950
+ for (const name of files) {
11951
+ if (!name.endsWith(".jsonl")) continue;
11952
+ const attemptId = name.replace(/\.jsonl$/i, "");
11953
+ const filePath = resolve(sessionsDir, name);
11954
+ const entries = await readJsonlTail(filePath, 5000, 10000000).catch(() => []);
11955
+ if (!entries.length) continue;
11956
+ const replayEvents = entries.map((entry, index) => toTrajectoryReplayEvent(entry, index));
11957
+ const first = replayEvents[0] || null;
11958
+ const last = replayEvents[replayEvents.length - 1] || null;
11959
+ const taskId = String(first?.taskId || last?.taskId || "").trim() || null;
11960
+ if (taskIdFilter && taskId !== taskIdFilter) continue;
11961
+ const startedAt = first?.timestamp || null;
11962
+ const endedAt = last?.type === "session_end" ? last.timestamp : null;
11963
+ const overview = buildReplayOverview(replayEvents);
11964
+ runs.push({
11965
+ attemptId,
11966
+ taskId,
11967
+ taskTitle: first?.taskTitle || last?.taskTitle || null,
11968
+ executor: first?.executor || last?.executor || null,
11969
+ startedAt,
11970
+ endedAt,
11971
+ status: last?.type === "session_end"
11972
+ ? String(last?.data?.completion_status || "completed")
11973
+ : "in_progress",
11974
+ eventCount: replayEvents.length,
11975
+ shortSteps: overview.shortSteps,
11976
+ totals: overview.totals,
11977
+ });
11978
+ }
11979
+
11980
+ runs.sort((a, b) => {
11981
+ const aTs = Date.parse(String(a.endedAt || a.startedAt || 0)) || 0;
11982
+ const bTs = Date.parse(String(b.endedAt || b.startedAt || 0)) || 0;
11983
+ return bTs - aTs;
11984
+ });
11985
+ return runs.slice(0, limit);
11986
+ }
11987
+
11988
+ async function readReplayableAgentRun(attemptId) {
11989
+ const normalizedAttemptId = String(attemptId || "").trim();
11990
+ if (!normalizedAttemptId) return null;
11991
+ const filePath = resolve(resolveAgentWorkLogDir(), "agent-sessions", `${normalizedAttemptId}.jsonl`);
11992
+ if (!existsSync(filePath)) return null;
11993
+ const entries = await readJsonlTail(filePath, 20000, 25000000).catch(() => []);
11994
+ if (!entries.length) return null;
11995
+ const events = entries.map((entry, index) => toTrajectoryReplayEvent(entry, index));
11996
+ const first = events[0] || null;
11997
+ const last = events[events.length - 1] || null;
11998
+ const overview = buildReplayOverview(events);
11999
+ return {
12000
+ attemptId: normalizedAttemptId,
12001
+ taskId: first?.taskId || last?.taskId || null,
12002
+ taskTitle: first?.taskTitle || last?.taskTitle || null,
12003
+ executor: first?.executor || last?.executor || null,
12004
+ startedAt: first?.timestamp || null,
12005
+ endedAt: last?.type === "session_end" ? last.timestamp : null,
12006
+ status: last?.type === "session_end"
12007
+ ? String(last?.data?.completion_status || "completed")
12008
+ : "in_progress",
12009
+ shortSteps: overview.shortSteps,
12010
+ totals: overview.totals,
12011
+ events,
12012
+ };
12013
+ }
11309
12014
  async function listAgentLogFiles(query = "", limit = 60) {
11310
12015
  const entries = [];
11311
12016
  const agentLogsDir = await resolveAgentLogsDir();
@@ -12166,6 +12871,25 @@ async function handleApi(req, res, url) {
12166
12871
  return;
12167
12872
  }
12168
12873
 
12874
+ if (path === "/api/tasks/diff") {
12875
+ try {
12876
+ const taskId =
12877
+ url.searchParams.get("taskId") || url.searchParams.get("id") || "";
12878
+ if (!taskId) {
12879
+ jsonResponse(res, 400, { ok: false, error: "taskId required" });
12880
+ return;
12881
+ }
12882
+ const workspaceContext = resolveWorkspaceContextFromRequest(url, { allowAll: false });
12883
+ const adapter = getKanbanAdapter();
12884
+ const task = await adapter.getTask(taskId);
12885
+ const payload = await buildTaskDiffPayload(task, workspaceContext);
12886
+ jsonResponse(res, 200, { ok: true, ...payload });
12887
+ } catch (err) {
12888
+ jsonResponse(res, 500, { ok: false, error: err.message });
12889
+ }
12890
+ return;
12891
+ }
12892
+
12169
12893
  if (path === "/api/tasks/detail") {
12170
12894
  try {
12171
12895
  const taskId =
@@ -12215,6 +12939,15 @@ async function handleApi(req, res, url) {
12215
12939
  workspaceDir: workspaceContext?.workspaceDir || repoRoot,
12216
12940
  });
12217
12941
  const diagnostics = buildTaskDiagnostics(detailTask, supervisorDiagnostics);
12942
+ const replayRuns = await buildReplayableTaskRuns(detailTask, getSessionTracker(), 8);
12943
+ if (replayRuns.length > 0) {
12944
+ detailTask.runs = replayRuns;
12945
+ detailTask.meta = {
12946
+ ...(detailTask.meta || {}),
12947
+ latestRunSummary: replayRuns[0]?.summary || null,
12948
+ replayRunCount: replayRuns.length,
12949
+ };
12950
+ }
12218
12951
 
12219
12952
  detailTask.meta = {
12220
12953
  ...(detailTask.meta || {}),
@@ -15266,7 +15999,15 @@ async function handleApi(req, res, url) {
15266
15999
  try {
15267
16000
  const worktrees = listActiveWorktrees(repoRoot);
15268
16001
  const stats = await getWorktreeStats(repoRoot);
15269
- jsonResponse(res, 200, { ok: true, data: worktrees, stats });
16002
+ const recovery = await readWorktreeRecoveryState(repoRoot);
16003
+ jsonResponse(res, 200, {
16004
+ ok: true,
16005
+ data: worktrees,
16006
+ stats: {
16007
+ ...stats,
16008
+ recovery,
16009
+ },
16010
+ });
15270
16011
  } catch (err) {
15271
16012
  jsonResponse(res, 500, { ok: false, error: err.message });
15272
16013
  }
@@ -15497,25 +16238,42 @@ async function handleApi(req, res, url) {
15497
16238
  const days = Number(url.searchParams.get("days") || "7");
15498
16239
  const logDir = resolveAgentWorkLogDir();
15499
16240
  const metricsPath = resolve(logDir, "agent-metrics.jsonl");
15500
- const metrics = await readJsonlTail(metricsPath, 3000);
16241
+ const metrics = await readJsonlTail(metricsPath, 100_000, 50_000_000);
15501
16242
  const summary = summarizeTelemetry(metrics, days) || {};
15502
- const runtimeStats = getRuntimeStats();
15503
- const completedSessions = getCompletedSessions(10_000);
15504
- const lifetimeTotals = completedSessions.reduce(
16243
+
16244
+ // Read lifetime totals from the full JSONL log (not the capped in-memory state)
16245
+ // so the count is accurate even when sessions exceed the in-memory cap.
16246
+ const { entries: allSessions } = await readCompletedSessionEntries(200_000);
16247
+ const lifetimeTotals = allSessions.reduce(
15505
16248
  (acc, session) => {
15506
16249
  acc.attemptsCount += 1;
15507
16250
  acc.tokenCount += Number(session?.tokenCount || 0);
15508
16251
  acc.inputTokens += Number(session?.inputTokens || 0);
15509
16252
  acc.outputTokens += Number(session?.outputTokens || 0);
16253
+ acc.durationMs += Math.max(0, Number(session?.durationMs || 0));
15510
16254
  return acc;
15511
16255
  },
15512
- { attemptsCount: 0, tokenCount: 0, inputTokens: 0, outputTokens: 0 },
16256
+ { attemptsCount: 0, tokenCount: 0, inputTokens: 0, outputTokens: 0, durationMs: 0 },
15513
16257
  );
15514
- summary.runtimeMs = Number(runtimeStats?.runtimeMs || 0);
15515
- summary.lifetimeTotals = {
15516
- ...lifetimeTotals,
15517
- durationMs: summary.runtimeMs,
15518
- };
16258
+
16259
+ // Supplement token counts from agent-metrics.jsonl which has actual LLM usage data
16260
+ // (prompt_tokens, completion_tokens, total_tokens) that the session log may lack.
16261
+ if (lifetimeTotals.tokenCount <= 0 && metrics.length > 0) {
16262
+ let metricsTokens = 0;
16263
+ let metricsInputTokens = 0;
16264
+ let metricsOutputTokens = 0;
16265
+ for (const m of metrics) {
16266
+ const met = m?.metrics || m;
16267
+ metricsTokens += Number(met?.total_tokens || 0);
16268
+ metricsInputTokens += Number(met?.prompt_tokens || 0);
16269
+ metricsOutputTokens += Number(met?.completion_tokens || 0);
16270
+ }
16271
+ lifetimeTotals.tokenCount = metricsTokens;
16272
+ lifetimeTotals.inputTokens = metricsInputTokens;
16273
+ lifetimeTotals.outputTokens = metricsOutputTokens;
16274
+ }
16275
+
16276
+ summary.lifetimeTotals = lifetimeTotals;
15519
16277
  jsonResponse(res, 200, { ok: true, data: summary });
15520
16278
  } catch (err) {
15521
16279
  jsonResponse(res, 500, { ok: false, error: err.message });
@@ -15807,7 +16565,33 @@ async function handleApi(req, res, url) {
15807
16565
  return;
15808
16566
  }
15809
16567
 
15810
- if (path === "/api/agent-logs/context") {
16568
+ if (path === "/api/agent-runs") {
16569
+ try {
16570
+ const limit = Number(url.searchParams.get("limit") || "25");
16571
+ const taskId = String(url.searchParams.get("taskId") || "").trim();
16572
+ const data = await listReplayableAgentRuns({ limit, taskId });
16573
+ jsonResponse(res, 200, { ok: true, data });
16574
+ } catch (err) {
16575
+ jsonResponse(res, 500, { ok: false, error: err.message });
16576
+ }
16577
+ return;
16578
+ }
16579
+
16580
+ if (path.startsWith("/api/agent-runs/")) {
16581
+ try {
16582
+ const attemptId = decodeURIComponent(path.slice("/api/agent-runs/".length));
16583
+ const data = await readReplayableAgentRun(attemptId);
16584
+ if (!data) {
16585
+ jsonResponse(res, 404, { ok: false, error: "run not found" });
16586
+ return;
16587
+ }
16588
+ jsonResponse(res, 200, { ok: true, data });
16589
+ } catch (err) {
16590
+ jsonResponse(res, 500, { ok: false, error: err.message });
16591
+ }
16592
+ return;
16593
+ }
16594
+ if (path === "/api/agent-logs/context") {
15811
16595
  try {
15812
16596
  const query = url.searchParams.get("query") || "";
15813
16597
  if (!query) {
@@ -15906,6 +16690,7 @@ async function handleApi(req, res, url) {
15906
16690
  try {
15907
16691
  const executor = uiDeps.getInternalExecutor?.();
15908
16692
  const status = executor?.getStatus?.() || {};
16693
+ const worktreeRecovery = await readWorktreeRecoveryState(repoRoot);
15909
16694
  const data = {
15910
16695
  executor: {
15911
16696
  mode: uiDeps.getExecutorMode?.() || "internal",
@@ -15913,6 +16698,7 @@ async function handleApi(req, res, url) {
15913
16698
  activeSlots: status.activeSlots || 0,
15914
16699
  paused: executor?.isPaused?.() || false,
15915
16700
  },
16701
+ worktreeRecovery,
15916
16702
  system: {
15917
16703
  uptime: process.uptime(),
15918
16704
  memoryMB: Math.round(process.memoryUsage.rss() / 1024 / 1024),
@@ -18833,7 +19619,23 @@ async function handleApi(req, res, url) {
18833
19619
  const detailed = tracker.getSessionById(session.id) || session;
18834
19620
  return sessionMatchesWorkspaceContext(detailed, workspaceContext);
18835
19621
  });
18836
- jsonResponse(res, 200, { ok: true, sessions });
19622
+ jsonResponse(res, 200, {
19623
+ ok: true,
19624
+ sessions,
19625
+ loadMeta: {
19626
+ stale: false,
19627
+ lastSuccessAt: new Date().toISOString(),
19628
+ lastFailureAt: null,
19629
+ staleReason: null,
19630
+ staleReasonCode: null,
19631
+ staleReasonLabel: null,
19632
+ staleReasonMeta: null,
19633
+ retryAttempt: 0,
19634
+ retryDelayMs: 0,
19635
+ nextRetryAt: null,
19636
+ retriesExhausted: false,
19637
+ },
19638
+ });
18837
19639
  } catch (err) {
18838
19640
  jsonResponse(res, 500, { ok: false, error: err.message });
18839
19641
  }
@@ -18869,6 +19671,7 @@ async function handleApi(req, res, url) {
18869
19671
  });
18870
19672
  jsonResponse(res, 200, { ok: true, session: { id: session.id, type: session.type, status: session.status, metadata: session.metadata } });
18871
19673
  broadcastUiEvent(["sessions"], "invalidate", { reason: "session-created", sessionId: id });
19674
+ broadcastSessionsSnapshot();
18872
19675
  } catch (err) {
18873
19676
  jsonResponse(res, 500, { ok: false, error: err.message });
18874
19677
  }
@@ -19019,6 +19822,7 @@ async function handleApi(req, res, url) {
19019
19822
  reason: wasRunning ? "session-stop-requested" : "session-stop-noop",
19020
19823
  sessionId,
19021
19824
  });
19825
+ broadcastSessionsSnapshot();
19022
19826
  } catch (err) {
19023
19827
  jsonResponse(res, 500, { ok: false, error: err.message });
19024
19828
  }
@@ -19071,6 +19875,7 @@ async function handleApi(req, res, url) {
19071
19875
  // Respond immediately so the UI doesn't block on agent execution
19072
19876
  jsonResponse(res, 200, { ok: true, messageId });
19073
19877
  broadcastUiEvent(["sessions"], "invalidate", { reason: "session-message", sessionId });
19878
+ broadcastSessionsSnapshot();
19074
19879
 
19075
19880
  // Build an onEvent callback so intermediate SDK events (thinking,
19076
19881
  // tool calls, code edits, etc.) are streamed to the UI in real-time
@@ -19122,6 +19927,7 @@ async function handleApi(req, res, url) {
19122
19927
  // sessions from staying "active" forever and causing session bloat.
19123
19928
  tracker.updateSessionStatus(sessionId, "completed");
19124
19929
  broadcastUiEvent(["sessions"], "invalidate", { reason: "agent-response", sessionId });
19930
+ broadcastSessionsSnapshot();
19125
19931
  }).catch((execErr) => {
19126
19932
  const wasAborted =
19127
19933
  abortController.signal.aborted ||
@@ -19139,6 +19945,7 @@ async function handleApi(req, res, url) {
19139
19945
  reason: "agent-stopped",
19140
19946
  sessionId,
19141
19947
  });
19948
+ broadcastSessionsSnapshot();
19142
19949
  return;
19143
19950
  }
19144
19951
  // Record error as system message so user sees feedback
@@ -19150,6 +19957,7 @@ async function handleApi(req, res, url) {
19150
19957
  });
19151
19958
  tracker.updateSessionStatus(sessionId, "failed");
19152
19959
  broadcastUiEvent(["sessions"], "invalidate", { reason: "agent-error", sessionId });
19960
+ broadcastSessionsSnapshot();
19153
19961
  }).finally(() => {
19154
19962
  // Clear only if this turn still owns the session abort controller.
19155
19963
  if (sessionRunAbortControllers.get(sessionId) === abortController) {
@@ -19176,6 +19984,7 @@ async function handleApi(req, res, url) {
19176
19984
  });
19177
19985
  jsonResponse(res, 200, { ok: true, messageId, warning: "no_agent_available" });
19178
19986
  broadcastUiEvent(["sessions"], "invalidate", { reason: "session-message", sessionId });
19987
+ broadcastSessionsSnapshot();
19179
19988
  }
19180
19989
  } catch (err) {
19181
19990
  console.error("[ui-server] session message failed for %s: %s", String(sessionId), String(err?.message || err || "unknown"));
@@ -19215,6 +20024,7 @@ async function handleApi(req, res, url) {
19215
20024
  reason: "session-message-edited",
19216
20025
  sessionId,
19217
20026
  });
20027
+ broadcastSessionsSnapshot();
19218
20028
  } catch (err) {
19219
20029
  jsonResponse(res, 500, { ok: false, error: err.message });
19220
20030
  }
@@ -19234,6 +20044,24 @@ async function handleApi(req, res, url) {
19234
20044
  reason: "session-archived",
19235
20045
  sessionId,
19236
20046
  });
20047
+ broadcastSessionsSnapshot();
20048
+ } catch (err) {
20049
+ jsonResponse(res, 500, { ok: false, error: err.message });
20050
+ }
20051
+ return;
20052
+ }
20053
+
20054
+ if (action === "pause" && req.method === "POST") {
20055
+ try {
20056
+ const session = getScopedSession();
20057
+ if (!session) {
20058
+ jsonResponse(res, 404, { ok: false, error: "Session not found" });
20059
+ return;
20060
+ }
20061
+ tracker.updateSessionStatus(sessionId, "paused");
20062
+ jsonResponse(res, 200, { ok: true });
20063
+ broadcastUiEvent(["sessions"], "invalidate", { reason: "session-paused", sessionId });
20064
+ broadcastSessionsSnapshot();
19237
20065
  } catch (err) {
19238
20066
  jsonResponse(res, 500, { ok: false, error: err.message });
19239
20067
  }
@@ -19250,6 +20078,7 @@ async function handleApi(req, res, url) {
19250
20078
  tracker.updateSessionStatus(sessionId, "active");
19251
20079
  jsonResponse(res, 200, { ok: true });
19252
20080
  broadcastUiEvent(["sessions"], "invalidate", { reason: "session-resumed", sessionId });
20081
+ broadcastSessionsSnapshot();
19253
20082
  } catch (err) {
19254
20083
  jsonResponse(res, 500, { ok: false, error: err.message });
19255
20084
  }
@@ -19269,6 +20098,7 @@ async function handleApi(req, res, url) {
19269
20098
  reason: "session-deleted",
19270
20099
  sessionId,
19271
20100
  });
20101
+ broadcastSessionsSnapshot();
19272
20102
  } catch (err) {
19273
20103
  jsonResponse(res, 500, { ok: false, error: err.message });
19274
20104
  }
@@ -19291,6 +20121,7 @@ async function handleApi(req, res, url) {
19291
20121
  tracker.renameSession(sessionId, title);
19292
20122
  jsonResponse(res, 200, { ok: true });
19293
20123
  broadcastUiEvent(["sessions"], "invalidate", { reason: "session-renamed", sessionId });
20124
+ broadcastSessionsSnapshot();
19294
20125
  } catch (err) {
19295
20126
  jsonResponse(res, 500, { ok: false, error: err.message });
19296
20127
  }
@@ -19320,10 +20151,20 @@ async function handleApi(req, res, url) {
19320
20151
  jsonResponse(res, 200, { ok: true, diff: { files: [], totalFiles: 0, totalAdditions: 0, totalDeletions: 0, formatted: "(no worktree)" }, summary: "(no worktree)", commits: [] });
19321
20152
  return;
19322
20153
  }
19323
- const stats = collectDiffStats(worktreePath);
19324
- const summary = getCompactDiffSummary(worktreePath);
20154
+ const stats = collectDiffStats(worktreePath, { includePatch: true });
20155
+ const summary = stats.formatted || getCompactDiffSummary(worktreePath);
19325
20156
  const commits = getRecentCommits(worktreePath);
19326
- jsonResponse(res, 200, { ok: true, diff: stats, summary, commits });
20157
+ jsonResponse(res, 200, {
20158
+ ok: true,
20159
+ diff: stats,
20160
+ summary,
20161
+ commits,
20162
+ source: {
20163
+ kind: "session",
20164
+ label: stats.sourceRange || "origin/main...HEAD",
20165
+ detail: worktreePath,
20166
+ },
20167
+ });
19327
20168
  } catch (err) {
19328
20169
  jsonResponse(res, 500, { ok: false, error: err.message });
19329
20170
  }
@@ -21052,13 +21893,45 @@ export async function startTelegramUiServer(options = {}) {
21052
21893
  wsServer = new WebSocketServer({ noServer: true });
21053
21894
  if (!sessionListenerAttached) {
21054
21895
  sessionListenerAttached = true;
21055
- addSessionEventListener((payload) => {
21896
+ removeSessionEventListener = addSessionEventListener((payload) => {
21056
21897
  broadcastSessionMessage(payload);
21057
21898
  });
21058
21899
  }
21900
+ if (!sessionStateListenerAttached) {
21901
+ sessionStateListenerAttached = true;
21902
+ removeSessionStateListener = addSessionStateListener((payload) => {
21903
+ broadcastTuiSessionsSnapshot(payload?.reason || payload?.event?.reason || "updated", payload || {});
21904
+ });
21905
+ }
21906
+ if (!activeSessionListenerAttached) {
21907
+ activeSessionListenerAttached = true;
21908
+ removeActiveSessionListener = addActiveSessionListener((sessions, detail = {}) => {
21909
+ const snapshot = getCurrentSessionSnapshot();
21910
+ broadcastCanonicalEvent(["sessions", "tui"], "sessions:update", snapshot);
21911
+ if (detail?.taskKey) {
21912
+ const session = snapshot.find((entry) => String(entry?.taskId || entry?.id || "").trim() === String(detail.taskKey || "").trim()) || {
21913
+ id: String(detail.taskKey || "").trim(),
21914
+ taskId: String(detail.taskKey || "").trim(),
21915
+ type: "task",
21916
+ status: "active",
21917
+ lastActiveAt: new Date().toISOString(),
21918
+ turnCount: 0,
21919
+ };
21920
+ broadcastCanonicalEvent(["sessions", "tui"], "session:event", buildSessionEventPayload({
21921
+ sessionId: session?.id || detail.taskKey,
21922
+ taskId: session?.taskId || detail.taskKey,
21923
+ session,
21924
+ event: {
21925
+ kind: "state",
21926
+ reason: detail?.reason || "update",
21927
+ },
21928
+ }));
21929
+ }
21930
+ });
21931
+ }
21059
21932
  if (!sessionAccumulatorListenerAttached) {
21060
21933
  sessionAccumulatorListenerAttached = true;
21061
- addSessionAccumulationListener((payload) => {
21934
+ removeSessionAccumulatorListener = addSessionAccumulationListener((payload) => {
21062
21935
  broadcastUiEvent(["tasks", "overview", "telemetry", "sessions"], "invalidate", {
21063
21936
  reason: "session-accumulated",
21064
21937
  taskId: payload?.taskId || null,
@@ -21068,22 +21941,16 @@ export async function startTelegramUiServer(options = {}) {
21068
21941
  });
21069
21942
  }
21070
21943
 
21071
- // Periodic stats broadcast for TUI
21072
- let statsBroadcastInterval = null;
21073
- function startStatsBroadcast() {
21074
- if (statsBroadcastInterval) return;
21075
- const intervalMs = Number(process.env.BOSUN_STATS_BROADCAST_MS) || 2000;
21076
- statsBroadcastInterval = setInterval(async () => {
21077
- try {
21078
- const stats = await collectUiStats();
21079
- broadcastUiEvent(["stats", "tui"], "stats", stats);
21080
- } catch (err) {
21081
- // best effort
21082
- }
21083
- }, intervalMs);
21084
- statsBroadcastInterval.unref?.();
21085
- }
21086
- startStatsBroadcast();
21944
+ tuiStatsEmitter?.stop?.();
21945
+ tuiStatsEmitter = createTuiStatsEmitter({
21946
+ intervalMs: Number(process.env.BOSUN_STATS_BROADCAST_MS) || 2000,
21947
+ getPayload: () => buildCurrentTuiMonitorStats(),
21948
+ emit: (stats) => {
21949
+ broadcastCanonicalEvent(["monitor", "stats", "tui"], "monitor:stats", stats);
21950
+ broadcastUiEvent(["stats", "tui"], "stats", stats);
21951
+ },
21952
+ });
21953
+ tuiStatsEmitter.start();
21087
21954
 
21088
21955
  // Retry queue tracking
21089
21956
  let _retryQueue = { count: 0, items: [] };
@@ -21118,10 +21985,7 @@ export async function startTelegramUiServer(options = {}) {
21118
21985
  let _activeSessions = [];
21119
21986
  function updateActiveSessions(sessions) {
21120
21987
  _activeSessions = sessions || [];
21121
- // Broadcast session updates
21122
- for (const session of _activeSessions) {
21123
- broadcastUiEvent(["sessions", "tui"], "session:update", session);
21124
- }
21988
+ broadcastTuiSessionsSnapshot("active-sessions", { sessions: _activeSessions });
21125
21989
  }
21126
21990
 
21127
21991
  // Task CRUD events
@@ -21142,6 +22006,18 @@ export async function startTelegramUiServer(options = {}) {
21142
22006
  payload: { connected: true },
21143
22007
  ts: Date.now(),
21144
22008
  });
22009
+ sendWsMessage(socket, {
22010
+ type: "sessions:update",
22011
+ channels: ["sessions", "tui"],
22012
+ payload: getCurrentSessionSnapshot(),
22013
+ ts: Date.now(),
22014
+ });
22015
+ sendWsMessage(socket, {
22016
+ type: "monitor:stats",
22017
+ channels: ["monitor", "stats", "tui"],
22018
+ payload: buildCurrentTuiMonitorStats(),
22019
+ ts: Date.now(),
22020
+ });
21145
22021
 
21146
22022
  socket.on("message", (raw) => {
21147
22023
  try {
@@ -21637,9 +22513,24 @@ export function stopTelegramUiServer() {
21637
22513
  if (!uiServer) return;
21638
22514
  stopTunnel();
21639
22515
  stopWsHeartbeat();
22516
+ _activeSessions = [];
21640
22517
  // Clear injected configDir so it does not leak between server lifecycles
21641
22518
  // (tests start/stop servers repeatedly with different config directories).
21642
22519
  delete uiDeps.configDir;
22520
+ tuiStatsEmitter?.stop?.();
22521
+ tuiStatsEmitter = null;
22522
+ removeSessionEventListener?.();
22523
+ removeSessionEventListener = null;
22524
+ sessionListenerAttached = false;
22525
+ removeSessionStateListener?.();
22526
+ removeSessionStateListener = null;
22527
+ sessionStateListenerAttached = false;
22528
+ removeActiveSessionListener?.();
22529
+ removeActiveSessionListener = null;
22530
+ activeSessionListenerAttached = false;
22531
+ removeSessionAccumulatorListener?.();
22532
+ removeSessionAccumulatorListener = null;
22533
+ sessionAccumulatorListenerAttached = false;
21643
22534
  for (const socket of wsClients) {
21644
22535
  try {
21645
22536
  stopLogStream(socket);
@@ -21674,3 +22565,6 @@ export function stopTelegramUiServer() {
21674
22565
  }
21675
22566
 
21676
22567
  export { getLocalLanIp };
22568
+
22569
+
22570
+