codex-autorunner 0.1.2__py3-none-any.whl → 1.0.0__py3-none-any.whl

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 (189) hide show
  1. codex_autorunner/__main__.py +4 -0
  2. codex_autorunner/agents/opencode/client.py +68 -35
  3. codex_autorunner/agents/opencode/logging.py +21 -5
  4. codex_autorunner/agents/opencode/run_prompt.py +1 -0
  5. codex_autorunner/agents/opencode/runtime.py +118 -30
  6. codex_autorunner/agents/opencode/supervisor.py +36 -48
  7. codex_autorunner/agents/registry.py +136 -8
  8. codex_autorunner/api.py +25 -0
  9. codex_autorunner/bootstrap.py +16 -35
  10. codex_autorunner/cli.py +157 -139
  11. codex_autorunner/core/about_car.py +44 -32
  12. codex_autorunner/core/adapter_utils.py +21 -0
  13. codex_autorunner/core/app_server_logging.py +7 -3
  14. codex_autorunner/core/app_server_prompts.py +27 -260
  15. codex_autorunner/core/app_server_threads.py +15 -26
  16. codex_autorunner/core/codex_runner.py +6 -0
  17. codex_autorunner/core/config.py +390 -100
  18. codex_autorunner/core/docs.py +10 -2
  19. codex_autorunner/core/drafts.py +82 -0
  20. codex_autorunner/core/engine.py +278 -262
  21. codex_autorunner/core/flows/__init__.py +25 -0
  22. codex_autorunner/core/flows/controller.py +178 -0
  23. codex_autorunner/core/flows/definition.py +82 -0
  24. codex_autorunner/core/flows/models.py +75 -0
  25. codex_autorunner/core/flows/runtime.py +351 -0
  26. codex_autorunner/core/flows/store.py +485 -0
  27. codex_autorunner/core/flows/transition.py +133 -0
  28. codex_autorunner/core/flows/worker_process.py +242 -0
  29. codex_autorunner/core/hub.py +15 -9
  30. codex_autorunner/core/locks.py +4 -0
  31. codex_autorunner/core/prompt.py +15 -7
  32. codex_autorunner/core/redaction.py +29 -0
  33. codex_autorunner/core/review_context.py +5 -8
  34. codex_autorunner/core/run_index.py +6 -0
  35. codex_autorunner/core/runner_process.py +5 -2
  36. codex_autorunner/core/state.py +0 -88
  37. codex_autorunner/core/static_assets.py +55 -0
  38. codex_autorunner/core/supervisor_utils.py +67 -0
  39. codex_autorunner/core/update.py +20 -11
  40. codex_autorunner/core/update_runner.py +2 -0
  41. codex_autorunner/core/utils.py +29 -2
  42. codex_autorunner/discovery.py +2 -4
  43. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  44. codex_autorunner/flows/ticket_flow/definition.py +91 -0
  45. codex_autorunner/integrations/agents/__init__.py +27 -0
  46. codex_autorunner/integrations/agents/agent_backend.py +142 -0
  47. codex_autorunner/integrations/agents/codex_backend.py +307 -0
  48. codex_autorunner/integrations/agents/opencode_backend.py +325 -0
  49. codex_autorunner/integrations/agents/run_event.py +71 -0
  50. codex_autorunner/integrations/app_server/client.py +576 -92
  51. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  52. codex_autorunner/integrations/telegram/adapter.py +141 -167
  53. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  54. codex_autorunner/integrations/telegram/config.py +175 -0
  55. codex_autorunner/integrations/telegram/constants.py +16 -1
  56. codex_autorunner/integrations/telegram/dispatch.py +17 -0
  57. codex_autorunner/integrations/telegram/doctor.py +47 -0
  58. codex_autorunner/integrations/telegram/handlers/callbacks.py +0 -4
  59. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
  60. codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
  61. codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
  62. codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
  63. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
  64. codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
  65. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
  66. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +133 -475
  67. codex_autorunner/integrations/telegram/handlers/commands_spec.py +11 -4
  68. codex_autorunner/integrations/telegram/handlers/messages.py +120 -9
  69. codex_autorunner/integrations/telegram/helpers.py +88 -16
  70. codex_autorunner/integrations/telegram/outbox.py +208 -37
  71. codex_autorunner/integrations/telegram/progress_stream.py +3 -10
  72. codex_autorunner/integrations/telegram/service.py +214 -40
  73. codex_autorunner/integrations/telegram/state.py +100 -2
  74. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
  75. codex_autorunner/integrations/telegram/transport.py +36 -3
  76. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  77. codex_autorunner/manifest.py +2 -0
  78. codex_autorunner/plugin_api.py +22 -0
  79. codex_autorunner/routes/__init__.py +23 -14
  80. codex_autorunner/routes/analytics.py +239 -0
  81. codex_autorunner/routes/base.py +81 -109
  82. codex_autorunner/routes/file_chat.py +836 -0
  83. codex_autorunner/routes/flows.py +980 -0
  84. codex_autorunner/routes/messages.py +459 -0
  85. codex_autorunner/routes/system.py +6 -1
  86. codex_autorunner/routes/usage.py +87 -0
  87. codex_autorunner/routes/workspace.py +271 -0
  88. codex_autorunner/server.py +2 -1
  89. codex_autorunner/static/agentControls.js +1 -0
  90. codex_autorunner/static/agentEvents.js +248 -0
  91. codex_autorunner/static/app.js +25 -22
  92. codex_autorunner/static/autoRefresh.js +29 -1
  93. codex_autorunner/static/bootstrap.js +1 -0
  94. codex_autorunner/static/bus.js +1 -0
  95. codex_autorunner/static/cache.js +1 -0
  96. codex_autorunner/static/constants.js +20 -4
  97. codex_autorunner/static/dashboard.js +162 -196
  98. codex_autorunner/static/diffRenderer.js +37 -0
  99. codex_autorunner/static/docChatCore.js +324 -0
  100. codex_autorunner/static/docChatStorage.js +65 -0
  101. codex_autorunner/static/docChatVoice.js +65 -0
  102. codex_autorunner/static/docEditor.js +133 -0
  103. codex_autorunner/static/env.js +1 -0
  104. codex_autorunner/static/eventSummarizer.js +166 -0
  105. codex_autorunner/static/fileChat.js +182 -0
  106. codex_autorunner/static/health.js +155 -0
  107. codex_autorunner/static/hub.js +41 -118
  108. codex_autorunner/static/index.html +787 -858
  109. codex_autorunner/static/liveUpdates.js +1 -0
  110. codex_autorunner/static/loader.js +1 -0
  111. codex_autorunner/static/messages.js +470 -0
  112. codex_autorunner/static/mobileCompact.js +2 -1
  113. codex_autorunner/static/settings.js +24 -211
  114. codex_autorunner/static/styles.css +7567 -3865
  115. codex_autorunner/static/tabs.js +28 -5
  116. codex_autorunner/static/terminal.js +14 -0
  117. codex_autorunner/static/terminalManager.js +34 -59
  118. codex_autorunner/static/ticketChatActions.js +333 -0
  119. codex_autorunner/static/ticketChatEvents.js +16 -0
  120. codex_autorunner/static/ticketChatStorage.js +16 -0
  121. codex_autorunner/static/ticketChatStream.js +264 -0
  122. codex_autorunner/static/ticketEditor.js +750 -0
  123. codex_autorunner/static/ticketVoice.js +9 -0
  124. codex_autorunner/static/tickets.js +1315 -0
  125. codex_autorunner/static/utils.js +32 -3
  126. codex_autorunner/static/voice.js +1 -0
  127. codex_autorunner/static/workspace.js +672 -0
  128. codex_autorunner/static/workspaceApi.js +53 -0
  129. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  130. codex_autorunner/tickets/__init__.py +20 -0
  131. codex_autorunner/tickets/agent_pool.py +377 -0
  132. codex_autorunner/tickets/files.py +85 -0
  133. codex_autorunner/tickets/frontmatter.py +55 -0
  134. codex_autorunner/tickets/lint.py +102 -0
  135. codex_autorunner/tickets/models.py +95 -0
  136. codex_autorunner/tickets/outbox.py +232 -0
  137. codex_autorunner/tickets/replies.py +179 -0
  138. codex_autorunner/tickets/runner.py +823 -0
  139. codex_autorunner/tickets/spec_ingest.py +77 -0
  140. codex_autorunner/web/app.py +269 -91
  141. codex_autorunner/web/middleware.py +3 -4
  142. codex_autorunner/web/schemas.py +89 -109
  143. codex_autorunner/web/static_assets.py +1 -44
  144. codex_autorunner/workspace/__init__.py +40 -0
  145. codex_autorunner/workspace/paths.py +319 -0
  146. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +18 -21
  147. codex_autorunner-1.0.0.dist-info/RECORD +251 -0
  148. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
  149. codex_autorunner/agents/execution/policy.py +0 -292
  150. codex_autorunner/agents/factory.py +0 -52
  151. codex_autorunner/agents/orchestrator.py +0 -358
  152. codex_autorunner/core/doc_chat.py +0 -1446
  153. codex_autorunner/core/snapshot.py +0 -580
  154. codex_autorunner/integrations/github/chatops.py +0 -268
  155. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  156. codex_autorunner/routes/docs.py +0 -381
  157. codex_autorunner/routes/github.py +0 -327
  158. codex_autorunner/routes/runs.py +0 -250
  159. codex_autorunner/spec_ingest.py +0 -812
  160. codex_autorunner/static/docChatActions.js +0 -287
  161. codex_autorunner/static/docChatEvents.js +0 -300
  162. codex_autorunner/static/docChatRender.js +0 -205
  163. codex_autorunner/static/docChatStream.js +0 -361
  164. codex_autorunner/static/docs.js +0 -20
  165. codex_autorunner/static/docsClipboard.js +0 -69
  166. codex_autorunner/static/docsCrud.js +0 -257
  167. codex_autorunner/static/docsDocUpdates.js +0 -62
  168. codex_autorunner/static/docsDrafts.js +0 -16
  169. codex_autorunner/static/docsElements.js +0 -69
  170. codex_autorunner/static/docsInit.js +0 -285
  171. codex_autorunner/static/docsParse.js +0 -160
  172. codex_autorunner/static/docsSnapshot.js +0 -87
  173. codex_autorunner/static/docsSpecIngest.js +0 -263
  174. codex_autorunner/static/docsState.js +0 -127
  175. codex_autorunner/static/docsThreadRegistry.js +0 -44
  176. codex_autorunner/static/docsUi.js +0 -153
  177. codex_autorunner/static/docsVoice.js +0 -56
  178. codex_autorunner/static/github.js +0 -504
  179. codex_autorunner/static/logs.js +0 -678
  180. codex_autorunner/static/review.js +0 -157
  181. codex_autorunner/static/runs.js +0 -418
  182. codex_autorunner/static/snapshot.js +0 -124
  183. codex_autorunner/static/state.js +0 -94
  184. codex_autorunner/static/todoPreview.js +0 -27
  185. codex_autorunner/workspace.py +0 -16
  186. codex_autorunner-0.1.2.dist-info/RECORD +0 -222
  187. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
  188. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
  189. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1315 @@
