agentopia 1.0.0

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 (140) hide show
  1. package/.claude/settings.local.json +28 -0
  2. package/dist/app.d.ts +10 -0
  3. package/dist/app.d.ts.map +1 -0
  4. package/dist/app.js +121 -0
  5. package/dist/app.js.map +1 -0
  6. package/dist/config.d.ts +9 -0
  7. package/dist/config.d.ts.map +1 -0
  8. package/dist/config.js +19 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/db/database.d.ts +5 -0
  11. package/dist/db/database.d.ts.map +1 -0
  12. package/dist/db/database.js +39 -0
  13. package/dist/db/database.js.map +1 -0
  14. package/dist/db/schema.d.ts +3 -0
  15. package/dist/db/schema.d.ts.map +1 -0
  16. package/dist/db/schema.js +621 -0
  17. package/dist/db/schema.js.map +1 -0
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +49 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/logger.d.ts +4 -0
  23. package/dist/logger.d.ts.map +1 -0
  24. package/dist/logger.js +9 -0
  25. package/dist/logger.js.map +1 -0
  26. package/dist/middleware/auth.d.ts +13 -0
  27. package/dist/middleware/auth.d.ts.map +1 -0
  28. package/dist/middleware/auth.js +733 -0
  29. package/dist/middleware/auth.js.map +1 -0
  30. package/dist/routes/agents.d.ts +3 -0
  31. package/dist/routes/agents.d.ts.map +1 -0
  32. package/dist/routes/agents.js +1058 -0
  33. package/dist/routes/agents.js.map +1 -0
  34. package/dist/routes/issues.d.ts +4 -0
  35. package/dist/routes/issues.d.ts.map +1 -0
  36. package/dist/routes/issues.js +946 -0
  37. package/dist/routes/issues.js.map +1 -0
  38. package/dist/routes/knowledge.d.ts +3 -0
  39. package/dist/routes/knowledge.d.ts.map +1 -0
  40. package/dist/routes/knowledge.js +117 -0
  41. package/dist/routes/knowledge.js.map +1 -0
  42. package/dist/routes/memories.d.ts +3 -0
  43. package/dist/routes/memories.d.ts.map +1 -0
  44. package/dist/routes/memories.js +115 -0
  45. package/dist/routes/memories.js.map +1 -0
  46. package/dist/routes/messages.d.ts +3 -0
  47. package/dist/routes/messages.d.ts.map +1 -0
  48. package/dist/routes/messages.js +130 -0
  49. package/dist/routes/messages.js.map +1 -0
  50. package/dist/routes/projects.d.ts +3 -0
  51. package/dist/routes/projects.d.ts.map +1 -0
  52. package/dist/routes/projects.js +754 -0
  53. package/dist/routes/projects.js.map +1 -0
  54. package/dist/routes/templates.d.ts +3 -0
  55. package/dist/routes/templates.d.ts.map +1 -0
  56. package/dist/routes/templates.js +117 -0
  57. package/dist/routes/templates.js.map +1 -0
  58. package/dist/routes/ui.d.ts +3 -0
  59. package/dist/routes/ui.d.ts.map +1 -0
  60. package/dist/routes/ui.js +38 -0
  61. package/dist/routes/ui.js.map +1 -0
  62. package/dist/services/agent-hierarchy.d.ts +14 -0
  63. package/dist/services/agent-hierarchy.d.ts.map +1 -0
  64. package/dist/services/agent-hierarchy.js +58 -0
  65. package/dist/services/agent-hierarchy.js.map +1 -0
  66. package/dist/services/agent-issue-batch.d.ts +17 -0
  67. package/dist/services/agent-issue-batch.d.ts.map +1 -0
  68. package/dist/services/agent-issue-batch.js +57 -0
  69. package/dist/services/agent-issue-batch.js.map +1 -0
  70. package/dist/services/controller.d.ts +4 -0
  71. package/dist/services/controller.d.ts.map +1 -0
  72. package/dist/services/controller.js +237 -0
  73. package/dist/services/controller.js.map +1 -0
  74. package/dist/services/langgraph-runner.d.ts +33 -0
  75. package/dist/services/langgraph-runner.d.ts.map +1 -0
  76. package/dist/services/langgraph-runner.js +478 -0
  77. package/dist/services/langgraph-runner.js.map +1 -0
  78. package/dist/services/orchestrator.d.ts +9 -0
  79. package/dist/services/orchestrator.d.ts.map +1 -0
  80. package/dist/services/orchestrator.js +116 -0
  81. package/dist/services/orchestrator.js.map +1 -0
  82. package/dist/services/pre-controller.d.ts +7 -0
  83. package/dist/services/pre-controller.d.ts.map +1 -0
  84. package/dist/services/pre-controller.js +101 -0
  85. package/dist/services/pre-controller.js.map +1 -0
  86. package/dist/services/process-manager.d.ts +67 -0
  87. package/dist/services/process-manager.d.ts.map +1 -0
  88. package/dist/services/process-manager.js +938 -0
  89. package/dist/services/process-manager.js.map +1 -0
  90. package/dist/services/project-permissions.d.ts +84 -0
  91. package/dist/services/project-permissions.d.ts.map +1 -0
  92. package/dist/services/project-permissions.js +129 -0
  93. package/dist/services/project-permissions.js.map +1 -0
  94. package/dist/services/scheduler.d.ts +6 -0
  95. package/dist/services/scheduler.d.ts.map +1 -0
  96. package/dist/services/scheduler.js +300 -0
  97. package/dist/services/scheduler.js.map +1 -0
  98. package/dist/services/system-prompt.d.ts +3 -0
  99. package/dist/services/system-prompt.d.ts.map +1 -0
  100. package/dist/services/system-prompt.js +285 -0
  101. package/dist/services/system-prompt.js.map +1 -0
  102. package/dist/services/terminal.d.ts +18 -0
  103. package/dist/services/terminal.d.ts.map +1 -0
  104. package/dist/services/terminal.js +222 -0
  105. package/dist/services/terminal.js.map +1 -0
  106. package/dist/services/websocket.d.ts +15 -0
  107. package/dist/services/websocket.d.ts.map +1 -0
  108. package/dist/services/websocket.js +204 -0
  109. package/dist/services/websocket.js.map +1 -0
  110. package/dist/types.d.ts +108 -0
  111. package/dist/types.d.ts.map +1 -0
  112. package/dist/types.js +3 -0
  113. package/dist/types.js.map +1 -0
  114. package/env.ini +18 -0
  115. package/package.json +38 -0
  116. package/project_id +0 -0
  117. package/public/admin-users.html +188 -0
  118. package/public/agent.html +199 -0
  119. package/public/css/issues.css +275 -0
  120. package/public/css/style.css +1299 -0
  121. package/public/index.html +166 -0
  122. package/public/issue.html +76 -0
  123. package/public/js/agent.js +19 -0
  124. package/public/js/common.js +735 -0
  125. package/public/js/dashboard.js +772 -0
  126. package/public/js/files-panel.js +703 -0
  127. package/public/js/interactive-terminal.js +201 -0
  128. package/public/js/issue-renderer.js +559 -0
  129. package/public/js/issue.js +57 -0
  130. package/public/js/project.js +2425 -0
  131. package/public/js/terminal.js +564 -0
  132. package/public/project.html +430 -0
  133. package/public/terminal.html +67 -0
  134. package/public/vendor/marked.js +74 -0
  135. package/public/vendor/xterm-addon-fit.js +2 -0
  136. package/public/vendor/xterm.css +209 -0
  137. package/public/vendor/xterm.js +2 -0
  138. package/send_message_and_update_issue.js +65 -0
  139. package/tsconfig.json +19 -0
  140. package/update_round2_and_create_round3.js +284 -0
