codex-autorunner 1.1.0__py3-none-any.whl → 1.2.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 (127) hide show
  1. codex_autorunner/agents/opencode/client.py +113 -4
  2. codex_autorunner/agents/opencode/supervisor.py +4 -0
  3. codex_autorunner/agents/registry.py +17 -7
  4. codex_autorunner/bootstrap.py +219 -1
  5. codex_autorunner/core/__init__.py +17 -1
  6. codex_autorunner/core/about_car.py +114 -1
  7. codex_autorunner/core/app_server_threads.py +6 -0
  8. codex_autorunner/core/config.py +236 -1
  9. codex_autorunner/core/context_awareness.py +38 -0
  10. codex_autorunner/core/docs.py +0 -122
  11. codex_autorunner/core/filebox.py +265 -0
  12. codex_autorunner/core/flows/controller.py +71 -1
  13. codex_autorunner/core/flows/reconciler.py +4 -1
  14. codex_autorunner/core/flows/runtime.py +22 -0
  15. codex_autorunner/core/flows/store.py +61 -9
  16. codex_autorunner/core/flows/transition.py +23 -16
  17. codex_autorunner/core/flows/ux_helpers.py +18 -3
  18. codex_autorunner/core/flows/worker_process.py +32 -6
  19. codex_autorunner/core/hub.py +198 -41
  20. codex_autorunner/core/lifecycle_events.py +253 -0
  21. codex_autorunner/core/path_utils.py +2 -1
  22. codex_autorunner/core/pma_audit.py +224 -0
  23. codex_autorunner/core/pma_context.py +496 -0
  24. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  25. codex_autorunner/core/pma_lifecycle.py +527 -0
  26. codex_autorunner/core/pma_queue.py +367 -0
  27. codex_autorunner/core/pma_safety.py +221 -0
  28. codex_autorunner/core/pma_state.py +115 -0
  29. codex_autorunner/core/ports/agent_backend.py +2 -5
  30. codex_autorunner/core/ports/run_event.py +1 -4
  31. codex_autorunner/core/prompt.py +0 -80
  32. codex_autorunner/core/prompts.py +56 -172
  33. codex_autorunner/core/redaction.py +0 -4
  34. codex_autorunner/core/review_context.py +11 -9
  35. codex_autorunner/core/runner_controller.py +35 -33
  36. codex_autorunner/core/runner_state.py +147 -0
  37. codex_autorunner/core/runtime.py +829 -0
  38. codex_autorunner/core/sqlite_utils.py +13 -4
  39. codex_autorunner/core/state.py +7 -10
  40. codex_autorunner/core/state_roots.py +5 -0
  41. codex_autorunner/core/templates/__init__.py +39 -0
  42. codex_autorunner/core/templates/git_mirror.py +234 -0
  43. codex_autorunner/core/templates/provenance.py +56 -0
  44. codex_autorunner/core/templates/scan_cache.py +120 -0
  45. codex_autorunner/core/ticket_linter_cli.py +17 -0
  46. codex_autorunner/core/ticket_manager_cli.py +154 -92
  47. codex_autorunner/core/time_utils.py +11 -0
  48. codex_autorunner/core/types.py +18 -0
  49. codex_autorunner/core/utils.py +34 -6
  50. codex_autorunner/flows/review/service.py +23 -25
  51. codex_autorunner/flows/ticket_flow/definition.py +43 -1
  52. codex_autorunner/integrations/agents/__init__.py +2 -0
  53. codex_autorunner/integrations/agents/backend_orchestrator.py +18 -0
  54. codex_autorunner/integrations/agents/codex_backend.py +19 -8
  55. codex_autorunner/integrations/agents/runner.py +3 -8
  56. codex_autorunner/integrations/agents/wiring.py +8 -0
  57. codex_autorunner/integrations/telegram/doctor.py +228 -6
  58. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  59. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  60. codex_autorunner/integrations/telegram/handlers/commands/flows.py +346 -58
  61. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  62. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +202 -45
  63. codex_autorunner/integrations/telegram/handlers/commands_spec.py +18 -7
  64. codex_autorunner/integrations/telegram/handlers/messages.py +26 -1
  65. codex_autorunner/integrations/telegram/helpers.py +1 -3
  66. codex_autorunner/integrations/telegram/runtime.py +9 -4
  67. codex_autorunner/integrations/telegram/service.py +30 -0
  68. codex_autorunner/integrations/telegram/state.py +38 -0
  69. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +10 -4
  70. codex_autorunner/integrations/telegram/transport.py +10 -3
  71. codex_autorunner/integrations/templates/__init__.py +27 -0
  72. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  73. codex_autorunner/server.py +2 -2
  74. codex_autorunner/static/agentControls.js +21 -5
  75. codex_autorunner/static/app.js +115 -11
  76. codex_autorunner/static/chatUploads.js +137 -0
  77. codex_autorunner/static/docChatCore.js +185 -13
  78. codex_autorunner/static/fileChat.js +68 -40
  79. codex_autorunner/static/fileboxUi.js +159 -0
  80. codex_autorunner/static/hub.js +46 -81
  81. codex_autorunner/static/index.html +303 -24
  82. codex_autorunner/static/messages.js +82 -4
  83. codex_autorunner/static/notifications.js +255 -0
  84. codex_autorunner/static/pma.js +1167 -0
  85. codex_autorunner/static/settings.js +3 -0
  86. codex_autorunner/static/streamUtils.js +57 -0
  87. codex_autorunner/static/styles.css +9125 -6742
  88. codex_autorunner/static/templateReposSettings.js +225 -0
  89. codex_autorunner/static/ticketChatActions.js +165 -3
  90. codex_autorunner/static/ticketChatStream.js +17 -119
  91. codex_autorunner/static/ticketEditor.js +41 -13
  92. codex_autorunner/static/ticketTemplates.js +798 -0
  93. codex_autorunner/static/tickets.js +69 -19
  94. codex_autorunner/static/turnEvents.js +27 -0
  95. codex_autorunner/static/turnResume.js +33 -0
  96. codex_autorunner/static/utils.js +28 -0
  97. codex_autorunner/static/workspace.js +258 -44
  98. codex_autorunner/static/workspaceFileBrowser.js +6 -4
  99. codex_autorunner/surfaces/cli/cli.py +1465 -155
  100. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  101. codex_autorunner/surfaces/web/app.py +253 -49
  102. codex_autorunner/surfaces/web/routes/__init__.py +4 -0
  103. codex_autorunner/surfaces/web/routes/analytics.py +29 -22
  104. codex_autorunner/surfaces/web/routes/file_chat.py +317 -36
  105. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  106. codex_autorunner/surfaces/web/routes/flows.py +219 -29
  107. codex_autorunner/surfaces/web/routes/messages.py +70 -39
  108. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  109. codex_autorunner/surfaces/web/routes/repos.py +1 -1
  110. codex_autorunner/surfaces/web/routes/shared.py +0 -3
  111. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  112. codex_autorunner/surfaces/web/runner_manager.py +2 -2
  113. codex_autorunner/surfaces/web/schemas.py +70 -18
  114. codex_autorunner/tickets/agent_pool.py +27 -0
  115. codex_autorunner/tickets/files.py +33 -16
  116. codex_autorunner/tickets/lint.py +50 -0
  117. codex_autorunner/tickets/models.py +3 -0
  118. codex_autorunner/tickets/outbox.py +41 -5
  119. codex_autorunner/tickets/runner.py +350 -69
  120. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/METADATA +15 -19
  121. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/RECORD +125 -94
  122. codex_autorunner/core/adapter_utils.py +0 -21
  123. codex_autorunner/core/engine.py +0 -3302
  124. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
  125. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
  126. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
  127. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,225 @@