1
+ // GENERATED FILE - do not edit directly. Source: static_src/
2
+ import { api, flash, getUrlParams, resolvePath, statusPill, getAuthToken, openModal } from "./utils.js";
3
+ import { activateTab } from "./tabs.js";
4
+ import { registerAutoRefresh } from "./autoRefresh.js";
5
+ import { CONSTANTS } from "./constants.js";
6
+ import { subscribe } from "./bus.js";
7
+ import { isRepoHealthy } from "./health.js";
8
+ import { closeTicketEditor, initTicketEditor, openTicketEditor } from "./ticketEditor.js";
9
+ import { parseAppServerEvent } from "./agentEvents.js";
10
+ import { summarizeEvents, renderCompactSummary, COMPACT_MAX_TEXT_LENGTH } from "./eventSummarizer.js";
11
+ import { refreshBell, renderMarkdown } from "./messages.js";
12
+ let currentRunId = null;
13
+ let ticketsExist = false;
14
+ let currentActiveTicket = null;
15
+ let currentFlowStatus = null;
16
+ let elapsedTimerId = null;
17
+ let flowStartedAt = null;
18
+ let eventSource = null;
19
+ let lastActivityTime = null;
20
+ let lastActivityTimerId = null;
21
+ let liveOutputDetailExpanded = false; // Start with summary view, one click for full
22
+ let liveOutputBuffer = [];
23
+ const MAX_OUTPUT_LINES = 200;
24
+ const LIVE_EVENT_MAX = 50;
25
+ let liveOutputEvents = [];
26
+ let liveOutputEventIndex = {};
27
+ let currentReasonFull = null; // Full reason text for modal display
28
+ // Dispatch panel collapse state (persisted to localStorage)
29
+ const DISPATCH_PANEL_COLLAPSED_KEY = "car-dispatch-panel-collapsed";
30
+ let dispatchPanelCollapsed = false;
31
+ // Throttling state
32
+ let liveOutputRenderPending = false;
33
+ let liveOutputTextPending = false;
34
+ function scheduleLiveOutputRender() {
35
+ if (liveOutputRenderPending)
36
+ return;
37
+ liveOutputRenderPending = true;
38
+ requestAnimationFrame(() => {
39
+ renderLiveOutputView();
40
+ liveOutputRenderPending = false;
41
+ });
42
+ }
43
+ function scheduleLiveOutputTextUpdate() {
44
+ if (liveOutputTextPending)
45
+ return;
46
+ liveOutputTextPending = true;
47
+ requestAnimationFrame(() => {
48
+ const outputEl = document.getElementById("ticket-live-output-text");
49
+ if (outputEl) {
50
+ const newText = liveOutputBuffer.join("\n");
51
+ if (outputEl.textContent !== newText) {
52
+ outputEl.textContent = newText;
53
+ }
54
+ // Auto-scroll to bottom when detail view is showing
55
+ const detailEl = document.getElementById("ticket-live-output-detail");
56
+ if (detailEl && liveOutputDetailExpanded) {
57
+ detailEl.scrollTop = detailEl.scrollHeight;
58
+ }
59
+ }
60
+ liveOutputTextPending = false;
61
+ });
62
+ }
63
+ /**
64
+ * Initialize dispatch panel collapse state from localStorage
65
+ */
66
+ function initDispatchPanelToggle() {
67
+ const { dispatchPanel, dispatchPanelToggle } = els();
68
+ if (!dispatchPanel || !dispatchPanelToggle)
69
+ return;
70
+ // Restore collapsed state from localStorage
71
+ const stored = localStorage.getItem(DISPATCH_PANEL_COLLAPSED_KEY);
72
+ dispatchPanelCollapsed = stored === "true";
73
+ if (dispatchPanelCollapsed) {
74
+ dispatchPanel.classList.add("collapsed");
75
+ }
76
+ // Handle toggle click
77
+ dispatchPanelToggle.addEventListener("click", () => {
78
+ dispatchPanelCollapsed = !dispatchPanelCollapsed;
79
+ dispatchPanel.classList.toggle("collapsed", dispatchPanelCollapsed);
80
+ localStorage.setItem(DISPATCH_PANEL_COLLAPSED_KEY, String(dispatchPanelCollapsed));
81
+ });
82
+ }
83
+ /**
84
+ * Render mini dispatch items for collapsed panel view.
85
+ * Shows compact dispatch indicators that can be clicked to expand.
86
+ */
87
+ function renderDispatchMiniList(entries) {
88
+ const { dispatchMiniList, dispatchPanel } = els();
89
+ if (!dispatchMiniList)
90
+ return;
91
+ dispatchMiniList.innerHTML = "";
92
+ // Only show first 8 items in mini view
93
+ const maxMiniItems = 8;
94
+ entries.slice(0, maxMiniItems).forEach((entry) => {
95
+ const dispatch = entry.dispatch;
96
+ const isTurnSummary = dispatch?.mode === "turn_summary" || dispatch?.extra?.is_turn_summary;
97
+ const isNotify = dispatch?.mode === "notify";
98
+ const mini = document.createElement("div");
99
+ mini.className = `dispatch-mini-item${isNotify ? " notify" : ""}`;
100
+ mini.textContent = `#${entry.seq || "?"}`;
101
+ mini.title = isTurnSummary
102
+ ? "Agent turn output"
103
+ : dispatch?.title || `Dispatch #${entry.seq}`;
104
+ // Click to expand panel and scroll to this item
105
+ mini.addEventListener("click", () => {
106
+ if (dispatchPanel && dispatchPanelCollapsed) {
107
+ dispatchPanelCollapsed = false;
108
+ dispatchPanel.classList.remove("collapsed");
109
+ localStorage.setItem(DISPATCH_PANEL_COLLAPSED_KEY, "false");
110
+ }
111
+ });
112
+ dispatchMiniList.appendChild(mini);
113
+ });
114
+ // Show overflow indicator if more items
115
+ if (entries.length > maxMiniItems) {
116
+ const more = document.createElement("div");
117
+ more.className = "dispatch-mini-item";
118
+ more.textContent = `+${entries.length - maxMiniItems}`;
119
+ more.title = `${entries.length - maxMiniItems} more dispatches`;
120
+ more.addEventListener("click", () => {
121
+ if (dispatchPanel && dispatchPanelCollapsed) {
122
+ dispatchPanelCollapsed = false;
123
+ dispatchPanel.classList.remove("collapsed");
124
+ localStorage.setItem(DISPATCH_PANEL_COLLAPSED_KEY, "false");
125
+ }
126
+ });
127
+ dispatchMiniList.appendChild(more);
128
+ }
129
+ }
130
+ function formatElapsed(startTime) {
131
+ const now = new Date();
132
+ const diffMs = now.getTime() - startTime.getTime();
133
+ const diffSecs = Math.floor(diffMs / 1000);
134
+ if (diffSecs < 60) {
135
+ return `${diffSecs}s`;
136
+ }
137
+ const mins = Math.floor(diffSecs / 60);
138
+ const secs = diffSecs % 60;
139
+ if (mins < 60) {
140
+ return `${mins}m ${secs}s`;
141
+ }
142
+ const hours = Math.floor(mins / 60);
143
+ const remainingMins = mins % 60;
144
+ return `${hours}h ${remainingMins}m`;
145
+ }
146
+ function startElapsedTimer() {
147
+ stopElapsedTimer();
148
+ if (!flowStartedAt)
149
+ return;
150
+ const update = () => {
151
+ const { elapsed } = els();
152
+ if (elapsed && flowStartedAt) {
153
+ elapsed.textContent = formatElapsed(flowStartedAt);
154
+ }
155
+ };
156
+ update(); // Update immediately
157
+ elapsedTimerId = setInterval(update, 1000);
158
+ }
159
+ function stopElapsedTimer() {
160
+ if (elapsedTimerId) {
161
+ clearInterval(elapsedTimerId);
162
+ elapsedTimerId = null;
163
+ }
164
+ }
165
+ // ---- SSE Event Stream Functions ----
166
+ function formatTimeAgo(timestamp) {
167
+ const now = new Date();
168
+ const diffMs = now.getTime() - timestamp.getTime();
169
+ const diffSecs = Math.floor(diffMs / 1000);
170
+ if (diffSecs < 5)
171
+ return "just now";
172
+ if (diffSecs < 60)
173
+ return `${diffSecs}s ago`;
174
+ const mins = Math.floor(diffSecs / 60);
175
+ if (mins < 60)
176
+ return `${mins}m ago`;
177
+ const hours = Math.floor(mins / 60);
178
+ return `${hours}h ago`;
179
+ }
180
+ function updateLastActivityDisplay() {
181
+ const el = document.getElementById("ticket-flow-last-activity");
182
+ if (el && lastActivityTime) {
183
+ el.textContent = formatTimeAgo(lastActivityTime);
184
+ }
185
+ }
186
+ function startLastActivityTimer() {
187
+ stopLastActivityTimer();
188
+ updateLastActivityDisplay();
189
+ lastActivityTimerId = setInterval(updateLastActivityDisplay, 1000);
190
+ }
191
+ function stopLastActivityTimer() {
192
+ if (lastActivityTimerId) {
193
+ clearInterval(lastActivityTimerId);
194
+ lastActivityTimerId = null;
195
+ }
196
+ }
197
+ function appendToLiveOutput(text) {
198
+ if (!text)
199
+ return;
200
+ const segments = text.split("\n");
201
+ // Merge first segment into the last buffered line to avoid artificial newlines between deltas
202
+ if (liveOutputBuffer.length === 0) {
203
+ liveOutputBuffer.push(segments[0]);
204
+ }
205
+ else {
206
+ liveOutputBuffer[liveOutputBuffer.length - 1] += segments[0];
207
+ }
208
+ // Remaining segments represent real new lines
209
+ for (let i = 1; i < segments.length; i++) {
210
+ liveOutputBuffer.push(segments[i]);
211
+ }
212
+ // Trim buffer if it exceeds max lines
213
+ while (liveOutputBuffer.length > MAX_OUTPUT_LINES) {
214
+ liveOutputBuffer.shift();
215
+ }
216
+ scheduleLiveOutputTextUpdate();
217
+ }
218
+ function addLiveOutputEvent(parsed) {
219
+ const { event, mergeStrategy } = parsed;
220
+ const itemId = event.itemId;
221
+ if (mergeStrategy && itemId && liveOutputEventIndex[itemId] !== undefined) {
222
+ const existingIndex = liveOutputEventIndex[itemId];
223
+ const existing = liveOutputEvents[existingIndex];
224
+ if (mergeStrategy === "append") {
225
+ existing.summary = `${existing.summary || ""}${event.summary}`;
226
+ }
227
+ else if (mergeStrategy === "newline") {
228
+ existing.summary = `${existing.summary || ""}\n\n`;
229
+ }
230
+ existing.time = event.time;
231
+ return;
232
+ }
233
+ liveOutputEvents.push(event);
234
+ if (liveOutputEvents.length > LIVE_EVENT_MAX) {
235
+ liveOutputEvents = liveOutputEvents.slice(-LIVE_EVENT_MAX);
236
+ liveOutputEventIndex = {};
237
+ liveOutputEvents.forEach((evt, idx) => {
238
+ if (evt.itemId)
239
+ liveOutputEventIndex[evt.itemId] = idx;
240
+ });
241
+ }
242
+ else if (itemId) {
243
+ liveOutputEventIndex[itemId] = liveOutputEvents.length - 1;
244
+ }
245
+ }
246
+ function renderLiveOutputEvents() {
247
+ const container = document.getElementById("ticket-live-output-events");
248
+ const list = document.getElementById("ticket-live-output-events-list");
249
+ const count = document.getElementById("ticket-live-output-events-count");
250
+ if (!container || !list || !count)
251
+ return;
252
+ const hasEvents = liveOutputEvents.length > 0;
253
+ if (count.textContent !== String(liveOutputEvents.length)) {
254
+ count.textContent = String(liveOutputEvents.length);
255
+ }
256
+ const shouldHide = !hasEvents || !liveOutputDetailExpanded;
257
+ if (container.classList.contains("hidden") !== shouldHide) {
258
+ container.classList.toggle("hidden", shouldHide);
259
+ }
260
+ if (shouldHide) {
261
+ if (list.innerHTML !== "")
262
+ list.innerHTML = "";
263
+ return;
264
+ }
265
+ // Track which IDs are currently in the list to remove stale ones
266
+ const currentIds = new Set();
267
+ liveOutputEvents.forEach((entry) => {
268
+ const id = entry.id;
269
+ currentIds.add(id);
270
+ // Safer lookup than querySelector with arbitrary ID
271
+ let wrapper = null;
272
+ for (let i = 0; i < list.children.length; i++) {
273
+ const child = list.children[i];
274
+ if (child.dataset.eventId === id) {
275
+ wrapper = child;
276
+ break;
277
+ }
278
+ }
279
+ if (!wrapper) {
280
+ wrapper = document.createElement("div");
281
+ wrapper.className = `ticket-chat-event ${entry.kind || ""}`.trim();
282
+ wrapper.dataset.eventId = id;
283
+ const title = document.createElement("div");
284
+ title.className = "ticket-chat-event-title";
285
+ wrapper.appendChild(title);
286
+ const summary = document.createElement("div");
287
+ summary.className = "ticket-chat-event-summary";
288
+ wrapper.appendChild(summary);
289
+ const detail = document.createElement("div");
290
+ detail.className = "ticket-chat-event-detail";
291
+ wrapper.appendChild(detail);
292
+ const meta = document.createElement("div");
293
+ meta.className = "ticket-chat-event-meta";
294
+ wrapper.appendChild(meta);
295
+ list.appendChild(wrapper);
296
+ }
297
+ // Efficiently update content only if changed
298
+ const titleEl = wrapper.querySelector(".ticket-chat-event-title");
299
+ const newTitle = entry.title || entry.method || "Update";
300
+ if (titleEl && titleEl.textContent !== newTitle) {
301
+ titleEl.textContent = newTitle;
302
+ }
303
+ const summaryEl = wrapper.querySelector(".ticket-chat-event-summary");
304
+ const newSummary = entry.summary || "";
305
+ if (summaryEl && summaryEl.textContent !== newSummary) {
306
+ summaryEl.textContent = newSummary;
307
+ }
308
+ const detailEl = wrapper.querySelector(".ticket-chat-event-detail");
309
+ const newDetail = entry.detail || "";
310
+ if (detailEl && detailEl.textContent !== newDetail) {
311
+ detailEl.textContent = newDetail;
312
+ }
313
+ const metaEl = wrapper.querySelector(".ticket-chat-event-meta");
314
+ if (metaEl) {
315
+ const newMeta = entry.time
316
+ ? new Date(entry.time).toLocaleTimeString([], {
317
+ hour: "2-digit",
318
+ minute: "2-digit",
319
+ })
320
+ : "";
321
+ if (metaEl.textContent !== newMeta) {
322
+ metaEl.textContent = newMeta;
323
+ }
324
+ }
325
+ });
326
+ // Remove stale events
327
+ Array.from(list.children).forEach((child) => {
328
+ const el = child;
329
+ if (el.dataset.eventId && !currentIds.has(el.dataset.eventId)) {
330
+ el.remove();
331
+ }
332
+ });
333
+ // Only scroll if near bottom or if height changed significantly?
334
+ // For now, just scroll as it's the expected behavior for live logs
335
+ list.scrollTop = list.scrollHeight;
336
+ }
337
+ function renderLiveOutputCompact() {
338
+ const compactEl = document.getElementById("ticket-live-output-compact");
339
+ if (!compactEl)
340
+ return;
341
+ const summary = summarizeEvents(liveOutputEvents, {
342
+ maxActions: 1, // Show only 1 action + thinking to fit in 3-line compact view
343
+ maxTextLength: COMPACT_MAX_TEXT_LENGTH,
344
+ startTime: flowStartedAt?.getTime(),
345
+ });
346
+ const text = liveOutputEvents.length ? renderCompactSummary(summary) : "";
347
+ const newText = text || "Waiting for agent output...";
348
+ if (compactEl.textContent !== newText) {
349
+ compactEl.textContent = newText;
350
+ }
351
+ }
352
+ function updateLiveOutputViewToggle() {
353
+ const viewToggle = document.getElementById("ticket-live-output-view-toggle");
354
+ if (!viewToggle)
355
+ return;
356
+ if (liveOutputDetailExpanded) {
357
+ if (!viewToggle.classList.contains("active"))
358
+ viewToggle.classList.add("active");
359
+ if (viewToggle.textContent !== "≡")
360
+ viewToggle.textContent = "≡";
361
+ if (viewToggle.title !== "Show summary")
362
+ viewToggle.title = "Show summary";
363
+ }
364
+ else {
365
+ if (viewToggle.classList.contains("active"))
366
+ viewToggle.classList.remove("active");
367
+ if (viewToggle.textContent !== "⋯")
368
+ viewToggle.textContent = "⋯";
369
+ if (viewToggle.title !== "Show full output")
370
+ viewToggle.title = "Show full output";
371
+ }
372
+ }
373
+ function renderLiveOutputView() {
374
+ const compactEl = document.getElementById("ticket-live-output-compact");
375
+ const detailEl = document.getElementById("ticket-live-output-detail");
376
+ const eventsEl = document.getElementById("ticket-live-output-events");
377
+ if (compactEl) {
378
+ compactEl.classList.toggle("hidden", liveOutputDetailExpanded);
379
+ }
380
+ if (detailEl) {
381
+ detailEl.classList.toggle("hidden", !liveOutputDetailExpanded);
382
+ }
383
+ if (eventsEl) {
384
+ eventsEl.classList.toggle("hidden", !liveOutputDetailExpanded);
385
+ }
386
+ renderLiveOutputCompact();
387
+ renderLiveOutputEvents();
388
+ updateLiveOutputViewToggle();
389
+ }
390
+ function clearLiveOutput() {
391
+ liveOutputBuffer = [];
392
+ const outputEl = document.getElementById("ticket-live-output-text");
393
+ if (outputEl)
394
+ outputEl.textContent = "";
395
+ liveOutputEvents = [];
396
+ liveOutputEventIndex = {};
397
+ scheduleLiveOutputRender();
398
+ }
399
+ function setLiveOutputStatus(status) {
400
+ const statusEl = document.getElementById("ticket-live-output-status");
401
+ if (!statusEl)
402
+ return;
403
+ statusEl.className = "ticket-live-output-status";
404
+ switch (status) {
405
+ case "disconnected":
406
+ statusEl.textContent = "Disconnected";
407
+ break;
408
+ case "connected":
409
+ statusEl.textContent = "Connected";
410
+ statusEl.classList.add("connected");
411
+ break;
412
+ case "streaming":
413
+ statusEl.textContent = "Streaming";
414
+ statusEl.classList.add("streaming");
415
+ break;
416
+ }
417
+ }
418
+ function handleFlowEvent(event) {
419
+ // Update last activity time
420
+ lastActivityTime = new Date(event.timestamp);
421
+ updateLastActivityDisplay();
422
+ // Handle agent stream delta events
423
+ if (event.event_type === "agent_stream_delta") {
424
+ setLiveOutputStatus("streaming");
425
+ const delta = event.data?.delta || "";
426
+ if (delta) {
427
+ appendToLiveOutput(delta);
428
+ }
429
+ }
430
+ // Handle rich app-server events (tools, commands, files, thinking, etc.)
431
+ if (event.event_type === "app_server_event") {
432
+ const parsed = parseAppServerEvent(event.data);
433
+ if (parsed) {
434
+ addLiveOutputEvent(parsed);
435
+ scheduleLiveOutputRender();
436
+ }
437
+ }
438
+ // Handle flow lifecycle events
439
+ if (event.event_type === "flow_completed" ||
440
+ event.event_type === "flow_failed" ||
441
+ event.event_type === "flow_stopped") {
442
+ setLiveOutputStatus("connected");
443
+ // Refresh the flow state
444
+ void loadTicketFlow();
445
+ }
446
+ // Handle step events
447
+ if (event.event_type === "step_started") {
448
+ const stepName = event.data?.step_name || "";
449
+ if (stepName) {
450
+ appendToLiveOutput(`\n--- Step: ${stepName} ---\n`);
451
+ }
452
+ }
453
+ }
454
+ function connectEventStream(runId) {
455
+ disconnectEventStream();
456
+ const token = getAuthToken();
457
+ let url = resolvePath(`/api/flows/${runId}/events`);
458
+ if (token) {
459
+ url += `?token=${encodeURIComponent(token)}`;
460
+ }
461
+ eventSource = new EventSource(url);
462
+ eventSource.onopen = () => {
463
+ setLiveOutputStatus("connected");
464
+ };
465
+ eventSource.onmessage = (event) => {
466
+ try {
467
+ const data = JSON.parse(event.data);
468
+ handleFlowEvent(data);
469
+ }
470
+ catch (err) {
471
+ // Ignore parse errors
472
+ }
473
+ };
474
+ eventSource.onerror = () => {
475
+ setLiveOutputStatus("disconnected");
476
+ // Don't auto-reconnect here - loadTicketFlow will handle it
477
+ };
478
+ }
479
+ function disconnectEventStream() {
480
+ if (eventSource) {
481
+ eventSource.close();
482
+ eventSource = null;
483
+ }
484
+ setLiveOutputStatus("disconnected");
485
+ }
486
+ function initLiveOutputPanel() {
487
+ const viewToggleBtn = document.getElementById("ticket-live-output-view-toggle");
488
+ // Toggle between summary and full view (one click)
489
+ const toggleView = () => {
490
+ liveOutputDetailExpanded = !liveOutputDetailExpanded;
491
+ renderLiveOutputView();
492
+ };
493
+ if (viewToggleBtn) {
494
+ viewToggleBtn.addEventListener("click", toggleView);
495
+ }
496
+ // Initial render
497
+ updateLiveOutputViewToggle();
498
+ renderLiveOutputView();
499
+ }
500
+ /**
501
+ * Initialize the reason modal click handler.
502
+ */
503
+ function initReasonModal() {
504
+ const reasonEl = document.getElementById("ticket-flow-reason");
505
+ const modalOverlay = document.getElementById("reason-modal");
506
+ const modalContent = document.getElementById("reason-modal-content");
507
+ const closeBtn = document.getElementById("reason-modal-close");
508
+ if (!reasonEl || !modalOverlay || !modalContent)
509
+ return;
510
+ let closeModal = null;
511
+ const showReasonModal = () => {
512
+ if (!currentReasonFull || !reasonEl.classList.contains("has-details"))
513
+ return;
514
+ modalContent.textContent = currentReasonFull;
515
+ closeModal = openModal(modalOverlay, {
516
+ closeOnEscape: true,
517
+ closeOnOverlay: true,
518
+ returnFocusTo: reasonEl,
519
+ });
520
+ };
521
+ reasonEl.addEventListener("click", showReasonModal);
522
+ if (closeBtn) {
523
+ closeBtn.addEventListener("click", () => {
524
+ if (closeModal)
525
+ closeModal();
526
+ });
527
+ }
528
+ }
529
+ function els() {
530
+ return {
531
+ card: document.getElementById("ticket-card"),
532
+ status: document.getElementById("ticket-flow-status"),
533
+ run: document.getElementById("ticket-flow-run"),
534
+ current: document.getElementById("ticket-flow-current"),
535
+ turn: document.getElementById("ticket-flow-turn"),
536
+ elapsed: document.getElementById("ticket-flow-elapsed"),
537
+ progress: document.getElementById("ticket-flow-progress"),
538
+ reason: document.getElementById("ticket-flow-reason"),
539
+ lastActivity: document.getElementById("ticket-flow-last-activity"),
540
+ dir: document.getElementById("ticket-flow-dir"),
541
+ tickets: document.getElementById("ticket-flow-tickets"),
542
+ history: document.getElementById("ticket-dispatch-history"),
543
+ dispatchNote: document.getElementById("ticket-dispatch-note"),
544
+ dispatchPanel: document.getElementById("dispatch-panel"),
545
+ dispatchPanelToggle: document.getElementById("dispatch-panel-toggle"),
546
+ dispatchMiniList: document.getElementById("dispatch-mini-list"),
547
+ bootstrapBtn: document.getElementById("ticket-flow-bootstrap"),
548
+ resumeBtn: document.getElementById("ticket-flow-resume"),
549
+ refreshBtn: document.getElementById("ticket-flow-refresh"),
550
+ stopBtn: document.getElementById("ticket-flow-stop"),
551
+ restartBtn: document.getElementById("ticket-flow-restart"),
552
+ archiveBtn: document.getElementById("ticket-flow-archive"),
553
+ };
554
+ }
555
+ function setButtonsDisabled(disabled) {
556
+ const { bootstrapBtn, resumeBtn, refreshBtn, stopBtn, restartBtn, archiveBtn } = els();
557
+ [bootstrapBtn, resumeBtn, refreshBtn, stopBtn, restartBtn, archiveBtn].forEach((btn) => {
558
+ if (btn)
559
+ btn.disabled = disabled;
560
+ });
561
+ }
562
+ function truncate(text, max = 100) {
563
+ if (text.length <= max)
564
+ return text;
565
+ return `${text.slice(0, max).trim()}…`;
566
+ }
567
+ function renderTickets(data) {
568
+ const { tickets, dir, bootstrapBtn } = els();
569
+ if (dir)
570
+ dir.textContent = data?.ticket_dir || "–";
571
+ if (!tickets)
572
+ return;
573
+ tickets.innerHTML = "";
574
+ const list = (data?.tickets || []);
575
+ ticketsExist = list.length > 0;
576
+ // Disable start button if no tickets exist
577
+ if (bootstrapBtn && !bootstrapBtn.disabled) {
578
+ bootstrapBtn.disabled = !ticketsExist;
579
+ if (!ticketsExist) {
580
+ bootstrapBtn.title = "Create a ticket first";
581
+ }
582
+ else {
583
+ bootstrapBtn.title = "";
584
+ }
585
+ }
586
+ if (!list.length) {
587
+ tickets.textContent = "No tickets found. Create TICKET-001.md to begin.";
588
+ return;
589
+ }
590
+ list.forEach((ticket) => {
591
+ const item = document.createElement("div");
592
+ const fm = (ticket.frontmatter || {});
593
+ const done = Boolean(fm?.done);
594
+ // Check if this ticket is currently being worked on
595
+ const isActive = currentActiveTicket && ticket.path === currentActiveTicket && currentFlowStatus === "running";
596
+ item.className = `ticket-item ${done ? "done" : ""} ${isActive ? "active" : ""} clickable`;
597
+ item.title = "Click to edit";
598
+ // Make ticket item clickable to open editor
599
+ item.addEventListener("click", () => {
600
+ openTicketEditor(ticket);
601
+ });
602
+ const head = document.createElement("div");
603
+ head.className = "ticket-item-head";
604
+ // Extract ticket number from path (e.g., "TICKET-001" from ".codex-autorunner/tickets/TICKET-001.md")
605
+ const ticketPath = ticket.path || "";
606
+ const ticketMatch = ticketPath.match(/TICKET-\d+/);
607
+ const ticketNumber = ticketMatch ? ticketMatch[0] : "TICKET";
608
+ const ticketTitle = fm?.title ? String(fm.title) : "";
609
+ const name = document.createElement("span");
610
+ name.className = "ticket-name";
611
+ // Split number and title into separate spans for responsive control
612
+ const numSpan = document.createElement("span");
613
+ numSpan.className = "ticket-num";
614
+ // Extract just the number (e.g., "001" from "TICKET-001")
615
+ const numMatch = ticketNumber.match(/\d+/);
616
+ numSpan.textContent = numMatch ? numMatch[0] : ticketNumber;
617
+ name.appendChild(numSpan);
618
+ if (ticketTitle) {
619
+ const titleSpan = document.createElement("span");
620
+ titleSpan.className = "ticket-title-text";
621
+ titleSpan.textContent = `: ${ticketTitle}`;
622
+ name.appendChild(titleSpan);
623
+ }
624
+ // Set full text as title attribute for tooltip on hover
625
+ item.title = ticketTitle ? `${ticketNumber}: ${ticketTitle}` : ticketNumber;
626
+ head.appendChild(name);
627
+ // Badge container for status + agent badges
628
+ const badges = document.createElement("span");
629
+ badges.className = "ticket-badges";
630
+ // Add WORKING badge for active ticket (to the left of agent badge)
631
+ if (isActive) {
632
+ const workingBadge = document.createElement("span");
633
+ workingBadge.className = "ticket-working-badge";
634
+ workingBadge.textContent = "Working";
635
+ badges.appendChild(workingBadge);
636
+ }
637
+ // Add DONE badge for completed tickets
638
+ if (done && !isActive) {
639
+ const doneBadge = document.createElement("span");
640
+ doneBadge.className = "ticket-done-badge";
641
+ doneBadge.textContent = "Done";
642
+ badges.appendChild(doneBadge);
643
+ }
644
+ const agent = document.createElement("span");
645
+ agent.className = "ticket-agent";
646
+ agent.textContent = fm?.agent || "codex";
647
+ badges.appendChild(agent);
648
+ head.appendChild(badges);
649
+ item.appendChild(head);
650
+ if (ticket.errors && ticket.errors.length) {
651
+ const errors = document.createElement("div");
652
+ errors.className = "ticket-errors";
653
+ errors.textContent = `Frontmatter issues: ${ticket.errors.join("; ")}`;
654
+ item.appendChild(errors);
655
+ }
656
+ if (ticket.body) {
657
+ const body = document.createElement("div");
658
+ body.className = "ticket-body";
659
+ body.textContent = truncate(ticket.body.replace(/\s+/g, " ").trim());
660
+ item.appendChild(body);
661
+ }
662
+ tickets.appendChild(item);
663
+ });
664
+ }
665
+ function renderDispatchHistory(runId, data) {
666
+ const { history, dispatchNote } = els();
667
+ if (!history)
668
+ return;
669
+ history.innerHTML = "";
670
+ const { dispatchMiniList } = els();
671
+ if (!runId) {
672
+ history.textContent = "Start the ticket flow to see agent dispatches.";
673
+ if (dispatchNote)
674
+ dispatchNote.textContent = "–";
675
+ if (dispatchMiniList)
676
+ dispatchMiniList.innerHTML = "";
677
+ return;
678
+ }
679
+ const entries = (data?.history || []);
680
+ if (!entries.length) {
681
+ history.textContent = "No dispatches yet.";
682
+ if (dispatchNote)
683
+ dispatchNote.textContent = "–";
684
+ if (dispatchMiniList)
685
+ dispatchMiniList.innerHTML = "";
686
+ return;
687
+ }
688
+ if (dispatchNote)
689
+ dispatchNote.textContent = `Latest #${entries[0]?.seq ?? "–"}`;
690
+ // Also render mini list for collapsed panel view
691
+ renderDispatchMiniList(entries);
692
+ entries.forEach((entry) => {
693
+ const dispatch = entry.dispatch;
694
+ const isTurnSummary = dispatch?.mode === "turn_summary" || dispatch?.extra?.is_turn_summary;
695
+ const isHandoff = dispatch?.mode === "pause";
696
+ const container = document.createElement("div");
697
+ container.className = `dispatch-item${isTurnSummary ? " turn-summary" : ""} clickable`;
698
+ container.title = isTurnSummary ? "Agent turn output" : "Click to view in Inbox";
699
+ // Add click handler to navigate to inbox (skip for turn summaries)
700
+ if (!isTurnSummary) {
701
+ container.addEventListener("click", () => {
702
+ if (runId) {
703
+ // Update URL with run_id so inbox tab loads the right thread
704
+ const url = new URL(window.location.href);
705
+ url.searchParams.set("run_id", runId);
706
+ window.history.replaceState({}, "", url.toString());
707
+ // Switch to inbox tab
708
+ activateTab("inbox");
709
+ }
710
+ });
711
+ }
712
+ // Determine mode label
713
+ let modeLabel;
714
+ if (isTurnSummary) {
715
+ modeLabel = "TURN";
716
+ }
717
+ else if (isHandoff) {
718
+ modeLabel = "HANDOFF";
719
+ }
720
+ else {
721
+ modeLabel = (dispatch?.mode || "notify").toUpperCase();
722
+ }
723
+ const head = document.createElement("div");
724
+ head.className = "dispatch-item-head";
725
+ const seq = document.createElement("span");
726
+ seq.className = "ticket-name";
727
+ seq.textContent = `#${entry.seq || "?"}`;
728
+ const mode = document.createElement("span");
729
+ mode.className = `ticket-agent${isTurnSummary ? " turn-summary-badge" : ""}`;
730
+ mode.textContent = modeLabel;
731
+ head.append(seq, mode);
732
+ // Add ticket reference if present
733
+ const ticketId = dispatch?.extra?.ticket_id;
734
+ if (ticketId) {
735
+ // Extract ticket number from path (e.g., "TICKET-009" from ".codex-autorunner/tickets/TICKET-009.md")
736
+ const ticketMatch = ticketId.match(/TICKET-\d+/);
737
+ if (ticketMatch) {
738
+ const ticketLabel = document.createElement("span");
739
+ ticketLabel.className = "dispatch-ticket-ref";
740
+ ticketLabel.textContent = ticketMatch[0];
741
+ ticketLabel.title = ticketId;
742
+ head.appendChild(ticketLabel);
743
+ }
744
+ }
745
+ container.appendChild(head);
746
+ if (entry.errors && entry.errors.length) {
747
+ const err = document.createElement("div");
748
+ err.className = "ticket-errors";
749
+ err.textContent = entry.errors.join("; ");
750
+ container.appendChild(err);
751
+ }
752
+ const title = dispatch?.title;
753
+ if (title) {
754
+ const titleEl = document.createElement("div");
755
+ titleEl.className = "ticket-body ticket-dispatch-title";
756
+ titleEl.textContent = title;
757
+ container.appendChild(titleEl);
758
+ }
759
+ const bodyText = dispatch?.body;
760
+ if (bodyText) {
761
+ const body = document.createElement("div");
762
+ body.className = "ticket-body ticket-dispatch-body messages-markdown";
763
+ body.innerHTML = renderMarkdown(bodyText);
764
+ container.appendChild(body);
765
+ }
766
+ const attachments = (entry.attachments || []);
767
+ if (attachments.length) {
768
+ const wrap = document.createElement("div");
769
+ wrap.className = "ticket-attachments";
770
+ attachments.forEach((att) => {
771
+ if (!att.url)
772
+ return;
773
+ const link = document.createElement("a");
774
+ link.href = resolvePath(att.url);
775
+ link.textContent = att.name || att.rel_path || "attachment";
776
+ link.target = "_blank";
777
+ link.rel = "noreferrer noopener";
778
+ link.title = att.path || "";
779
+ wrap.appendChild(link);
780
+ });
781
+ container.appendChild(wrap);
782
+ }
783
+ history.appendChild(container);
784
+ });
785
+ }
786
+ const MAX_REASON_LENGTH = 60;
787
+ /**
788
+ * Get the full reason text (summary + details) for modal display.
789
+ */
790
+ function getFullReason(run) {
791
+ if (!run)
792
+ return null;
793
+ const state = (run.state || {});
794
+ const engine = (state.ticket_engine || {});
795
+ const reason = engine.reason || run.error_message || "";
796
+ const details = engine.reason_details || "";
797
+ if (!reason && !details)
798
+ return null;
799
+ if (details) {
800
+ return `${reason}\n\n${details}`.trim();
801
+ }
802
+ return reason;
803
+ }
804
+ /**
805
+ * Get a truncated reason summary for display in the grid.
806
+ * Also updates currentReasonFull for modal access.
807
+ */
808
+ function summarizeReason(run) {
809
+ if (!run) {
810
+ currentReasonFull = null;
811
+ return "No ticket flow run yet.";
812
+ }
813
+ const state = (run.state || {});
814
+ const engine = (state.ticket_engine || {});
815
+ const fullReason = getFullReason(run);
816
+ currentReasonFull = fullReason;
817
+ const shortReason = engine.reason ||
818
+ run.error_message ||
819
+ (engine.current_ticket ? `Working on ${engine.current_ticket}` : "") ||
820
+ run.status ||
821
+ "";
822
+ // Truncate if too long
823
+ if (shortReason.length > MAX_REASON_LENGTH) {
824
+ return shortReason.slice(0, MAX_REASON_LENGTH - 3) + "...";
825
+ }
826
+ return shortReason;
827
+ }
828
+ async function loadTicketFiles() {
829
+ const { tickets } = els();
830
+ if (tickets)
831
+ tickets.textContent = "Loading tickets…";
832
+ try {
833
+ const data = (await api("/api/flows/ticket_flow/tickets"));
834
+ renderTickets(data);
835
+ }
836
+ catch (err) {
837
+ renderTickets(null);
838
+ flash(err.message || "Failed to load tickets", "error");
839
+ }
840
+ }
841
+ /**
842
+ * Open a ticket by its index
843
+ */
844
+ async function openTicketByIndex(index) {
845
+ try {
846
+ const data = (await api("/api/flows/ticket_flow/tickets"));
847
+ const ticket = data.tickets?.find((t) => t.index === index);
848
+ if (ticket) {
849
+ openTicketEditor(ticket);
850
+ }
851
+ else {
852
+ flash(`Ticket TICKET-${String(index).padStart(3, "0")} not found`, "error");
853
+ }
854
+ }
855
+ catch (err) {
856
+ flash(`Failed to open ticket: ${err.message}`, "error");
857
+ }
858
+ }
859
+ async function loadDispatchHistory(runId) {
860
+ const { history } = els();
861
+ if (history)
862
+ history.textContent = "Loading dispatch history…";
863
+ if (!runId) {
864
+ renderDispatchHistory(null, null);
865
+ return;
866
+ }
867
+ try {
868
+ // Use dispatch_history endpoint
869
+ const data = (await api(`/api/flows/${runId}/dispatch_history`));
870
+ renderDispatchHistory(runId, data);
871
+ }
872
+ catch (err) {
873
+ renderDispatchHistory(runId, null);
874
+ flash(err.message || "Failed to load dispatch history", "error");
875
+ }
876
+ }
877
+ async function loadTicketFlow() {
878
+ const { status, run, current, turn, elapsed, progress, reason, lastActivity, resumeBtn, bootstrapBtn, stopBtn, archiveBtn } = els();
879
+ if (!isRepoHealthy()) {
880
+ if (status)
881
+ statusPill(status, "error");
882
+ if (run)
883
+ run.textContent = "–";
884
+ if (current)
885
+ current.textContent = "–";
886
+ if (turn)
887
+ turn.textContent = "–";
888
+ if (elapsed)
889
+ elapsed.textContent = "–";
890
+ if (progress)
891
+ progress.textContent = "–";
892
+ if (lastActivity)
893
+ lastActivity.textContent = "–";
894
+ if (reason)
895
+ reason.textContent = "Repo offline or uninitialized.";
896
+ setButtonsDisabled(true);
897
+ stopElapsedTimer();
898
+ stopLastActivityTimer();
899
+ disconnectEventStream();
900
+ return;
901
+ }
902
+ try {
903
+ const runs = (await api("/api/flows/runs?flow_type=ticket_flow"));
904
+ // Only consider the newest run - if it's terminal, flow is idle.
905
+ // This matches the backend's _active_or_paused_run() logic which only checks runs[0].
906
+ // Using find() would incorrectly pick up older paused runs when a newer run has completed.
907
+ const newest = runs?.[0] || null;
908
+ // Keep the newest run even if terminal, so we can archive it or see its final state
909
+ const latest = newest;
910
+ currentRunId = latest?.id || null;
911
+ currentFlowStatus = latest?.status || null;
912
+ // Extract ticket engine state
913
+ const ticketEngine = latest?.state?.ticket_engine;
914
+ currentActiveTicket = ticketEngine?.current_ticket || null;
915
+ const ticketTurns = ticketEngine?.ticket_turns ?? null;
916
+ const totalTurns = ticketEngine?.total_turns ?? null;
917
+ if (status)
918
+ statusPill(status, latest?.status || "idle");
919
+ if (run)
920
+ run.textContent = latest?.id || "–";
921
+ if (current)
922
+ current.textContent = currentActiveTicket || "–";
923
+ // Display turn counter
924
+ if (turn) {
925
+ if (ticketTurns !== null && currentFlowStatus === "running") {
926
+ turn.textContent = `${ticketTurns}${totalTurns !== null ? ` (${totalTurns} total)` : ""}`;
927
+ }
928
+ else {
929
+ turn.textContent = "–";
930
+ }
931
+ }
932
+ // Handle elapsed time
933
+ if (latest?.started_at && (latest.status === "running" || latest.status === "pending")) {
934
+ flowStartedAt = new Date(latest.started_at);
935
+ startElapsedTimer();
936
+ }
937
+ else {
938
+ stopElapsedTimer();
939
+ flowStartedAt = null;
940
+ if (elapsed)
941
+ elapsed.textContent = "–";
942
+ }
943
+ if (reason) {
944
+ reason.textContent = summarizeReason(latest) || "–";
945
+ // Add clickable class if there are details to show
946
+ const state = (latest?.state || {});
947
+ const engine = (state.ticket_engine || {});
948
+ const hasDetails = Boolean(engine.reason_details ||
949
+ (currentReasonFull && currentReasonFull.length > MAX_REASON_LENGTH));
950
+ reason.classList.toggle("has-details", hasDetails);
951
+ }
952
+ if (resumeBtn) {
953
+ resumeBtn.disabled = !latest?.id || latest.status !== "paused";
954
+ }
955
+ if (stopBtn) {
956
+ const stoppable = latest?.status === "running" || latest?.status === "pending";
957
+ stopBtn.disabled = !latest?.id || !stoppable;
958
+ }
959
+ await loadTicketFiles();
960
+ // Calculate and display ticket progress (scoped to tickets container only)
961
+ if (progress) {
962
+ const ticketsContainer = document.getElementById("ticket-flow-tickets");
963
+ const doneCount = ticketsContainer?.querySelectorAll(".ticket-item.done").length ?? 0;
964
+ const totalCount = ticketsContainer?.querySelectorAll(".ticket-item").length ?? 0;
965
+ if (totalCount > 0) {
966
+ progress.textContent = `${doneCount} of ${totalCount} done`;
967
+ }
968
+ else {
969
+ progress.textContent = "–";
970
+ }
971
+ }
972
+ // Connect/disconnect event stream based on flow status
973
+ if (currentRunId && (latest?.status === "running" || latest?.status === "pending")) {
974
+ // Only connect if not already connected to this run
975
+ if (!eventSource || eventSource.url?.indexOf(currentRunId) === -1) {
976
+ connectEventStream(currentRunId);
977
+ startLastActivityTimer();
978
+ }
979
+ }
980
+ else {
981
+ disconnectEventStream();
982
+ stopLastActivityTimer();
983
+ if (lastActivity)
984
+ lastActivity.textContent = "–";
985
+ lastActivityTime = null;
986
+ }
987
+ if (bootstrapBtn) {
988
+ const busy = latest?.status === "running" || latest?.status === "pending";
989
+ // Disable if busy OR if no tickets exist
990
+ bootstrapBtn.disabled = busy || !ticketsExist;
991
+ bootstrapBtn.textContent = busy ? "Running…" : "Start Ticket Flow";
992
+ if (!ticketsExist && !busy) {
993
+ bootstrapBtn.title = "Create a ticket first";
994
+ }
995
+ else {
996
+ bootstrapBtn.title = "";
997
+ }
998
+ }
999
+ // Show restart button when flow is paused, stopping, or in terminal state (allows starting fresh)
1000
+ const { restartBtn } = els();
1001
+ if (restartBtn) {
1002
+ const isPaused = latest?.status === "paused";
1003
+ const isStopping = latest?.status === "stopping";
1004
+ const isTerminal = latest?.status === "completed" ||
1005
+ latest?.status === "stopped" ||
1006
+ latest?.status === "failed";
1007
+ const canRestart = (isPaused || isStopping || isTerminal) && ticketsExist && Boolean(currentRunId);
1008
+ restartBtn.style.display = canRestart ? "" : "none";
1009
+ restartBtn.disabled = !canRestart;
1010
+ }
1011
+ // Show archive button when flow is paused, stopping, or in terminal state and has tickets
1012
+ if (archiveBtn) {
1013
+ const isPaused = latest?.status === "paused";
1014
+ const isStopping = latest?.status === "stopping";
1015
+ const isTerminal = latest?.status === "completed" ||
1016
+ latest?.status === "stopped" ||
1017
+ latest?.status === "failed";
1018
+ const canArchive = (isPaused || isStopping || isTerminal) && ticketsExist && Boolean(currentRunId);
1019
+ archiveBtn.style.display = canArchive ? "" : "none";
1020
+ archiveBtn.disabled = !canArchive;
1021
+ }
1022
+ await loadDispatchHistory(currentRunId);
1023
+ }
1024
+ catch (err) {
1025
+ if (reason)
1026
+ reason.textContent = err.message || "Ticket flow unavailable";
1027
+ flash(err.message || "Failed to load ticket flow state", "error");
1028
+ }
1029
+ }
1030
+ async function bootstrapTicketFlow() {
1031
+ const { bootstrapBtn } = els();
1032
+ if (!bootstrapBtn)
1033
+ return;
1034
+ if (!isRepoHealthy()) {
1035
+ flash("Repo offline; cannot start ticket flow.", "error");
1036
+ return;
1037
+ }
1038
+ if (!ticketsExist) {
1039
+ flash("Create a ticket first before starting the flow.", "error");
1040
+ return;
1041
+ }
1042
+ setButtonsDisabled(true);
1043
+ bootstrapBtn.textContent = "Starting…";
1044
+ try {
1045
+ const res = (await api("/api/flows/ticket_flow/bootstrap", {
1046
+ method: "POST",
1047
+ body: {},
1048
+ }));
1049
+ currentRunId = res?.id || null;
1050
+ if (res?.state?.hint === "active_run_reused") {
1051
+ flash("Ticket flow already running; continuing existing run", "info");
1052
+ }
1053
+ else {
1054
+ flash("Ticket flow started");
1055
+ clearLiveOutput(); // Clear output for new run
1056
+ }
1057
+ await loadTicketFlow();
1058
+ }
1059
+ catch (err) {
1060
+ flash(err.message || "Failed to start ticket flow", "error");
1061
+ }
1062
+ finally {
1063
+ bootstrapBtn.textContent = "Start Ticket Flow";
1064
+ setButtonsDisabled(false);
1065
+ }
1066
+ }
1067
+ async function resumeTicketFlow() {
1068
+ const { resumeBtn } = els();
1069
+ if (!resumeBtn)
1070
+ return;
1071
+ if (!isRepoHealthy()) {
1072
+ flash("Repo offline; cannot resume ticket flow.", "error");
1073
+ return;
1074
+ }
1075
+ if (!currentRunId) {
1076
+ flash("No ticket flow run to resume", "info");
1077
+ return;
1078
+ }
1079
+ setButtonsDisabled(true);
1080
+ resumeBtn.textContent = "Resuming…";
1081
+ try {
1082
+ await api(`/api/flows/${currentRunId}/resume`, { method: "POST", body: {} });
1083
+ flash("Ticket flow resumed");
1084
+ await loadTicketFlow();
1085
+ }
1086
+ catch (err) {
1087
+ flash(err.message || "Failed to resume", "error");
1088
+ }
1089
+ finally {
1090
+ resumeBtn.textContent = "Resume";
1091
+ setButtonsDisabled(false);
1092
+ }
1093
+ }
1094
+ async function stopTicketFlow() {
1095
+ const { stopBtn } = els();
1096
+ if (!stopBtn)
1097
+ return;
1098
+ if (!isRepoHealthy()) {
1099
+ flash("Repo offline; cannot stop ticket flow.", "error");
1100
+ return;
1101
+ }
1102
+ if (!currentRunId) {
1103
+ flash("No ticket flow run to stop", "info");
1104
+ return;
1105
+ }
1106
+ setButtonsDisabled(true);
1107
+ stopBtn.textContent = "Stopping…";
1108
+ try {
1109
+ await api(`/api/flows/${currentRunId}/stop`, { method: "POST", body: {} });
1110
+ flash("Ticket flow stopping");
1111
+ await loadTicketFlow();
1112
+ }
1113
+ catch (err) {
1114
+ flash(err.message || "Failed to stop ticket flow", "error");
1115
+ }
1116
+ finally {
1117
+ stopBtn.textContent = "Stop";
1118
+ setButtonsDisabled(false);
1119
+ }
1120
+ }
1121
+ async function restartTicketFlow() {
1122
+ const { restartBtn } = els();
1123
+ if (!restartBtn)
1124
+ return;
1125
+ if (!isRepoHealthy()) {
1126
+ flash("Repo offline; cannot restart ticket flow.", "error");
1127
+ return;
1128
+ }
1129
+ if (!ticketsExist) {
1130
+ flash("Create a ticket first before restarting the flow.", "error");
1131
+ return;
1132
+ }
1133
+ if (!confirm("Restart ticket flow? This will stop the current run and start a new one.")) {
1134
+ return;
1135
+ }
1136
+ setButtonsDisabled(true);
1137
+ restartBtn.textContent = "Restarting…";
1138
+ try {
1139
+ // Stop the current run first if it exists
1140
+ if (currentRunId) {
1141
+ await api(`/api/flows/${currentRunId}/stop`, { method: "POST", body: {} });
1142
+ }
1143
+ // Start a new run with force_new to bypass reuse logic
1144
+ const res = (await api("/api/flows/ticket_flow/bootstrap", {
1145
+ method: "POST",
1146
+ body: { metadata: { force_new: true } },
1147
+ }));
1148
+ currentRunId = res?.id || null;
1149
+ flash("Ticket flow restarted");
1150
+ clearLiveOutput();
1151
+ await loadTicketFlow();
1152
+ }
1153
+ catch (err) {
1154
+ flash(err.message || "Failed to restart ticket flow", "error");
1155
+ }
1156
+ finally {
1157
+ restartBtn.textContent = "Restart";
1158
+ setButtonsDisabled(false);
1159
+ }
1160
+ }
1161
+ async function archiveTicketFlow() {
1162
+ const { archiveBtn, reason } = els();
1163
+ if (!archiveBtn)
1164
+ return;
1165
+ if (!isRepoHealthy()) {
1166
+ flash("Repo offline; cannot archive ticket flow.", "error");
1167
+ return;
1168
+ }
1169
+ if (!currentRunId) {
1170
+ flash("No ticket flow run to archive", "info");
1171
+ return;
1172
+ }
1173
+ if (!confirm("Archive all tickets from this flow? They will be moved to the run's artifact directory.")) {
1174
+ return;
1175
+ }
1176
+ setButtonsDisabled(true);
1177
+ archiveBtn.textContent = "Archiving…";
1178
+ try {
1179
+ // Force archive if flow is stuck in stopping or paused state
1180
+ const force = currentFlowStatus === "stopping" || currentFlowStatus === "paused";
1181
+ const res = (await api(`/api/flows/${currentRunId}/archive?force=${force}`, {
1182
+ method: "POST",
1183
+ body: {},
1184
+ }));
1185
+ const count = res?.tickets_archived ?? 0;
1186
+ flash(`Archived ${count} ticket${count !== 1 ? "s" : ""}`);
1187
+ clearLiveOutput();
1188
+ // Reset all state variables
1189
+ currentRunId = null;
1190
+ currentFlowStatus = null;
1191
+ currentActiveTicket = null;
1192
+ currentReasonFull = null;
1193
+ // Reset all UI elements to idle state directly (avoid re-fetching stale data)
1194
+ const { status, run, current, turn, elapsed, progress, lastActivity, bootstrapBtn, resumeBtn, stopBtn, restartBtn } = els();
1195
+ if (status)
1196
+ statusPill(status, "idle");
1197
+ if (run)
1198
+ run.textContent = "–";
1199
+ if (current)
1200
+ current.textContent = "–";
1201
+ if (turn)
1202
+ turn.textContent = "–";
1203
+ if (elapsed)
1204
+ elapsed.textContent = "–";
1205
+ if (progress)
1206
+ progress.textContent = "–";
1207
+ if (lastActivity)
1208
+ lastActivity.textContent = "–";
1209
+ if (reason) {
1210
+ reason.textContent = "No ticket flow run yet.";
1211
+ reason.classList.remove("has-details");
1212
+ }
1213
+ renderDispatchHistory(null, null);
1214
+ // Stop timers and disconnect event stream
1215
+ disconnectEventStream();
1216
+ stopElapsedTimer();
1217
+ stopLastActivityTimer();
1218
+ lastActivityTime = null;
1219
+ // Update button states for no active run
1220
+ if (bootstrapBtn) {
1221
+ bootstrapBtn.disabled = false;
1222
+ bootstrapBtn.textContent = "Start Ticket Flow";
1223
+ bootstrapBtn.title = "";
1224
+ }
1225
+ if (resumeBtn)
1226
+ resumeBtn.disabled = true;
1227
+ if (stopBtn)
1228
+ stopBtn.disabled = true;
1229
+ if (restartBtn)
1230
+ restartBtn.style.display = "none";
1231
+ if (archiveBtn)
1232
+ archiveBtn.style.display = "none";
1233
+ // Refresh inbox badge and ticket list (tickets were archived/moved)
1234
+ void refreshBell();
1235
+ await loadTicketFiles();
1236
+ }
1237
+ catch (err) {
1238
+ flash(err.message || "Failed to archive ticket flow", "error");
1239
+ }
1240
+ finally {
1241
+ if (archiveBtn) {
1242
+ archiveBtn.textContent = "Archive Flow";
1243
+ }
1244
+ setButtonsDisabled(false);
1245
+ }
1246
+ }
1247
+ export function initTicketFlow() {
1248
+ const { card, bootstrapBtn, resumeBtn, refreshBtn, stopBtn, restartBtn, archiveBtn } = els();
1249
+ if (!card || card.dataset.ticketInitialized === "1")
1250
+ return;
1251
+ card.dataset.ticketInitialized = "1";
1252
+ if (bootstrapBtn)
1253
+ bootstrapBtn.addEventListener("click", bootstrapTicketFlow);
1254
+ if (resumeBtn)
1255
+ resumeBtn.addEventListener("click", resumeTicketFlow);
1256
+ if (stopBtn)
1257
+ stopBtn.addEventListener("click", stopTicketFlow);
1258
+ if (restartBtn)
1259
+ restartBtn.addEventListener("click", restartTicketFlow);
1260
+ if (archiveBtn)
1261
+ archiveBtn.addEventListener("click", archiveTicketFlow);
1262
+ if (refreshBtn)
1263
+ refreshBtn.addEventListener("click", loadTicketFlow);
1264
+ // Initialize reason click handler for modal
1265
+ initReasonModal();
1266
+ // Initialize live output panel
1267
+ initLiveOutputPanel();
1268
+ // Initialize dispatch panel toggle for medium screens
1269
+ initDispatchPanelToggle();
1270
+ const newThreadBtn = document.getElementById("ticket-chat-new-thread");
1271
+ if (newThreadBtn) {
1272
+ newThreadBtn.addEventListener("click", async () => {
1273
+ const { startNewTicketChatThread } = await import("./ticketChatActions.js");
1274
+ await startNewTicketChatThread();
1275
+ });
1276
+ }
1277
+ // Initialize the ticket editor modal
1278
+ initTicketEditor();
1279
+ loadTicketFlow();
1280
+ registerAutoRefresh("ticket-flow", {
1281
+ callback: loadTicketFlow,
1282
+ tabId: null,
1283
+ interval: CONSTANTS.UI?.AUTO_REFRESH_INTERVAL ||
1284
+ 15000,
1285
+ refreshOnActivation: true,
1286
+ immediate: false,
1287
+ });
1288
+ subscribe("repo:health", (payload) => {
1289
+ const status = payload?.status || "";
1290
+ if (status === "ok" || status === "degraded") {
1291
+ void loadTicketFlow();
1292
+ }
1293
+ });
1294
+ // Refresh ticket list when tickets are updated (from editor)
1295
+ subscribe("tickets:updated", () => {
1296
+ void loadTicketFiles();
1297
+ });
1298
+ // Handle browser navigation (back/forward)
1299
+ window.addEventListener("popstate", () => {
1300
+ const params = getUrlParams();
1301
+ const ticketIndex = params.get("ticket");
1302
+ if (ticketIndex) {
1303
+ void openTicketByIndex(parseInt(ticketIndex, 10));
1304
+ }
1305
+ else {
1306
+ closeTicketEditor();
1307
+ }
1308
+ });
1309
+ // Check URL for ticket param on initial load
1310
+ const params = getUrlParams();
1311
+ const ticketIndex = params.get("ticket");
1312
+ if (ticketIndex) {
1313
+ void openTicketByIndex(parseInt(ticketIndex, 10));
1314
+ }
1315
+ }