@bill10/agent-007 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +222 -0
  3. package/VERSION +1 -0
  4. package/bin/adduser.js +69 -0
  5. package/bin/agent-007.js +88 -0
  6. package/lib/cron.js +189 -0
  7. package/lib/helpers.js +541 -0
  8. package/lib/jobs.js +965 -0
  9. package/package.json +63 -0
  10. package/public/app.js +650 -0
  11. package/public/assets/characters/LICENSE +21 -0
  12. package/public/assets/characters/char_0.png +0 -0
  13. package/public/assets/characters/char_1.png +0 -0
  14. package/public/assets/characters/char_2.png +0 -0
  15. package/public/assets/characters/char_3.png +0 -0
  16. package/public/assets/characters/char_4.png +0 -0
  17. package/public/assets/characters/char_5.png +0 -0
  18. package/public/assets/furniture/bookshelf.png +0 -0
  19. package/public/assets/furniture/cactus.png +0 -0
  20. package/public/assets/furniture/chair_back.png +0 -0
  21. package/public/assets/furniture/chair_front.png +0 -0
  22. package/public/assets/furniture/chair_side.png +0 -0
  23. package/public/assets/furniture/coffee.png +0 -0
  24. package/public/assets/furniture/coffee_table.png +0 -0
  25. package/public/assets/furniture/desk.png +0 -0
  26. package/public/assets/furniture/desk2.png +0 -0
  27. package/public/assets/furniture/plant_2.png +0 -0
  28. package/public/assets/furniture/sofa_front.png +0 -0
  29. package/public/assets/furniture/sofa_side.png +0 -0
  30. package/public/assets/furniture/table_front.png +0 -0
  31. package/public/index.html +245 -0
  32. package/public/modules/auth.js +83 -0
  33. package/public/modules/explorer.js +760 -0
  34. package/public/modules/jobs.js +971 -0
  35. package/public/modules/office.js +2154 -0
  36. package/public/modules/paths.js +20 -0
  37. package/public/modules/shortcuts.js +54 -0
  38. package/public/modules/state.js +75 -0
  39. package/public/modules/terminal.js +651 -0
  40. package/public/modules/voice.js +393 -0
  41. package/public/modules/ws.js +56 -0
  42. package/public/style.css +1843 -0
  43. package/server/agent-mcp-bridge.js +45 -0
  44. package/server/agent-mcp.js +184 -0
  45. package/server/agent-transcripts.js +195 -0
  46. package/server/approvals.js +155 -0
  47. package/server/auth.js +162 -0
  48. package/server/billion.js +176 -0
  49. package/server/claude-trust.js +66 -0
  50. package/server/command-path.js +102 -0
  51. package/server/config.js +184 -0
  52. package/server/direct-run.js +33 -0
  53. package/server/git.js +630 -0
  54. package/server/http.js +276 -0
  55. package/server/jobs.js +2044 -0
  56. package/server/mcp.js +596 -0
  57. package/server/messages.js +319 -0
  58. package/server/permission-hook.js +47 -0
  59. package/server/pty.js +360 -0
  60. package/server/state.js +104 -0
  61. package/server/ws.js +583 -0
  62. package/server.js +306 -0
  63. package/templates/billion/COMPANY.md +14 -0
  64. package/templates/billion/STATE.md +17 -0
  65. package/templates/billion/charter.md +232 -0
  66. package/templates/billion/owner.md +11 -0