1
+ // GENERATED FILE - do not edit directly. Source: static_src/
2
+ import { api, confirmModal, flash } from "./utils.js";
3
+ import { checkTemplatesEnabled } from "./ticketTemplates.js";
4
+ function els() {
5
+ return {
6
+ list: document.getElementById("template-repos-list"),
7
+ addBtn: document.getElementById("template-repos-add"),
8
+ form: document.getElementById("template-repo-form"),
9
+ idInput: document.getElementById("repo-id"),
10
+ urlInput: document.getElementById("repo-url"),
11
+ refInput: document.getElementById("repo-ref"),
12
+ trustedInput: document.getElementById("repo-trusted"),
13
+ saveBtn: document.getElementById("repo-save"),
14
+ cancelBtn: document.getElementById("repo-cancel"),
15
+ };
16
+ }
17
+ const state = {
18
+ mode: "create",
19
+ editId: null,
20
+ enabled: false,
21
+ repos: [],
22
+ busy: false,
23
+ };
24
+ function setBusy(busy) {
25
+ state.busy = busy;
26
+ const { saveBtn, addBtn } = els();
27
+ if (saveBtn)
28
+ saveBtn.disabled = busy;
29
+ if (addBtn)
30
+ addBtn.disabled = busy;
31
+ }
32
+ function showForm(show) {
33
+ const { form } = els();
34
+ if (!form)
35
+ return;
36
+ if (show)
37
+ form.classList.remove("hidden");
38
+ else
39
+ form.classList.add("hidden");
40
+ }
41
+ function resetForm() {
42
+ const { idInput, urlInput, refInput, trustedInput } = els();
43
+ if (idInput)
44
+ idInput.value = "";
45
+ if (urlInput)
46
+ urlInput.value = "";
47
+ if (refInput)
48
+ refInput.value = "main";
49
+ if (trustedInput)
50
+ trustedInput.checked = false;
51
+ state.mode = "create";
52
+ state.editId = null;
53
+ if (idInput)
54
+ idInput.disabled = false;
55
+ }
56
+ function openCreateForm() {
57
+ resetForm();
58
+ showForm(true);
59
+ const { idInput } = els();
60
+ idInput?.focus();
61
+ }
62
+ function openEditForm(repo) {
63
+ const { idInput, urlInput, refInput, trustedInput } = els();
64
+ state.mode = "edit";
65
+ state.editId = repo.id;
66
+ if (idInput) {
67
+ idInput.value = repo.id;
68
+ idInput.disabled = true;
69
+ }
70
+ if (urlInput)
71
+ urlInput.value = repo.url;
72
+ if (refInput)
73
+ refInput.value = repo.default_ref || "main";
74
+ if (trustedInput)
75
+ trustedInput.checked = Boolean(repo.trusted);
76
+ showForm(true);
77
+ urlInput?.focus();
78
+ }
79
+ function normalizeRequired(value, label) {
80
+ const v = (value || "").trim();
81
+ if (!v) {
82
+ flash(`${label} is required`, "error");
83
+ return null;
84
+ }
85
+ return v;
86
+ }
87
+ function renderRepos() {
88
+ const { list } = els();
89
+ if (!list)
90
+ return;
91
+ list.innerHTML = "";
92
+ if (!state.enabled) {
93
+ const hint = document.createElement("div");
94
+ hint.className = "muted small";
95
+ hint.textContent = "Templates are disabled (templates.enabled=false).";
96
+ list.appendChild(hint);
97
+ }
98
+ if (!state.repos.length) {
99
+ const empty = document.createElement("div");
100
+ empty.className = "muted small";
101
+ empty.textContent = "No template repos configured.";
102
+ list.appendChild(empty);
103
+ return;
104
+ }
105
+ for (const repo of state.repos) {
106
+ const row = document.createElement("div");
107
+ row.className = "template-repo-item";
108
+ row.innerHTML = `
109
+ <div class="template-repo-meta">
110
+ <span class="template-repo-id">${repo.id}</span>
111
+ <span class="template-repo-url">${repo.url}</span>
112
+ <span class="muted small">ref: ${repo.default_ref}${repo.trusted ? " · trusted" : ""}</span>
113
+ </div>
114
+ <div class="template-repo-actions">
115
+ <button class="ghost sm" data-action="edit" data-id="${repo.id}">Edit</button>
116
+ <button class="danger sm" data-action="delete" data-id="${repo.id}">Delete</button>
117
+ </div>
118
+ `;
119
+ list.appendChild(row);
120
+ }
121
+ list.querySelectorAll("button[data-action]").forEach((btn) => {
122
+ btn.addEventListener("click", async () => {
123
+ const action = btn.dataset.action;
124
+ const id = btn.dataset.id;
125
+ if (!action || !id)
126
+ return;
127
+ const repo = state.repos.find((r) => r.id === id);
128
+ if (!repo)
129
+ return;
130
+ if (action === "edit") {
131
+ openEditForm(repo);
132
+ return;
133
+ }
134
+ if (action === "delete") {
135
+ await deleteRepo(id);
136
+ }
137
+ });
138
+ });
139
+ }
140
+ export async function loadTemplateRepos() {
141
+ const { list } = els();
142
+ if (!list)
143
+ return;
144
+ try {
145
+ const data = (await api("/api/templates/repos"));
146
+ state.enabled = Boolean(data.enabled);
147
+ state.repos = Array.isArray(data.repos) ? data.repos : [];
148
+ renderRepos();
149
+ }
150
+ catch (err) {
151
+ state.enabled = false;
152
+ state.repos = [];
153
+ renderRepos();
154
+ flash(err.message || "Failed to load template repos", "error");
155
+ }
156
+ }
157
+ async function saveRepo() {
158
+ const { idInput, urlInput, refInput, trustedInput } = els();
159
+ if (!idInput || !urlInput || !refInput || !trustedInput)
160
+ return;
161
+ const id = normalizeRequired(idInput.value, "ID");
162
+ const url = normalizeRequired(urlInput.value, "Git URL");
163
+ const ref = normalizeRequired(refInput.value, "Default ref");
164
+ if (!id || !url || !ref)
165
+ return;
166
+ setBusy(true);
167
+ try {
168
+ if (state.mode === "create") {
169
+ await api("/api/templates/repos", {
170
+ method: "POST",
171
+ body: { id, url, trusted: Boolean(trustedInput.checked), default_ref: ref },
172
+ });
173
+ flash("Template repo added", "success");
174
+ }
175
+ else if (state.mode === "edit" && state.editId) {
176
+ await api(`/api/templates/repos/${encodeURIComponent(state.editId)}`, {
177
+ method: "PUT",
178
+ body: { url, trusted: Boolean(trustedInput.checked), default_ref: ref },
179
+ });
180
+ flash("Template repo updated", "success");
181
+ }
182
+ await loadTemplateRepos();
183
+ await checkTemplatesEnabled();
184
+ showForm(false);
185
+ resetForm();
186
+ }
187
+ catch (err) {
188
+ flash(err.message || "Failed to save template repo", "error");
189
+ }
190
+ finally {
191
+ setBusy(false);
192
+ }
193
+ }
194
+ async function deleteRepo(id) {
195
+ const confirmed = await confirmModal(`Delete template repo "${id}"?`, {
196
+ confirmText: "Delete",
197
+ danger: true,
198
+ });
199
+ if (!confirmed)
200
+ return;
201
+ setBusy(true);
202
+ try {
203
+ await api(`/api/templates/repos/${encodeURIComponent(id)}`, { method: "DELETE" });
204
+ flash("Template repo deleted", "success");
205
+ await loadTemplateRepos();
206
+ await checkTemplatesEnabled();
207
+ }
208
+ catch (err) {
209
+ flash(err.message || "Failed to delete template repo", "error");
210
+ }
211
+ finally {
212
+ setBusy(false);
213
+ }
214
+ }
215
+ export function initTemplateReposSettings() {
216
+ const { list, addBtn, saveBtn, cancelBtn } = els();
217
+ if (!list || !addBtn || !saveBtn || !cancelBtn)
218
+ return;
219
+ addBtn.addEventListener("click", () => openCreateForm());
220
+ saveBtn.addEventListener("click", () => void saveRepo());
221
+ cancelBtn.addEventListener("click", () => {
222
+ showForm(false);
223
+ resetForm();
224
+ });
225
+ }
@@ -2,16 +2,20 @@
2
2
  /**
3
3
  * Ticket Chat Actions - handles sending messages, applying/discarding patches
4
4
  */