@@ -0,0 +1,2425 @@
1
+ const projectId = window.location.pathname.split('/').pop();
2
+ let projectData = null;
3
+ let agentsData = [];
4
+ let orchestrationRunsData = [];
5
+ let agentOutputPollTimer = null;
6
+ let agentOutputPollingAgentId = null;
7
+ let agentOutputRefreshInFlight = false;
8
+ let projectMembersData = [];
9
+ let projectFilesAgentId = '';
10
+ let projectFilesPanel = null;
11
+ const AGENT_OUTPUT_POLL_MS = 2000;
12
+
13
+ const PROJECT_ACCESS_META = {
14
+ owner: {
15
+ badge: 'OWNER',
16
+ tone: 'owner',
17
+ summary: 'Project Owner',
18
+ detail: 'You can edit the project, manage sharing, and maintain project settings.',
19
+ },
20
+ member: {
21
+ badge: 'SHARED',
22
+ tone: 'shared',
23
+ summary: 'Shared Member',
24
+ detail: 'This is a shared read-only view. You can browse the project and member list, but cannot manage sharing.',
25
+ },
26
+ admin: {
27
+ badge: 'ADMIN VIEW',
28
+ tone: 'admin',
29
+ summary: 'Global Admin',
30
+ detail: 'You are viewing this project as a global admin.',
31
+ },
32
+ bypass: {
33
+ badge: 'DEBUG',
34
+ tone: 'debug',
35
+ summary: 'Debug mode',
36
+ detail: 'legacy / localhost bypass, for debugging only and not a normal user role.',
37
+ },
38
+ none: {
39
+ badge: 'UNKNOWN',
40
+ tone: 'shared',
41
+ summary: 'Unknown role',
42
+ detail: 'Role info missing.',
43
+ },
44
+ };
45
+
46
+ function displayProjectUser(user) {
47
+ if (!user) return 'Not set';
48
+ return user.display_name || user.username || 'Not set';
49
+ }
50
+
51
+ function getProjectAccessLevel(project) {
52
+ if (project?.owner?.id && _currentUser?.id && project.owner.id === _currentUser.id) {
53
+ return 'owner';
54
+ }
55
+ return project?.permission_level || 'none';
56
+ }
57
+
58
+ function getProjectAccessMeta(project) {
59
+ return PROJECT_ACCESS_META[getProjectAccessLevel(project)] || PROJECT_ACCESS_META.none;
60
+ }
61
+
62
+ function canManageProject() {
63
+ return !!projectData?.can_manage;
64
+ }
65
+
66
+ function requireProjectManageAccess(message) {
67
+ if (canManageProject()) return true;
68
+ showToast(message || 'Insufficient permission', 'error');
69
+ return false;
70
+ }
71
+
72
+ function getControllerAgent() {
73
+ return agentsData.find((agent) => agent.is_controller);
74
+ }
75
+
76
+ function getAgentMap() {
77
+ return new Map((agentsData || []).map((agent) => [agent.id, agent]));
78
+ }
79
+
80
+ function getDirectChildAgents(agentId) {
81
+ return (agentsData || []).filter((agent) => agent.parent_agent_id === agentId);
82
+ }
83
+
84
+ function getDescendantAgentIds(agentId) {
85
+ const descendants = new Set();
86
+ const queue = getDirectChildAgents(agentId).map((agent) => agent.id);
87
+
88
+ while (queue.length > 0) {
89
+ const currentId = queue.shift();
90
+ if (!currentId || descendants.has(currentId)) continue;
91
+ descendants.add(currentId);
92
+ getDirectChildAgents(currentId).forEach((child) => {
93
+ if (!descendants.has(child.id)) queue.push(child.id);
94
+ });
95
+ }
96
+
97
+ return descendants;
98
+ }
99
+
100
+ function buildParentAgentOptions(currentAgentId, selectedParentId) {
101
+ const excludedIds = new Set();
102
+ if (currentAgentId) {
103
+ excludedIds.add(currentAgentId);
104
+ getDescendantAgentIds(currentAgentId).forEach((id) => excludedIds.add(id));
105
+ }
106
+
107
+ const options = ['<option value="">No parent (top-level agent)</option>'];
108
+ agentsData.forEach((agent) => {
109
+ if (excludedIds.has(agent.id)) return;
110
+ const suffix = agent.is_controller ? ' [controller]' : '';
111
+ const selected = selectedParentId && selectedParentId === agent.id ? ' selected' : '';
112
+ options.push(`<option value="${agent.id}"${selected}>${esc(agent.name)}${suffix}</option>`);
113
+ });
114
+ return options.join('');
115
+ }
116
+
117
+ function syncParentAgentSelect(selectId, currentAgentId, selectedParentId, disabled) {
118
+ const select = document.getElementById(selectId);
119
+ if (!select) return;
120
+ select.innerHTML = buildParentAgentOptions(currentAgentId, selectedParentId);
121
+ select.disabled = !!disabled;
122
+ select.value = selectedParentId || '';
123
+ }
124
+
125
+ function getDisplayParentAgent(agent) {
126
+ if (!agent?.parent_agent_id) return null;
127
+ return getAgentMap().get(agent.parent_agent_id) || null;
128
+ }
129
+
130
+ function getGraphParentId(agent) {
131
+ if (!agent) return null;
132
+ const byId = getAgentMap();
133
+ if (agent.parent_agent_id && byId.has(agent.parent_agent_id)) return agent.parent_agent_id;
134
+ const controller = getControllerAgent();
135
+ if (controller && !agent.is_controller && controller.id !== agent.id) {
136
+ return controller.id;
137
+ }
138
+ return null;
139
+ }
140
+
141
+ function hasHierarchyLayout() {
142
+ return agentsData.some((agent) => !!agent.parent_agent_id);
143
+ }
144
+
145
+ function renderPermissionBadge(meta) {
146
+ return `<span class="permission-badge permission-${meta.tone}" title="${esc(meta.summary)}">${meta.badge}</span>`;
147
+ }
148
+
149
+ function applyProjectManageState() {
150
+ if (!projectData) return;
151
+
152
+ const canManage = canManageProject();
153
+ const meta = getProjectAccessMeta(projectData);
154
+ const manageIds = ['btn-toggle', 'btn-trigger', 'btn-delete-project', 'btn-share-project', 'btn-save-overview', 'btn-new-agent', 'btn-new-issue', 'btn-new-knowledge'];
155
+ manageIds.forEach((id) => {
156
+ const el = document.getElementById(id);
157
+ if (el) el.style.display = canManage ? '' : 'none';
158
+ });
159
+
160
+ const overviewIds = ['project-name-edit', 'project-desc-edit', 'project-task', 'project-cmd'];
161
+ overviewIds.forEach((id) => {
162
+ const el = document.getElementById(id);
163
+ if (el) el.disabled = !canManage;
164
+ });
165
+
166
+ const headerActions = document.getElementById('project-manage-actions');
167
+ if (headerActions) headerActions.style.display = canManage ? 'flex' : 'none';
168
+
169
+ const readonlyBanner = document.getElementById('project-readonly-banner');
170
+ if (readonlyBanner) {
171
+ readonlyBanner.style.display = canManage ? 'none' : '';
172
+ readonlyBanner.textContent = canManage ? '' : meta.detail;
173
+ }
174
+
175
+ const overviewReadonlyHint = document.getElementById('project-overview-readonly-hint');
176
+ if (overviewReadonlyHint) {
177
+ overviewReadonlyHint.style.display = canManage ? 'none' : '';
178
+ overviewReadonlyHint.textContent = canManage ? '' : 'Shared members can view the project overview, but project settings and sharing are read-only.';
179
+ }
180
+
181
+ if (projectFilesPanel) {
182
+ projectFilesPanel.setWriteEnabled(canManage);
183
+ }
184
+ }
185
+
186
+ function renderProjectAccessSummary() {
187
+ if (!projectData) return;
188
+
189
+ const meta = getProjectAccessMeta(projectData);
190
+ const memberCount = Number.isFinite(projectData.member_count) ? projectData.member_count : 0;
191
+ const ownerName = displayProjectUser(projectData.owner);
192
+ const ownerRole = projectData.owner?.role === 'admin' ? 'Global Admin' : 'Project Member';
193
+
194
+ const accessBadge = document.getElementById('project-access-badge');
195
+ if (accessBadge) accessBadge.innerHTML = renderPermissionBadge(meta);
196
+
197
+ const accessSummary = document.getElementById('project-access-summary');
198
+ if (accessSummary) accessSummary.innerHTML = `<span class="meta-chip-label">Access</span><span>${esc(meta.summary)}</span>`;
199
+
200
+ const ownerSummary = document.getElementById('project-owner-summary');
201
+ if (ownerSummary) ownerSummary.innerHTML = `<span class="meta-chip-label">Owner</span><span>${esc(ownerName)}</span><span class="meta-chip-secondary">${esc(ownerRole)}</span>`;
202
+
203
+ const membersButton = document.getElementById('btn-view-members');
204
+ if (membersButton) membersButton.textContent = `Members (${memberCount})`;
205
+
206
+ const debugNote = document.getElementById('project-debug-note');
207
+ if (debugNote) {
208
+ debugNote.style.display = meta.tone === 'debug' ? '' : 'none';
209
+ debugNote.textContent = meta.tone === 'debug' ? meta.detail : '';
210
+ }
211
+ }
212
+
213
+ function mergeOwnerIntoMembers(members) {
214
+ const normalized = Array.isArray(members) ? [...members] : [];
215
+ if (projectData?.owner?.id && !normalized.some((member) => member.user_id === projectData.owner.id)) {
216
+ normalized.unshift({
217
+ id: `owner-${projectData.owner.id}`,
218
+ user_id: projectData.owner.id,
219
+ username: projectData.owner.username,
220
+ display_name: projectData.owner.display_name,
221
+ user_role: projectData.owner.role,
222
+ role: 'owner',
223
+ });
224
+ }
225
+ return normalized.sort((a, b) => {
226
+ if (a.role === 'owner' && b.role !== 'owner') return -1;
227
+ if (a.role !== 'owner' && b.role === 'owner') return 1;
228
+ return displayProjectUser(a).localeCompare(displayProjectUser(b), 'zh-Hans-CN');
229
+ });
230
+ }
231
+
232
+ function renderProjectMembers() {
233
+ const list = document.getElementById('project-members-list');
234
+ if (!list) return;
235
+
236
+ const members = mergeOwnerIntoMembers(projectMembersData);
237
+ if (!members.length) {
238
+ list.innerHTML = '<div class="empty-state">No member information</div>';
239
+ return;
240
+ }
241
+
242
+ const canManage = !!projectData?.can_manage;
243
+ list.innerHTML = members.map((member) => {
244
+ const isOwner = member.role === 'owner';
245
+ const displayName = displayProjectUser(member);
246
+ const encodedDisplayName = encodeURIComponent(displayName);
247
+ const username = member.username ? `@${member.username}` : member.user_id;
248
+ const membershipLabel = isOwner ? 'Project Owner' : 'Shared Member';
249
+ const accountRole = member.user_role === 'admin' ? 'Global Admin' : 'Member';
250
+ const removeButton = isOwner
251
+ ? '<span class="project-member-static">Owner cannot be removed</span>'
252
+ : canManage
253
+ ? `<button class="btn btn-sm" onclick="removeProjectMember('${member.user_id}', '${encodedDisplayName}')" style="color:var(--error)">Remove</button>`
254
+ : '<span class="project-member-static">Read only</span>';
255
+ return `
256
+ <div class="project-member-item">
257
+ <div class="project-member-main">
258
+ <div class="project-member-name-row">
259
+ <strong>${esc(displayName)}</strong>
260
+ ${renderPermissionBadge({
261
+ badge: isOwner ? 'OWNER' : 'MEMBER',
262
+ tone: isOwner ? 'owner' : 'shared',
263
+ summary: membershipLabel,
264
+ })}
265
+ </div>
266
+ <div class="project-member-meta">${esc(username)} · ${esc(accountRole)}</div>
267
+ </div>
268
+ <div class="project-member-actions">
269
+ ${removeButton}
270
+ </div>
271
+ </div>
272
+ `;
273
+ }).join('');
274
+ }
275
+
276
+ async function loadProjectMembers() {
277
+ if (!projectData) return;
278
+
279
+ const list = document.getElementById('project-members-list');
280
+ if (list) list.innerHTML = renderLoading('Loading members...');
281
+
282
+ try {
283
+ const res = await fetch(`/api/projects/${projectId}/members`, { headers: apiHeaders() });
284
+ if (!res.ok) {
285
+ const err = await res.json().catch(() => ({}));
286
+ throw new Error(err.error || 'Failed to load members');
287
+ }
288
+ const data = await res.json();
289
+ projectMembersData = Array.isArray(data.members) ? data.members : [];
290
+ renderProjectMembers();
291
+ } catch (e) {
292
+ if (list) list.innerHTML = renderError(e, 'loadProjectMembers()');
293
+ }
294
+ }
295
+
296
+ async function openProjectMembersModal(focusShare) {
297
+ if (!projectData) return;
298
+
299
+ const meta = getProjectAccessMeta(projectData);
300
+ const canManage = !!projectData.can_manage;
301
+ document.getElementById('projectMembersModal').classList.add('active');
302
+
303
+ const subtitle = document.getElementById('project-members-subtitle');
304
+ if (subtitle) subtitle.textContent = `Access: ${meta.summary} · Members ${Number.isFinite(projectData.member_count) ? projectData.member_count : 0}`;
305
+
306
+ const readonlyNote = document.getElementById('project-members-readonly-note');
307
+ if (readonlyNote) {
308
+ readonlyNote.style.display = canManage ? 'none' : '';
309
+ readonlyNote.textContent = canManage ? '' : 'You are a shared member. You can view the member list, but cannot add or remove members.';
310
+ }
311
+
312
+ const debugHint = document.getElementById('project-members-debug-hint');
313
+ if (debugHint) {
314
+ debugHint.style.display = meta.tone === 'debug' ? '' : 'none';
315
+ debugHint.textContent = meta.tone === 'debug' ? meta.detail : '';
316
+ }
317
+
318
+ const managePanel = document.getElementById('project-members-manage-panel');
319
+ if (managePanel) managePanel.style.display = canManage ? '' : 'none';
320
+
321
+ await loadProjectMembers();
322
+
323
+ if (focusShare && canManage) {
324
+ const input = document.getElementById('project-share-username');
325
+ if (input) input.focus();
326
+ }
327
+ }
328
+
329
+ function statusBadge(s) {
330
+ const map = {
331
+ 'open': '<span class="status-badge status-active">open</span>',
332
+ 'in_progress': '<span class="status-badge status-running">in progress</span>',
333
+ 'pending': '<span class="status-badge status-warning">pending</span>',
334
+ 'done': '<span class="status-badge status-completed">done</span>',
335
+ 'closed': '<span class="status-badge status-idle">closed</span>',
336
+ };
337
+ return map[s] || s;
338
+ }
339
+
340
+ // ─── Project ───
341
+
342
+ async function loadProject() {
343
+ const res = await fetch(`/api/projects/${projectId}`, { headers: apiHeaders() });
344
+ if (!res.ok) { showToast('Failed to load project', 'error'); return; }
345
+ projectData = await res.json();
346
+
347
+ document.getElementById('project-name').textContent = projectData.name;
348
+ document.getElementById('project-title').textContent = projectData.name;
349
+ document.getElementById('project-status').textContent = projectData.status;
350
+ document.getElementById('project-status').className = `status-badge status-${projectData.status}`;
351
+ document.title = `Argus - ${projectData.name}`;
352
+ renderProjectAccessSummary();
353
+ applyProjectManageState();
354
+
355
+ // Editable fields (only set on first load to avoid overwriting user edits)
356
+ if (!window._overviewLoaded) {
357
+ window._overviewLoaded = true;
358
+ document.getElementById('project-name-edit').value = projectData.name;
359
+ document.getElementById('project-desc-edit').value = projectData.description || '';
360
+ document.getElementById('project-task').value = projectData.task_description || '';
361
+ document.getElementById('project-cmd').value = projectData.command_template;
362
+ }
363
+ document.getElementById('project-created').textContent = formatLocalDateTime(projectData.created_at);
364
+
365
+ document.getElementById('btn-toggle').innerHTML = projectData.status === 'active' ? '⏸' : '▶';
366
+ document.getElementById('btn-toggle').title = projectData.status === 'active' ? 'Pause' : 'Resume';
367
+ const triggerButton = document.getElementById('btn-trigger');
368
+ if (triggerButton) triggerButton.style.display = projectData.can_manage && projectData.status === 'active' ? '' : 'none';
369
+
370
+ // Load cost
371
+ fetch(`/api/projects/${projectId}/costs`, { headers: apiHeaders() }).then(r => r.ok ? r.json() : null).then(c => {
372
+ if (c && (c.total_cost_usd > 0 || c.total_input_tokens > 0 || c.total_output_tokens > 0)) {
373
+ document.getElementById('project-cost').style.display = '';
374
+ const costText = c.total_cost_usd > 0 ? `$${c.total_cost_usd.toFixed(4)}` : 'Cost unavailable';
375
+ document.getElementById('project-cost-value').textContent = `${costText} (${c.total_input_tokens} in / ${c.total_output_tokens} out)`;
376
+ }
377
+ }).catch(() => {});
378
+
379
+ loadAgents();
380
+ }
381
+
382
+ async function toggleProjectStatus() {
383
+ if (!projectData) return;
384
+ if (!projectData.can_manage) { showToast('Insufficient permission to update project status', 'error'); return; }
385
+ const newStatus = projectData.status === 'active' ? 'paused' : 'active';
386
+ const res = await fetch(`/api/projects/${projectId}`, { method: 'PUT', headers: apiHeaders(), body: JSON.stringify({ status: newStatus }) });
387
+ if (res.ok) showToast('Status updated', 'success');
388
+ else showToast('Failed to update status', 'error');
389
+ loadProject();
390
+ }
391
+
392
+ async function triggerController() {
393
+ if (!projectData?.can_manage) { showToast('Insufficient permission to trigger Controller', 'error'); return; }
394
+ const btn = event ? event.target : null;
395
+ const run = async () => {
396
+ const controller = agentsData.find(a => a.is_controller);
397
+ if (!controller) { showToast('No controller agent found', 'error'); return; }
398
+ if (controller.status === 'running') { showToast('Controller is already running', 'error'); return; }
399
+ const res = await fetch(`/api/agents/${controller.id}/start`, { method: 'POST', headers: apiHeaders(), body: JSON.stringify({}) });
400
+ if (res.ok) { loadAgents(); showToast('Controller started', 'success'); } else { const err = await res.json().catch(() => ({})); showToast(err.error || 'Failed to start', 'error'); }
401
+ };
402
+ if (btn) await withLoading(btn, run); else await run();
403
+ }
404
+
405
+ async function saveOverview() {
406
+ if (!projectData?.can_manage) { showToast('Insufficient permission to update project settings', 'error'); return; }
407
+ const body = {
408
+ name: document.getElementById('project-name-edit').value.trim(),
409
+ description: document.getElementById('project-desc-edit').value.trim(),
410
+ task_description: document.getElementById('project-task').value.trim(),
411
+ command_template: document.getElementById('project-cmd').value.trim() || 'cld',
412
+ };
413
+ if (!body.name) { showToast('Name cannot be empty', 'error'); return; }
414
+ if (!body.task_description) { showToast('Task description cannot be empty', 'error'); return; }
415
+ const btn = document.querySelector('button[onclick="saveOverview()"]');
416
+ await withLoading(btn, async () => {
417
+ const res = await fetch(`/api/projects/${projectId}`, { method: 'PUT', headers: apiHeaders(), body: JSON.stringify(body) });
418
+ if (res.ok) { window._overviewLoaded = false; loadProject(); showToast('Saved', 'success'); }
419
+ else showToast('Failed to save', 'error');
420
+ });
421
+ }
422
+
423
+ async function deleteProject() {
424
+ if (!projectData?.can_manage) { showToast('Insufficient permission to delete project', 'error'); return; }
425
+ if (!await showConfirm('Delete this project and all agents/issues?')) return;
426
+ const res = await fetch(`/api/projects/${projectId}`, { method: 'DELETE' });
427
+ if (res.ok) { showToast('Project deleted', 'success'); window.location.href = '/'; }
428
+ else { showToast('Failed to delete', 'error'); }
429
+ }
430
+
431
+ async function addProjectMember() {
432
+ if (!projectData?.can_manage) { showToast('Insufficient permission to manage sharing', 'error'); return; }
433
+
434
+ const input = document.getElementById('project-share-username');
435
+ const username = input?.value?.trim();
436
+ if (!username) {
437
+ showToast('Please enter a username', 'error');
438
+ return;
439
+ }
440
+
441
+ const button = document.getElementById('btn-add-member');
442
+ await withLoading(button, async () => {
443
+ const res = await fetch(`/api/projects/${projectId}/members`, {
444
+ method: 'POST',
445
+ headers: apiHeaders(),
446
+ body: JSON.stringify({ username }),
447
+ });
448
+ if (!res.ok) {
449
+ const err = await res.json().catch(() => ({}));
450
+ showToast(err.error || 'Failed to add member', 'error');
451
+ return;
452
+ }
453
+
454
+ if (input) input.value = '';
455
+ showToast('Member added', 'success');
456
+ await loadProject();
457
+ await loadProjectMembers();
458
+ });
459
+ }
460
+
461
+ async function removeProjectMember(userId, encodedDisplayName) {
462
+ if (!requireProjectManageAccess('Insufficient permission to manage sharing')) return;
463
+ if (projectData?.owner?.id === userId) {
464
+ showToast('Project owner cannot be removed', 'error');
465
+ return;
466
+ }
467
+
468
+ const displayName = decodeURIComponent(encodedDisplayName || '');
469
+
470
+ const confirmed = await showConfirm(`Remove ${displayName} from this project?\n\nThey will no longer see this project after removal.`);
471
+ if (!confirmed) return;
472
+
473
+ const res = await fetch(`/api/projects/${projectId}/members/${userId}`, {
474
+ method: 'DELETE',
475
+ });
476
+ if (!res.ok) {
477
+ const err = await res.json().catch(() => ({}));
478
+ showToast(err.error || 'Failed to remove member', 'error');
479
+ return;
480
+ }
481
+
482
+ showToast('Member removed', 'success');
483
+ await loadProject();
484
+ await loadProjectMembers();
485
+ }
486
+
487
+ // ─── Agents ───
488
+
489
+ async function loadAgents() {
490
+ const res = await fetch(`/api/projects/${projectId}/agents`, { headers: apiHeaders() });
491
+ agentsData = await res.json();
492
+ syncParentAgentSelect('agent-parent', null, document.getElementById('agent-parent')?.value || '', !canManageProject());
493
+ const list = document.getElementById('agent-list');
494
+ const canManage = canManageProject();
495
+
496
+ // Update tab count
497
+ updateTabCounts();
498
+
499
+ if (!agentsData.length) { list.innerHTML = '<li class="empty-state">No agents yet.</li>'; return; }
500
+
501
+ // Update issue assign dropdown (preserve current selection, default to controller)
502
+ const assignSel = document.getElementById('issue-assign');
503
+ if (assignSel) {
504
+ const prev = assignSel.value;
505
+ const controllerId = agentsData.find(a => a.is_controller)?.id || '';
506
+ assignSel.innerHTML = '<option value="">Unassigned</option><option value="all">All (broadcast)</option><option value="user">User (me)</option>';
507
+ agentsData.forEach(a => { assignSel.innerHTML += `<option value="${a.id}">${esc(a.name)}${a.is_controller ? ' [controller]' : ''}</option>`; });
508
+ if (prev) assignSel.value = prev;
509
+ else if (controllerId) assignSel.value = controllerId;
510
+ }
511
+
512
+ // Fetch active issues (open/in_progress/pending) per agent
513
+ const agentIssues = {};
514
+ try {
515
+ const activeStatuses = ['open', 'in_progress', 'pending'];
516
+ const issPromises = activeStatuses.map(s => fetch(`/api/projects/${projectId}/issues?status=${s}&per_page=200`, { headers: apiHeaders() }).then(r => r.ok ? r.json() : { issues: [] }));
517
+ const issResults = await Promise.all(issPromises);
518
+ for (const issData of issResults) {
519
+ for (const iss of (issData.issues || [])) {
520
+ if (iss.assigned_to) {
521
+ if (!agentIssues[iss.assigned_to]) agentIssues[iss.assigned_to] = [];
522
+ agentIssues[iss.assigned_to].push(iss);
523
+ }
524
+ }
525
+ }
526
+ } catch (e) { console.error('Failed to fetch agent issues', e); }
527
+
528
+ // Fetch errors
529
+ const errorLogs = {};
530
+ await Promise.all(agentsData.filter(a => a.status === 'error').map(async (a) => {
531
+ try {
532
+ const r = await fetch(`/api/agents/${a.id}/logs?limit=5`, { headers: apiHeaders() });
533
+ if (r.ok) {
534
+ // Use status API for last_error instead of raw logs
535
+ const sr = await fetch(`/api/agents/${a.id}/status`, { headers: apiHeaders() });
536
+ if (sr.ok) { const st = await sr.json(); errorLogs[a.id] = st.last_error || ''; }
537
+ }
538
+ } catch (e) { console.error('Failed to fetch error logs for agent', a.id, e); }
539
+ }));
540
+
541
+ // Error banner for agents in error state
542
+ const errorAgents = agentsData.filter(a => a.status === 'error');
543
+ const bannerEl = document.getElementById('agent-error-banner');
544
+ if (bannerEl) {
545
+ if (errorAgents.length > 0) {
546
+ bannerEl.style.display = '';
547
+ bannerEl.innerHTML = errorAgents.map(a => {
548
+ const errMsg = errorLogs[a.id] ? esc(errorLogs[a.id].slice(0, 300)) : 'Unknown error';
549
+ const retryAction = canManage
550
+ ? `<button class="btn btn-sm" onclick="retryAgent('${a.id}')" style="margin-left:8px;color:var(--warning);padding:2px 8px">Retry</button>`
551
+ : '';
552
+ return `<div style="margin-bottom:4px"><strong>${esc(a.name)}</strong> failed: <span style="font-family:monospace;font-size:11px">${errMsg}</span>${retryAction}</div>`;
553
+ }).join('');
554
+ } else {
555
+ bannerEl.style.display = 'none';
556
+ bannerEl.innerHTML = '';
557
+ }
558
+ }
559
+
560
+ // Browser notification for newly errored agents
561
+ if ('Notification' in window && Notification.permission === 'granted') {
562
+ for (const a of errorAgents) {
563
+ if (!window._notifiedErrors) window._notifiedErrors = new Set();
564
+ const key = a.id + ':' + (a.finished_at || '');
565
+ if (!window._notifiedErrors.has(key)) {
566
+ window._notifiedErrors.add(key);
567
+ new Notification('Argus: Agent Error', { body: `${a.name} failed. ${(errorLogs[a.id] || '').slice(0, 100)}`, tag: 'argus-error-' + a.id });
568
+ }
569
+ }
570
+ }
571
+
572
+ // Render a single agent list item
573
+ function renderAgentItem(a, depth) {
574
+ const indent = depth * 20;
575
+ const tag = a.is_controller ? ' <span style="color:var(--accent);font-size:11px">[controller]</span>' : '';
576
+ const parentAgent = getDisplayParentAgent(a);
577
+ const childAgents = getDirectChildAgents(a.id);
578
+ const hierarchyMeta = depth > 0 ? '' : [
579
+ parentAgent ? `Parent ${esc(parentAgent.name)}` : null,
580
+ childAgents.length > 0 ? `${childAgents.length} direct reports` : null,
581
+ ].filter(Boolean).join(' · ');
582
+ const errBox = a.status === 'error' && errorLogs[a.id]
583
+ ? `<div style="margin-top:4px;padding:6px 8px;background:rgba(220,50,47,0.1);border:1px solid rgba(220,50,47,0.3);border-radius:4px;font-size:11px;color:var(--error);font-family:monospace;max-height:60px;overflow:auto;white-space:pre-wrap">${esc(errorLogs[a.id].slice(0, 500))}</div>` : '';
584
+ const spinner = a.status === 'running' ? '<span class="thinking-spinner">✦</span> ' : '';
585
+ const deleteBtn = canManage && !a.is_controller && a.status !== 'running'
586
+ ? `<button class="btn btn-sm" onclick="event.stopPropagation();deleteAgent('${a.id}')" style="color:var(--error);padding:3px 6px" title="Delete">✕</button>` : '';
587
+ const retryBtn = canManage && a.status === 'error' && a.last_prompt && !a.paused
588
+ ? `<button class="btn btn-sm" onclick="event.stopPropagation();retryAgent('${a.id}')" style="color:var(--warning);padding:3px 6px" title="Retry last prompt">Retry</button>` : '';
589
+ const pauseBtn = canManage && !a.paused
590
+ ? `<button class="btn btn-sm" onclick="event.stopPropagation();pauseAgent('${a.id}')" style="color:var(--warning);padding:3px 6px" title="Pause agent">⏸</button>`
591
+ : canManage
592
+ ? `<button class="btn btn-sm" onclick="event.stopPropagation();unpauseAgent('${a.id}')" style="color:var(--success);padding:3px 6px" title="Resume agent">▶</button>`
593
+ : '';
594
+ const chatBtn = canManage
595
+ ? `<button class="btn btn-sm" onclick="event.stopPropagation();openTerminal('${a.id}')" style="padding:3px 6px" title="Open terminal chat">Chat</button>`
596
+ : '';
597
+ let actions;
598
+ if (!canManage) {
599
+ actions = '';
600
+ } else if (a.paused) {
601
+ actions = `${chatBtn}${pauseBtn}${deleteBtn}`;
602
+ } else if (a.status === 'running') {
603
+ actions = `${chatBtn}${pauseBtn}<button class="btn btn-sm btn-danger" onclick="event.stopPropagation();stopAgentById('${a.id}')">Stop</button>`;
604
+ } else {
605
+ actions = `${chatBtn}${pauseBtn}${retryBtn}<button class="btn btn-sm btn-primary" onclick="event.stopPropagation();quickStartAgent('${a.id}')">Start</button>${deleteBtn}`;
606
+ }
607
+ const selected = currentAgentId === a.id ? 'background:var(--selected-bg);' : '';
608
+ const pausedStyle = a.paused ? 'opacity:0.55;' : '';
609
+ return `
610
+ <li class="agent-item" style="cursor:pointer;padding-left:${indent}px;${selected}${pausedStyle}" onclick="viewAgent('${a.id}')">
611
+ <div style="flex-shrink:0;margin-right:8px">${avatarSvg(a.name, 32)}</div>
612
+ <div class="agent-info">
613
+ <div class="agent-name">${spinner}${esc(a.name)}${tag}</div>
614
+ <div class="agent-role">${esc(a.role)}</div>
615
+ ${hierarchyMeta ? `<div style="margin-top:3px;font-size:10px;color:var(--text-secondary)">${hierarchyMeta}</div>` : ''}
616
+ ${(agentIssues[a.id] || []).length > 0
617
+ ? `<div style="margin-top:4px;display:flex;flex-wrap:wrap;gap:3px">${(agentIssues[a.id] || []).map(iss => {
618
+ const isActive = iss.status === 'in_progress';
619
+ const bg = isActive ? 'rgba(63,185,80,0.15)' : 'rgba(88,166,255,0.1)';
620
+ const border = isActive ? 'rgba(63,185,80,0.4)' : 'rgba(88,166,255,0.3)';
621
+ const color = isActive ? 'var(--success, #3fb950)' : 'var(--accent)';
622
+ const dot = isActive ? '<span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--success, #3fb950);margin-right:3px;animation:pulse 1.5s infinite"></span>' : '';
623
+ return `<a href="/issues/${iss.id}" onclick="event.stopPropagation()" style="display:inline-flex;align-items:center;padding:2px 6px;background:${bg};border:1px solid ${border};border-radius:3px;font-size:10px;color:${color};text-decoration:none;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="#${iss.number} ${esc(iss.title)} [${iss.status}]">${dot}#${iss.number} ${esc(iss.title)}</a>`;
624
+ }).join('')}</div>`
625
+ : (a.status !== 'error' ? '<div style="margin-top:2px;font-size:10px;color:var(--text-secondary);opacity:0.5">Idle - no active tasks</div>' : '')}
626
+ ${errBox}
627
+ </div>
628
+ <div class="flex" style="gap:8px">
629
+ <span class="status-badge status-${a.paused ? 'paused' : a.status}">${a.paused ? 'paused' : a.status}</span>
630
+ ${actions}
631
+ </div>
632
+ </li>`;
633
+ }
634
+
635
+ if (hasHierarchyLayout()) {
636
+ // Tree rendering: recursively render agents by parent-child hierarchy
637
+ const rendered = new Set();
638
+ function renderAgentTree(parentId, depth) {
639
+ let html = '';
640
+ const children = agentsData.filter(a => (a.parent_agent_id || null) === parentId);
641
+ for (const a of children) {
642
+ if (rendered.has(a.id)) continue;
643
+ rendered.add(a.id);
644
+ html += renderAgentItem(a, depth);
645
+ html += renderAgentTree(a.id, depth + 1);
646
+ }
647
+ return html;
648
+ }
649
+ let treeHtml = renderAgentTree(null, 0);
650
+ // Render any orphaned agents (parent_agent_id points to non-existent agent)
651
+ for (const a of agentsData) {
652
+ if (!rendered.has(a.id)) {
653
+ rendered.add(a.id);
654
+ treeHtml += renderAgentItem(a, 0);
655
+ }
656
+ }
657
+ list.innerHTML = treeHtml;
658
+ } else {
659
+ // Flat rendering (no hierarchy)
660
+ list.innerHTML = agentsData.map(a => renderAgentItem(a, 0)).join('');
661
+ }
662
+
663
+ // Render agent collaboration graph
664
+ // Cache active issues for graph node task counts
665
+ Promise.all(['open','in_progress','pending'].map(s => fetch(`/api/projects/${projectId}/issues?status=${s}&per_page=200`, { headers: apiHeaders() }).then(r => r.ok ? r.json() : { issues: [] })))
666
+ .then(results => { window._dashboardIssues = results.flatMap(d => d.issues || []); renderAgentGraph(); })
667
+ .catch(() => renderAgentGraph());
668
+
669
+ syncProjectFilesAgents();
670
+ loadOrchestrationRuns();
671
+ }
672
+
673
+ let currentAgentId = null;
674
+
675
+ function startAgentOutputPolling(agentId) {
676
+ stopAgentOutputPolling();
677
+ if (!agentId) return;
678
+ agentOutputPollingAgentId = agentId;
679
+ agentOutputPollTimer = setInterval(() => {
680
+ const agentsTab = document.getElementById('tab-agents');
681
+ if (!currentAgentId || currentAgentId !== agentId || !agentsTab || agentsTab.style.display === 'none') return;
682
+ loadAgentOutput(agentId, { silent: true });
683
+ }, AGENT_OUTPUT_POLL_MS);
684
+ }
685
+
686
+ function stopAgentOutputPolling() {
687
+ if (agentOutputPollTimer) clearInterval(agentOutputPollTimer);
688
+ agentOutputPollTimer = null;
689
+ agentOutputPollingAgentId = null;
690
+ agentOutputRefreshInFlight = false;
691
+ }
692
+
693
+ async function viewAgent(agentId) {
694
+ currentAgentId = agentId;
695
+ // Highlight selected in list
696
+ document.querySelectorAll('#agent-list .agent-item').forEach(li => li.style.background = '');
697
+ event?.target?.closest?.('.agent-item')?.style && (event.target.closest('.agent-item').style.background = 'var(--selected-bg)');
698
+
699
+ const el = document.getElementById('agent-detail');
700
+ el.style.display = '';
701
+ el.innerHTML = '<div class="card">' + renderLoading('Loading agent details...') + '</div>';
702
+
703
+ try {
704
+ const agentRes = await fetch(`/api/agents/${agentId}`, { headers: apiHeaders() });
705
+ const agent = agentRes.ok ? await agentRes.json() : agentsData.find(a => a.id === agentId);
706
+ const canManage = canManageProject();
707
+ const parentAgent = getDisplayParentAgent(agent);
708
+ const childAgents = getDirectChildAgents(agentId);
709
+ const readOnlyAttr = canManage ? '' : 'disabled';
710
+ const readonlyNote = canManage
711
+ ? ''
712
+ : `<div class="project-readonly-banner" style="display:block;margin-bottom:16px">This is a shared read-only view. You cannot start, pause, retry, delete, chat with, or edit this agent.</div>`;
713
+ const detailActions = canManage
714
+ ? `
715
+ <button class="btn btn-sm" onclick="openTerminal('${agentId}')" title="Open terminal chat">Chat</button>
716
+ ${agent.status === 'error' && agent.last_prompt ? `<button class="btn btn-sm" onclick="retryAgent('${agentId}')" style="color:var(--warning)">Retry</button>` : ''}
717
+ `
718
+ : '';
719
+ const saveSettingsButton = canManage
720
+ ? '<button class="btn btn-primary" onclick="saveAllAgentFields(\'' + agentId + '\')">Save Settings</button>'
721
+ : '';
722
+
723
+ const L = 'font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.03em;opacity:0.6;margin-bottom:4px';
724
+ const B = 'padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px';
725
+
726
+ // Step 1: Render config immediately (no logs yet)
727
+ el.innerHTML = `
728
+ <div class="card" style="padding:0">
729
+ <div style="padding:16px 20px;border-bottom:1px solid var(--border)">
730
+ <div class="flex-between">
731
+ <h3 style="display:flex;align-items:center;gap:8px">${avatarSvg(agent.name, 28)} ${esc(agent.name)} ${agent.is_controller ? '<span style="color:var(--accent);font-size:12px">[controller]</span>' : ''}</h3>
732
+ <div class="flex" style="gap:6px">
733
+ ${detailActions}
734
+ <span class="status-badge status-${agent.status}">${agent.status}${agent.pid ? ' (PID:' + agent.pid + ')' : ''}</span>
735
+ </div>
736
+ </div>
737
+ <div style="font-size:12px;color:var(--text-secondary);margin-top:4px">${esc(agent.role)}</div>
738
+ </div>
739
+
740
+ <div id="agent-detail-scroll" style="padding:16px 20px">
741
+ ${readonlyNote}
742
+ <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px 16px;font-size:12px;color:var(--text-secondary);margin-bottom:16px">
743
+ <div>Started: <span style="color:var(--fg)">${formatLocalDateTime(agent.started_at)}</span></div>
744
+ <div>Finished: <span style="color:var(--fg)">${formatLocalDateTime(agent.finished_at)}</span></div>
745
+ <div>Session: <code style="color:var(--fg);font-size:10px">${agent.session_id ? agent.session_id.slice(0, 8) + '...' : 'none'}</code></div>
746
+ </div>
747
+
748
+ <div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-bottom:16px">
749
+ <div style="padding:10px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px">
750
+ <div style="${L}">Direct Parent</div>
751
+ <div style="font-size:13px;color:var(--fg)">${parentAgent ? esc(parentAgent.name) : 'None'}</div>
752
+ <div style="font-size:11px;color:var(--text-secondary);margin-top:4px">${parentAgent ? 'Messages are limited to this parent and direct reports.' : 'Without a parent, this agent is not restricted by hierarchy messaging rules.'}</div>
753
+ </div>
754
+ <div style="padding:10px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px">
755
+ <div style="${L}">Direct Reports</div>
756
+ <div style="font-size:13px;color:var(--fg)">${childAgents.length > 0 ? childAgents.map((child) => esc(child.name)).join(', ') : 'None'}</div>
757
+ <div style="font-size:11px;color:var(--text-secondary);margin-top:4px">${childAgents.length > 0 ? `${childAgents.length} direct reports total.` : 'This agent has no direct reports.'}</div>
758
+ </div>
759
+ </div>
760
+
761
+ <div id="agent-git-status-${agentId}" style="margin-bottom:16px"></div>
762
+
763
+ <div id="agent-cost-${agentId}" style="margin-bottom:16px"></div>
764
+
765
+ <div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap">
766
+ <div style="flex:1;min-width:200px">
767
+ <div style="${L}">Working Directory</div>
768
+ <input type="text" id="ad-workdir-${agentId}" value="${esc(agent.working_directory || '')}" placeholder="(default)" ${readOnlyAttr} style="${B};width:100%;font-size:12px;font-family:monospace;color:var(--fg)">
769
+ </div>
770
+ <div style="flex:1;min-width:200px">
771
+ <div style="${L}">Tool Path</div>
772
+ <input type="text" id="ad-cmdtpl-${agentId}" value="${esc(agent.command_template || '')}" placeholder="(use project default)" ${readOnlyAttr} style="${B};width:100%;font-size:12px;font-family:monospace;color:var(--fg)">
773
+ <div style="font-size:10px;color:var(--text-secondary);opacity:0.6;margin-top:2px">Leave blank to use the project default.</div>
774
+ </div>
775
+ <div style="width:140px">
776
+ <div style="${L}">Max Cache Tokens</div>
777
+ <input type="number" id="ad-maxtokens-${agentId}" value="${agent.session_max_tokens ?? 200000}" min="0" ${readOnlyAttr} style="${B};width:80px;font-size:12px;color:var(--fg);text-align:center">
778
+ <div style="font-size:10px;color:var(--text-secondary);opacity:0.6;margin-top:2px">0 = run-count mode</div>
779
+ </div>
780
+ <div style="width:120px">
781
+ <div style="${L}">Max Runs/Session</div>
782
+ <input type="number" id="ad-maxruns-${agentId}" value="${agent.session_max_runs ?? 10}" min="1" ${readOnlyAttr} style="${B};width:60px;font-size:12px;color:var(--fg);text-align:center">
783
+ </div>
784
+ <div style="width:140px">
785
+ <div style="${L}">Resume Timeout(s)</div>
786
+ <input type="number" id="ad-resumetimeout-${agentId}" value="${agent.session_resume_timeout ?? 300}" min="0" ${readOnlyAttr} style="${B};width:80px;font-size:12px;color:var(--fg);text-align:center">
787
+ <div style="font-size:10px;color:var(--text-secondary);opacity:0.6;margin-top:2px">0 = unlimited</div>
788
+ </div>
789
+ <div style="min-width:220px;flex:1">
790
+ <div style="${L}">Parent Agent</div>
791
+ <select id="ad-parent-${agentId}" ${!canManage || agent.is_controller ? 'disabled' : ''} style="${B};width:100%;font-size:12px;color:var(--fg)">
792
+ ${buildParentAgentOptions(agentId, agent.parent_agent_id)}
793
+ </select>
794
+ <div style="font-size:10px;color:var(--text-secondary);opacity:0.6;margin-top:2px">${agent.is_controller ? 'The controller stays at the root by default.' : 'You cannot choose this agent or its descendants as the parent.'}</div>
795
+ </div>
796
+ </div>
797
+
798
+ <div style="margin-bottom:16px">
799
+ <div style="${L}">Custom Instructions</div>
800
+ <textarea id="ad-instructions-${agentId}" rows="3" ${readOnlyAttr} style="${B};width:100%;font-size:12px;font-family:inherit;color:var(--fg);resize:vertical" placeholder="Extra instructions appended to system prompt...">${esc(agent.custom_instructions || '')}</textarea>
801
+ </div>
802
+
803
+ <div style="margin-bottom:16px;text-align:right">
804
+ ${saveSettingsButton}
805
+ </div>
806
+
807
+ <div style="margin-bottom:16px">
808
+ <div style="${L};cursor:pointer;user-select:none" onclick="toggleAgentSystemPrompt('${agentId}')">
809
+ <span id="agent-sysprompt-arrow-${agentId}">▶</span> System Prompt (auto-generated)
810
+ </div>
811
+ <pre id="agent-sysprompt-${agentId}" style="display:none;${B};font-size:11px;max-height:400px;overflow-y:auto;white-space:pre-wrap;word-break:break-word;color:var(--text-secondary);margin:0"></pre>
812
+ </div>
813
+
814
+ <div style="margin-bottom:16px">
815
+ <div style="${L};cursor:pointer;user-select:none" onclick="toggleRunHistory('${agentId}')">
816
+ <span id="agent-runs-arrow-${agentId}">▶</span> Run History
817
+ </div>
818
+ <div id="agent-runs-${agentId}" style="display:none"></div>
819
+ </div>
820
+
821
+ <div>
822
+ <div style="${L}">History</div>
823
+ <div id="agent-output-${agentId}" style="color:var(--text-secondary);font-size:12px">Loading output...</div>
824
+ </div>
825
+ </div>
826
+ </div>
827
+ `;
828
+
829
+ // Step 2: Load cost, git status, and logs async (doesn't block config display)
830
+ loadAgentCost(agentId);
831
+ loadAgentGitStatus(agentId);
832
+ loadAgentOutput(agentId);
833
+ startAgentOutputPolling(agentId);
834
+
835
+ } catch (e) {
836
+ stopAgentOutputPolling();
837
+ el.innerHTML = '<div class="card">' + renderError(e, 'viewAgent(\'' + agentId + '\')') + '</div>';
838
+ }
839
+ }
840
+
841
+ async function loadAgentCost(agentId) {
842
+ const container = document.getElementById('agent-cost-' + agentId);
843
+ if (!container) return;
844
+ try {
845
+ const res = await fetch(`/api/agents/${agentId}/costs`, { headers: apiHeaders() });
846
+ if (!res.ok) { container.innerHTML = ''; return; }
847
+ const data = await res.json();
848
+ if (data.total_runs === 0) { container.innerHTML = ''; return; }
849
+
850
+ const fmtCostAgent = v => v > 0 ? (v < 0.01 ? '<$0.01' : '$' + v.toFixed(2)) : 'N/A';
851
+ const fmtTokens = v => v >= 1000000 ? (v / 1000000).toFixed(1) + 'M' : v >= 1000 ? (v / 1000).toFixed(1) + 'K' : v;
852
+ const avgCost = data.total_runs > 0 ? data.total_cost_usd / data.total_runs : 0;
853
+
854
+ container.innerHTML = `
855
+ <div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;font-size:12px">
856
+ <div style="padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px">
857
+ <div style="font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.03em;opacity:0.6;margin-bottom:2px">Total Cost</div>
858
+ <div style="font-size:16px;font-weight:600;color:var(--accent)">${fmtCostAgent(data.total_cost_usd)}</div>
859
+ </div>
860
+ <div style="padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px">
861
+ <div style="font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.03em;opacity:0.6;margin-bottom:2px">Avg/Run</div>
862
+ <div style="font-size:16px;font-weight:600">${fmtCostAgent(avgCost)}</div>
863
+ </div>
864
+ <div style="padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px">
865
+ <div style="font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.03em;opacity:0.6;margin-bottom:2px">Runs</div>
866
+ <div style="font-size:16px;font-weight:600">${data.total_runs}</div>
867
+ </div>
868
+ <div style="padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px">
869
+ <div style="font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.03em;opacity:0.6;margin-bottom:2px">Tokens</div>
870
+ <div style="font-size:14px;font-weight:600">${fmtTokens(data.total_input_tokens)}↑ ${fmtTokens(data.total_output_tokens)}↓</div>
871
+ </div>
872
+ </div>`;
873
+ } catch {
874
+ container.innerHTML = '';
875
+ }
876
+ }
877
+
878
+ async function loadAgentGitStatus(agentId) {
879
+ const container = document.getElementById('agent-git-status-' + agentId);
880
+ if (!container) return;
881
+ try {
882
+ const res = await fetch(`/api/agents/${agentId}/git-status`, { headers: apiHeaders() });
883
+ if (!res.ok) { container.innerHTML = ''; return; }
884
+ const data = await res.json();
885
+ if (!data.branch) { container.innerHTML = ''; return; }
886
+
887
+ const lastCommit = data.recent_commits && data.recent_commits[0]
888
+ ? `<code style="color:var(--accent)">${esc(data.recent_commits[0].hash)}</code> ${esc(data.recent_commits[0].message.slice(0, 50))} <span style="color:var(--text-secondary)">${timeAgo(data.recent_commits[0].date)}</span>`
889
+ : '<span style="color:var(--text-secondary)">no commits</span>';
890
+ const uncommitted = data.has_uncommitted
891
+ ? `<span style="color:var(--warning)"> | ${(data.uncommitted_files || []).length} uncommitted files</span>` : '';
892
+
893
+ container.innerHTML = `
894
+ <div style="padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:12px">
895
+ <span style="font-family:monospace;background:var(--card);padding:2px 8px;border-radius:10px;border:1px solid var(--border)">${esc(data.branch)}</span>
896
+ <span style="margin-left:8px">Last commit: ${lastCommit}</span>${uncommitted}
897
+ </div>`;
898
+ } catch {
899
+ container.innerHTML = '';
900
+ }
901
+ }
902
+
903
+ async function loadAgentOutput(agentId, options) {
904
+ const opts = options || {};
905
+ const container = document.getElementById('agent-output-' + agentId);
906
+ if (!container) return;
907
+ if (opts.silent && agentOutputRefreshInFlight) return;
908
+ agentOutputRefreshInFlight = true;
909
+ try {
910
+ const logsRes = await fetch(`/api/agents/${agentId}/logs?limit=100`, { headers: apiHeaders() });
911
+ const logs = logsRes.ok ? await logsRes.json() : [];
912
+ logs.reverse();
913
+
914
+ // Group by run, only show last 3 runs
915
+ const runs = [];
916
+ let curRun = null;
917
+ for (const l of logs) {
918
+ if (l.run_id !== curRun) { curRun = l.run_id; runs.push({ id: l.run_id, logs: [] }); }
919
+ runs[runs.length - 1].logs.push(l);
920
+ }
921
+ // Show last 5 runs, oldest first (newest at bottom)
922
+ const recentRuns = runs.slice(-5);
923
+
924
+ const html = recentRuns.map((run, idx) => {
925
+ const filtered = run.logs.filter(l =>
926
+ l.stream !== 'cost' && !l.content.includes('proxychains') &&
927
+ !l.content.includes('Executing through proxy') && !l.content.includes('Port 7897')
928
+ );
929
+ if (!filtered.length) return '';
930
+ const content = filtered.map(l => {
931
+ const ts = l.created_at ? `<span style="color:var(--text-secondary);opacity:0.7;cursor:default" title="${esc(formatLocalDateTime(l.created_at))}">[${esc(formatLocalTime(l.created_at))}]</span> ` : '';
932
+ if (l.stream === 'stdin') {
933
+ const inputHtml = renderCollapsibleText(l.content, { previewChars: 240, style: 'display:flex;width:100%;margin-top:4px' });
934
+ return `<div style="background:var(--accent-bg, rgba(59,130,246,0.08));border-left:3px solid var(--accent);padding:4px 8px;margin:4px 0;border-radius:0 4px 4px 0">${ts}<span style="color:var(--accent);font-weight:600">▶ INPUT</span><div>${inputHtml}</div></div>`;
935
+ }
936
+ const text = l.content.length > 1500 ? l.content.slice(0, 1500) + '\n... (truncated)' : l.content;
937
+ const msg = l.stream === 'stderr' ? `<span style="color:var(--error)">${esc(text)}</span>` : esc(text);
938
+ return ts + msg;
939
+ }).join('');
940
+ const label = idx === recentRuns.length - 1 ? 'Latest Run' : `${recentRuns.length - idx} runs ago`;
941
+ return `<div style="margin-bottom:8px"><div style="font-size:10px;font-weight:600;color:var(--text-secondary);margin-bottom:2px">${label}</div><div>${content}</div></div>`;
942
+ }).filter(Boolean).join('<hr style="border:none;border-top:1px solid var(--border);margin:8px 0">');
943
+
944
+ const nextHtml = html
945
+ ? `<div style="padding:10px;background:var(--bg);border:1px solid var(--border);border-radius:4px;font-size:12px;font-family:monospace;white-space:pre-wrap;word-break:break-word;margin:0;line-height:1.5;overflow-x:hidden;max-height:400px;overflow-y:auto">${html}</div>`
946
+ : '<span style="color:var(--text-secondary)">No history yet.</span>';
947
+
948
+ const prevScroller = container.firstElementChild;
949
+ const prevScrollTop = prevScroller ? prevScroller.scrollTop : 0;
950
+ const wasNearBottom = prevScroller
951
+ ? (prevScroller.scrollHeight - prevScroller.clientHeight - prevScroller.scrollTop) < 24
952
+ : true;
953
+
954
+ if (container.innerHTML !== nextHtml) {
955
+ container.innerHTML = nextHtml;
956
+ const scroller = container.firstElementChild;
957
+ if (scroller) {
958
+ if (wasNearBottom || !opts.silent) scroller.scrollTop = scroller.scrollHeight;
959
+ else scroller.scrollTop = prevScrollTop;
960
+ }
961
+ }
962
+ } catch {
963
+ if (!opts.silent) {
964
+ container.innerHTML = renderError(null, 'loadAgentOutput(\'' + agentId + '\')');
965
+ }
966
+ } finally {
967
+ agentOutputRefreshInFlight = false;
968
+ }
969
+ }
970
+
971
+ async function saveAllAgentFields(agentId) {
972
+ if (!requireProjectManageAccess('Insufficient permission to update agent settings')) return;
973
+ const btn = document.querySelector(`button[onclick="saveAllAgentFields('${agentId}')"]`);
974
+ if (btn) { btn.disabled = true; btn.textContent = 'Saving...'; }
975
+ try {
976
+ const instructionsVal = document.getElementById('ad-instructions-' + agentId).value;
977
+ const maxTokensRaw = parseInt(document.getElementById('ad-maxtokens-' + agentId).value, 10);
978
+ const maxRunsRaw = parseInt(document.getElementById('ad-maxruns-' + agentId).value, 10);
979
+ const resumeTimeoutRaw = parseInt(document.getElementById('ad-resumetimeout-' + agentId).value, 10);
980
+ const parentAgentId = document.getElementById('ad-parent-' + agentId)?.value || null;
981
+ const body = {
982
+ working_directory: document.getElementById('ad-workdir-' + agentId).value || null,
983
+ command_template: document.getElementById('ad-cmdtpl-' + agentId).value || null,
984
+ parent_agent_id: parentAgentId,
985
+ session_max_tokens: Number.isNaN(maxTokensRaw) ? 200000 : Math.max(0, maxTokensRaw),
986
+ session_max_runs: Number.isNaN(maxRunsRaw) ? 10 : Math.max(1, maxRunsRaw),
987
+ session_resume_timeout: Number.isNaN(resumeTimeoutRaw) ? 0 : Math.max(0, resumeTimeoutRaw),
988
+ custom_instructions: instructionsVal.trim() === '' ? null : instructionsVal
989
+ };
990
+ const res = await fetch(`/api/agents/${agentId}`, { method: 'PUT', headers: apiHeaders(), body: JSON.stringify(body) });
991
+ if (res.ok) {
992
+ await loadAgents();
993
+ await viewAgent(agentId);
994
+ showToast('Saved', 'success');
995
+ } else {
996
+ const err = await res.json().catch(() => ({}));
997
+ showToast(err.error || 'Failed to save', 'error');
998
+ }
999
+ } catch (e) {
1000
+ console.error('Failed to save agent fields', e);
1001
+ showToast('Failed to save: network error', 'error');
1002
+ } finally {
1003
+ if (btn) { btn.disabled = false; btn.textContent = 'Save Settings'; }
1004
+ }
1005
+ }
1006
+
1007
+ async function toggleAgentSystemPrompt(agentId) {
1008
+ const el = document.getElementById('agent-sysprompt-' + agentId);
1009
+ const arrow = document.getElementById('agent-sysprompt-arrow-' + agentId);
1010
+ if (!el) return;
1011
+ if (el.style.display !== 'none') {
1012
+ el.style.display = 'none';
1013
+ if (arrow) arrow.textContent = '▶';
1014
+ return;
1015
+ }
1016
+ el.style.display = '';
1017
+ if (arrow) arrow.textContent = '▼';
1018
+ if (el.textContent) return;
1019
+ el.innerHTML = renderLoading('', true);
1020
+ try {
1021
+ const res = await fetch(`/api/agents/${agentId}/system-prompt`, { headers: apiHeaders() });
1022
+ if (res.ok) { const data = await res.json(); el.textContent = data.prompt; }
1023
+ else { el.innerHTML = renderError({ status: res.status }); }
1024
+ } catch (e) { el.innerHTML = renderError(e); }
1025
+ }
1026
+
1027
+ async function toggleRunHistory(agentId) {
1028
+ const el = document.getElementById('agent-runs-' + agentId);
1029
+ const arrow = document.getElementById('agent-runs-arrow-' + agentId);
1030
+ if (!el) return;
1031
+ if (el.style.display !== 'none') {
1032
+ el.style.display = 'none';
1033
+ if (arrow) arrow.textContent = '▶';
1034
+ return;
1035
+ }
1036
+ el.style.display = '';
1037
+ if (arrow) arrow.textContent = '▼';
1038
+ if (el.innerHTML) return;
1039
+ el.innerHTML = renderLoading('Loading runs...', true);
1040
+ await loadRunHistory(agentId);
1041
+ }
1042
+
1043
+ async function loadRunHistory(agentId) {
1044
+ const container = document.getElementById('agent-runs-' + agentId);
1045
+ if (!container) return;
1046
+ try {
1047
+ const res = await fetch(`/api/agents/${agentId}/runs?limit=10`, { headers: apiHeaders() });
1048
+ if (!res.ok) { container.innerHTML = renderError({ status: res.status }, 'loadRunHistory(\'' + agentId + '\')'); return; }
1049
+ const data = await res.json();
1050
+ const runs = data.runs || [];
1051
+ if (!runs.length) { container.innerHTML = '<span style="color:var(--text-secondary);font-size:12px">No runs yet.</span>'; return; }
1052
+
1053
+ const fmtCost = v => v > 0 ? (v < 0.01 ? '<$0.01' : '$' + v.toFixed(2)) : '';
1054
+ const fmtTokens = v => v >= 1000000 ? (v / 1000000).toFixed(1) + 'M' : v >= 1000 ? (v / 1000).toFixed(1) + 'K' : v;
1055
+ const fmtDur = ms => {
1056
+ if (!ms) return '-';
1057
+ if (ms < 60000) return Math.round(ms / 1000) + 's';
1058
+ return Math.round(ms / 60000) + 'm ' + Math.round((ms % 60000) / 1000) + 's';
1059
+ };
1060
+
1061
+ container.innerHTML = `<div style="display:flex;flex-direction:column;gap:6px">${runs.map((r, idx) => {
1062
+ const statusColor = r.status === 'error' ? 'var(--error)' : 'var(--success)';
1063
+ const statusIcon = r.status === 'error' ? '✕' : '✓';
1064
+ const costLabel = r.cost_usd > 0 ? fmtCost(r.cost_usd) : (r.input_tokens > 0 || r.output_tokens > 0 ? fmtTokens(r.input_tokens) + '↑' + fmtTokens(r.output_tokens) + '↓' : '-');
1065
+ return `<div style="padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:12px;cursor:pointer" onclick="viewRunReport('${agentId}','${r.run_id}')">
1066
+ <div style="display:flex;align-items:center;gap:10px;justify-content:space-between">
1067
+ <div style="display:flex;align-items:center;gap:8px">
1068
+ <span style="color:${statusColor};font-weight:600">${statusIcon}</span>
1069
+ <span style="color:var(--text-secondary)">${timeAgo(r.started_at)}</span>
1070
+ </div>
1071
+ <div style="display:flex;gap:12px;color:var(--text-secondary);font-size:11px">
1072
+ <span title="Tools">\u{1F527} ${r.tool_call_count}</span>
1073
+ <span title="${r.cost_usd > 0 ? 'Cost' : 'Tokens'}">${costLabel}</span>
1074
+ <span title="Duration">${fmtDur(r.duration_ms)}</span>
1075
+ </div>
1076
+ </div>
1077
+ ${r.result_snippet ? `<div style="margin-top:4px;color:var(--fg);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(r.result_snippet.slice(0, 120))}</div>` : ''}
1078
+ </div>`;
1079
+ }).join('')}</div>`;
1080
+ } catch {
1081
+ container.innerHTML = renderError(null, 'loadRunHistory(\'' + agentId + '\')');
1082
+ }
1083
+ }
1084
+
1085
+ async function viewRunReport(agentId, runId) {
1086
+ const container = document.getElementById('agent-runs-' + agentId);
1087
+ if (!container) return;
1088
+ container.innerHTML = renderLoading('Loading report...', true);
1089
+ try {
1090
+ const res = await fetch(`/api/agents/${agentId}/runs/${runId}/report`, { headers: apiHeaders() });
1091
+ if (!res.ok) { container.innerHTML = renderError({ status: res.status }, 'viewRunReport(\'' + agentId + '\',\'' + runId + '\')'); return; }
1092
+ const r = await res.json();
1093
+
1094
+ const fmtCost = v => v > 0 ? (v < 0.01 ? '<$0.01' : '$' + v.toFixed(4)) : 'N/A';
1095
+ const fmtTokens = v => v >= 1000000 ? (v / 1000000).toFixed(1) + 'M' : v >= 1000 ? (v / 1000).toFixed(1) + 'K' : String(v);
1096
+ const fmtDur = ms => {
1097
+ if (!ms) return '-';
1098
+ if (ms < 60000) return Math.round(ms / 1000) + 's';
1099
+ return Math.round(ms / 60000) + 'm ' + Math.round((ms % 60000) / 1000) + 's';
1100
+ };
1101
+ const statusColor = r.status === 'error' ? 'var(--error)' : 'var(--success)';
1102
+
1103
+ // Tool frequency
1104
+ const toolFreqHtml = Object.entries(r.summary.tool_frequency || {})
1105
+ .sort((a, b) => (b[1]) - (a[1]))
1106
+ .map(([name, count]) => `<span style="padding:2px 8px;background:rgba(88,166,255,0.1);border:1px solid rgba(88,166,255,0.3);border-radius:12px;font-size:10px">${esc(name)} ×${count}</span>`)
1107
+ .join(' ');
1108
+
1109
+ // File changes
1110
+ const filesHtml = (r.summary.files_changed || []).map(f =>
1111
+ `<div style="font-family:monospace;font-size:11px;padding:2px 0">${esc(f)}</div>`
1112
+ ).join('') || '<span style="color:var(--text-secondary)">None</span>';
1113
+
1114
+ // Tool call timeline
1115
+ const toolsHtml = (r.tool_calls || []).map((tc, i) => {
1116
+ const inputHtml = renderCollapsibleText(tc.input, { previewChars: 100, style: 'width:100%' });
1117
+ return `<div style="padding:4px 0;border-bottom:1px solid var(--border);font-size:11px">
1118
+ <div style="display:flex;gap:6px;align-items:flex-start">
1119
+ <span style="color:var(--accent);font-weight:600;min-width:20px">${i + 1}.</span>
1120
+ <span style="color:var(--accent);font-weight:500">${esc(tc.name)}</span>
1121
+ <div style="min-width:0;flex:1;color:var(--text-secondary);font-family:monospace">${inputHtml}</div>
1122
+ </div>
1123
+ ${tc.result ? `<div style="margin-left:26px;color:var(--text-secondary);font-family:monospace;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:500px">${esc(tc.result.slice(0, 150))}</div>` : ''}
1124
+ </div>`;
1125
+ }).join('');
1126
+
1127
+ container.innerHTML = `
1128
+ <div style="padding:12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:12px">
1129
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
1130
+ <button class="btn btn-sm" onclick="loadRunHistory('${agentId}')" style="font-size:11px">← Back to runs</button>
1131
+ <span style="color:${statusColor};font-weight:600">${r.status === 'error' ? 'Failed' : 'Success'}</span>
1132
+ </div>
1133
+
1134
+ <div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:12px">
1135
+ <div style="padding:6px 10px;background:var(--surface);border:1px solid var(--border);border-radius:6px;text-align:center">
1136
+ <div style="font-size:10px;text-transform:uppercase;opacity:0.6">Duration</div>
1137
+ <div style="font-size:14px;font-weight:600">${fmtDur(r.cost?.duration_ms)}</div>
1138
+ </div>
1139
+ <div style="padding:6px 10px;background:var(--surface);border:1px solid var(--border);border-radius:6px;text-align:center">
1140
+ <div style="font-size:10px;text-transform:uppercase;opacity:0.6">Cost</div>
1141
+ <div style="font-size:14px;font-weight:600;color:var(--accent)">${r.cost ? fmtCost(r.cost.total_usd) : '-'}</div>
1142
+ </div>
1143
+ <div style="padding:6px 10px;background:var(--surface);border:1px solid var(--border);border-radius:6px;text-align:center">
1144
+ <div style="font-size:10px;text-transform:uppercase;opacity:0.6">Tools</div>
1145
+ <div style="font-size:14px;font-weight:600">${r.summary.total_tool_calls}</div>
1146
+ </div>
1147
+ <div style="padding:6px 10px;background:var(--surface);border:1px solid var(--border);border-radius:6px;text-align:center">
1148
+ <div style="font-size:10px;text-transform:uppercase;opacity:0.6">Tokens</div>
1149
+ <div style="font-size:14px;font-weight:600">${r.cost ? fmtTokens(r.cost.input_tokens) + '↑ ' + fmtTokens(r.cost.output_tokens) + '↓' : '-'}</div>
1150
+ </div>
1151
+ </div>
1152
+
1153
+ ${r.error_message ? `<div style="margin-bottom:12px;padding:8px;background:rgba(220,50,47,0.1);border:1px solid rgba(220,50,47,0.3);border-radius:4px;font-size:11px;color:var(--error);font-family:monospace;white-space:pre-wrap">${esc(r.error_message.slice(0, 500))}</div>` : ''}
1154
+
1155
+ ${toolFreqHtml ? `<div style="margin-bottom:12px"><div style="font-size:10px;font-weight:600;text-transform:uppercase;opacity:0.6;margin-bottom:4px">Tool Usage</div><div style="display:flex;gap:6px;flex-wrap:wrap">${toolFreqHtml}</div></div>` : ''}
1156
+
1157
+ ${r.summary.files_changed.length > 0 ? `<div style="margin-bottom:12px"><div style="font-size:10px;font-weight:600;text-transform:uppercase;opacity:0.6;margin-bottom:4px">Files Changed (${r.summary.files_changed.length})</div>${filesHtml}</div>` : ''}
1158
+
1159
+ ${r.final_result ? `<div style="margin-bottom:12px"><div style="font-size:10px;font-weight:600;text-transform:uppercase;opacity:0.6;margin-bottom:4px">Final Result</div><pre style="padding:8px;background:var(--surface);border:1px solid var(--border);border-radius:4px;font-size:11px;white-space:pre-wrap;word-break:break-word;margin:0;max-height:200px;overflow-y:auto">${esc(r.final_result.slice(0, 1000))}</pre></div>` : ''}
1160
+
1161
+ ${toolsHtml ? `<div><div style="font-size:10px;font-weight:600;text-transform:uppercase;opacity:0.6;margin-bottom:4px">Tool Call Timeline (${r.tool_calls.length})</div><div style="max-height:300px;overflow-y:auto">${toolsHtml}</div></div>` : ''}
1162
+ </div>`;
1163
+ } catch (e) {
1164
+ container.innerHTML = renderError(e, 'viewRunReport(\'' + agentId + '\',\'' + runId + '\')');
1165
+ }
1166
+ }
1167
+
1168
+ function closeAgentDetail() {
1169
+ console.log('closeAgentDetail called');
1170
+ stopAgentOutputPolling();
1171
+ currentAgentId = null;
1172
+ const el = document.getElementById('agent-detail');
1173
+ if (el) { el.style.display = 'none'; el.innerHTML = ''; }
1174
+ document.querySelectorAll('#agent-list .agent-item').forEach(li => li.style.background = '');
1175
+ }
1176
+
1177
+ async function deleteAgent(id) {
1178
+ if (!requireProjectManageAccess('Insufficient permission to delete agent')) return;
1179
+ const agent = agentsData.find(a => a.id === id);
1180
+ if (!await showConfirm(`Delete agent "${agent?.name || id}"?`)) return;
1181
+ const res = await fetch(`/api/agents/${id}`, { method: 'DELETE' });
1182
+ if (res.ok) {
1183
+ if (currentAgentId === id) closeAgentDetail();
1184
+ loadAgents(); showToast('Agent deleted', 'success');
1185
+ } else { showToast('Failed to delete', 'error'); }
1186
+ }
1187
+
1188
+ async function retryAgent(id) {
1189
+ if (!requireProjectManageAccess('Insufficient permission to retry agent')) return;
1190
+ const btn = event ? event.target : null;
1191
+ await withLoading(btn, async () => {
1192
+ const res = await fetch(`/api/agents/${id}/retry`, { method: 'POST', headers: apiHeaders(), body: JSON.stringify({}) });
1193
+ if (res.ok) { loadAgents(); showToast('Agent retried', 'success'); } else { const e = await res.json().catch(() => ({})); showToast(e.error || 'Failed to retry', 'error'); }
1194
+ });
1195
+ }
1196
+
1197
+ function openTerminal(agentId) {
1198
+ if (!requireProjectManageAccess('Insufficient permission to open the agent terminal')) return;
1199
+ window.location.href = `/terminal?agentId=${agentId}&newSession=true`;
1200
+ }
1201
+
1202
+ async function quickStartAgent(id) {
1203
+ if (!requireProjectManageAccess('Insufficient permission to start agent')) return;
1204
+ const btn = event ? event.target : null;
1205
+ await withLoading(btn, async () => {
1206
+ const res = await fetch(`/api/agents/${id}/start`, { method: 'POST', headers: apiHeaders(), body: JSON.stringify({}) });
1207
+ if (res.ok) { loadAgents(); showToast('Agent started', 'success'); } else { const e = await res.json().catch(() => ({})); showToast(e.error || 'Failed to start', 'error'); }
1208
+ });
1209
+ }
1210
+ async function pauseAgent(id) {
1211
+ if (!requireProjectManageAccess('Insufficient permission to pause agent')) return;
1212
+ const btn = event ? event.target : null;
1213
+ await withLoading(btn, async () => {
1214
+ const res = await fetch(`/api/agents/${id}/pause`, { method: 'POST', headers: apiHeaders(), body: '{}' });
1215
+ if (res.ok) { loadAgents(); showToast('Agent paused', 'success'); } else { const e = await res.json().catch(() => ({})); showToast(e.error || 'Failed to pause', 'error'); }
1216
+ });
1217
+ }
1218
+
1219
+ async function unpauseAgent(id) {
1220
+ if (!requireProjectManageAccess('Insufficient permission to resume agent')) return;
1221
+ const btn = event ? event.target : null;
1222
+ await withLoading(btn, async () => {
1223
+ const res = await fetch(`/api/agents/${id}/unpause`, { method: 'POST', headers: apiHeaders(), body: '{}' });
1224
+ if (res.ok) { loadAgents(); showToast('Agent resumed', 'success'); } else { const e = await res.json().catch(() => ({})); showToast(e.error || 'Failed to resume', 'error'); }
1225
+ });
1226
+ }
1227
+
1228
+ async function stopAgentById(id) {
1229
+ if (!requireProjectManageAccess('Insufficient permission to stop agent')) return;
1230
+ if (!await showConfirm('Stop this agent?')) return;
1231
+ const btn = event ? event.target : null;
1232
+ await withLoading(btn, async () => {
1233
+ const res = await fetch(`/api/agents/${id}/stop`, { method: 'POST', headers: apiHeaders(), body: '{}' });
1234
+ if (res.ok) { showToast('Agent stopped', 'success'); } else { const e = await res.json().catch(() => ({})); showToast(e.error || 'Failed to stop', 'error'); }
1235
+ loadAgents();
1236
+ });
1237
+ }
1238
+
1239
+ function showCreateAgentModal() {
1240
+ if (!projectData?.can_manage) { showToast('Insufficient permission to create agent', 'error'); return; }
1241
+ document.getElementById('agent-name').value = '';
1242
+ document.getElementById('agent-role').value = '';
1243
+ document.getElementById('agent-workdir').value = '';
1244
+ syncParentAgentSelect('agent-parent', null, '', false);
1245
+ document.getElementById('agent-cmdtpl').value = '';
1246
+ document.getElementById('createAgentModal').classList.add('active');
1247
+ }
1248
+ function hideModal(id) { document.getElementById(id).classList.remove('active'); }
1249
+
1250
+ async function createAgent() {
1251
+ if (!requireProjectManageAccess('Insufficient permission to create agent')) return;
1252
+ const btn = document.querySelector('#createAgentModal button[onclick="createAgent()"]');
1253
+ await withLoading(btn, async () => {
1254
+ const body = {
1255
+ name: document.getElementById('agent-name').value,
1256
+ role: document.getElementById('agent-role').value,
1257
+ working_directory: document.getElementById('agent-workdir').value || undefined,
1258
+ parent_agent_id: document.getElementById('agent-parent').value || null,
1259
+ command_template: document.getElementById('agent-cmdtpl').value.trim() || undefined
1260
+ };
1261
+ if (!body.name) { showToast('Name is required', 'error'); return; }
1262
+ const res = await fetch(`/api/projects/${projectId}/agents`, { method: 'POST', headers: apiHeaders(), body: JSON.stringify(body) });
1263
+ if (res.ok) { hideModal('createAgentModal'); loadAgents(); showToast('Agent created', 'success'); } else { const e = await res.json().catch(() => ({})); showToast(e.error || 'Failed to create', 'error'); }
1264
+ });
1265
+ }
1266
+
1267
+ // ─── Issues ───
1268
+
1269
+ let currentIssueFilter = 'open';
1270
+ let currentIssuePage = 1;
1271
+
1272
+ // Restore filter/search state from URL params
1273
+ (function restoreIssueFilterState() {
1274
+ const params = new URLSearchParams(window.location.search);
1275
+ if (params.has('status')) currentIssueFilter = params.get('status');
1276
+ if (params.has('page')) currentIssuePage = parseInt(params.get('page')) || 1;
1277
+ if (params.has('q')) {
1278
+ setTimeout(() => {
1279
+ const el = document.getElementById('issue-search');
1280
+ if (el) el.value = params.get('q');
1281
+ }, 0);
1282
+ }
1283
+ })();
1284
+
1285
+ function updateIssueUrlParams() {
1286
+ const params = new URLSearchParams(window.location.search);
1287
+ const q = document.getElementById('issue-search')?.value?.trim() || '';
1288
+ if (currentIssueFilter) params.set('status', currentIssueFilter); else params.delete('status');
1289
+ if (q) params.set('q', q); else params.delete('q');
1290
+ if (currentIssuePage > 1) params.set('page', currentIssuePage); else params.delete('page');
1291
+ const newUrl = params.toString() ? `${window.location.pathname}?${params}${window.location.hash}` : `${window.location.pathname}${window.location.hash}`;
1292
+ history.replaceState(null, '', newUrl);
1293
+ }
1294
+
1295
+ function renderActiveFilters() {
1296
+ const el = document.getElementById('issue-active-filters');
1297
+ if (!el) return;
1298
+ const q = document.getElementById('issue-search')?.value?.trim() || '';
1299
+ const chips = [];
1300
+ if (currentIssueFilter) {
1301
+ chips.push(`<span style="display:inline-flex;align-items:center;gap:4px;padding:2px 8px;background:var(--selected-bg);border-radius:4px;font-size:11px">Status: ${currentIssueFilter} <span onclick="clearIssueFilter()" style="cursor:pointer;opacity:0.6;font-weight:bold" title="Clear">&times;</span></span>`);
1302
+ }
1303
+ if (q) {
1304
+ chips.push(`<span style="display:inline-flex;align-items:center;gap:4px;padding:2px 8px;background:var(--selected-bg);border-radius:4px;font-size:11px">Search: "${esc(q)}" <span onclick="clearIssueSearch()" style="cursor:pointer;opacity:0.6;font-weight:bold" title="Clear">&times;</span></span>`);
1305
+ }
1306
+ if (chips.length > 1) {
1307
+ chips.push(`<span onclick="clearAllIssueFilters()" style="cursor:pointer;color:var(--accent);font-size:11px;text-decoration:underline">Clear all filters</span>`);
1308
+ }
1309
+ el.style.display = chips.length ? 'flex' : 'none';
1310
+ el.innerHTML = chips.join('');
1311
+ }
1312
+
1313
+ function clearIssueFilter() { currentIssueFilter = ''; currentIssuePage = 1; loadIssues(); }
1314
+ function clearIssueSearch() {
1315
+ const el = document.getElementById('issue-search');
1316
+ if (el) el.value = '';
1317
+ currentIssuePage = 1;
1318
+ loadIssues();
1319
+ }
1320
+ function clearAllIssueFilters() {
1321
+ currentIssueFilter = '';
1322
+ const el = document.getElementById('issue-search');
1323
+ if (el) el.value = '';
1324
+ currentIssuePage = 1;
1325
+ loadIssues();
1326
+ }
1327
+
1328
+ const LABEL_COLORS = ['#e06c75','#98c379','#e5c07b','#61afef','#c678dd','#56b6c2','#d19a66','#b5bd68','#cc6666','#8abeb7'];
1329
+ function issueLabelHtml(text) {
1330
+ const h = hashCode(text.trim());
1331
+ const bg = LABEL_COLORS[h % LABEL_COLORS.length];
1332
+ return `<span style="font-size:10px;padding:1px 6px;border-radius:12px;background:${bg}22;color:${bg};border:1px solid ${bg}44">${esc(text.trim())}</span>`;
1333
+ }
1334
+
1335
+ async function loadIssues() {
1336
+ const sort = document.getElementById('issue-sort')?.value || 'priority';
1337
+ const q = document.getElementById('issue-search')?.value?.trim() || '';
1338
+
1339
+ // Fetch counts via lightweight endpoint
1340
+ const countsRes = await fetch(`/api/projects/${projectId}/issues/counts`, { headers: apiHeaders() });
1341
+ const counts = await countsRes.json();
1342
+ issueCount = counts.total || 0;
1343
+ updateTabCounts();
1344
+
1345
+ // Filter tabs
1346
+ const tabs = document.getElementById('issue-filter-tabs');
1347
+ if (tabs) {
1348
+ const filters = [
1349
+ { key: 'open', label: 'Open', count: counts.open, icon: '<circle cx="8" cy="8" r="7" fill="none" stroke="#3fb950" stroke-width="2"/><circle cx="8" cy="8" r="2" fill="#3fb950"/>' },
1350
+ { key: 'in_progress', label: 'In Progress', count: counts.in_progress, icon: '<circle cx="8" cy="8" r="7" fill="none" stroke="#d29922" stroke-width="2"/><circle cx="8" cy="8" r="2" fill="#d29922"/>' },
1351
+ { key: 'pending', label: 'Pending', count: counts.pending, icon: '<circle cx="8" cy="8" r="7" fill="none" stroke="#d29922" stroke-width="2" stroke-dasharray="4 2"/><circle cx="8" cy="8" r="2" fill="#d29922"/>' },
1352
+ { key: 'done', label: 'Done', count: counts.done, icon: '<circle cx="8" cy="8" r="7" fill="none" stroke="#8b6fcf" stroke-width="2"/><path d="M5.5 8l2 2 3.5-3.5" fill="none" stroke="#8b6fcf" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>' },
1353
+ { key: 'closed', label: 'Closed', count: counts.closed, icon: '<circle cx="8" cy="8" r="7" fill="none" stroke="gray" stroke-width="2"/><line x1="5" y1="5" x2="11" y2="11" stroke="gray" stroke-width="1.5"/><line x1="11" y1="5" x2="5" y2="11" stroke="gray" stroke-width="1.5"/>' },
1354
+ { key: '', label: 'All', count: counts.total || 0 },
1355
+ ];
1356
+ tabs.innerHTML = filters.map(f =>
1357
+ `<span onclick="setIssueFilter('${f.key}')" style="cursor:pointer;padding:4px 10px;border-radius:6px;${currentIssueFilter===f.key?'background:var(--selected-bg);font-weight:600':'color:var(--text-secondary)'}">
1358
+ ${f.icon ? `<svg width="14" height="14" viewBox="0 0 16 16" style="vertical-align:-2px">${f.icon}</svg>` : ''}
1359
+ ${f.count} ${f.label}
1360
+ </span>`
1361
+ ).join('');
1362
+ }
1363
+
1364
+ // Fetch filtered + sorted + paginated
1365
+ let url = `/api/projects/${projectId}/issues?sort=${sort}&page=${currentIssuePage}&per_page=30`;
1366
+ if (currentIssueFilter) url += `&status=${currentIssueFilter}`;
1367
+ if (q) url += `&q=${encodeURIComponent(q)}`;
1368
+ const res = await fetch(url, { headers: apiHeaders() });
1369
+ const data = await res.json();
1370
+ const issues = data.issues || [];
1371
+
1372
+ const container = document.getElementById('issue-list');
1373
+ if (!issues.length) { container.innerHTML = '<div class="card"><div class="empty-state">No issues.</div></div>'; renderPagination(0, 0); return; }
1374
+
1375
+ container.innerHTML = `<div class="card" style="padding:0">${issues.map(i => {
1376
+ const labels = i.labels ? i.labels.split(',').filter(l=>l.trim()).map(l => issueLabelHtml(l)).join(' ') : '';
1377
+ const icon = i.status === 'pending'
1378
+ ? '<svg width="16" height="16" viewBox="0 0 16 16"><circle cx="8" cy="8" r="7" fill="none" stroke="#d29922" stroke-width="2" stroke-dasharray="4 2"/><circle cx="8" cy="8" r="2" fill="#d29922"/></svg>'
1379
+ : (i.status === 'open' || i.status === 'in_progress')
1380
+ ? `<svg width="16" height="16" viewBox="0 0 16 16"><circle cx="8" cy="8" r="7" fill="none" stroke="${i.status==='in_progress'?'#d29922':'#3fb950'}" stroke-width="2"/><circle cx="8" cy="8" r="2" fill="${i.status==='in_progress'?'#d29922':'#3fb950'}"/></svg>`
1381
+ : '<svg width="16" height="16" viewBox="0 0 16 16"><circle cx="8" cy="8" r="7" fill="none" stroke="#8b6fcf" stroke-width="2"/><path d="M5.5 8l2 2 3.5-3.5" fill="none" stroke="#8b6fcf" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
1382
+ return `<a href="/projects/${projectId}/issues/${i.number}" class="issue-list-item" style="text-decoration:none;color:inherit">
1383
+ <div style="flex-shrink:0;margin-top:2px">${icon}</div>
1384
+ <div class="issue-main">
1385
+ <div class="issue-title-row"><span class="issue-title">${esc(i.title)}</span> ${labels}</div>
1386
+ <div class="issue-meta">#${i.number} by ${nameOf(i.created_by)} · ${i.assigned_to ? nameOf(i.assigned_to) : 'unassigned'} · ${timeAgo(i.created_at)}</div>
1387
+ </div>
1388
+ ${i.comment_count ? `<div style="flex-shrink:0;display:flex;align-items:center;gap:4px;color:var(--text-secondary);font-size:12px" title="${i.comment_count} comments"><svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0113.25 12H9.06l-2.573 2.573A1.458 1.458 0 014 13.543V12H2.75A1.75 1.75 0 011 10.25zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h2a.75.75 0 01.75.75v2.19l2.72-2.72a.749.749 0 01.53-.22h4.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25z"/></svg>${i.comment_count}</div>` : ''}
1389
+ ${i.assigned_to ? `<div style="flex-shrink:0">${avatarSvg(nameOf(i.assigned_to), 22)}</div>` : ''}
1390
+ </a>`;
1391
+ }).join('')}</div>`;
1392
+
1393
+ renderPagination(data.total_pages || 1, data.page || 1);
1394
+ renderActiveFilters();
1395
+ updateIssueUrlParams();
1396
+ }
1397
+
1398
+ function renderPagination(totalPages, currentPage) {
1399
+ const el = document.getElementById('issue-pagination');
1400
+ if (!el || totalPages <= 1) { if (el) el.innerHTML = ''; return; }
1401
+ const btnStyle = 'padding:4px 8px;min-width:28px;';
1402
+ const activeStyle = 'background:var(--accent);color:#fff;';
1403
+ const disabledStyle = 'opacity:0.4;pointer-events:none;';
1404
+ const pageBtn = (p, label) => `<button onclick="goToIssuePage(${p})" class="btn btn-sm" style="${btnStyle}${p===currentPage?activeStyle:''}">${label||p}</button>`;
1405
+ let html = '';
1406
+ // First + Prev
1407
+ html += `<button onclick="goToIssuePage(1)" class="btn btn-sm" style="${btnStyle}${currentPage===1?disabledStyle:''}" title="First page">«</button>`;
1408
+ html += `<button onclick="goToIssuePage(${currentPage-1})" class="btn btn-sm" style="${btnStyle}${currentPage===1?disabledStyle:''}" title="Previous page">‹</button>`;
1409
+ // Page numbers with ellipsis
1410
+ const pages = [];
1411
+ if (totalPages <= 9) {
1412
+ for (let p = 1; p <= totalPages; p++) pages.push(p);
1413
+ } else {
1414
+ pages.push(1);
1415
+ let start = Math.max(2, currentPage - 2);
1416
+ let end = Math.min(totalPages - 1, currentPage + 2);
1417
+ if (currentPage <= 4) end = Math.min(6, totalPages - 1);
1418
+ if (currentPage >= totalPages - 3) start = Math.max(2, totalPages - 5);
1419
+ if (start > 2) pages.push('...');
1420
+ for (let p = start; p <= end; p++) pages.push(p);
1421
+ if (end < totalPages - 1) pages.push('...');
1422
+ pages.push(totalPages);
1423
+ }
1424
+ for (const p of pages) {
1425
+ if (p === '...') { html += `<span style="padding:4px 2px;opacity:0.5">…</span>`; }
1426
+ else html += pageBtn(p);
1427
+ }
1428
+ // Next + Last
1429
+ html += `<button onclick="goToIssuePage(${currentPage+1})" class="btn btn-sm" style="${btnStyle}${currentPage===totalPages?disabledStyle:''}" title="Next page">›</button>`;
1430
+ html += `<button onclick="goToIssuePage(${totalPages})" class="btn btn-sm" style="${btnStyle}${currentPage===totalPages?disabledStyle:''}" title="Last page">»</button>`;
1431
+ // Page info
1432
+ html += `<span style="margin-left:8px;font-size:11px;color:var(--text-secondary)">Page ${currentPage} of ${totalPages}</span>`;
1433
+ el.innerHTML = html;
1434
+ }
1435
+
1436
+ function goToIssuePage(p) { currentIssuePage = p; loadIssues(); }
1437
+ function setIssueFilter(f) { currentIssueFilter = f; currentIssuePage = 1; loadIssues(); }
1438
+ function searchIssues() {
1439
+ const q = document.getElementById('issue-search')?.value?.trim() || '';
1440
+ if (q) currentIssueFilter = ''; // Clear the status filter while searching to avoid conflicting constraints.
1441
+ currentIssuePage = 1;
1442
+ loadIssues();
1443
+ }
1444
+
1445
+
1446
+
1447
+ const ISSUE_TEMPLATES = {
1448
+ bug: { labels: 'bug', body: `## Problem Description\n\n## Steps to Reproduce\n1. \n2. \n\n## Expected Behavior\n\n## Actual Behavior\n` },
1449
+ feature: { labels: 'feature', body: `## Background and Motivation\n\n## Requested Feature\n\n## Acceptance Criteria\n` },
1450
+ };
1451
+
1452
+ function applyIssueTemplate(tpl) {
1453
+ const t = ISSUE_TEMPLATES[tpl];
1454
+ const bodyEl = document.getElementById('issue-body');
1455
+ const labelsEl = document.getElementById('issue-labels');
1456
+ if (t) {
1457
+ bodyEl.value = t.body;
1458
+ if (labelsEl && !labelsEl.value) labelsEl.value = t.labels;
1459
+ } else {
1460
+ bodyEl.value = '';
1461
+ }
1462
+ }
1463
+
1464
+ function showCreateIssueModal() {
1465
+ if (!requireProjectManageAccess('Insufficient permission to create issue')) return;
1466
+ document.getElementById('issue-title').value = '';
1467
+ document.getElementById('issue-body').value = '';
1468
+ document.getElementById('issue-labels').value = '';
1469
+ const tplSel = document.getElementById('issue-template');
1470
+ if (tplSel) tplSel.value = '';
1471
+ const sel = document.getElementById('issue-assign');
1472
+ if (sel) {
1473
+ const controllerId = agentsData.find(a => a.is_controller)?.id || '';
1474
+ sel.value = controllerId || '';
1475
+ }
1476
+ document.getElementById('createIssueModal').classList.add('active');
1477
+ const issueBodyTextarea = document.getElementById('issue-body');
1478
+ if (issueBodyTextarea) setupMentionAutocomplete(issueBodyTextarea, agentsData);
1479
+ }
1480
+
1481
+ async function createIssue() {
1482
+ if (!requireProjectManageAccess('Insufficient permission to create issue')) return;
1483
+ const btn = document.querySelector('#createIssueModal button[onclick="createIssue()"]');
1484
+ await withLoading(btn, async () => {
1485
+ const body = {
1486
+ title: document.getElementById('issue-title').value,
1487
+ body: document.getElementById('issue-body').value,
1488
+ created_by: 'user',
1489
+ assigned_to: document.getElementById('issue-assign').value || undefined,
1490
+ labels: document.getElementById('issue-labels').value || undefined,
1491
+ };
1492
+ if (!body.title) { showToast('Title is required', 'error'); return; }
1493
+ const res = await fetch(`/api/projects/${projectId}/issues`, { method: 'POST', headers: apiHeaders(), body: JSON.stringify(body) });
1494
+ if (res.ok) { hideModal('createIssueModal'); loadIssues(); showToast('Issue created', 'success'); } else { const e = await res.json().catch(() => ({})); showToast(e.error || 'Failed to create', 'error'); }
1495
+ });
1496
+ }
1497
+
1498
+ // ─── Tabs ───
1499
+
1500
+ let issueCount = 0;
1501
+ async function updateTabCounts() {
1502
+ const tabs = document.querySelectorAll('.tab-bar .tab');
1503
+ tabs.forEach(t => {
1504
+ const text = t.textContent.replace(/\s*\(\d+\)/, '').trim().toLowerCase();
1505
+ if (text === 'agents') t.textContent = `Agents (${agentsData.length})`;
1506
+ else if (text === 'issues') t.textContent = `Issues (${issueCount})`;
1507
+ });
1508
+ }
1509
+
1510
+ function switchTab(tab) {
1511
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
1512
+ document.querySelectorAll('.tab-bar .tab').forEach(t => {
1513
+ if (t.textContent.replace(/\s*\(\d+\)/, '').trim().toLowerCase() === tab) t.classList.add('active');
1514
+ });
1515
+ document.getElementById('tab-overview').style.display = tab === 'overview' ? '' : 'none';
1516
+ document.getElementById('tab-agents').style.display = tab === 'agents' ? '' : 'none';
1517
+ document.getElementById('tab-issues').style.display = tab === 'issues' ? '' : 'none';
1518
+ document.getElementById('tab-activity').style.display = tab === 'activity' ? '' : 'none';
1519
+ document.getElementById('tab-git').style.display = tab === 'git' ? '' : 'none';
1520
+ document.getElementById('tab-knowledge').style.display = tab === 'knowledge' ? '' : 'none';
1521
+ document.getElementById('tab-files').style.display = tab === 'files' ? '' : 'none';
1522
+ // Update breadcrumb section
1523
+ const sectionNames = { overview: '', agents: 'Agents', issues: 'Issues', activity: 'Activity', git: 'Git', knowledge: 'Knowledge', files: 'Files' };
1524
+ const sectionEl = document.getElementById('breadcrumb-section');
1525
+ if (sectionEl) {
1526
+ sectionEl.textContent = sectionNames[tab] ? ' / ' + sectionNames[tab] : '';
1527
+ }
1528
+ // Update URL hash
1529
+ window.location.hash = tab === 'overview' ? '' : tab;
1530
+ if (tab === 'agents' && currentAgentId) startAgentOutputPolling(currentAgentId);
1531
+ else stopAgentOutputPolling();
1532
+ if (tab === 'issues') loadIssues();
1533
+ if (tab === 'activity') loadActivity();
1534
+ if (tab === 'git') loadGitTab();
1535
+ if (tab === 'knowledge') loadKnowledge();
1536
+ if (tab === 'files') loadProjectFilesTab();
1537
+ }
1538
+
1539
+ function ensureProjectFilesPanel() {
1540
+ if (projectFilesPanel || !window.ArgusFilesPanel) return;
1541
+ projectFilesPanel = window.ArgusFilesPanel.create({
1542
+ publicApiName: 'ProjectFiles',
1543
+ shellId: 'project-files-shell',
1544
+ treeId: 'project-file-tree',
1545
+ rootLabelId: 'project-files-root-label',
1546
+ noteId: 'project-files-note',
1547
+ currentPathId: 'project-file-current-path',
1548
+ saveButtonId: 'project-file-save-btn',
1549
+ bannerId: 'project-file-editor-banner',
1550
+ statusId: 'project-file-editor-status',
1551
+ editorId: 'project-file-editor',
1552
+ showHiddenId: 'project-file-show-hidden',
1553
+ canWrite: canManageProject(),
1554
+ isVisible: () => {
1555
+ const tab = document.getElementById('tab-files');
1556
+ return !!tab && tab.style.display !== 'none';
1557
+ },
1558
+ });
1559
+ window.ProjectFiles = projectFilesPanel;
1560
+ }
1561
+
1562
+ function getProjectFilesAgent() {
1563
+ return agentsData.find((agent) => agent.id === projectFilesAgentId) || null;
1564
+ }
1565
+
1566
+ function syncProjectFilesAgents() {
1567
+ ensureProjectFilesPanel();
1568
+ const select = document.getElementById('project-files-agent');
1569
+ if (!select) return;
1570
+
1571
+ if (!agentsData.length) {
1572
+ select.innerHTML = '<option value="">No agents available</option>';
1573
+ select.disabled = true;
1574
+ projectFilesAgentId = '';
1575
+ if (projectFilesPanel) projectFilesPanel.setAgent(null);
1576
+ return;
1577
+ }
1578
+
1579
+ const previousAgentId = projectFilesAgentId;
1580
+ const options = agentsData.map((agent) => {
1581
+ const suffix = agent.is_controller ? ' [controller]' : '';
1582
+ return `<option value="${agent.id}">${esc(agent.name)}${suffix}</option>`;
1583
+ }).join('');
1584
+
1585
+ select.innerHTML = `<option value="">Select an agent</option>${options}`;
1586
+ select.disabled = false;
1587
+
1588
+ let nextAgentId = previousAgentId;
1589
+ if (!nextAgentId || !agentsData.some((agent) => agent.id === nextAgentId)) {
1590
+ const preferredAgent = agentsData.find((agent) => agent.id === currentAgentId)
1591
+ || agentsData.find((agent) => !!agent.working_directory)
1592
+ || agentsData[0];
1593
+ nextAgentId = preferredAgent?.id || '';
1594
+ }
1595
+
1596
+ projectFilesAgentId = nextAgentId;
1597
+ select.value = nextAgentId || '';
1598
+ if (projectFilesPanel) {
1599
+ projectFilesPanel.setWriteEnabled(canManageProject());
1600
+ projectFilesPanel.setAgent(getProjectFilesAgent());
1601
+ }
1602
+ }
1603
+
1604
+ function handleProjectFilesAgentChange(agentId) {
1605
+ projectFilesAgentId = agentId || '';
1606
+ if (projectFilesPanel) {
1607
+ projectFilesPanel.setAgent(getProjectFilesAgent());
1608
+ projectFilesPanel.activate();
1609
+ }
1610
+ }
1611
+
1612
+ function loadProjectFilesTab() {
1613
+ ensureProjectFilesPanel();
1614
+ syncProjectFilesAgents();
1615
+ if (projectFilesPanel) {
1616
+ projectFilesPanel.setWriteEnabled(canManageProject());
1617
+ projectFilesPanel.activate();
1618
+ }
1619
+ }
1620
+
1621
+ window.handleProjectFilesAgentChange = handleProjectFilesAgentChange;
1622
+
1623
+ async function loadActivity() {
1624
+ const container = document.getElementById('activity-list');
1625
+ try {
1626
+ const res = await fetch(`/api/projects/${projectId}/activity?limit=200`, { headers: apiHeaders() });
1627
+ if (!res.ok) return;
1628
+ const events = await res.json();
1629
+
1630
+ if (!events.length) { container.innerHTML = '<div class="empty-state">No activity yet.</div>'; return; }
1631
+
1632
+ container.innerHTML = events.map(e => {
1633
+ const time = timeAgo(e.time);
1634
+ if (e.event_type === 'issue') {
1635
+ const icon = e.status === 'open' ? '●' : '✓';
1636
+ const color = e.status === 'open' ? 'var(--success)' : (e.status === 'closed' ? 'var(--text-secondary)' : 'var(--accent)');
1637
+ return `<div style="display:flex;gap:10px;padding:8px 0;border-bottom:1px solid var(--border);font-size:13px">
1638
+ <span style="color:${color};flex-shrink:0">${icon}</span>
1639
+ <div><strong>${esc(nameOf(e.actor))}</strong> ${e.status === 'open' ? 'opened' : 'updated'} issue <strong>#${e.number}</strong> ${esc(e.title)} <span style="color:var(--text-secondary)">${time}</span></div>
1640
+ </div>`;
1641
+ } else if (e.event_type === 'comment') {
1642
+ return `<div style="display:flex;gap:10px;padding:8px 0;border-bottom:1px solid var(--border);font-size:13px">
1643
+ <span style="color:var(--text-secondary);flex-shrink:0">💬</span>
1644
+ <div><strong>${esc(nameOf(e.actor))}</strong> commented on <strong>#${e.issue_number}</strong> ${esc(e.issue_title)} <span style="color:var(--text-secondary)">${time}</span>
1645
+ <div style="font-size:12px;color:var(--text-secondary);margin-top:2px">${esc((e.body || '').slice(0, 150))}</div></div>
1646
+ </div>`;
1647
+ } else if (e.event_type === 'agent_run') {
1648
+ const color = e.agent_status === 'running' ? 'var(--success)' : (e.agent_status === 'error' ? 'var(--error)' : 'var(--text-secondary)');
1649
+ return `<div style="display:flex;gap:10px;padding:8px 0;border-bottom:1px solid var(--border);font-size:13px">
1650
+ <span style="color:${color};flex-shrink:0">⚡</span>
1651
+ <div>Agent <strong>${esc(e.name)}</strong> [${e.agent_status}] <span style="color:var(--text-secondary)">${time}</span></div>
1652
+ </div>`;
1653
+ }
1654
+ return '';
1655
+ }).join('');
1656
+ } catch (e) { container.innerHTML = renderError(e, 'loadActivity()'); }
1657
+ }
1658
+
1659
+ // ─── Git Tab ───
1660
+
1661
+ async function loadGitTab() {
1662
+ const commitContainer = document.getElementById('git-commit-list');
1663
+ const statusContainer = document.getElementById('git-status-summary');
1664
+ const uncommittedContainer = document.getElementById('git-uncommitted');
1665
+
1666
+ // Load git log and per-agent git status in parallel
1667
+ try {
1668
+ const [logRes, ...agentStatuses] = await Promise.all([
1669
+ fetch(`/api/projects/${projectId}/git-log?limit=30`, { headers: apiHeaders() }),
1670
+ ...agentsData.filter(a => a.working_directory).map(a =>
1671
+ fetch(`/api/agents/${a.id}/git-status`, { headers: apiHeaders() }).then(r => r.ok ? r.json() : null).then(data => ({ agent: a, data }))
1672
+ )
1673
+ ]);
1674
+
1675
+ // Render status summary (branch info per agent)
1676
+ const validStatuses = agentStatuses.filter(s => s && s.data && s.data.branch);
1677
+ if (validStatuses.length > 0) {
1678
+ statusContainer.innerHTML = `<div class="card" style="padding:14px 18px">
1679
+ <div style="font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;opacity:0.6;margin-bottom:10px">Repository Status</div>
1680
+ ${validStatuses.map(s => {
1681
+ const d = s.data;
1682
+ const lastCommit = d.recent_commits && d.recent_commits[0]
1683
+ ? `<code style="color:var(--accent)">${esc(d.recent_commits[0].hash)}</code> ${esc(d.recent_commits[0].message.slice(0, 60))} <span style="color:var(--text-secondary)">${timeAgo(d.recent_commits[0].date)}</span>`
1684
+ : '<span style="color:var(--text-secondary)">no commits</span>';
1685
+ const uncommitted = d.has_uncommitted
1686
+ ? `<span style="color:var(--warning);margin-left:12px">${(d.uncommitted_files || []).length} uncommitted</span>`
1687
+ : '';
1688
+ return `<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border);font-size:12px">
1689
+ <div style="flex-shrink:0">${avatarSvg(s.agent.name, 22)}</div>
1690
+ <strong style="min-width:100px">${esc(s.agent.name)}</strong>
1691
+ <span style="background:var(--bg);padding:2px 8px;border-radius:10px;border:1px solid var(--border);font-family:monospace;font-size:11px">${esc(d.branch)}</span>
1692
+ <div style="flex:1">${lastCommit}</div>
1693
+ ${uncommitted}
1694
+ </div>`;
1695
+ }).join('')}
1696
+ </div>`;
1697
+ } else {
1698
+ statusContainer.innerHTML = '';
1699
+ }
1700
+
1701
+ // Render commit list
1702
+ if (!logRes.ok) { commitContainer.innerHTML = renderError({ status: logRes.status }, 'loadGitTab()'); return; }
1703
+ const commits = await logRes.json();
1704
+
1705
+ if (!commits.length) {
1706
+ commitContainer.innerHTML = '<div class="empty-state">No git commits found. Ensure agents have a working directory that is a git repository.</div>';
1707
+ uncommittedContainer.innerHTML = '';
1708
+ return;
1709
+ }
1710
+
1711
+ commitContainer.innerHTML = `
1712
+ <div style="font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;opacity:0.6;margin-bottom:10px;padding:0 4px">Recent Commits</div>
1713
+ ${commits.map(c => `<div style="display:flex;gap:10px;padding:8px 4px;border-bottom:1px solid var(--border);font-size:13px;align-items:flex-start">
1714
+ <span style="color:var(--success);flex-shrink:0;margin-top:2px">●</span>
1715
+ <code style="color:var(--accent);flex-shrink:0;font-size:12px">${esc(c.short_hash)}</code>
1716
+ <div style="flex:1;min-width:0">
1717
+ <div style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(c.message)}</div>
1718
+ <div style="font-size:11px;color:var(--text-secondary);margin-top:2px">${esc(c.author)} <span style="color:var(--text-secondary)">${timeAgo(c.date)}</span></div>
1719
+ </div>
1720
+ </div>`).join('')}`;
1721
+
1722
+ // Render uncommitted changes
1723
+ const allUncommitted = validStatuses.filter(s => s.data.has_uncommitted && s.data.uncommitted_files && s.data.uncommitted_files.length > 0);
1724
+ if (allUncommitted.length > 0) {
1725
+ uncommittedContainer.innerHTML = `<div class="card" style="padding:14px 18px">
1726
+ <div style="font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;opacity:0.6;margin-bottom:10px">Uncommitted Changes</div>
1727
+ ${allUncommitted.map(s => s.data.uncommitted_files.map(f => `<div style="display:flex;gap:8px;padding:4px 0;font-size:12px;font-family:monospace;border-bottom:1px solid var(--border)">
1728
+ <span style="color:${f.status === 'M' ? 'var(--warning)' : f.status === 'A' || f.status === '?' ? 'var(--success)' : 'var(--error)'};width:20px;text-align:center;flex-shrink:0">${esc(f.status)}</span>
1729
+ <span>${esc(f.file)}</span>
1730
+ </div>`).join('')).join('')}
1731
+ </div>`;
1732
+ } else {
1733
+ uncommittedContainer.innerHTML = '';
1734
+ }
1735
+ } catch (e) {
1736
+ commitContainer.innerHTML = renderError(e, 'loadGitTab()');
1737
+ statusContainer.innerHTML = '';
1738
+ uncommittedContainer.innerHTML = '';
1739
+ }
1740
+ }
1741
+
1742
+ // ─── Dashboard & Visualization ───
1743
+
1744
+ async function loadDashboard() {
1745
+ const el = document.getElementById('project-dashboard');
1746
+ if (!el) return;
1747
+ try {
1748
+ const [agentsRes, issueCountsRes, costRes] = await Promise.all([
1749
+ fetch(`/api/projects/${projectId}/agents`, { headers: apiHeaders() }),
1750
+ fetch(`/api/projects/${projectId}/issues/counts`, { headers: apiHeaders() }),
1751
+ fetch(`/api/projects/${projectId}/costs`, { headers: apiHeaders() }),
1752
+ ]);
1753
+ const agents = agentsRes.ok ? await agentsRes.json() : [];
1754
+ const issueCounts = issueCountsRes.ok ? await issueCountsRes.json() : { open: 0, in_progress: 0, done: 0, closed: 0, total: 0 };
1755
+ const cost = costRes.ok ? await costRes.json() : null;
1756
+
1757
+ const running = agents.filter(a => a.status === 'running').length;
1758
+ const errors = agents.filter(a => a.status === 'error').length;
1759
+ const paused = agents.filter(a => a.paused).length;
1760
+ const openIssues = (issueCounts.open || 0) + (issueCounts.in_progress || 0);
1761
+ const doneIssues = (issueCounts.done || 0) + (issueCounts.closed || 0);
1762
+ const fmtCostOverview = v => !v ? '$0' : v < 0.01 ? '<$0.01' : '$' + v.toFixed(2);
1763
+ const fmtTokensOverview = v => v >= 1000000 ? (v / 1000000).toFixed(1) + 'M' : v >= 1000 ? (v / 1000).toFixed(1) + 'K' : v;
1764
+
1765
+ // Update issue count for tab display (fixes #97: count shows 0 until clicking Issues tab)
1766
+ issueCount = issueCounts.total || 0;
1767
+ updateTabCounts();
1768
+
1769
+ const card = (label, value, color, sub) => `
1770
+ <div style="padding:12px 16px;background:var(--bg);border:1px solid var(--border);border-radius:8px">
1771
+ <div style="font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;opacity:0.6;margin-bottom:4px">${label}</div>
1772
+ <div style="font-size:22px;font-weight:700;color:${color || 'var(--fg)'}">${value}</div>
1773
+ ${sub ? `<div style="font-size:11px;color:var(--text-secondary);margin-top:2px">${sub}</div>` : ''}
1774
+ </div>`;
1775
+
1776
+ // Show cost or token usage if cost is unavailable
1777
+ const costValue = cost?.total_cost_usd > 0 ? fmtCostOverview(cost.total_cost_usd) : (cost?.total_input_tokens > 0 ? fmtTokensOverview(cost.total_input_tokens) + '↑' + fmtTokensOverview(cost.total_output_tokens) + '↓' : '$0');
1778
+ const costLabel = cost?.total_cost_usd > 0 ? 'Total Cost' : (cost?.total_input_tokens > 0 ? 'Token Usage' : 'Total Cost');
1779
+
1780
+ el.innerHTML =
1781
+ card('Agents', `${running}/${agents.length}`, running > 0 ? 'var(--success)' : 'var(--fg)',
1782
+ `${errors > 0 ? `<span style="color:var(--error)">${errors} error</span>` : ''}${paused > 0 ? ` <span style="color:var(--warning)">${paused} paused</span>` : ''}`) +
1783
+ card('Open Issues', openIssues, openIssues > 0 ? 'var(--warning)' : 'var(--fg)',
1784
+ `${doneIssues} completed`) +
1785
+ card(costLabel, costValue, 'var(--accent)',
1786
+ cost ? `${cost.total_runs || 0} runs` : '') +
1787
+ card('Issues Progress', issues.length > 0 ? Math.round(doneIssues / issues.length * 100) + '%' : '-', 'var(--fg)',
1788
+ `${doneIssues}/${issues.length} total`);
1789
+ } catch { el.innerHTML = ''; }
1790
+ }
1791
+
1792
+ function getAgentGraphStatusColor(agent) {
1793
+ if (agent.paused) return '#d29922';
1794
+ switch (agent.status) {
1795
+ case 'running': return '#3fb950';
1796
+ case 'error': return '#f85149';
1797
+ case 'stopped': return '#d29922';
1798
+ default: return '#8b949e';
1799
+ }
1800
+ }
1801
+
1802
+ function getAgentGraphContext() {
1803
+ const latestRun = getLatestOrchestrationRun();
1804
+ const dispatchResults = Array.isArray(latestRun?.dispatch_results) ? latestRun.dispatch_results : [];
1805
+ const plannedActions = Array.isArray(latestRun?.actions) ? latestRun.actions : [];
1806
+ return {
1807
+ latestRun,
1808
+ dispatchedAgents: new Set(dispatchResults.filter((result) => result && result.started).map((result) => result.agentId)),
1809
+ actionReasonByAgent: new Map(
1810
+ plannedActions
1811
+ .filter((action) => action && action.agentId)
1812
+ .map((action) => [action.agentId, action.reason || ''])
1813
+ ),
1814
+ };
1815
+ }
1816
+
1817
+ function renderStarAgentGraph(container, graphContext) {
1818
+ const W = Math.min(container.clientWidth || 600, 700);
1819
+ const H = 280;
1820
+ const cx = W / 2;
1821
+ const cy = H / 2;
1822
+ const controller = getControllerAgent();
1823
+ const workers = agentsData.filter((agent) => !agent.is_controller);
1824
+ const orbitRadius = Math.min(W / 2 - 60, H / 2 - 50);
1825
+ const nodeRadius = 30;
1826
+ let svg = '<svg width="' + W + '" height="' + H + '" viewBox="0 0 ' + W + ' ' + H + '" style="display:block;margin:0 auto">';
1827
+
1828
+ if (controller) {
1829
+ workers.forEach((worker, index) => {
1830
+ const angle = (2 * Math.PI * index / workers.length) - Math.PI / 2;
1831
+ const wx = cx + orbitRadius * Math.cos(angle);
1832
+ const wy = cy + orbitRadius * Math.sin(angle);
1833
+ const dispatched = graphContext.dispatchedAgents.has(worker.id);
1834
+ const lineColor = dispatched ? 'var(--accent)' : 'var(--border)';
1835
+ const lineWidth = dispatched ? 2 : 1;
1836
+ const lineDash = dispatched ? '' : '4,4';
1837
+ const lineOpacity = dispatched ? 0.9 : 0.5;
1838
+
1839
+ svg += '<line x1="' + cx + '" y1="' + cy + '" x2="' + wx + '" y2="' + wy + '" stroke="' + lineColor + '" stroke-width="' + lineWidth + '"' +
1840
+ (lineDash ? (' stroke-dasharray="' + lineDash + '"') : '') + ' opacity="' + lineOpacity + '"/>';
1841
+
1842
+ if (dispatched) {
1843
+ const mx = (cx + wx) / 2;
1844
+ const my = (cy + wy) / 2;
1845
+ svg += '<text x="' + mx + '" y="' + (my - 4) + '" text-anchor="middle" fill="var(--accent)" font-size="8">dispatch</text>';
1846
+ }
1847
+ });
1848
+ }
1849
+
1850
+ workers.forEach((worker, index) => {
1851
+ const angle = (2 * Math.PI * index / workers.length) - Math.PI / 2;
1852
+ const wx = cx + orbitRadius * Math.cos(angle);
1853
+ const wy = cy + orbitRadius * Math.sin(angle);
1854
+ const color = getAgentGraphStatusColor(worker);
1855
+ const pulse = worker.status === 'running' ? '<animate attributeName="r" values="' + nodeRadius + ';' + (nodeRadius + 4) + ';' + nodeRadius + '" dur="2s" repeatCount="indefinite"/>' : '';
1856
+ const assignedCount = (window._dashboardIssues || []).filter((issue) => issue.assigned_to === worker.id && ['open', 'in_progress', 'pending'].includes(issue.status)).length;
1857
+ const dispatched = graphContext.dispatchedAgents.has(worker.id);
1858
+ const dispatchHint = dispatched ? ' · dispatched' : '';
1859
+ const reason = graphContext.actionReasonByAgent.get(worker.id);
1860
+
1861
+ svg += '<g style="cursor:pointer" onclick="viewAgent(\"' + worker.id + '\")">' +
1862
+ '<circle cx="' + wx + '" cy="' + wy + '" r="' + nodeRadius + '" fill="' + color + '22" stroke="' + color + '" stroke-width="' + (dispatched ? '2.8' : '2') + '"' + (worker.paused ? ' stroke-dasharray="4,4"' : '') + '>' + pulse + '</circle>' +
1863
+ '<text x="' + wx + '" y="' + (wy - 2) + '" text-anchor="middle" fill="var(--fg)" font-size="11" font-weight="600">' + esc(worker.name.length > 10 ? worker.name.slice(0, 9) + '…' : worker.name) + '</text>' +
1864
+ '<text x="' + wx + '" y="' + (wy + 12) + '" text-anchor="middle" fill="' + (dispatched ? 'var(--accent)' : color) + '" font-size="9">' + (worker.paused ? 'paused' : worker.status) + (assignedCount > 0 ? (' · ' + assignedCount + ' tasks') : '') + dispatchHint + '</text>' +
1865
+ (reason ? ('<title>' + esc(reason) + '</title>') : '') +
1866
+ '</g>';
1867
+ });
1868
+
1869
+ if (controller) {
1870
+ const color = getAgentGraphStatusColor(controller);
1871
+ const pulse = controller.status === 'running' ? '<animate attributeName="r" values="34;38;34" dur="2s" repeatCount="indefinite"/>' : '';
1872
+ const decision = graphContext.latestRun?.decision || '';
1873
+ svg += '<g style="cursor:pointer" onclick="viewAgent(\"' + controller.id + '\")">' +
1874
+ '<circle cx="' + cx + '" cy="' + cy + '" r="34" fill="' + color + '22" stroke="' + color + '" stroke-width="2.5">' + pulse + '</circle>' +
1875
+ '<text x="' + cx + '" y="' + (cy - 4) + '" text-anchor="middle" fill="var(--fg)" font-size="12" font-weight="700">' + esc(controller.name.length > 12 ? controller.name.slice(0, 11) + '…' : controller.name) + '</text>' +
1876
+ '<text x="' + cx + '" y="' + (cy + 10) + '" text-anchor="middle" fill="' + color + '" font-size="9">' + controller.status + '</text>' +
1877
+ '<text x="' + cx + '" y="' + (cy + 21) + '" text-anchor="middle" fill="var(--accent)" font-size="8">controller' + (decision ? (' · ' + decision) : '') + '</text>' +
1878
+ '</g>';
1879
+ }
1880
+
1881
+ svg += '</svg>';
1882
+ return {
1883
+ title: 'Agent Collaboration · Star',
1884
+ note: 'No hierarchy is configured yet, so the compatibility star layout is used.',
1885
+ svg,
1886
+ };
1887
+ }
1888
+
1889
+ function renderHierarchyAgentGraph(container, graphContext) {
1890
+ const byId = getAgentMap();
1891
+ const controller = getControllerAgent();
1892
+ const levelMap = new Map();
1893
+ const visited = new Set();
1894
+ const syntheticLinks = [];
1895
+
1896
+ function walk(agent, depth) {
1897
+ if (!agent || visited.has(agent.id)) return;
1898
+ visited.add(agent.id);
1899
+ if (!levelMap.has(depth)) levelMap.set(depth, []);
1900
+ levelMap.get(depth).push(agent);
1901
+
1902
+ const children = agentsData.filter((candidate) => getGraphParentId(candidate) === agent.id);
1903
+ children.forEach((child) => {
1904
+ if (!child.parent_agent_id) {
1905
+ syntheticLinks.push(child.id);
1906
+ }
1907
+ walk(child, depth + 1);
1908
+ });
1909
+ }
1910
+
1911
+ const roots = [];
1912
+ if (controller) {
1913
+ roots.push(controller);
1914
+ }
1915
+ agentsData.forEach((agent) => {
1916
+ if (agent.is_controller) return;
1917
+ const parentId = getGraphParentId(agent);
1918
+ if (!parentId || !byId.has(parentId)) {
1919
+ roots.push(agent);
1920
+ }
1921
+ });
1922
+
1923
+ roots.forEach((root) => walk(root, 0));
1924
+ agentsData.forEach((agent) => {
1925
+ if (!visited.has(agent.id)) walk(agent, 0);
1926
+ });
1927
+
1928
+ const levels = Array.from(levelMap.entries())
1929
+ .sort((a, b) => a[0] - b[0])
1930
+ .map(([, items]) => items);
1931
+
1932
+ const W = Math.min(Math.max(container.clientWidth || 760, 640), 960);
1933
+ const levelGap = 112;
1934
+ const topPadding = 56;
1935
+ const H = Math.max(280, topPadding + Math.max(levels.length - 1, 1) * levelGap + 96);
1936
+ const nodeRadius = 28;
1937
+ const positions = new Map();
1938
+
1939
+ levels.forEach((level, depth) => {
1940
+ const spacing = W / (level.length + 1);
1941
+ const y = topPadding + depth * levelGap;
1942
+ level.forEach((agent, index) => {
1943
+ positions.set(agent.id, {
1944
+ x: spacing * (index + 1),
1945
+ y,
1946
+ });
1947
+ });
1948
+ });
1949
+
1950
+ let svg = '<svg width="' + W + '" height="' + H + '" viewBox="0 0 ' + W + ' ' + H + '" style="display:block;margin:0 auto">';
1951
+
1952
+ agentsData.forEach((agent) => {
1953
+ const parentId = getGraphParentId(agent);
1954
+ if (!parentId) return;
1955
+ const parentPos = positions.get(parentId);
1956
+ const childPos = positions.get(agent.id);
1957
+ if (!parentPos || !childPos) return;
1958
+
1959
+ const dispatched = graphContext.dispatchedAgents.has(agent.id);
1960
+ const synthetic = !agent.parent_agent_id;
1961
+ svg += '<line x1="' + parentPos.x + '" y1="' + (parentPos.y + nodeRadius) + '" x2="' + childPos.x + '" y2="' + (childPos.y - nodeRadius) + '" stroke="' + (dispatched ? 'var(--accent)' : 'var(--border)') + '" stroke-width="' + (dispatched ? 2.2 : 1.2) + '"' +
1962
+ (synthetic ? ' stroke-dasharray="6,4"' : '') +
1963
+ ' opacity="' + (dispatched ? 0.95 : synthetic ? 0.45 : 0.7) + '"/>';
1964
+
1965
+ if (dispatched) {
1966
+ const mx = (parentPos.x + childPos.x) / 2;
1967
+ const my = (parentPos.y + childPos.y) / 2;
1968
+ svg += '<text x="' + mx + '" y="' + (my - 8) + '" text-anchor="middle" fill="var(--accent)" font-size="8">dispatch</text>';
1969
+ }
1970
+ });
1971
+
1972
+ agentsData.forEach((agent) => {
1973
+ const position = positions.get(agent.id);
1974
+ if (!position) return;
1975
+ const color = getAgentGraphStatusColor(agent);
1976
+ const pulse = agent.status === 'running'
1977
+ ? '<animate attributeName="r" values="' + nodeRadius + ';' + (nodeRadius + 4) + ';' + nodeRadius + '" dur="2s" repeatCount="indefinite"/>'
1978
+ : '';
1979
+ const assignedCount = (window._dashboardIssues || []).filter((issue) => issue.assigned_to === agent.id && ['open', 'in_progress', 'pending'].includes(issue.status)).length;
1980
+ const childCount = getDirectChildAgents(agent.id).length;
1981
+ const dispatched = graphContext.dispatchedAgents.has(agent.id);
1982
+ const reason = graphContext.actionReasonByAgent.get(agent.id);
1983
+ const statusLabel = agent.paused ? 'paused' : agent.status;
1984
+ const metaParts = [statusLabel, assignedCount > 0 ? assignedCount + ' tasks' : null, childCount > 0 ? childCount + ' child' : null].filter(Boolean).join(' · ');
1985
+
1986
+ svg += '<g style="cursor:pointer" onclick="viewAgent(\"' + agent.id + '\")">' +
1987
+ '<circle cx="' + position.x + '" cy="' + position.y + '" r="' + nodeRadius + '" fill="' + color + '22" stroke="' + color + '" stroke-width="' + (dispatched ? '2.8' : '2') + '"' + (agent.paused ? ' stroke-dasharray="4,4"' : '') + '>' + pulse + '</circle>' +
1988
+ '<text x="' + position.x + '" y="' + (position.y - 2) + '" text-anchor="middle" fill="var(--fg)" font-size="11" font-weight="600">' + esc(agent.name.length > 11 ? agent.name.slice(0, 10) + '…' : agent.name) + '</text>' +
1989
+ '<text x="' + position.x + '" y="' + (position.y + 12) + '" text-anchor="middle" fill="' + (dispatched ? 'var(--accent)' : color) + '" font-size="8.5">' + esc(metaParts || statusLabel) + '</text>' +
1990
+ '<title>' + esc([agent.name, reason].filter(Boolean).join(' · ')) + '</title>' +
1991
+ '</g>';
1992
+ });
1993
+
1994
+ svg += '</svg>';
1995
+
1996
+ const syntheticNote = syntheticLinks.length > 0
1997
+ ? 'Agents without a parent are shown as direct controller children with dashed links.'
1998
+ : 'Links in the graph follow the configured direct parent-child hierarchy.';
1999
+
2000
+ return {
2001
+ title: 'Agent Collaboration · Tree',
2002
+ note: syntheticNote,
2003
+ svg,
2004
+ };
2005
+ }
2006
+
2007
+ function renderAgentGraph() {
2008
+ const container = document.getElementById('agent-graph-container');
2009
+ if (!container || !agentsData.length) {
2010
+ if (container) container.innerHTML = '';
2011
+ return;
2012
+ }
2013
+
2014
+ const graphContext = getAgentGraphContext();
2015
+ const graph = hasHierarchyLayout()
2016
+ ? renderHierarchyAgentGraph(container, graphContext)
2017
+ : renderStarAgentGraph(container, graphContext);
2018
+
2019
+ const runInfo = graphContext.latestRun
2020
+ ? '<div style="font-size:11px;color:var(--text-secondary);margin-top:6px">Latest decision: <span style="color:var(--fg)">' + esc(graphContext.latestRun.decision || '-') + '</span> · ' + esc(timeAgo(graphContext.latestRun.created_at)) + '</div>'
2021
+ : '<div style="font-size:11px;color:var(--text-secondary);margin-top:6px">No orchestration decision records yet.</div>';
2022
+
2023
+ container.innerHTML = '<div class="card" style="padding:12px;text-align:center">' +
2024
+ '<div style="font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;opacity:0.6;margin-bottom:8px">' + graph.title + '</div>' +
2025
+ graph.svg +
2026
+ '<div style="font-size:11px;color:var(--text-secondary);margin-top:8px">' + graph.note + '</div>' +
2027
+ runInfo +
2028
+ '</div>';
2029
+ }
2030
+
2031
+ function getLatestOrchestrationRun() {
2032
+ if (!Array.isArray(orchestrationRunsData) || orchestrationRunsData.length === 0) return null;
2033
+ return orchestrationRunsData[0];
2034
+ }
2035
+
2036
+ async function loadOrchestrationRuns() {
2037
+ const container = document.getElementById('orchestration-decision-container');
2038
+ if (!container) return;
2039
+ try {
2040
+ const res = await fetch('/api/projects/' + projectId + '/orchestration-runs?limit=12', { headers: apiHeaders() });
2041
+ if (!res.ok) throw new Error('failed');
2042
+ const data = await res.json();
2043
+ orchestrationRunsData = Array.isArray(data) ? data : [];
2044
+ } catch {
2045
+ orchestrationRunsData = [];
2046
+ }
2047
+ renderOrchestrationDecisionPanel();
2048
+ renderAgentGraph();
2049
+ }
2050
+
2051
+ function renderOrchestrationDecisionPanel() {
2052
+ const container = document.getElementById('orchestration-decision-container');
2053
+ if (!container) return;
2054
+
2055
+ const latest = getLatestOrchestrationRun();
2056
+ if (!latest) {
2057
+ container.innerHTML = '<div class="card" style="padding:12px">' +
2058
+ '<div style="font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;opacity:0.6;margin-bottom:8px">Orchestration Decisions</div>' +
2059
+ '<div class="empty-state" style="padding:8px 0">No orchestration runs yet.</div>' +
2060
+ '</div>';
2061
+ return;
2062
+ }
2063
+
2064
+ const decisionColors = {
2065
+ execute_controller: 'var(--warning)',
2066
+ finish: 'var(--success)',
2067
+ error: 'var(--error)'
2068
+ };
2069
+ const decisionColor = decisionColors[latest.decision] || 'var(--text-secondary)';
2070
+
2071
+ const reasons = Array.isArray(latest.reasons) ? latest.reasons : [];
2072
+ const dispatchResults = Array.isArray(latest.dispatch_results) ? latest.dispatch_results : [];
2073
+ const actions = Array.isArray(latest.actions) ? latest.actions : [];
2074
+
2075
+ const reasonsHtml = reasons.length
2076
+ ? reasons.slice(0, 5).map((r) => '<li style="margin:2px 0">' + esc(r) + '</li>').join('')
2077
+ : '<li style="margin:2px 0;color:var(--text-secondary)">none</li>';
2078
+
2079
+ const dispatchHtml = dispatchResults.length
2080
+ ? dispatchResults.slice(0, 10).map((r) => {
2081
+ const agent = agentsData.find(a => a.id === r.agentId);
2082
+ const name = agent ? agent.name : r.agentId;
2083
+ const status = r.started ? 'started' : 'skipped';
2084
+ const color = r.started ? 'var(--success)' : 'var(--warning)';
2085
+ return '<div style="display:flex;justify-content:space-between;gap:8px;padding:4px 0;border-bottom:1px dashed var(--border);font-size:12px">' +
2086
+ '<div style="min-width:0"><strong>' + esc(name) + '</strong><div style="color:var(--text-secondary);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:420px">' + esc(r.message || '') + '</div></div>' +
2087
+ '<span style="color:' + color + ';font-weight:600;flex-shrink:0">' + status + '</span>' +
2088
+ '</div>';
2089
+ }).join('')
2090
+ : '<div style="color:var(--text-secondary);font-size:12px">No worker dispatch this run.</div>';
2091
+
2092
+ const history = orchestrationRunsData.slice(0, 8).map((r) => {
2093
+ const c = decisionColors[r.decision] || 'var(--text-secondary)';
2094
+ return '<div style="display:flex;justify-content:space-between;gap:8px;font-size:11px;padding:3px 0;border-bottom:1px dashed var(--border)">' +
2095
+ '<span><span style="color:' + c + ';font-weight:600">' + esc(r.decision || '-') + '</span> · ' + esc(r.engine || '-') + '</span>' +
2096
+ '<span style="color:var(--text-secondary)">' + esc(timeAgo(r.created_at)) + '</span>' +
2097
+ '</div>';
2098
+ }).join('');
2099
+
2100
+ container.innerHTML = '<div class="card" style="padding:12px">' +
2101
+ '<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:8px">' +
2102
+ '<div style="font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;opacity:0.6">Orchestration Decisions</div>' +
2103
+ '<button class="btn btn-sm" onclick="loadOrchestrationRuns()">Refresh</button>' +
2104
+ '</div>' +
2105
+
2106
+ '<div style="display:grid;grid-template-columns:1.2fr 1.8fr;gap:12px">' +
2107
+ '<div style="padding:10px;background:var(--bg);border:1px solid var(--border);border-radius:8px">' +
2108
+ '<div style="font-size:12px;margin-bottom:4px">Latest: <strong style="color:' + decisionColor + '">' + esc(latest.decision || '-') + '</strong></div>' +
2109
+ '<div style="font-size:11px;color:var(--text-secondary);margin-bottom:6px">engine=' + esc(latest.engine || '-') + ' · ' + esc(timeAgo(latest.created_at)) + '</div>' +
2110
+ '<div style="font-size:11px;color:var(--text-secondary);margin-bottom:8px">dispatch=' + esc(String(latest.dispatch_count || 0)) + ' · controller=' + (latest.controller_started ? 'started' : 'not started') + '</div>' +
2111
+ '<div style="font-size:11px;font-weight:600;margin-bottom:4px">Reasons</div>' +
2112
+ '<ul style="margin:0 0 8px 16px;padding:0;font-size:11px">' + reasonsHtml + '</ul>' +
2113
+ '<div style="font-size:11px;font-weight:600;margin-bottom:4px">Planned actions</div>' +
2114
+ '<div style="font-size:11px;color:var(--text-secondary)">' + esc(String(actions.length)) + ' action(s)</div>' +
2115
+ '</div>' +
2116
+
2117
+ '<div style="padding:10px;background:var(--bg);border:1px solid var(--border);border-radius:8px">' +
2118
+ '<div style="font-size:11px;font-weight:600;margin-bottom:6px">Dispatch Results</div>' +
2119
+ '<div style="max-height:165px;overflow:auto">' + dispatchHtml + '</div>' +
2120
+ '<div style="font-size:11px;font-weight:600;margin-top:10px;margin-bottom:4px">Recent Runs</div>' +
2121
+ '<div style="max-height:120px;overflow:auto">' + history + '</div>' +
2122
+ '</div>' +
2123
+ '</div>' +
2124
+ '</div>';
2125
+ }
2126
+
2127
+ // ─── Cost Time-Series Chart ───
2128
+
2129
+ const _agentColors = ['#58a6ff','#3fb950','#d29922','#f85149','#bc8cff','#39d2c0','#ff7b72','#79c0ff','#7ee787','#e3b341'];
2130
+ let _currentCostPeriod = 'hour';
2131
+
2132
+ function switchCostPeriod(period) {
2133
+ _currentCostPeriod = period;
2134
+ document.querySelectorAll('.cost-period-btn').forEach(b => {
2135
+ b.style.background = b.dataset.period === period ? 'var(--accent)' : '';
2136
+ b.style.color = b.dataset.period === period ? '#fff' : '';
2137
+ });
2138
+ loadCostChart();
2139
+ }
2140
+
2141
+ async function loadCostChart() {
2142
+ try {
2143
+ const res = await fetch(`/api/projects/${projectId}/costs?period=${_currentCostPeriod}`, { headers: apiHeaders() });
2144
+ if (!res.ok) return;
2145
+ const data = await res.json();
2146
+
2147
+ const panel = document.getElementById('cost-chart-panel');
2148
+ if (!data.time_series || data.time_series.length === 0) {
2149
+ panel.style.display = 'none';
2150
+ return;
2151
+ }
2152
+ panel.style.display = '';
2153
+
2154
+ // Highlight active period tab
2155
+ document.querySelectorAll('.cost-period-btn').forEach(b => {
2156
+ b.style.background = b.dataset.period === _currentCostPeriod ? 'var(--accent)' : '';
2157
+ b.style.color = b.dataset.period === _currentCostPeriod ? '#fff' : '';
2158
+ });
2159
+
2160
+ // Build a stable agent→color map so both charts use the same colors
2161
+ const allAgentNames = new Set();
2162
+ if (data.time_series_by_agent) Object.keys(data.time_series_by_agent).forEach(n => allAgentNames.add(n));
2163
+ if (data.by_agent) Object.keys(data.by_agent).forEach(n => allAgentNames.add(n));
2164
+ const _agentColorMap = {};
2165
+ [...allAgentNames].sort().forEach((name, i) => { _agentColorMap[name] = _agentColors[i % _agentColors.length]; });
2166
+
2167
+ // Render per-agent stacked bar chart
2168
+ const agentsEl = document.getElementById('cost-chart-agents');
2169
+ if (data.time_series_by_agent && Object.keys(data.time_series_by_agent).length > 0) {
2170
+ const agents = Object.entries(data.time_series_by_agent);
2171
+ agentsEl.innerHTML = renderStackedBarChart(agents, data.time_series, 600, 200, _agentColorMap);
2172
+ } else {
2173
+ agentsEl.innerHTML = '';
2174
+ }
2175
+
2176
+ // Render agent comparison chart
2177
+ renderAgentCostComparison(data.by_agent || {}, _agentColorMap);
2178
+ } catch {}
2179
+ }
2180
+
2181
+ function renderAgentCostComparison(byAgent, colorMap) {
2182
+ const el = document.getElementById('cost-agent-comparison');
2183
+ if (!el) return;
2184
+ const entries = Object.entries(byAgent).filter(([, v]) => v.cost > 0 || v.input_tokens > 0 || v.output_tokens > 0).sort((a, b) => b[1].cost - a[1].cost);
2185
+ if (entries.length === 0) { el.innerHTML = '<div style="font-size:12px;color:var(--text-secondary)">No data</div>'; return; }
2186
+
2187
+ const totalCost = entries.reduce((s, [, v]) => s + v.cost, 0);
2188
+ const hasCost = totalCost > 0;
2189
+ // When no USD cost available (e.g. Codex), fall back to total tokens for bar sizing
2190
+ const totalTokens = entries.reduce((s, [, v]) => s + (v.input_tokens || 0) + (v.output_tokens || 0), 0);
2191
+ const metric = hasCost ? (v) => v.cost : (v) => (v.input_tokens || 0) + (v.output_tokens || 0);
2192
+ const maxMetric = Math.max(...entries.map(([, v]) => metric(v)), 1);
2193
+ const totalMetric = hasCost ? totalCost : totalTokens;
2194
+ const fmtTokensComp = v => v >= 1000000 ? (v / 1000000).toFixed(1) + 'M' : v >= 1000 ? (v / 1000).toFixed(1) + 'K' : v;
2195
+
2196
+ // Horizontal bar chart + percentage
2197
+ el.innerHTML = entries.map(([name, v], idx) => {
2198
+ const val = metric(v);
2199
+ const pct = totalMetric > 0 ? (val / totalMetric * 100).toFixed(1) : '0';
2200
+ const barWidth = maxMetric > 0 ? (val / maxMetric * 100).toFixed(1) : '0';
2201
+ const color = (colorMap && colorMap[name]) || _agentColors[idx % _agentColors.length];
2202
+ const label = hasCost ? ('$' + (v.cost < 0.01 ? v.cost.toFixed(4) : v.cost.toFixed(2))) : (fmtTokensComp((v.input_tokens||0)+(v.output_tokens||0)) + ' tokens');
2203
+ return `<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
2204
+ <div style="width:120px;font-size:11px;color:var(--fg);overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(name)}">${esc(name)}</div>
2205
+ <div style="flex:1;height:18px;background:var(--bg);border:1px solid var(--border);border-radius:3px;overflow:hidden">
2206
+ <div style="height:100%;width:${barWidth}%;background:${color};opacity:0.8;border-radius:3px;transition:width 0.3s"></div>
2207
+ </div>
2208
+ <div style="width:80px;font-size:11px;color:var(--text-secondary);text-align:right">${label}</div>
2209
+ <div style="width:40px;font-size:10px;color:var(--text-secondary);text-align:right">${pct}%</div>
2210
+ </div>`;
2211
+ }).join('') +
2212
+ `<div style="margin-top:8px;font-size:12px;color:var(--fg);font-weight:600">Total: ${hasCost ? '$' + (totalCost < 0.01 ? totalCost.toFixed(4) : totalCost.toFixed(2)) : fmtTokensComp(totalTokens) + ' tokens'}</div>`;
2213
+ }
2214
+
2215
+ function renderStackedBarChart(agents, totalSeries, width, height, colorMap) {
2216
+ const PAD_L = 50, PAD_R = 16, PAD_T = 12, PAD_B = 32;
2217
+ const W = width, H = height;
2218
+ const cw = W - PAD_L - PAD_R, ch = H - PAD_T - PAD_B;
2219
+
2220
+ const allDates = totalSeries.map(d => d.period_start);
2221
+ const n = allDates.length;
2222
+ const maxCost = Math.max(...totalSeries.map(d => d.cost), 0.001);
2223
+ const barW = Math.max(2, (cw / n) * 0.7);
2224
+ const gap = cw / n;
2225
+
2226
+ // Y-axis labels
2227
+ const yLabels = [0, maxCost / 2, maxCost].map(v => {
2228
+ const y = PAD_T + ch - (v / maxCost) * ch;
2229
+ return `<text x="${PAD_L - 6}" y="${y + 3}" text-anchor="end" fill="var(--text-secondary)" font-size="9">$${v < 0.01 ? v.toFixed(4) : v < 1 ? v.toFixed(3) : v.toFixed(2)}</text>
2230
+ <line x1="${PAD_L}" y1="${y}" x2="${W - PAD_R}" y2="${y}" stroke="var(--border)" stroke-width="0.5" opacity="0.5"/>`;
2231
+ }).join('');
2232
+
2233
+ // X-axis labels
2234
+ const step = Math.max(1, Math.floor(n / 6));
2235
+ const xLabels = allDates.map((d, i) => {
2236
+ if (i % step !== 0 && i !== n - 1) return '';
2237
+ const x = PAD_L + i * gap + gap / 2;
2238
+ const label = d.slice(5);
2239
+ return `<text x="${x}" y="${H - 4}" text-anchor="middle" fill="var(--text-secondary)" font-size="8">${label}</text>`;
2240
+ }).join('');
2241
+
2242
+ // Build per-date agent cost lookup
2243
+ const agentDateMaps = agents.map(([, series]) => {
2244
+ const m = {};
2245
+ series.forEach(d => { m[d.period_start] = d; });
2246
+ return m;
2247
+ });
2248
+
2249
+ // Stacked bars
2250
+ let bars = '';
2251
+ allDates.forEach((date, i) => {
2252
+ const x = PAD_L + i * gap + (gap - barW) / 2;
2253
+ let yOffset = 0;
2254
+ agents.forEach(([agentName], idx) => {
2255
+ const cost = agentDateMaps[idx][date]?.cost || 0;
2256
+ if (cost <= 0) return;
2257
+ const barH = (cost / maxCost) * ch;
2258
+ const y = PAD_T + ch - yOffset - barH;
2259
+ const color = (colorMap && colorMap[agentName]) || _agentColors[idx % _agentColors.length];
2260
+ const runs = agentDateMaps[idx][date]?.runs || 0;
2261
+ bars += `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${barW.toFixed(1)}" height="${barH.toFixed(1)}" fill="${color}" opacity="0.85" rx="1">
2262
+ <title>${agentName} ${date}: $${cost.toFixed(4)} (${runs} runs)</title>
2263
+ </rect>`;
2264
+ yOffset += barH;
2265
+ });
2266
+ });
2267
+
2268
+ // Legend
2269
+ const legend = agents.map(([name], idx) => {
2270
+ const color = (colorMap && colorMap[name]) || _agentColors[idx % _agentColors.length];
2271
+ return `<span style="display:inline-flex;align-items:center;gap:4px;margin-right:12px;font-size:11px;color:var(--text-secondary)">
2272
+ <span style="width:10px;height:10px;background:${color};border-radius:2px;display:inline-block"></span>${name.length > 15 ? name.slice(0, 14) + '…' : name}
2273
+ </span>`;
2274
+ }).join('');
2275
+
2276
+ return `<svg width="100%" viewBox="0 0 ${W} ${H}" style="display:block">
2277
+ ${yLabels}${xLabels}${bars}
2278
+ </svg>
2279
+ <div style="margin-top:6px;line-height:1.8">${legend}</div>`;
2280
+ }
2281
+
2282
+ // ─── Knowledge Base ───
2283
+
2284
+ async function loadKnowledge() {
2285
+ const el = document.getElementById('knowledge-list');
2286
+ if (!el) return;
2287
+ const canManage = canManageProject();
2288
+ const importance = document.getElementById('knowledge-filter-importance')?.value || '';
2289
+ const qs = importance ? `?importance=${importance}` : '';
2290
+ try {
2291
+ const res = await fetch(`/api/projects/${projectId}/knowledge${qs}`, { headers: apiHeaders() });
2292
+ if (!res.ok) { el.innerHTML = renderError({ status: res.status }, 'loadKnowledge()'); return; }
2293
+ const data = await res.json();
2294
+ const entries = data.entries || [];
2295
+ if (entries.length === 0) {
2296
+ el.innerHTML = `<div class="empty-state">No knowledge entries yet.${canManage ? ' Click "Add Knowledge" to start building the project knowledge base.' : ''}</div>`;
2297
+ return;
2298
+ }
2299
+ const impBadge = (imp) => {
2300
+ const colors = { high: 'var(--error)', medium: 'var(--warning)', low: 'var(--text-secondary)' };
2301
+ const labels = { high: 'High', medium: 'Medium', low: 'Low' };
2302
+ return `<span style="padding:1px 6px;border-radius:3px;font-size:10px;background:${colors[imp] || 'var(--text-secondary)'};color:#fff">${labels[imp] || imp}</span>`;
2303
+ };
2304
+ el.innerHTML = '<div style="padding:8px 0">' + entries.map(e => `
2305
+ <div style="padding:12px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:flex-start">
2306
+ <div style="flex:1;min-width:0">
2307
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
2308
+ ${impBadge(e.importance)}
2309
+ <span style="font-weight:600;font-size:13px">${esc(e.title)}</span>
2310
+ </div>
2311
+ <div style="font-size:12px;color:var(--text-secondary);margin-bottom:4px;max-height:60px;overflow:hidden;white-space:pre-wrap">${esc((e.content || '').slice(0, 200))}${e.content && e.content.length > 200 ? '...' : ''}</div>
2312
+ ${e.tags ? `<div style="display:flex;gap:4px;flex-wrap:wrap">${e.tags.split(',').filter(t => t.trim()).map(t => `<span style="padding:1px 6px;background:var(--bg);border:1px solid var(--border);border-radius:3px;font-size:10px">${esc(t.trim())}</span>`).join('')}</div>` : ''}
2313
+ </div>
2314
+ ${canManage ? `<div style="display:flex;gap:4px;flex-shrink:0;margin-left:12px">
2315
+ <button class="btn btn-sm" onclick="editKnowledge('${e.id}')" style="padding:3px 8px">Edit</button>
2316
+ <button class="btn btn-sm" onclick="deleteKnowledge('${e.id}')" style="padding:3px 8px;color:var(--error)">Delete</button>
2317
+ </div>` : ''}
2318
+ </div>
2319
+ `).join('') + '</div>';
2320
+ } catch (e) { el.innerHTML = renderError(e, 'loadKnowledge()'); }
2321
+ }
2322
+
2323
+ let _knowledgeCache = [];
2324
+
2325
+ function showCreateKnowledgeModal() {
2326
+ if (!requireProjectManageAccess('Insufficient permission to add knowledge')) return;
2327
+ document.getElementById('knowledge-modal-title').textContent = 'Add Knowledge Entry';
2328
+ document.getElementById('knowledge-edit-id').value = '';
2329
+ document.getElementById('knowledge-title').value = '';
2330
+ document.getElementById('knowledge-content').value = '';
2331
+ document.getElementById('knowledge-tags').value = '';
2332
+ document.getElementById('knowledge-importance').value = 'medium';
2333
+ document.getElementById('knowledgeModal').classList.add('active');
2334
+ }
2335
+
2336
+ async function editKnowledge(id) {
2337
+ if (!requireProjectManageAccess('Insufficient permission to edit knowledge')) return;
2338
+ try {
2339
+ const res = await fetch(`/api/knowledge/${id}`, { headers: apiHeaders() });
2340
+ if (!res.ok) return;
2341
+ const e = await res.json();
2342
+ document.getElementById('knowledge-modal-title').textContent = 'Edit Knowledge Entry';
2343
+ document.getElementById('knowledge-edit-id').value = id;
2344
+ document.getElementById('knowledge-title').value = e.title || '';
2345
+ document.getElementById('knowledge-content').value = e.content || '';
2346
+ document.getElementById('knowledge-tags').value = e.tags || '';
2347
+ document.getElementById('knowledge-importance').value = e.importance || 'medium';
2348
+ document.getElementById('knowledgeModal').classList.add('active');
2349
+ } catch { showToast('Failed to load', 'error'); }
2350
+ }
2351
+
2352
+ async function saveKnowledge() {
2353
+ if (!requireProjectManageAccess('Insufficient permission to save knowledge')) return;
2354
+ const id = document.getElementById('knowledge-edit-id').value;
2355
+ const body = {
2356
+ title: document.getElementById('knowledge-title').value,
2357
+ content: document.getElementById('knowledge-content').value,
2358
+ tags: document.getElementById('knowledge-tags').value,
2359
+ importance: document.getElementById('knowledge-importance').value,
2360
+ };
2361
+ if (!body.title) { showToast('Title is required', 'error'); return; }
2362
+ try {
2363
+ const url = id ? `/api/knowledge/${id}` : `/api/projects/${projectId}/knowledge`;
2364
+ const method = id ? 'PUT' : 'POST';
2365
+ const res = await fetch(url, { method, headers: { ...apiHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
2366
+ if (res.ok) {
2367
+ hideModal('knowledgeModal');
2368
+ showToast(id ? 'Updated' : 'Created', 'success');
2369
+ loadKnowledge();
2370
+ } else {
2371
+ const err = await res.json().catch(() => ({}));
2372
+ showToast(err.error || 'Failed to save', 'error');
2373
+ }
2374
+ } catch { showToast('Failed to save', 'error'); }
2375
+ }
2376
+
2377
+ async function deleteKnowledge(id) {
2378
+ if (!requireProjectManageAccess('Insufficient permission to delete knowledge')) return;
2379
+ if (!await showConfirm('Delete this knowledge entry?')) return;
2380
+ try {
2381
+ const res = await fetch(`/api/knowledge/${id}`, { method: 'DELETE', headers: apiHeaders() });
2382
+ if (res.ok) { showToast('Deleted', 'success'); loadKnowledge(); }
2383
+ else showToast('Failed to delete', 'error');
2384
+ } catch { showToast('Failed to delete', 'error'); }
2385
+ }
2386
+
2387
+ // ─── Init ───
2388
+ loadProject();
2389
+ loadAgents();
2390
+ loadDashboard();
2391
+ loadCostChart();
2392
+ window.addEventListener('argus:user-ready', () => { renderProjectAccessSummary(); });
2393
+
2394
+ // Slow fallback polling (WS handles real-time)
2395
+ setInterval(loadAgents, 30000);
2396
+
2397
+ // Connect to project-level WebSocket for real-time updates
2398
+ const _projectEvents = connectProjectEvents(projectId);
2399
+
2400
+ _projectEvents.on('agent_status', function(data) {
2401
+ loadAgents();
2402
+ loadDashboard();
2403
+ // If viewing this agent's detail, refresh output too
2404
+ if (currentAgentId === data.agentId) {
2405
+ loadAgentOutput(data.agentId);
2406
+ }
2407
+ });
2408
+
2409
+ _projectEvents.on('issue_created', function() {
2410
+ loadIssues();
2411
+ });
2412
+
2413
+ _projectEvents.on('issue_updated', function() {
2414
+ loadIssues();
2415
+ });
2416
+
2417
+ _projectEvents.on('comment_added', function() {
2418
+ loadIssues();
2419
+ });
2420
+
2421
+ // Handle hash navigation (e.g., #issues or #agents from dashboard)
2422
+ const hash = window.location.hash.replace('#', '');
2423
+ if (['overview', 'agents', 'issues', 'activity', 'git', 'knowledge', 'files'].includes(hash)) {
2424
+ setTimeout(() => switchTab(hash), 500);
2425
+ }