@@ -0,0 +1,760 @@
1
+ // File explorer panel — repo sections, branch trees, inline diffs
2
+ import { agents, repos, orphans, activeSessionId, setView, billionEnabled } from './state.js';
3
+ import { send } from './ws.js';
4
+ import { switchToSession } from './terminal.js';
5
+
6
+ let explorerVisible = true;
7
+ let expandedBranches = new Set(); // sessionIds with expanded file trees
8
+ let activeDiff = null; // { sessionId, filePath, diff } for modal
9
+ let pendingDiffs = new Set(); // loading state
10
+ let conflicts = []; // current conflict list
11
+ let fullTreeSessions = new Set(); // sessionIds showing full tree
12
+ let fullTreeCache = new Map(); // sessionId -> full tree data
13
+ let fullTreePending = new Set(); // in-flight full tree requests
14
+ let expandedDirs = new Set(); // "sessionId:dir/path" expanded directories (collapsed by default)
15
+ const COLLAPSED_KEY = 'agent007-collapsed-repos';
16
+ let collapsedRepos = loadCollapsedRepos(); // repoPaths with collapsed sections
17
+
18
+ function loadCollapsedRepos() {
19
+ try {
20
+ const saved = JSON.parse(localStorage.getItem(COLLAPSED_KEY));
21
+ return new Set(Array.isArray(saved) ? saved : []);
22
+ } catch { return new Set(); }
23
+ }
24
+
25
+ export function setupExplorer() {
26
+ document.getElementById('btn-toggle-explorer').onclick = toggleExplorer;
27
+ document.getElementById('btn-reopen-explorer').onclick = toggleExplorer;
28
+ // Refresh button
29
+ const refreshBtn = document.getElementById('btn-refresh-tree');
30
+ if (refreshBtn) {
31
+ refreshBtn.onclick = () => {
32
+ for (const [sessionId, agent] of agents) {
33
+ if (agent.repoPath) send({ type: 'refresh-tree', sessionId });
34
+ }
35
+ };
36
+ }
37
+ // Start collapsed if no repos
38
+ if (repos.size === 0) {
39
+ setExplorerVisible(false);
40
+ }
41
+ }
42
+
43
+ export function toggleExplorer() {
44
+ setExplorerVisible(!explorerVisible);
45
+ }
46
+
47
+ function setExplorerVisible(visible) {
48
+ explorerVisible = visible;
49
+ const panel = document.getElementById('explorer-panel');
50
+ const divider = document.getElementById('divider-explorer');
51
+ const toggleBtn = document.getElementById('btn-toggle-explorer');
52
+ const reopenBtn = document.getElementById('btn-reopen-explorer');
53
+ if (visible) {
54
+ panel.classList.remove('collapsed');
55
+ divider.style.display = '';
56
+ reopenBtn.style.display = 'none';
57
+ toggleBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12"><path d="M8 1L3 6l5 5" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>';
58
+ } else {
59
+ panel.classList.add('collapsed');
60
+ divider.style.display = 'none';
61
+ reopenBtn.style.display = '';
62
+ toggleBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12"><path d="M4 1l5 5-5 5" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>';
63
+ }
64
+ }
65
+
66
+ export function handleReposList(msg) {
67
+ const hadRepos = repos.size;
68
+ repos.clear();
69
+ for (const r of msg.repos) {
70
+ repos.set(r.path, { slug: r.slug, exists: r.exists });
71
+ }
72
+ renderExplorer();
73
+ // Auto-expand explorer when first repo added
74
+ if (repos.size > 0 && !explorerVisible) {
75
+ setExplorerVisible(true);
76
+ }
77
+ }
78
+
79
+ export function handleRepoError(msg) {
80
+ const bar = document.getElementById('status-bar');
81
+ if (bar) {
82
+ bar.style.display = '';
83
+ bar.textContent = msg.error;
84
+ bar.style.color = 'var(--state-disconnected)';
85
+ setTimeout(() => { bar.style.display = 'none'; bar.style.color = ''; }, 5000);
86
+ }
87
+ }
88
+
89
+ export function handleFileTree(msg) {
90
+ const agent = agents.get(msg.sessionId);
91
+ if (!agent) return;
92
+ agent.fileTree = msg.tree;
93
+ agent.changedCount = msg.changedCount;
94
+ agent.additions = msg.additions || 0;
95
+ agent.removals = msg.removals || 0;
96
+ fullTreeCache.delete(msg.sessionId); // invalidate stale full tree
97
+ renderExplorer();
98
+ }
99
+
100
+ export function handleFullTree(msg) {
101
+ fullTreePending.delete(msg.sessionId);
102
+ fullTreeCache.set(msg.sessionId, msg.tree);
103
+ renderExplorer();
104
+ }
105
+
106
+ export function handleConflictsUpdate(msg) {
107
+ conflicts = msg.conflicts || [];
108
+ // Update agent conflict state
109
+ for (const [, agent] of agents) {
110
+ agent.conflicts = [];
111
+ }
112
+ for (const c of conflicts) {
113
+ for (const sid of c.sessionIds) {
114
+ const agent = agents.get(sid);
115
+ if (agent) {
116
+ agent.conflicts = [...(agent.conflicts || []), ...c.files];
117
+ }
118
+ }
119
+ }
120
+ renderExplorer();
121
+ }
122
+
123
+ export function handleFileDiff(msg) {
124
+ const key = `${msg.sessionId}:${msg.filePath}`;
125
+ pendingDiffs.delete(key);
126
+ activeDiff = { sessionId: msg.sessionId, filePath: msg.filePath, diff: msg.diff };
127
+ showDiffViewer();
128
+ renderExplorer();
129
+ }
130
+
131
+ export function handleOrphansList(msg) {
132
+ orphans.clear();
133
+ for (const o of msg.orphans) {
134
+ orphans.set(o.id, o);
135
+ }
136
+ renderExplorer();
137
+ }
138
+
139
+ export function renderExplorer() {
140
+ const content = document.getElementById('explorer-content');
141
+ if (!content) return;
142
+ const scrollTop = content.scrollTop;
143
+ content.innerHTML = '';
144
+
145
+ renderBillionRow(content);
146
+
147
+ const otherAgents = [...agents.values()].filter(a => !a.isBillion).length;
148
+ if (repos.size === 0 && otherAgents === 0 && orphans.size === 0) {
149
+ const empty = document.createElement('div');
150
+ empty.className = 'explorer-empty';
151
+ empty.textContent = 'No repos yet';
152
+ content.appendChild(empty);
153
+ }
154
+
155
+ // Group agents by repo
156
+ const repoAgents = new Map();
157
+ for (const [sessionId, agent] of agents) {
158
+ if (!agent.repoPath) continue;
159
+ if (!repoAgents.has(agent.repoPath)) repoAgents.set(agent.repoPath, []);
160
+ repoAgents.get(agent.repoPath).push({ sessionId, agent });
161
+ }
162
+
163
+ // Group orphans by repo
164
+ const repoOrphans = new Map();
165
+ for (const [orphanId, orphan] of orphans) {
166
+ if (!repoOrphans.has(orphan.repoPath)) repoOrphans.set(orphan.repoPath, []);
167
+ repoOrphans.get(orphan.repoPath).push({ orphanId, orphan });
168
+ }
169
+
170
+ // Render each configured repo
171
+ for (const [repoPath, repo] of repos) {
172
+ const section = document.createElement('div');
173
+ section.className = 'explorer-repo';
174
+
175
+ // Sticky header — click to collapse/expand section
176
+ const branchAgents = repoAgents.get(repoPath) || [];
177
+ const repoOrphanList = repoOrphans.get(repoPath) || [];
178
+ const { header, isCollapsed } = makeRepoHeader(repoPath, repo.slug);
179
+
180
+ if (!repo.exists) {
181
+ const err = document.createElement('span');
182
+ err.className = 'explorer-error-badge';
183
+ err.textContent = 'not found';
184
+ header.appendChild(err);
185
+ }
186
+
187
+ const headerActions = document.createElement('span');
188
+ headerActions.className = 'explorer-repo-actions';
189
+
190
+ // Spawn agent on this repo
191
+ const addBtn = document.createElement('button');
192
+ addBtn.className = 'explorer-icon-btn';
193
+ addBtn.textContent = '+';
194
+ addBtn.title = 'Spawn agent on this repo';
195
+ addBtn.onclick = (e) => {
196
+ e.stopPropagation();
197
+ openSpawnFormWithRepo(repoPath);
198
+ };
199
+ headerActions.appendChild(addBtn);
200
+
201
+ // Remove repo
202
+ const removeBtn = document.createElement('button');
203
+ removeBtn.className = 'explorer-icon-btn explorer-remove-btn';
204
+ removeBtn.textContent = '\u00d7';
205
+ removeBtn.title = 'Remove repo';
206
+ removeBtn.onclick = (e) => {
207
+ e.stopPropagation();
208
+ collapsedRepos.delete(repoPath);
209
+ saveCollapsedRepos();
210
+ send({ type: 'remove-repo', path: repoPath });
211
+ };
212
+ headerActions.appendChild(removeBtn);
213
+
214
+ if (isCollapsed) {
215
+ header.appendChild(makeCollapsedBadge(
216
+ branchAgents.map(b => b.agent),
217
+ branchAgents.length + repoOrphanList.length
218
+ ));
219
+ }
220
+ header.appendChild(headerActions);
221
+ section.appendChild(header);
222
+
223
+ // Branches
224
+ if (!isCollapsed) {
225
+ if (branchAgents.length === 0 && repoOrphanList.length === 0 && repo.exists) {
226
+ const noBranch = document.createElement('div');
227
+ noBranch.className = 'explorer-no-agents';
228
+ noBranch.textContent = '(no agents)';
229
+ section.appendChild(noBranch);
230
+ }
231
+
232
+ for (const { sessionId, agent } of branchAgents) {
233
+ const branchEl = createBranchEntry(sessionId, agent);
234
+ section.appendChild(branchEl);
235
+ }
236
+
237
+ // Render orphans for this repo
238
+ for (const { orphanId, orphan } of repoOrphanList) {
239
+ section.appendChild(createOrphanEntry(orphanId, orphan));
240
+ }
241
+ }
242
+
243
+ content.appendChild(section);
244
+ }
245
+
246
+ // Render orphans for repos not in the configured repos list
247
+ for (const [repoPath, orphanList] of repoOrphans) {
248
+ if (repos.has(repoPath)) continue; // Already rendered above
249
+ const section = document.createElement('div');
250
+ section.className = 'explorer-repo';
251
+ const { header, isCollapsed } = makeRepoHeader(repoPath, orphanList[0].orphan.repoSlug || repoPath.split('/').pop());
252
+ if (isCollapsed) header.appendChild(makeCollapsedBadge([], orphanList.length));
253
+ section.appendChild(header);
254
+ if (!isCollapsed) {
255
+ for (const { orphanId, orphan } of orphanList) {
256
+ section.appendChild(createOrphanEntry(orphanId, orphan));
257
+ }
258
+ }
259
+ content.appendChild(section);
260
+ }
261
+
262
+ // Agents without repos (legacy mode)
263
+ const noRepoAgents = [...agents.entries()].filter(([, a]) => !a.repoPath && !a.isBillion);
264
+ if (noRepoAgents.length > 0) {
265
+ const section = document.createElement('div');
266
+ section.className = 'explorer-repo';
267
+ const { header, isCollapsed } = makeRepoHeader('(no repo)', 'No repo');
268
+ if (isCollapsed) {
269
+ header.appendChild(makeCollapsedBadge(noRepoAgents.map(([, a]) => a), noRepoAgents.length));
270
+ }
271
+ section.appendChild(header);
272
+ if (!isCollapsed) {
273
+ for (const [sessionId, agent] of noRepoAgents) {
274
+ const entry = document.createElement('div');
275
+ entry.className = `explorer-branch${sessionId === activeSessionId ? ' active' : ''}`;
276
+ entry.onclick = () => switchToSession(sessionId);
277
+ const dot = document.createElement('span');
278
+ dot.className = 'explorer-dot';
279
+ dot.style.background = stateColor(agent.state);
280
+ entry.appendChild(dot);
281
+ entry.appendChild(document.createTextNode(agent.name));
282
+ section.appendChild(entry);
283
+ }
284
+ }
285
+ content.appendChild(section);
286
+ }
287
+
288
+ content.scrollTop = scrollTop;
289
+ }
290
+
291
+ // Billion, pinned above the repos (docs/BILLION.md). The row stays while it is
292
+ // stopped, with a Start button: nothing restarts it on its own.
293
+ function renderBillionRow(content) {
294
+ const [sessionId, agent] = [...agents.entries()].find(([, a]) => a.isBillion) || [];
295
+ if (!billionEnabled && !agent) return;
296
+ const running = !!agent && agent.state !== 'DISCONNECTED';
297
+ const section = document.createElement('div');
298
+ section.className = 'explorer-repo explorer-billion';
299
+ const entry = document.createElement('div');
300
+ entry.className = `explorer-branch${sessionId && sessionId === activeSessionId ? ' active' : ''}`;
301
+ // The whole row starts a stopped Billion, not just its button.
302
+ entry.onclick = running ? () => switchToSession(sessionId) : () => send({ type: 'billion-start' });
303
+ const dot = document.createElement('span');
304
+ dot.className = 'explorer-dot';
305
+ // Red only for a Billion that ran and stopped; grey for one not started yet.
306
+ dot.style.background = stateColor(running ? agent.state : agent ? 'DISCONNECTED' : 'IDLE');
307
+ entry.appendChild(dot);
308
+ const name = document.createElement('span');
309
+ name.className = 'explorer-billion-name';
310
+ name.textContent = 'Billion';
311
+ entry.appendChild(name);
312
+ if (!running) {
313
+ const stopped = document.createElement('span');
314
+ stopped.className = 'explorer-branch-label';
315
+ stopped.textContent = 'stopped';
316
+ entry.appendChild(stopped);
317
+ const start = document.createElement('button');
318
+ start.className = 'explorer-icon-btn explorer-billion-start';
319
+ start.textContent = '\u25b6';
320
+ start.title = 'Start Billion';
321
+ start.setAttribute('aria-label', 'Start Billion');
322
+ start.onclick = (e) => {
323
+ e.stopPropagation();
324
+ send({ type: 'billion-start' });
325
+ };
326
+ entry.appendChild(start);
327
+ }
328
+ section.appendChild(entry);
329
+ content.appendChild(section);
330
+ }
331
+
332
+ function saveCollapsedRepos() {
333
+ localStorage.setItem(COLLAPSED_KEY, JSON.stringify([...collapsedRepos]));
334
+ }
335
+
336
+ function toggleRepoCollapse(repoPath) {
337
+ if (collapsedRepos.has(repoPath)) collapsedRepos.delete(repoPath);
338
+ else collapsedRepos.add(repoPath);
339
+ saveCollapsedRepos();
340
+ renderExplorer();
341
+ // Re-render destroys the focused header node; refocus it so keyboard
342
+ // toggling works repeatedly, not just once.
343
+ for (const h of document.querySelectorAll('.explorer-repo-header')) {
344
+ if (h.dataset.repoKey === repoPath) { h.focus(); break; }
345
+ }
346
+ }
347
+
348
+ // Clickable section header: ▸/▾ arrow + name, click toggles collapse
349
+ function makeRepoHeader(key, label) {
350
+ const isCollapsed = collapsedRepos.has(key);
351
+ const header = document.createElement('div');
352
+ header.className = 'explorer-repo-header';
353
+ header.onclick = () => toggleRepoCollapse(key);
354
+ // No role="button": the configured-repo header nests real buttons (+/×),
355
+ // and the button role makes descendants presentational (see learning
356
+ // job-card-role-button-hides-nested-controls). Keyboard path kept via
357
+ // tabindex + keydown instead.
358
+ header.tabIndex = 0;
359
+ header.dataset.repoKey = key;
360
+ header.setAttribute('aria-expanded', String(!isCollapsed));
361
+ header.onkeydown = (e) => {
362
+ if (e.target !== header) return; // let nested +/× buttons keep their keys
363
+ if (e.key === 'Enter' || e.key === ' ') {
364
+ e.preventDefault();
365
+ toggleRepoCollapse(key);
366
+ }
367
+ };
368
+ const arrow = document.createElement('span');
369
+ arrow.className = 'explorer-toggle-arrow';
370
+ arrow.textContent = isCollapsed ? '▸' : '▾';
371
+ header.appendChild(arrow);
372
+ const headerName = document.createElement('span');
373
+ headerName.className = 'explorer-repo-name';
374
+ headerName.textContent = label;
375
+ header.appendChild(headerName);
376
+ return { header, isCollapsed };
377
+ }
378
+
379
+ // Collapsed sections still surface entry count + most urgent agent state
380
+ const STATE_PRIORITY = ['MESSAGE', 'WAITING', 'WORKING', 'DISCONNECTED', 'IDLE'];
381
+ function makeCollapsedBadge(agentList, count) {
382
+ const badge = document.createElement('span');
383
+ badge.className = 'explorer-collapsed-badge';
384
+ const top = STATE_PRIORITY.find(s => agentList.some(a => a.state === s));
385
+ if (top) {
386
+ const dot = document.createElement('span');
387
+ dot.className = 'explorer-dot';
388
+ dot.style.background = stateColor(top);
389
+ badge.appendChild(dot);
390
+ }
391
+ badge.appendChild(document.createTextNode(String(count)));
392
+ return badge;
393
+ }
394
+
395
+ function stateColor(state) {
396
+ switch (state) {
397
+ case 'WORKING': return 'var(--state-working)';
398
+ case 'WAITING': return 'var(--state-waiting)';
399
+ case 'MESSAGE': return 'var(--state-message)';
400
+ case 'IDLE': return 'var(--state-idle)';
401
+ case 'DISCONNECTED': return 'var(--state-disconnected)';
402
+ default: return 'var(--state-idle)';
403
+ }
404
+ }
405
+
406
+ function createBranchEntry(sessionId, agent) {
407
+ const isActive = sessionId === activeSessionId;
408
+ const isExpanded = expandedBranches.has(sessionId);
409
+ const wrapper = document.createElement('div');
410
+ wrapper.className = 'explorer-branch-wrapper';
411
+
412
+ // Branch header row — click to toggle file tree
413
+ const branchEl = document.createElement('div');
414
+ branchEl.className = `explorer-branch${isActive ? ' active' : ''}`;
415
+ if (isActive) branchEl.style.borderLeftColor = agent.color;
416
+ branchEl.onclick = () => {
417
+ if (expandedBranches.has(sessionId)) {
418
+ expandedBranches.delete(sessionId);
419
+ } else {
420
+ expandedBranches.add(sessionId);
421
+ }
422
+ renderExplorer();
423
+ };
424
+
425
+ // Status dot
426
+ const dot = document.createElement('span');
427
+ dot.className = 'explorer-dot';
428
+ dot.style.background = stateColor(agent.state);
429
+ branchEl.appendChild(dot);
430
+
431
+ // Agent name (click to switch session)
432
+ const nameEl = document.createElement('span');
433
+ nameEl.className = 'explorer-branch-name';
434
+ nameEl.textContent = agent.name;
435
+ nameEl.onclick = (e) => {
436
+ e.stopPropagation();
437
+ switchToSession(sessionId);
438
+ if (expandedBranches.has(sessionId)) {
439
+ expandedBranches.delete(sessionId);
440
+ } else {
441
+ expandedBranches.add(sessionId);
442
+ }
443
+ renderExplorer();
444
+ };
445
+ branchEl.appendChild(nameEl);
446
+
447
+ // Branch label: "on branch/vesper"
448
+ if (agent.branchName) {
449
+ const onLabel = document.createElement('span');
450
+ onLabel.className = 'explorer-branch-label';
451
+ onLabel.textContent = `on ${agent.branchName}`;
452
+ branchEl.appendChild(onLabel);
453
+ }
454
+
455
+ // Tree mode toggle switch (changes vs all files)
456
+ let toggleEl = null;
457
+ if (isExpanded && (agent.fileTree || fullTreeCache.has(sessionId))) {
458
+ const showFull = fullTreeSessions.has(sessionId);
459
+ const toggle = document.createElement('span');
460
+ toggle.className = 'explorer-tree-toggle';
461
+
462
+ const changesOpt = document.createElement('span');
463
+ changesOpt.className = 'explorer-tree-toggle-opt' + (!showFull ? ' active' : '');
464
+ changesOpt.textContent = 'changes';
465
+ changesOpt.onclick = (e) => {
466
+ e.stopPropagation();
467
+ fullTreeSessions.delete(sessionId);
468
+ renderExplorer();
469
+ };
470
+
471
+ const allOpt = document.createElement('span');
472
+ allOpt.className = 'explorer-tree-toggle-opt' + (showFull ? ' active' : '');
473
+ allOpt.textContent = 'all';
474
+ allOpt.onclick = (e) => {
475
+ e.stopPropagation();
476
+ fullTreeSessions.add(sessionId);
477
+ if (!fullTreeCache.has(sessionId) && !fullTreePending.has(sessionId)) {
478
+ fullTreePending.add(sessionId);
479
+ send({ type: 'get-full-tree', sessionId });
480
+ }
481
+ renderExplorer();
482
+ };
483
+
484
+ toggle.appendChild(changesOpt);
485
+ toggle.appendChild(allOpt);
486
+ toggleEl = toggle;
487
+ }
488
+
489
+ wrapper.appendChild(branchEl);
490
+
491
+ // File tree (if expanded)
492
+ if (isExpanded) {
493
+ const showFull = fullTreeSessions.has(sessionId);
494
+ const treeData = showFull ? fullTreeCache.get(sessionId) : agent.fileTree;
495
+
496
+ if (fullTreePending.has(sessionId)) {
497
+ const loading = document.createElement('div');
498
+ loading.className = 'explorer-diff-loading';
499
+ loading.style.paddingLeft = '16px';
500
+ loading.textContent = 'Loading file tree\u2026';
501
+ wrapper.appendChild(loading);
502
+ } else if (treeData) {
503
+ // Filter toggle gets its own row above the tree, so it no longer
504
+ // overlaps (and looks attached to) the first file/folder row.
505
+ if (toggleEl) {
506
+ const toggleRow = document.createElement('div');
507
+ toggleRow.className = 'explorer-tree-toggle-row';
508
+ toggleRow.appendChild(toggleEl);
509
+ wrapper.appendChild(toggleRow);
510
+ }
511
+ const treeEl = document.createElement('div');
512
+ treeEl.className = 'explorer-file-tree';
513
+ renderTreeNode(treeEl, treeData, sessionId, 1, showFull);
514
+ wrapper.appendChild(treeEl);
515
+ }
516
+ }
517
+
518
+ return wrapper;
519
+ }
520
+
521
+ function createOrphanEntry(orphanId, orphan) {
522
+ const wrapper = document.createElement('div');
523
+ wrapper.className = 'explorer-branch-wrapper explorer-orphan';
524
+
525
+ const row = document.createElement('div');
526
+ row.className = 'explorer-branch';
527
+
528
+ // Warning icon instead of toggle arrow
529
+ const warn = document.createElement('span');
530
+ warn.className = 'explorer-toggle-arrow';
531
+ warn.textContent = '\u26a0'; // ⚠
532
+ warn.style.color = 'var(--state-message)';
533
+ row.appendChild(warn);
534
+
535
+ // Dimmed dot
536
+ const dot = document.createElement('span');
537
+ dot.className = 'explorer-dot';
538
+ dot.style.background = 'var(--state-idle)';
539
+ row.appendChild(dot);
540
+
541
+ // Name
542
+ const nameEl = document.createElement('span');
543
+ nameEl.className = 'explorer-branch-name';
544
+ nameEl.textContent = orphan.name;
545
+ row.appendChild(nameEl);
546
+
547
+ // Reason badge
548
+ const badge = document.createElement('span');
549
+ badge.className = 'orphan-badge';
550
+ badge.textContent = orphan.reason;
551
+ row.appendChild(badge);
552
+
553
+ wrapper.appendChild(row);
554
+
555
+ // Action buttons
556
+ const actions = document.createElement('div');
557
+ actions.className = 'explorer-orphan-actions';
558
+
559
+ const respawnBtn = document.createElement('button');
560
+ respawnBtn.textContent = 'Re-spawn';
561
+ respawnBtn.onclick = (e) => {
562
+ e.stopPropagation();
563
+ send({ type: 're-adopt-orphan', orphanId });
564
+ };
565
+ actions.appendChild(respawnBtn);
566
+
567
+ const deleteBtn = document.createElement('button');
568
+ deleteBtn.className = 'orphan-delete-btn';
569
+ deleteBtn.textContent = 'Delete';
570
+ deleteBtn.onclick = (e) => {
571
+ e.stopPropagation();
572
+ if (confirm(`Delete orphan ${orphan.name}? This will remove the worktree and branch permanently.`)) {
573
+ send({ type: 'delete-orphan', orphanId });
574
+ }
575
+ };
576
+ actions.appendChild(deleteBtn);
577
+
578
+ wrapper.appendChild(actions);
579
+ return wrapper;
580
+ }
581
+
582
+ function renderTreeNode(container, node, sessionId, depth, fullTreeMode = false, parentPath = '') {
583
+ if (!node || !node.children) return;
584
+ // Sort: dirs first, then files, alphabetical
585
+ const sorted = [...node.children].sort((a, b) => {
586
+ if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
587
+ return a.name.localeCompare(b.name);
588
+ });
589
+ for (const child of sorted) {
590
+ if (child.type === 'dir') {
591
+ const dirPath = parentPath ? `${parentPath}/${child.name}` : child.name;
592
+ const dirKey = `${sessionId}:${dirPath}`;
593
+ const isExpanded = expandedDirs.has(dirKey);
594
+ const dirEl = document.createElement('div');
595
+ dirEl.className = 'explorer-dir';
596
+ dirEl.style.paddingLeft = `${depth * 8}px`;
597
+ dirEl.textContent = (isExpanded ? '\u25be ' : '\u25b8 ') + child.name + '/';
598
+ dirEl.onclick = (e) => {
599
+ e.stopPropagation();
600
+ if (expandedDirs.has(dirKey)) expandedDirs.delete(dirKey);
601
+ else expandedDirs.add(dirKey);
602
+ renderExplorer();
603
+ };
604
+ container.appendChild(dirEl);
605
+ if (isExpanded) {
606
+ renderTreeNode(container, child, sessionId, depth + 1, fullTreeMode, dirPath);
607
+ }
608
+ } else {
609
+ const hasChange = child.status != null;
610
+ const fileEl = document.createElement('div');
611
+ fileEl.className = 'explorer-file';
612
+ fileEl.style.paddingLeft = `${depth * 8}px`;
613
+
614
+ // Status dot (only for changed files)
615
+ if (hasChange) {
616
+ const statusDot = document.createElement('span');
617
+ statusDot.className = 'explorer-status-dot';
618
+ statusDot.style.background = statusColor(child.status);
619
+ statusDot.setAttribute('aria-label', statusLabel(child.status));
620
+ fileEl.appendChild(statusDot);
621
+ }
622
+
623
+ // Filename (dim unchanged files in full tree mode)
624
+ const fname = document.createElement('span');
625
+ fname.className = 'explorer-filename';
626
+ if (fullTreeMode && !hasChange) fname.style.opacity = '0.45';
627
+ fname.textContent = child.name;
628
+ fileEl.appendChild(fname);
629
+
630
+ // Conflict indicator
631
+ const agent = agents.get(sessionId);
632
+ if (agent && agent.conflicts && agent.conflicts.includes(child.path)) {
633
+ const warn = document.createElement('span');
634
+ warn.className = 'explorer-conflict';
635
+ warn.textContent = '\u26a0';
636
+ const otherConflict = conflicts.find(c =>
637
+ c.sessionIds.includes(sessionId) && c.files.includes(child.path)
638
+ );
639
+ if (otherConflict) {
640
+ const otherId = otherConflict.sessionIds.find(id => id !== sessionId);
641
+ const otherAgent = agents.get(otherId);
642
+ if (otherAgent) {
643
+ warn.title = `Also modified by ${otherAgent.name}`;
644
+ warn.onclick = (e) => {
645
+ e.stopPropagation();
646
+ switchToSession(otherId);
647
+ };
648
+ }
649
+ }
650
+ fileEl.appendChild(warn);
651
+ }
652
+
653
+ // Click to open diff modal (only for changed files)
654
+ if (hasChange) {
655
+ const diffKey = `${sessionId}:${child.path}`;
656
+ fileEl.onclick = () => {
657
+ if (!pendingDiffs.has(diffKey)) {
658
+ pendingDiffs.add(diffKey);
659
+ send({ type: 'get-diff', sessionId, filePath: child.path, status: child.status });
660
+ }
661
+ };
662
+ fileEl.style.cursor = 'pointer';
663
+ }
664
+ container.appendChild(fileEl);
665
+ }
666
+ }
667
+ }
668
+
669
+ function renderDiffHtml(diff) {
670
+ if (!diff || !diff.trim()) return '<span class="diff-empty">No changes</span>';
671
+ return diff.split('\n').map(line => {
672
+ const escaped = line.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
673
+ if (line.startsWith('+') && !line.startsWith('+++')) {
674
+ return `<span class="diff-add">${escaped}</span>`;
675
+ } else if (line.startsWith('-') && !line.startsWith('---')) {
676
+ return `<span class="diff-del">${escaped}</span>`;
677
+ } else if (line.startsWith('@@')) {
678
+ return `<span class="diff-hunk">${escaped}</span>`;
679
+ }
680
+ return escaped;
681
+ }).join('\n');
682
+ }
683
+
684
+ function showDiffViewer() {
685
+ if (!activeDiff) return;
686
+ const viewer = document.getElementById('diff-viewer');
687
+ const canvas = document.getElementById('office-canvas');
688
+ const empty = document.getElementById('office-empty');
689
+ const pathEl = document.getElementById('diff-viewer-path');
690
+ const contentEl = document.getElementById('diff-viewer-content');
691
+
692
+ pathEl.textContent = activeDiff.filePath;
693
+ contentEl.innerHTML = renderDiffHtml(activeDiff.diff);
694
+
695
+ canvas.style.display = 'none';
696
+ empty.style.display = 'none';
697
+ viewer.style.display = 'flex';
698
+ setView('office');
699
+
700
+ // Close button
701
+ document.getElementById('diff-viewer-close').onclick = closeDiffViewer;
702
+
703
+ // Escape to close
704
+ if (!closeDiffViewer._keyHandler) {
705
+ closeDiffViewer._keyHandler = (e) => { if (e.key === 'Escape' && viewer.style.display !== 'none') closeDiffViewer(); };
706
+ document.addEventListener('keydown', closeDiffViewer._keyHandler);
707
+ }
708
+ }
709
+
710
+ export function closeDiffViewer() {
711
+ activeDiff = null;
712
+ const viewer = document.getElementById('diff-viewer');
713
+ const canvas = document.getElementById('office-canvas');
714
+ const empty = document.getElementById('office-empty');
715
+ // A phone was moved to the office panel to read this; send it back to the
716
+ // file list it picked from (desktop CSS ignores the view).
717
+ if (viewer && viewer.style.display !== 'none') setView('files');
718
+ if (viewer) viewer.style.display = 'none';
719
+ if (canvas) canvas.style.display = '';
720
+ // Restore empty state if no agents
721
+ if (empty && agents.size === 0) empty.style.display = 'flex';
722
+ }
723
+
724
+ function statusColor(status) {
725
+ switch (status) {
726
+ case 'M': return 'var(--state-working)'; // yellow
727
+ case 'A': return 'var(--state-waiting)'; // green
728
+ case 'D': return 'var(--state-disconnected)'; // red
729
+ case '?': return 'var(--state-idle)'; // gray
730
+ case 'R': return 'var(--state-message)'; // orange
731
+ default: return 'var(--state-idle)';
732
+ }
733
+ }
734
+
735
+ function statusLabel(status) {
736
+ switch (status) {
737
+ case 'M': return 'modified';
738
+ case 'A': return 'added';
739
+ case 'D': return 'deleted';
740
+ case '?': return 'untracked';
741
+ case 'R': return 'renamed';
742
+ default: return status;
743
+ }
744
+ }
745
+
746
+ function openSpawnFormWithRepo(repoPath) {
747
+ const form = document.getElementById('spawn-form');
748
+ const repoInput = document.getElementById('spawn-repo');
749
+ form.style.display = 'flex';
750
+ if (repoInput) repoInput.value = repoPath;
751
+ const nameInput = document.getElementById('spawn-name');
752
+ if (nameInput) {
753
+ nameInput.value = '';
754
+ nameInput.focus();
755
+ }
756
+ }
757
+
758
+ export function isExplorerVisible() {
759
+ return explorerVisible;
760
+ }