5
- import { api, flash, splitMarkdownFrontmatter } from "./utils.js";
5
+ import { api, confirmModal, flash, splitMarkdownFrontmatter } from "./utils.js";
6
6
  import { performTicketChatRequest } from "./ticketChatStream.js";
7
- import { renderTicketMessages } from "./ticketChatEvents.js";
7
+ import { renderTicketMessages, renderTicketEvents } from "./ticketChatEvents.js";
8
8
  import { publish } from "./bus.js";
9
9
  import { createDocChat } from "./docChatCore.js";
10
10
  import { saveTicketChatHistory } from "./ticketChatStorage.js";
11
11
  import { renderDiff } from "./diffRenderer.js";
12
+ import { newClientTurnId, streamTurnEvents } from "./fileChat.js";
13
+ import { loadPendingTurn, savePendingTurn, clearPendingTurn } from "./turnResume.js";
14
+ import { resumeFileChatTurn } from "./turnEvents.js";
12
15
  // Limits for events display
13
16
  export const TICKET_CHAT_EVENT_LIMIT = 8;
14
17
  export const TICKET_CHAT_EVENT_MAX = 50;
18
+ const pendingKeyForTicket = (index) => index != null ? `car.ticketChat.pending.${index}` : "car.ticketChat.pending";
15
19
  export const ticketChat = createDocChat({
16
20
  idPrefix: "ticket-chat",
17
21
  storage: { keyPrefix: "car-ticket-chat-", maxMessages: 50, version: 1 },
@@ -39,7 +43,9 @@ export const ticketChat = createDocChat({
39
43
  export const ticketChatState = Object.assign(ticketChat.state, {
40
44
  ticketIndex: null,
41
45
  draft: null,
46
+ contextUsagePercent: null,
42
47
  });
48
+ let currentTurnEventsController = null;
43
49
  export function getTicketChatElements() {
44
50
  const base = ticketChat.elements;
45
51
  return {
@@ -75,13 +81,14 @@ export function resetTicketChatState() {
75
81
  ticketChatState.streamText = "";
76
82
  ticketChatState.statusText = "";
77
83
  ticketChatState.controller = null;
84
+ ticketChatState.contextUsagePercent = null;
78
85
  // Note: events are cleared at the start of each new request, not here
79
86
  // Messages persist across requests within the same ticket
80
87
  }
81
88
  export async function startNewTicketChatThread() {
82
89
  if (ticketChatState.ticketIndex == null)
83
90
  return;
84
- const confirmed = window.confirm("Start a new conversation thread for this ticket?");
91
+ const confirmed = await confirmModal("Start a new conversation thread for this ticket?");
85
92
  if (!confirmed)
86
93
  return;
87
94
  try {
@@ -111,6 +118,97 @@ export async function startNewTicketChatThread() {
111
118
  export function clearTicketEvents() {
112
119
  ticketChat.clearEvents();
113
120
  }
121
+ function clearTurnEventsStream() {
122
+ if (currentTurnEventsController) {
123
+ try {
124
+ currentTurnEventsController.abort();
125
+ }
126
+ catch {
127
+ // ignore
128
+ }
129
+ currentTurnEventsController = null;
130
+ }
131
+ }
132
+ function clearPendingTurnState(pendingKey) {
133
+ clearTurnEventsStream();
134
+ clearPendingTurn(pendingKey);
135
+ }
136
+ function handleTicketTurnMeta(update) {
137
+ const threadId = typeof update.thread_id === "string" ? update.thread_id : "";
138
+ const turnId = typeof update.turn_id === "string" ? update.turn_id : "";
139
+ const agent = typeof update.agent === "string" ? update.agent : "codex";
140
+ if (!threadId || !turnId)
141
+ return;
142
+ clearTurnEventsStream();
143
+ currentTurnEventsController = streamTurnEvents({ agent, threadId, turnId }, {
144
+ onEvent: (event) => {
145
+ ticketChat.applyAppEvent(event);
146
+ ticketChat.renderEvents();
147
+ ticketChat.render();
148
+ },
149
+ });
150
+ }
151
+ export function applyTicketChatResult(payload) {
152
+ if (!payload || typeof payload !== "object")
153
+ return;
154
+ const result = payload;
155
+ handleTicketTurnMeta(result);
156
+ if (result.status === "interrupted") {
157
+ ticketChatState.status = "interrupted";
158
+ ticketChatState.error = "";
159
+ addAssistantMessage("Request interrupted", true);
160
+ renderTicketChat();
161
+ renderTicketMessages();
162
+ return;
163
+ }
164
+ if (result.status === "error" || result.error) {
165
+ ticketChatState.status = "error";
166
+ ticketChatState.error =
167
+ result.detail || result.error || "Chat failed";
168
+ addAssistantMessage(`Error: ${ticketChatState.error}`, true);
169
+ renderTicketChat();
170
+ renderTicketMessages();
171
+ return;
172
+ }
173
+ // Success
174
+ ticketChatState.status = "done";
175
+ if (result.message) {
176
+ ticketChatState.streamText = result.message;
177
+ }
178
+ if (result.agent_message || result.agentMessage) {
179
+ ticketChatState.statusText =
180
+ result.agent_message || result.agentMessage || "";
181
+ }
182
+ // Check for draft/patch in response
183
+ const hasDraft = result.has_draft ?? result.hasDraft;
184
+ if (hasDraft === false) {
185
+ ticketChatState.draft = null;
186
+ }
187
+ else if (hasDraft === true || result.draft || result.patch || result.content) {
188
+ ticketChatState.draft = {
189
+ content: result.content || "",
190
+ patch: result.patch || "",
191
+ agentMessage: result.agent_message || result.agentMessage || "",
192
+ createdAt: result.created_at || result.createdAt || "",
193
+ baseHash: result.base_hash || result.baseHash || "",
194
+ };
195
+ }
196
+ // Add assistant message from response
197
+ const responseText = ticketChatState.streamText ||
198
+ ticketChatState.statusText ||
199
+ (ticketChatState.draft ? "Changes ready to apply" : "Done");
200
+ if (responseText && ticketChatState.messages.length > 0) {
201
+ // Only add if we have messages (i.e., a user message was sent)
202
+ const lastMessage = ticketChatState.messages[ticketChatState.messages.length - 1];
203
+ // Avoid duplicate assistant messages
204
+ if (lastMessage.role === "user") {
205
+ addAssistantMessage(responseText, true);
206
+ }
207
+ }
208
+ renderTicketChat();
209
+ renderTicketMessages();
210
+ renderTicketEvents();
211
+ }
114
212
  /**
115
213
  * Add a user message to the chat history.
116
214
  */
@@ -129,6 +227,7 @@ export function setTicketIndex(index) {
129
227
  ticketChatState.ticketIndex = index;
130
228
  ticketChatState.draft = null;
131
229
  resetTicketChatState();
230
+ clearTurnEventsStream();
132
231
  // Clear chat history when switching tickets
133
232
  if (changed) {
134
233
  ticketChat.setTarget(index != null ? String(index) : null);
@@ -183,7 +282,16 @@ export async function sendTicketChat() {
183
282
  resetTicketChatState();
184
283
  ticketChatState.status = "running";
185
284
  ticketChatState.statusText = "queued";
285
+ clearTurnEventsStream();
186
286
  ticketChatState.controller = new AbortController();
287
+ const pendingKey = pendingKeyForTicket(ticketChatState.ticketIndex);
288
+ const clientTurnId = newClientTurnId("ticket");
289
+ savePendingTurn(pendingKey, {
290
+ clientTurnId,
291
+ message,
292
+ startedAtMs: Date.now(),
293
+ target: ticketChatState.ticketIndex != null ? `ticket:${ticketChatState.ticketIndex}` : "ticket",
294
+ });
187
295
  renderTicketChat();
188
296
  if (els.input) {
189
297
  els.input.value = "";
@@ -196,12 +304,14 @@ export async function sendTicketChat() {
196
304
  agent,
197
305
  model,
198
306
  reasoning,
307
+ clientTurnId,
199
308
  });
200
309
  // Try to load any pending draft
201
310
  await loadTicketPending(ticketChatState.ticketIndex, true);
202
311
  if (ticketChatState.status === "running") {
203
312
  ticketChatState.status = "done";
204
313
  }
314
+ clearPendingTurnState(pendingKey);
205
315
  }
206
316
  catch (err) {
207
317
  const error = err;
@@ -213,6 +323,7 @@ export async function sendTicketChat() {
213
323
  ticketChatState.status = "error";
214
324
  ticketChatState.error = error.message || "Ticket chat failed";
215
325
  }
326
+ clearPendingTurnState(pendingKey);
216
327
  }
217
328
  finally {
218
329
  ticketChatState.controller = null;
@@ -226,6 +337,7 @@ export async function cancelTicketChat() {
226
337
  if (ticketChatState.controller) {
227
338
  ticketChatState.controller.abort();
228
339
  }
340
+ clearTurnEventsStream();
229
341
  // Send interrupt to server
230
342
  if (ticketChatState.ticketIndex != null) {
231
343
  try {
@@ -242,6 +354,56 @@ export async function cancelTicketChat() {
242
354
  ticketChatState.statusText = "";
243
355
  ticketChatState.controller = null;
244
356
  renderTicketChat();
357
+ if (ticketChatState.ticketIndex != null) {
358
+ clearPendingTurnState(pendingKeyForTicket(ticketChatState.ticketIndex));
359
+ }
360
+ }
361
+ export async function resumeTicketPendingTurn(index) {
362
+ if (index == null)
363
+ return;
364
+ const pendingKey = pendingKeyForTicket(index);
365
+ const pending = loadPendingTurn(pendingKey);
366
+ if (!pending || pending.target !== `ticket:${index}`)
367
+ return;
368
+ const chatState = ticketChatState;
369
+ chatState.status = "running";
370
+ chatState.statusText = "Recovering previous turn…";
371
+ ticketChat.render();
372
+ ticketChat.renderMessages();
373
+ try {
374
+ const outcome = await resumeFileChatTurn(pending.clientTurnId, {
375
+ onEvent: (event) => {
376
+ ticketChat.applyAppEvent(event);
377
+ ticketChat.renderEvents();
378
+ ticketChat.render();
379
+ },
380
+ onResult: (result) => {
381
+ applyTicketChatResult(result);
382
+ const status = result.status;
383
+ if (status === "ok" || status === "error" || status === "interrupted") {
384
+ clearPendingTurnState(pendingKey);
385
+ }
386
+ },
387
+ onError: (msg) => {
388
+ chatState.statusText = msg;
389
+ renderTicketChat();
390
+ },
391
+ });
392
+ currentTurnEventsController = outcome.controller;
393
+ if (outcome.lastResult && outcome.lastResult.status) {
394
+ applyTicketChatResult(outcome.lastResult);
395
+ clearPendingTurnState(pendingKey);
396
+ return;
397
+ }
398
+ if (!outcome.controller) {
399
+ window.setTimeout(() => void resumeTicketPendingTurn(index), 1000);
400
+ }
401
+ }
402
+ catch (err) {
403
+ const msg = err.message || "Failed to resume turn";
404
+ chatState.statusText = msg;
405
+ renderTicketChat();
406
+ }
245
407
  }
246
408
  export async function applyTicketPatch() {
247
409
  if (ticketChatState.ticketIndex == null) {
@@ -3,21 +3,14 @@
3
3
  * Ticket Chat Stream - handles SSE streaming for ticket chat
4
4
  */
5
5
  import { resolvePath, getAuthToken } from "./utils.js";
6
- import { ticketChatState, renderTicketChat, clearTicketEvents, addUserMessage, addAssistantMessage, } from "./ticketChatActions.js";
6
+ import { ticketChatState, renderTicketChat, clearTicketEvents, addUserMessage, addAssistantMessage, applyTicketChatResult, } from "./ticketChatActions.js";
7
7
  import { applyTicketEvent, renderTicketEvents, renderTicketMessages } from "./ticketChatEvents.js";
8
- const decoder = new TextDecoder();
9
- function parseMaybeJson(data) {
10
- try {
11
- return JSON.parse(data);
12
- }
13
- catch {
14
- return data;
15
- }
16
- }
8
+ import { extractContextRemainingPercent, readEventStream, parseMaybeJson } from "./streamUtils.js";
17
9
  export async function performTicketChatRequest(ticketIndex, message, signal, options = {}) {
18
10
  // Clear events from previous request and add user message to history
19
11
  clearTicketEvents();
20
12
  addUserMessage(message);
13
+ ticketChatState.contextUsagePercent = null;
21
14
  // Render both chat (for container visibility) and messages
22
15
  renderTicketChat();
23
16
  renderTicketMessages();
@@ -39,6 +32,8 @@ export async function performTicketChatRequest(ticketIndex, message, signal, opt
39
32
  payload.model = options.model;
40
33
  if (options.reasoning)
41
34
  payload.reasoning = options.reasoning;
35
+ if (options.clientTurnId)
36
+ payload.client_turn_id = options.clientTurnId;
42
37
  const res = await fetch(endpoint, {
43
38
  method: "POST",
44
39
  headers,
@@ -59,7 +54,7 @@ export async function performTicketChatRequest(ticketIndex, message, signal, opt
59
54
  }
60
55
  const contentType = res.headers.get("content-type") || "";
61
56
  if (contentType.includes("text/event-stream")) {
62
- await readTicketChatStream(res);
57
+ await readEventStream(res, (event, data) => handleTicketStreamEvent(event, data));
63
58
  }
64
59
  else {
65
60
  // Non-streaming response
@@ -69,54 +64,6 @@ export async function performTicketChatRequest(ticketIndex, message, signal, opt
69
64
  applyTicketChatResult(responsePayload);
70
65
  }
71
66
  }
72
- async function readTicketChatStream(res) {
73
- if (!res.body)
74
- throw new Error("Streaming not supported in this browser");
75
- const reader = res.body.getReader();
76
- let buffer = "";
77
- let escapedNewlines = false;
78
- for (;;) {
79
- const { value, done } = await reader.read();
80
- if (done)
81
- break;
82
- const decoded = decoder.decode(value, { stream: true });
83
- // Handle escaped newlines
84
- if (!escapedNewlines) {
85
- const combined = buffer + decoded;
86
- if (!combined.includes("\n") && combined.includes("\\n")) {
87
- escapedNewlines = true;
88
- buffer = buffer.replace(/\\n(?=event:|data:|\\n)/g, "\n");
89
- }
90
- }
91
- buffer += escapedNewlines
92
- ? decoded.replace(/\\n(?=event:|data:|\\n)/g, "\n")
93
- : decoded;
94
- // Split on double newlines (SSE message delimiter)
95
- const chunks = buffer.split("\n\n");
96
- buffer = chunks.pop() || "";
97
- for (const chunk of chunks) {
98
- if (!chunk.trim())
99
- continue;
100
- let event = "message";
101
- const dataLines = [];
102
- chunk.split("\n").forEach((line) => {
103
- if (line.startsWith("event:")) {
104
- event = line.slice(6).trim();
105
- }
106
- else if (line.startsWith("data:")) {
107
- dataLines.push(line.slice(5).trimStart());
108
- }
109
- else if (line.trim()) {
110
- dataLines.push(line);
111
- }
112
- });
113
- if (dataLines.length === 0)
114
- continue;
115
- const data = dataLines.join("\n");
116
- handleTicketStreamEvent(event, data);
117
- }
118
- }
119
- }
120
67
  function handleTicketStreamEvent(event, rawData) {
121
68
  const parsed = parseMaybeJson(rawData);
122
69
  switch (event) {
@@ -154,6 +101,17 @@ function handleTicketStreamEvent(event, rawData) {
154
101
  renderTicketEvents();
155
102
  break;
156
103
  }
104
+ case "token_usage": {
105
+ // Token usage events - context window usage
106
+ if (typeof parsed === "object" && parsed !== null) {
107
+ const percentRemaining = extractContextRemainingPercent(parsed);
108
+ if (percentRemaining !== null) {
109
+ ticketChatState.contextUsagePercent = percentRemaining;
110
+ renderTicketChat();
111
+ }
112
+ }
113
+ break;
114
+ }
157
115
  case "error": {
158
116
  const message = typeof parsed === "object" && parsed !== null
159
117
  ? parsed.detail ||
@@ -202,63 +160,3 @@ function handleTicketStreamEvent(event, rawData) {
202
160
  break;
203
161
  }
204
162
  }
205
- function applyTicketChatResult(payload) {
206
- if (!payload || typeof payload !== "object")
207
- return;
208
- const result = payload;
209
- if (result.status === "interrupted") {
210
- ticketChatState.status = "interrupted";
211
- ticketChatState.error = "";
212
- addAssistantMessage("Request interrupted", true);
213
- renderTicketChat();
214
- renderTicketMessages();
215
- return;
216
- }
217
- if (result.status === "error" || result.error) {
218
- ticketChatState.status = "error";
219
- ticketChatState.error =
220
- result.detail || result.error || "Chat failed";
221
- addAssistantMessage(`Error: ${ticketChatState.error}`, true);
222
- renderTicketChat();
223
- renderTicketMessages();
224
- return;
225
- }
226
- // Success
227
- ticketChatState.status = "done";
228
- if (result.message) {
229
- ticketChatState.streamText = result.message;
230
- }
231
- if (result.agent_message || result.agentMessage) {
232
- ticketChatState.statusText =
233
- result.agent_message || result.agentMessage || "";
234
- }
235
- // Check for draft/patch in response
236
- const hasDraft = result.has_draft ?? result.hasDraft;
237
- if (hasDraft === false) {
238
- ticketChatState.draft = null;
239
- }
240
- else if (hasDraft === true || result.draft || result.patch || result.content) {
241
- ticketChatState.draft = {
242
- content: result.content || "",
243
- patch: result.patch || "",
244
- agentMessage: result.agent_message || result.agentMessage || "",
245
- createdAt: result.created_at || result.createdAt || "",
246
- baseHash: result.base_hash || result.baseHash || "",
247
- };
248
- }
249
- // Add assistant message from response
250
- const responseText = ticketChatState.streamText ||
251
- ticketChatState.statusText ||
252
- (ticketChatState.draft ? "Changes ready to apply" : "Done");
253
- if (responseText && ticketChatState.messages.length > 0) {
254
- // Only add if we have messages (i.e., a user message was sent)
255
- const lastMessage = ticketChatState.messages[ticketChatState.messages.length - 1];
256
- // Avoid duplicate assistant messages
257
- if (lastMessage.role === "user") {
258
- addAssistantMessage(responseText, true);
259
- }
260
- }
261
- renderTicketChat();
262
- renderTicketMessages();
263
- renderTicketEvents();
264
- }