@dirwin517/bot-farm 0.1.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 (55) hide show
  1. package/.idea/bot-farm.iml +9 -0
  2. package/.idea/misc.xml +6 -0
  3. package/.idea/modules.xml +8 -0
  4. package/.idea/vcs.xml +6 -0
  5. package/LICENSE +21 -0
  6. package/README.md +584 -0
  7. package/package.json +13 -0
  8. package/src/api.mjs +567 -0
  9. package/src/cli.mjs +241 -0
  10. package/src/craft.mjs +172 -0
  11. package/src/db.mjs +93 -0
  12. package/src/farmhands.mjs +111 -0
  13. package/src/git.mjs +578 -0
  14. package/src/identity.mjs +199 -0
  15. package/src/legacy-defaults.mjs +44 -0
  16. package/src/mcp.mjs +681 -0
  17. package/src/mesh.mjs +222 -0
  18. package/src/packets.mjs +125 -0
  19. package/src/patches.mjs +141 -0
  20. package/src/pipelines.mjs +2145 -0
  21. package/src/pricing.mjs +91 -0
  22. package/src/projects.mjs +148 -0
  23. package/src/public/classic.html +1810 -0
  24. package/src/public/index.html +3284 -0
  25. package/src/questions.mjs +69 -0
  26. package/src/quota.mjs +76 -0
  27. package/src/registry.mjs +88 -0
  28. package/src/replay.mjs +151 -0
  29. package/src/rooms.mjs +320 -0
  30. package/src/server.mjs +765 -0
  31. package/src/store.mjs +546 -0
  32. package/src/supervisor.mjs +1803 -0
  33. package/src/tasks.mjs +191 -0
  34. package/src/template.mjs +86 -0
  35. package/src/tools.mjs +241 -0
  36. package/src/workspaces.mjs +605 -0
  37. package/src/ws.mjs +130 -0
  38. package/src/yaml.mjs +266 -0
  39. package/test/board.mjs +108 -0
  40. package/test/craft.mjs +281 -0
  41. package/test/extend.mjs +145 -0
  42. package/test/flow.mjs +165 -0
  43. package/test/handoff.mjs +228 -0
  44. package/test/linked.mjs +83 -0
  45. package/test/loop.mjs +168 -0
  46. package/test/mock-opencode.mjs +217 -0
  47. package/test/parallel.mjs +135 -0
  48. package/test/patches.mjs +93 -0
  49. package/test/repos.mjs +118 -0
  50. package/test/roles.mjs +157 -0
  51. package/test/shot.mjs +65 -0
  52. package/test/smoke.mjs +70 -0
  53. package/test/transport.mjs +131 -0
  54. package/test/workspaces.mjs +228 -0
  55. package/test/workspaces.mjs.tmp +0 -0
@@ -0,0 +1,1810 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
+ <title>botfarm</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
10
+ <style>
11
+ :root {
12
+ /* Status is carried by amber / violet / blue, never red-versus-green:
13
+ the one pair a red-green colour blind operator cannot separate. Red is
14
+ used only for failure, where it never has to be told apart from success,
15
+ and every state is also labelled in words. */
16
+ --ink: #0a111c; --panel: #172232; --panel-2: #1f2c3f; --line: #37495f;
17
+ --text: #f0f4fa; --dim: #a3b2c9; --faint: #6c7f9c;
18
+ --busy: #ffab2e; --wait: #b494ff; --error: #ff6f61; --idle: #7d92b0; --ok: #4cc3ff;
19
+ --radius: 5px;
20
+ --mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
21
+ --sans: "IBM Plex Sans", ui-sans-serif, system-ui, -apple-system, sans-serif;
22
+ }
23
+ * { box-sizing: border-box; }
24
+ [hidden] { display: none !important; }
25
+ html, body { margin: 0; height: 100%; }
26
+ body { background: var(--ink); color: var(--text); font-family: var(--sans); font-size: 14px; line-height: 1.45; -webkit-font-smoothing: antialiased; }
27
+ body.modal-open { overflow: hidden; }
28
+ .num { font-family: var(--mono); font-variant-numeric: tabular-nums; }
29
+ button, input, textarea, select { font: inherit; color: inherit; background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--radius); padding: 5px 9px; }
30
+ button { cursor: pointer; transition: background .12s, border-color .12s; }
31
+ button:hover { background: #22304a; }
32
+ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px solid var(--wait); outline-offset: 1px; }
33
+ input::placeholder, textarea::placeholder { color: var(--faint); }
34
+ .ghost { background: transparent; color: var(--dim); }
35
+ .ghost:hover { color: var(--text); }
36
+ .danger:hover { background: #3a1f20; border-color: #6b2f2c; color: #ffb3ab; }
37
+ .primary { background: #24405f; border-color: #33587f; }
38
+ .primary:hover { background: #2c4e74; }
39
+ .tiny { font-size: 11px; padding: 2px 7px; }
40
+
41
+ header { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: 24px; flex-wrap: wrap; padding: 12px 20px; background: var(--ink); border-bottom: 1px solid var(--line); }
42
+ .brand { display: flex; align-items: baseline; gap: 10px; }
43
+ .brand b { font-weight: 600; font-size: 16px; letter-spacing: -.01em; }
44
+ .brand span { color: var(--faint); font-size: 12px; font-family: var(--mono); }
45
+ .readout { display: flex; gap: 20px; align-items: flex-end; }
46
+ .stat { display: flex; flex-direction: column; }
47
+ .stat b { font-family: var(--mono); font-weight: 500; font-size: 17px; letter-spacing: -.02em; }
48
+ .stat i { font-style: normal; font-size: 11px; color: var(--dim); }
49
+ .stat.est b { color: var(--ok); }
50
+ .header-right { margin-left: auto; display: flex; align-items: center; gap: 10px; }
51
+
52
+ .shell { display: grid; grid-template-columns: 228px 1fr; align-items: start; }
53
+ nav { position: sticky; top: 53px; padding: 16px 12px 40px; border-right: 1px solid var(--line); height: calc(100vh - 53px); overflow-y: auto; }
54
+ nav h4 { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--faint); margin: 18px 6px 6px; }
55
+ nav h4:first-child { margin-top: 0; }
56
+ .navitem { display: flex; align-items: center; gap: 8px; width: 100%; border: 0; background: transparent; border-radius: var(--radius); padding: 6px 8px; text-align: left; color: var(--dim); }
57
+ .navitem:hover { background: var(--panel); color: var(--text); }
58
+ .navitem[aria-current="true"] { background: var(--panel-2); color: var(--text); }
59
+ .navitem .c { margin-left: auto; font-family: var(--mono); font-size: 11px; color: var(--faint); }
60
+ .navitem .c b { color: var(--wait); font-weight: 500; }
61
+ .navitem.room .n { font-family: var(--mono); font-size: 12.5px; }
62
+ .dotm { width: 6px; height: 6px; border-radius: 50%; background: var(--wait); display: inline-block; margin-right: 6px; }
63
+ .muted .dotm { background: var(--faint); }
64
+ .navadd { color: var(--faint); font-size: 12px; }
65
+
66
+ main { padding: 16px 20px 60px; min-width: 0; }
67
+ .toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 14px; flex-wrap: wrap; }
68
+ .toolbar input { width: 250px; }
69
+ .banner { display: flex; gap: 10px; align-items: center; background: var(--panel); border: 1px solid var(--line); border-left: 3px solid var(--busy); border-radius: var(--radius); padding: 9px 12px; margin-bottom: 14px; font-size: 13px; color: var(--dim); }
70
+ .grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); }
71
+
72
+ .pod { display: grid; grid-template-columns: 4px 1fr; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
73
+ .rail { background: var(--idle); }
74
+ .pod[data-status="busy"] .rail { background: var(--busy); }
75
+ .pod[data-status="waiting"] .rail { background: var(--wait); }
76
+ .pod[data-status="error"] .rail { background: var(--error); }
77
+ .body { padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; min-width: 0; }
78
+ .row1 { display: flex; align-items: center; gap: 9px; min-width: 0; }
79
+ .avatar { width: 54px; height: 54px; border-radius: 10px; flex: none; cursor: pointer; }
80
+ .who { display: flex; flex-direction: column; min-width: 0; }
81
+ .handle { font-family: var(--mono); font-size: 15px; font-weight: 500; cursor: pointer; }
82
+ .handle:hover { text-decoration: underline; }
83
+ .name { font-size: 12.5px; color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
84
+ .state { margin-left: auto; font-family: var(--mono); font-size: 11px; color: var(--dim); white-space: nowrap; }
85
+ .pod[data-status="busy"] .state { color: var(--busy); }
86
+ .pod[data-status="waiting"] .state { color: var(--wait); }
87
+ .pod[data-status="error"] .state { color: var(--error); }
88
+ .dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: currentColor; margin-right: 6px; vertical-align: 1px; }
89
+ .pod[data-status="busy"] .dot { animation: pulse 1.6s ease-in-out infinite; }
90
+ @keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: .25 } }
91
+ @media (prefers-reduced-motion: reduce) { .dot { animation: none !important } }
92
+ .meta { display: flex; gap: 8px; font-family: var(--mono); font-size: 11px; color: var(--dim); min-width: 0; flex-wrap: wrap; }
93
+ .meta .path { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
94
+ .tag { border: 1px solid var(--line); border-radius: 3px; padding: 0 5px; white-space: nowrap; }
95
+ .tag.diff { color: var(--ok); border-color: #1d4a63; }
96
+ .tag.mail { color: var(--wait); border-color: #3b3159; background: #221b3a; }
97
+ .tag.grp { color: var(--faint); }
98
+ .charts { display: flex; gap: 14px; }
99
+ .chart { flex: 1; min-width: 0; }
100
+ .chart .cap { display: flex; justify-content: space-between; align-items: baseline; font-size: 11px; color: var(--dim); }
101
+ .chart .cap b { font-family: var(--mono); font-weight: 500; font-size: 13px; color: var(--text); }
102
+ svg.spark { display: block; width: 100%; height: 28px; }
103
+ .say { font-size: 12.5px; color: var(--dim); background: var(--ink); border: 1px solid var(--line); border-radius: var(--radius); padding: 7px 9px; min-height: 54px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
104
+ .say.tool { color: var(--busy); font-family: var(--mono); font-size: 12px; }
105
+ .actions { display: flex; gap: 6px; }
106
+ .actions input { flex: 1; min-width: 0; font-size: 12.5px; }
107
+ .perm { background: #2a2145; border: 1px solid #453a72; border-radius: var(--radius); padding: 6px 9px; font-size: 12px; display: flex; gap: 8px; align-items: center; }
108
+ .perm b { color: var(--wait); font-weight: 500; }
109
+
110
+ .scrim { position: fixed; inset: 0; background: rgba(6,9,15,.72); z-index: 30; }
111
+ .modal { position: fixed; inset: 4vh 3vw; z-index: 31; display: grid; grid-template-columns: 1fr 340px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; box-shadow: 0 24px 70px rgba(0,0,0,.5); animation: pop .16s ease-out; }
112
+ @keyframes pop { from { transform: scale(.985); opacity: .5 } }
113
+ @media (prefers-reduced-motion: reduce) { .modal { animation: none } }
114
+ .modal .left { display: flex; flex-direction: column; min-width: 0; }
115
+ .modal .top { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--line); }
116
+ .modal .top .h { font-family: var(--mono); font-size: 15px; font-weight: 500; }
117
+ .modal .top .s { color: var(--dim); font-size: 12px; }
118
+ .modal .top .right { margin-left: auto; display: flex; gap: 6px; }
119
+ .transcript { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
120
+ .turn { display: flex; gap: 10px; }
121
+ .turn img, .turn .avdot { width: 40px; height: 40px; border-radius: 8px; flex: none; }
122
+ .turn .avdot { background: #14344a; border: 1px solid #245c7d; }
123
+ .turn .c { min-width: 0; flex: 1; }
124
+ .turn .m { font-family: var(--mono); font-size: 11px; color: var(--dim); margin-bottom: 2px; }
125
+ .turn.user .m, .turn.op .m { color: var(--ok); }
126
+ .turn .b { font-size: 13.5px; white-space: pre-wrap; word-break: break-word; }
127
+ .turn .tools { margin-top: 5px; display: flex; gap: 6px; flex-wrap: wrap; }
128
+ .turn .tools span { font-family: var(--mono); font-size: 11px; color: var(--busy); border: 1px solid #45391f; border-radius: 3px; padding: 0 5px; }
129
+ .turn .b.long { max-height: 9.5em; overflow: hidden; -webkit-mask-image: linear-gradient(#000 70%, transparent); mask-image: linear-gradient(#000 70%, transparent); }
130
+ .turn details.more > summary, .turn details.think > summary { cursor: pointer; font-family: var(--mono); font-size: 11px; color: var(--faint); list-style: none; margin-top: 3px; }
131
+ .turn details.more > summary .lbl { display: block; margin-top: 2px; }
132
+ .turn details.more > summary .less, .turn details.more[open] > summary .b, .turn details.more[open] > summary .lbl:not(.less) { display: none; }
133
+ .turn details.more[open] > summary .less { display: block; }
134
+ .turn details.think { margin: 4px 0; }
135
+ .turn details.think > div { font-size: 12.5px; color: var(--dim); font-style: italic; white-space: pre-wrap; border-left: 2px solid var(--line); padding-left: 8px; margin-top: 3px; }
136
+ .turn .call { display: flex; gap: 8px; align-items: baseline; font-family: var(--mono); font-size: 11.5px; margin: 3px 0; padding: 3px 7px; border: 1px solid var(--line); border-radius: 4px; background: var(--ink); min-width: 0; }
137
+ .turn .call .nm { color: var(--busy); flex: none; }
138
+ .turn .call .sm { color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; flex: 1; }
139
+ .turn .call .st { color: var(--faint); flex: none; }
140
+ .turn .call.s-running .st, .turn .call.s-pending .st { color: var(--busy); }
141
+ .turn .call.s-error { border-color: var(--error); }
142
+ .turn .call.s-error .st { color: var(--error); }
143
+ .turn .out { font-family: var(--mono); font-size: 11px; color: var(--dim); white-space: pre-wrap; max-height: 12em; overflow: auto; margin: 0 0 4px 8px; }
144
+ .working { font-family: var(--mono); font-size: 12px; color: var(--busy); padding: 2px 0 2px 50px; }
145
+ .working i { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--busy); margin-right: 6px; animation: pulse 1.2s ease-in-out infinite; }
146
+ @keyframes pulse { 50% { opacity: .25 } }
147
+ @media (prefers-reduced-motion: reduce) { .working i { animation: none } }
148
+ .crew .who .doing { display: block; max-width: 220px; font-size: 11px; color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); }
149
+ .composer { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--line); }
150
+ .composer textarea { flex: 1; resize: none; min-height: 40px; max-height: 140px; font-size: 13px; }
151
+ .side { border-left: 1px solid var(--line); overflow-y: auto; padding: 0 16px 28px; background: var(--ink); }
152
+ .sec { margin-top: 18px; }
153
+ .sec h3 { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; font-weight: 600; color: var(--faint); margin: 0 0 8px; }
154
+ table.kv { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; }
155
+ table.kv td { padding: 3px 0; border-bottom: 1px dotted var(--line); }
156
+ table.kv td:last-child { text-align: right; }
157
+ .row { display: flex; align-items: center; gap: 8px; padding: 4px 0; border-bottom: 1px dotted var(--line); font-size: 12px; }
158
+ .row .g { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); }
159
+ .row .st { font-size: 10.5px; color: var(--faint); }
160
+ .sw { width: 34px; height: 19px; border-radius: 10px; background: #2a3550; border: 1px solid var(--line); position: relative; padding: 0; flex: none; }
161
+ .sw::after { content: ""; position: absolute; top: 2px; left: 2px; width: 13px; height: 13px; border-radius: 50%; background: var(--faint); transition: left .12s, background .12s; }
162
+ .sw[aria-checked="true"] { background: #24405f; border-color: #33587f; }
163
+ .sw[aria-checked="true"]::after { left: 17px; background: var(--ok); }
164
+ .sw::before { content: "off"; position: absolute; right: -24px; top: 1px; font-size: 10px; color: var(--faint); }
165
+ .sw[aria-checked="true"]::before { content: "on"; color: var(--ok); }
166
+ .seg { display: inline-flex; border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
167
+ .seg button { border: 0; border-radius: 0; background: transparent; padding: 4px 9px; font-size: 11.5px; color: var(--dim); }
168
+ .seg button[aria-pressed="true"] { background: #24405f; color: var(--text); }
169
+ .note { font-size: 11.5px; color: var(--faint); margin-top: 6px; }
170
+ .wire { font-family: var(--mono); font-size: 10.5px; color: var(--faint); word-break: break-all; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 5px 7px; margin-top: 6px; }
171
+ .project { display: grid; grid-template-columns: 1fr 340px; gap: 12px; align-items: start; }
172
+ .project > .work { min-width: 0; }
173
+ .chatpane { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); display: flex; flex-direction: column; height: calc(100vh - 150px); position: sticky; top: 66px; }
174
+ .chatpane .ph { padding: 9px 12px; border-bottom: 1px solid var(--line); display: flex; align-items: center; gap: 8px; }
175
+ .chatpane .ph b { font-family: var(--mono); font-size: 13px; font-weight: 500; }
176
+ .chatpane .log { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 10px; }
177
+ .crew { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
178
+ .crew .who { display: flex; align-items: center; gap: 8px; background: var(--panel); border: 1px solid var(--line); border-radius: 28px; padding: 4px 14px 4px 5px; font-family: var(--mono); font-size: 12.5px; cursor: pointer; }
179
+ .crew .who img { width: 40px; height: 40px; border-radius: 50%; }
180
+ .crew .who[data-status="busy"] { border-color: var(--busy); color: var(--busy); }
181
+ .crew .who[data-status="waiting"] { border-color: var(--wait); color: var(--wait); }
182
+ .crew .who[data-status="error"] { border-color: var(--error); color: var(--error); }
183
+ .phead { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; }
184
+ .phead h2 { margin: 0; font-size: 17px; font-weight: 600; letter-spacing: -.01em; }
185
+ .phead .sub { font-family: var(--mono); font-size: 12px; color: var(--dim); }
186
+ .phead .right { margin-left: auto; display: flex; gap: 6px; }
187
+ .modal .left.split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); overflow: hidden; }
188
+ .modal .left.split > * { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
189
+ .modal .left.split .taskside { overflow-y: auto; border-right: 1px solid var(--line); display: block; padding-bottom: 20px; }
190
+ .modal .left.split .taskside .sec { padding: 0 16px; }
191
+ .ask { background: #2b2350; border: 1px solid #4a3d84; border-left: 3px solid var(--wait); border-radius: var(--radius); padding: 11px 13px; margin-bottom: 10px; }
192
+ .ask .q { font-size: 13.5px; margin-bottom: 4px; }
193
+ .ask .asker { font-family: var(--mono); font-size: 11px; color: var(--wait); margin-bottom: 6px; display: flex; align-items: center; gap: 7px; }
194
+ .ask .asker img { width: 26px; height: 26px; border-radius: 6px; }
195
+ .ask .opts { display: flex; gap: 6px; flex-wrap: wrap; margin: 8px 0; }
196
+ .ask .reply { display: flex; gap: 6px; }
197
+ .ask .reply input { flex: 1; }
198
+ .col.waiting h5 { color: var(--wait); }
199
+ .card.question { border-left: 3px solid var(--wait); }
200
+ .editor { display: grid; gap: 10px; }
201
+ .stagebox { background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--radius); padding: 10px 12px; }
202
+ .stagebox .hd { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
203
+ .stagebox .hd select { max-width: 180px; }
204
+ .stagebox .hd input.sid { width: 130px; font-family: var(--mono); }
205
+ .stagebox .hd .sp { margin-left: auto; display: flex; gap: 4px; }
206
+ .stagebox label { display: block; font-size: 11px; color: var(--faint); margin: 8px 0 3px; }
207
+ .stagebox textarea { width: 100%; min-height: 80px; font-family: var(--mono); font-size: 12px; resize: vertical; }
208
+ .stagebox .recv { display: flex; gap: 8px; flex-wrap: wrap; font-size: 11.5px; color: var(--dim); }
209
+ .stagebox .recv label { display: inline-flex; align-items: center; gap: 4px; margin: 0; color: var(--dim); font-size: 11.5px; }
210
+ .kanban { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(230px, 1fr); gap: 10px; align-items: start; overflow-x: auto; padding-bottom: 8px; }
211
+ .col { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); min-height: 90px; }
212
+ .col h5 { margin: 0; padding: 8px 10px; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--faint); border-bottom: 1px solid var(--line); display: flex; }
213
+ .col h5 span { margin-left: auto; font-family: var(--mono); }
214
+ .col.active h5 { color: var(--busy); }
215
+ .col.review h5 { color: var(--wait); }
216
+ .col.done h5 { color: var(--ok); }
217
+ .col.cancelled h5 { color: var(--error); }
218
+ .card { padding: 8px 10px; border-bottom: 1px dotted var(--line); cursor: pointer; }
219
+ .card:hover { background: var(--panel-2); }
220
+ .card .t { font-size: 12.5px; }
221
+ .card .s { display: flex; gap: 7px; align-items: center; margin-top: 6px; font-family: var(--mono); font-size: 11px; color: var(--faint); }
222
+ .card .s img { width: 24px; height: 24px; border-radius: 5px; }
223
+ .card .s .stage { color: var(--wait); }
224
+ .runs { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; }
225
+ .run { background: var(--panel); border: 1px solid var(--line); border-left: 3px solid var(--ok); border-radius: var(--radius); padding: 8px 11px; min-width: 240px; }
226
+ .run .rt { font-size: 12.5px; }
227
+ .run .flow { display: flex; gap: 4px; align-items: center; margin-top: 6px; font-family: var(--mono); font-size: 10.5px; }
228
+ .run .step { padding: 1px 6px; border-radius: 3px; border: 1px solid var(--line); color: var(--faint); }
229
+ .run .step.done { color: var(--ok); border-color: #1d4a63; }
230
+ .run .step.active { color: var(--busy); border-color: #45391f; }
231
+ .run .step.review { color: var(--wait); border-color: #3b3159; }
232
+ .run .arrow { color: var(--faint); }
233
+ .diff { font-family: var(--mono); font-size: 11.5px; white-space: pre; overflow-x: auto; background: var(--ink); border: 1px solid var(--line); border-radius: var(--radius); padding: 8px; max-height: 50vh; overflow-y: auto; }
234
+ /* +/- prefixes do the work; colour only reinforces it. */
235
+ .diff .add { color: var(--ok); background: rgba(76,195,255,.07); }
236
+ .diff .del { color: var(--busy); background: rgba(255,171,46,.07); }
237
+ .diff .hunk { color: var(--wait); }
238
+ .empty { color: var(--dim); padding: 50px 0; text-align: center; }
239
+ .empty b { display: block; color: var(--text); font-size: 16px; margin-bottom: 6px; font-weight: 600; }
240
+ dialog { background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 20px; width: min(560px, 94vw); }
241
+ dialog::backdrop { background: rgba(6,9,15,.65); }
242
+ dialog label { display: block; font-size: 12px; color: var(--dim); margin: 12px 0 4px; }
243
+ dialog input, dialog textarea, dialog select { width: 100%; }
244
+ .pick { max-height: 290px; overflow-y: auto; border: 1px solid var(--line); border-radius: var(--radius); margin-top: 6px; }
245
+ .pick label { display: flex; gap: 9px; align-items: center; margin: 0; padding: 6px 9px; color: var(--text); border-bottom: 1px dotted var(--line); font-size: 12.5px; }
246
+ .pick label:hover { background: var(--panel-2); }
247
+ .pick input { width: auto; }
248
+ .pick .d { color: var(--faint); font-family: var(--mono); font-size: 11px; margin-left: auto; white-space: nowrap; }
249
+ .feed { font-family: var(--mono); font-size: 11.5px; color: var(--faint); max-height: 150px; overflow-y: auto; }
250
+ .feed .warn { color: var(--busy); }
251
+ .feed .error { color: var(--error); }
252
+ @media (max-width: 1200px) {
253
+ .project { grid-template-columns: 1fr; }
254
+ .chatpane { height: 420px; position: static; }
255
+ .split { grid-template-columns: 1fr; }
256
+ }
257
+ @media (max-width: 900px) {
258
+ .shell { grid-template-columns: 1fr; }
259
+ nav { position: static; height: auto; border-right: 0; border-bottom: 1px solid var(--line); }
260
+ .modal { inset: 0; border-radius: 0; grid-template-columns: 1fr; }
261
+ .side { display: none; }
262
+ .grid { grid-template-columns: 1fr; }
263
+ }
264
+ </style>
265
+ </head>
266
+ <body>
267
+ <header>
268
+ <div class="brand"><b>botfarm</b><span id="conn">connecting…</span></div>
269
+ <div class="readout">
270
+ <div class="stat"><b class="num" id="s-busy">0</b><i>running</i></div>
271
+ <div class="stat"><b class="num" id="s-sessions">0</b><i>on the board</i></div>
272
+ <div class="stat"><b class="num" id="s-tpm">0</b><i>tokens/min</i></div>
273
+ <div class="stat"><b class="num" id="s-tools">0</b><i>tool calls</i></div>
274
+ <div class="stat est"><b class="num" id="s-cost">$0.00</b><i id="s-cost-l">est. spend</i></div>
275
+ </div>
276
+ <div class="header-right">
277
+ <svg class="spark" id="global-spark" style="width:170px"></svg>
278
+ <button class="ghost" id="add-existing">Add existing…</button>
279
+ <button class="primary" id="new-pod">New session</button>
280
+ </div>
281
+ </header>
282
+
283
+ <div class="shell">
284
+ <nav>
285
+ <h4>Sessions</h4>
286
+ <div id="nav-views"></div>
287
+ <div id="nav-work"></div>
288
+ <h4>Projects</h4>
289
+ <div id="nav-projects"></div>
290
+ <button class="navitem navadd" id="add-project">+ New project</button>
291
+ </nav>
292
+
293
+ <main>
294
+ <div class="banner" id="loose" hidden></div>
295
+ <div class="toolbar">
296
+ <input id="filter" placeholder="Filter by handle, branch or path /" />
297
+ <span class="num" style="color:var(--faint);font-size:12px" id="scope"></span>
298
+ <button class="ghost" id="new-task">New task</button>
299
+ <button class="ghost danger" id="abort-all" style="margin-left:auto">Abort all</button>
300
+ <span style="color:var(--faint);font-size:12px" id="updated"></span>
301
+ </div>
302
+ <div id="project-view" hidden></div>
303
+ <div class="runs" id="runs"></div>
304
+ <div class="kanban" id="kanban" hidden></div>
305
+ <div class="grid" id="grid"></div>
306
+ <div class="empty" id="empty" hidden>
307
+ <b>Nothing on the board</b>
308
+ Start one with “New session”, or use “Add existing…” to put a session opencode already knows about here.
309
+ </div>
310
+ <div class="sec"><h3>Activity</h3><div class="feed" id="feed"></div></div>
311
+ </main>
312
+ </div>
313
+
314
+ <dialog id="adopt-dialog">
315
+ <b>Add existing sessions</b>
316
+ <div class="note">opencode keeps every session you have ever opened. Pick the ones you are actually running; the rest stay out of the way.</div>
317
+ <label for="a-search">Search</label>
318
+ <input id="a-search" placeholder="title or path" />
319
+ <div class="pick" id="a-list"></div>
320
+ <label for="a-group">Group</label>
321
+ <select id="a-group"></select>
322
+ <div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
323
+ <button class="ghost" id="a-cancel">Cancel</button>
324
+ <button class="primary" id="a-add">Add selected</button>
325
+ </div>
326
+ </dialog>
327
+
328
+ <dialog id="new-dialog">
329
+ <b>New session</b>
330
+ <label for="f-repo">Repository path</label>
331
+ <input id="f-repo" placeholder="~/code/app" />
332
+ <div class="note" id="f-repo-info"></div>
333
+ <div class="pick" id="f-services" hidden></div>
334
+ <label for="f-branch">Branch — creates a git worktree; leave empty to work in the repo itself</label>
335
+ <input id="f-branch" placeholder="fix/auth-redirect" />
336
+ <label for="f-task">First instruction (optional)</label>
337
+ <textarea id="f-task" rows="3"></textarea>
338
+ <label for="f-group">Group</label>
339
+ <select id="f-group"></select>
340
+ <div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
341
+ <button class="ghost" id="f-cancel">Cancel</button>
342
+ <button class="primary" id="f-create">Create</button>
343
+ </div>
344
+ <div id="f-error" style="color:var(--error);font-size:12px;margin-top:10px"></div>
345
+ </dialog>
346
+
347
+ <dialog id="task-dialog">
348
+ <b>New task</b>
349
+ <div class="note">The same thing a bot does with botfarm_task_create — assigned work is offered to a session when it next goes idle.</div>
350
+ <label for="t-title">Title</label>
351
+ <input id="t-title" placeholder="Fix the double-redirect on refresh" />
352
+ <label for="t-brief">Brief — the assignee cannot see your other conversations</label>
353
+ <textarea id="t-brief" rows="4"></textarea>
354
+ <label for="t-project">Project</label>
355
+ <select id="t-project"></select>
356
+ <label for="t-assignee">Assign to</label>
357
+ <select id="t-assignee"></select>
358
+ <div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
359
+ <button class="ghost" id="t-cancel">Cancel</button>
360
+ <button class="primary" id="t-create">Add to board</button>
361
+ </div>
362
+ <div id="t-error" style="color:var(--error);font-size:12px;margin-top:10px"></div>
363
+ </dialog>
364
+
365
+ <dialog id="project-dialog">
366
+ <b>New project</b>
367
+ <div class="note">A project owns a checkout, the sessions working in it, its board and its chat.</div>
368
+ <label for="pj-name">Name</label>
369
+ <input id="pj-name" placeholder="SSO return path" />
370
+ <label for="pj-repo">Repository path (optional)</label>
371
+ <input id="pj-repo" placeholder="~/workspace/app" />
372
+ <label>Sessions to move into it</label>
373
+ <div class="pick" id="pj-sessions"></div>
374
+ <div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
375
+ <button class="ghost" id="pj-cancel">Cancel</button>
376
+ <button class="primary" id="pj-create">Create</button>
377
+ </div>
378
+ <div id="pj-error" style="color:var(--error);font-size:12px;margin-top:10px"></div>
379
+ </dialog>
380
+
381
+ <dialog id="edit-dialog" style="width:min(860px,96vw)">
382
+ <div style="display:flex;align-items:baseline;gap:10px">
383
+ <b id="ed-title">Edit pipeline</b>
384
+ <span class="note" style="margin:0" id="ed-file"></span>
385
+ <span style="margin-left:auto;display:flex;gap:6px">
386
+ <button class="ghost tiny" id="ed-yaml-toggle">Edit as YAML</button>
387
+ <button class="ghost tiny" id="ed-cancel">Close</button>
388
+ <button class="primary tiny" id="ed-save">Save</button>
389
+ </span>
390
+ </div>
391
+ <div id="ed-body" class="editor" style="max-height:70vh;overflow-y:auto;margin-top:12px"></div>
392
+ <textarea id="ed-yaml" hidden style="width:100%;height:60vh;font-family:var(--mono);font-size:12px;margin-top:12px"></textarea>
393
+ <div id="ed-error" style="color:var(--error);font-size:12px;margin-top:10px;white-space:pre-wrap"></div>
394
+ </dialog>
395
+
396
+ <dialog id="pipe-dialog">
397
+ <b>Run a pipeline</b>
398
+ <div class="note">Every persona in the pipeline gets its own session, all sharing one worktree, plus a group chat.</div>
399
+ <label for="p-pipeline">Pipeline</label>
400
+ <select id="p-pipeline"></select>
401
+ <div class="note" id="p-stages"></div>
402
+ <div style="display:flex;gap:6px;margin-top:6px">
403
+ <button class="ghost tiny" id="p-edit">Edit this pipeline</button>
404
+ <button class="ghost tiny" id="p-personas">Edit personas</button>
405
+ </div>
406
+ <label for="p-repo">Repository path</label>
407
+ <input id="p-repo" placeholder="~/code/app" />
408
+ <div class="note" id="p-repo-info"></div>
409
+ <div class="pick" id="p-services" hidden></div>
410
+ <label for="p-branch">Branch for the worktree</label>
411
+ <input id="p-branch" placeholder="feat/sso-return-path" />
412
+ <label for="p-story">Story</label>
413
+ <textarea id="p-story" rows="4" placeholder="Users bounce to the dashboard root after SSO instead of their last page."></textarea>
414
+ <div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
415
+ <button class="ghost" id="p-reload">Reload YAML</button>
416
+ <button class="ghost" id="p-cancel">Cancel</button>
417
+ <button class="primary" id="p-run">Start</button>
418
+ </div>
419
+ <div id="p-error" style="color:var(--error);font-size:12px;margin-top:10px"></div>
420
+ </dialog>
421
+
422
+ <script>
423
+ const $ = (s, r = document) => r.querySelector(s)
424
+ const $$ = (s, r = document) => [...r.querySelectorAll(s)]
425
+ const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]))
426
+
427
+ const fmt = {
428
+ n(v) {
429
+ if (!v) return "0"
430
+ if (v >= 1e9) return (v / 1e9).toFixed(1) + "B"
431
+ if (v >= 1e6) return (v / 1e6).toFixed(1) + "M"
432
+ if (v >= 1e3) return (v / 1e3).toFixed(v >= 1e4 ? 0 : 1) + "k"
433
+ return String(Math.round(v))
434
+ },
435
+ money(v) { return "$" + (v ?? 0).toFixed(v && v < 1 ? 3 : 2) },
436
+ ago(ts) {
437
+ if (!ts) return "—"
438
+ const s = Math.max(0, Math.round((Date.now() - ts) / 1000))
439
+ if (s < 60) return s + "s"
440
+ if (s < 3600) return Math.floor(s / 60) + "m" + String(s % 60).padStart(2, "0") + "s"
441
+ if (s < 86400) return Math.floor(s / 3600) + "h" + String(Math.floor((s % 3600) / 60)).padStart(2, "0") + "m"
442
+ return Math.floor(s / 86400) + "d"
443
+ },
444
+ ms(v) { return v == null ? "" : v < 1000 ? v + "ms" : (v / 1000).toFixed(1) + "s" },
445
+ path(p, keep = 3) {
446
+ const short = (p ?? "").replace(/^\/(Users|home)\/[^/]+/, "~")
447
+ const seg = short.split("/")
448
+ return seg.length > keep + 1 ? "…/" + seg.slice(-keep).join("/") : short
449
+ },
450
+ }
451
+ const COLOR = { busy: "#f0a437", waiting: "#a888f4", error: "#e2564a", idle: "#55698c" }
452
+ const avatarUrl = (s) => `/avatar/${encodeURIComponent(s.id)}.svg?h=${encodeURIComponent(s.handle ?? "")}`
453
+
454
+ function spark(svg, values, color, { w = 240, h = 28 } = {}) {
455
+ const n = values.length
456
+ const peak = Math.max(...values)
457
+ svg.setAttribute("viewBox", `0 0 ${w} ${h}`)
458
+ svg.setAttribute("preserveAspectRatio", "none")
459
+ if (!peak) { svg.innerHTML = ""; return }
460
+ const dx = w / (n - 1)
461
+ const y = (v) => h - 1.5 - (v / peak) * (h - 4)
462
+ let d = ""
463
+ values.forEach((v, i) => { d += (i ? "L" : "M") + (i * dx).toFixed(1) + "," + y(v).toFixed(1) })
464
+ const id = "g" + Math.abs([...color].reduce((a, ch) => (a * 31 + ch.charCodeAt(0)) | 0, 7))
465
+ svg.innerHTML =
466
+ `<defs><linearGradient id="${id}" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="${color}" stop-opacity=".38"/><stop offset="1" stop-color="${color}" stop-opacity="0"/></linearGradient></defs>` +
467
+ `<path d="${d}L${w},${h}L0,${h}Z" fill="url(#${id})"/>` +
468
+ `<path d="${d}" fill="none" stroke="${color}" stroke-width="1.4" stroke-linejoin="round" vector-effect="non-scaling-stroke"/>`
469
+ }
470
+
471
+ async function api(path, opts = {}) {
472
+ const res = await fetch(path, {
473
+ method: opts.method ?? "GET",
474
+ headers: opts.body ? { "content-type": "application/json" } : undefined,
475
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
476
+ })
477
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? res.statusText)
478
+ return res.status === 204 ? null : res.json()
479
+ }
480
+
481
+ let state = { at: Date.now(), sessions: [], rooms: [], groups: [], totals: {}, events: [] }
482
+ let view = { kind: "all", value: null }
483
+ let filterText = ""
484
+ let openId = null
485
+ // Live transcript for the open session, fed by websocket frames.
486
+ let live = { id: null, msgs: null, status: null, currentTool: null, at: 0 }
487
+ const openThoughts = new Set()
488
+ let liveFrame = 0
489
+ let sock = null
490
+ const wsSend = (text) => { if (sock?.readyState === 1) sock.send(text) }
491
+ let roomId = null
492
+ let modalSig = null
493
+ let roomSig = null
494
+ let navSig = null
495
+
496
+ // ------------------------------------------------------------------ cards
497
+ const pods = new Map()
498
+
499
+ function podEl(s) {
500
+ const el = document.createElement("div")
501
+ el.className = "pod"
502
+ el.innerHTML = `
503
+ <div class="rail"></div>
504
+ <div class="body">
505
+ <div class="row1">
506
+ <img class="avatar" alt="" />
507
+ <span class="who"><span class="handle"></span><span class="name"></span></span>
508
+ <span class="state"><i class="dot"></i><em class="stxt"></em></span>
509
+ </div>
510
+ <div class="meta"><span class="path"></span><span class="tag branch"></span><span class="tag diff"></span>
511
+ <span class="tag ctx"></span><span class="tag cost"></span><span class="tag mail"></span><span class="tag grp"></span></div>
512
+ <div class="charts">
513
+ <div class="chart"><div class="cap"><span>tokens/min</span><b class="tpm">0</b></div><svg class="spark s-tok"></svg></div>
514
+ <div class="chart"><div class="cap"><span>tools/min</span><b class="tlm">0</b></div><svg class="spark s-tool"></svg></div>
515
+ </div>
516
+ <div class="say"></div>
517
+ <div class="perm" hidden><b>Waiting for permission</b>
518
+ <button class="allow" style="margin-left:auto">Allow once</button><button class="always">Always</button><button class="deny danger">Deny</button></div>
519
+ <div class="actions">
520
+ <input class="msg-in" placeholder="Continue… (⏎ to send)" />
521
+ <button class="send">Send</button>
522
+ <button class="abort danger">Abort</button>
523
+ <button class="open ghost">Open</button>
524
+ </div>
525
+ </div>`
526
+ const r = {
527
+ el, avatar: $(".avatar", el), handle: $(".handle", el), name: $(".name", el), stxt: $(".stxt", el),
528
+ path: $(".path", el), branch: $(".branch", el), diff: $(".diff", el), ctx: $(".ctx", el),
529
+ cost: $(".cost", el), mail: $(".mail", el), grp: $(".grp", el),
530
+ tpm: $(".tpm", el), tlm: $(".tlm", el), sTok: $(".s-tok", el), sTool: $(".s-tool", el),
531
+ say: $(".say", el), perm: $(".perm", el), input: $(".msg-in", el),
532
+ }
533
+ const send = async () => {
534
+ const text = r.input.value.trim()
535
+ if (!text) return
536
+ r.input.value = ""
537
+ try { await api(`/api/sessions/${s.id}/send`, { method: "POST", body: { text } }) }
538
+ catch (e) { r.input.value = text; alert(e.message) }
539
+ }
540
+ $(".send", el).onclick = send
541
+ r.input.onkeydown = (e) => { if (e.key === "Enter") send() }
542
+ $(".abort", el).onclick = () => api(`/api/sessions/${s.id}/abort`, { method: "POST" }).catch((e) => alert(e.message))
543
+ $(".open", el).onclick = () => openSession(s.id)
544
+ r.handle.onclick = () => openSession(s.id)
545
+ r.avatar.onclick = () => openSession(s.id)
546
+ for (const [cls, d] of [[".allow", "once"], [".always", "always"], [".deny", "reject"]]) {
547
+ $(cls, el).onclick = () => replyPermission(s.id, d)
548
+ }
549
+ return r
550
+ }
551
+
552
+ function updatePod(r, s) {
553
+ r.el.dataset.status = s.status
554
+ r.avatar.src = avatarUrl(s)
555
+ r.handle.textContent = s.handle ?? "…"
556
+ r.name.textContent = s.name
557
+ r.name.title = s.id
558
+ r.stxt.textContent =
559
+ s.status === "busy" ? "running " + fmt.ago(s.busySince ?? s.updated)
560
+ : s.status === "waiting" ? "needs input"
561
+ : s.status === "error" ? (s.lastError ?? "error")
562
+ : "idle " + fmt.ago(s.updated)
563
+ r.path.textContent = fmt.path(s.directory) || "—"
564
+ r.path.title = s.directory ?? ""
565
+ r.branch.hidden = !s.branch
566
+ r.branch.textContent = s.branch ?? ""
567
+ const g = s.git
568
+ r.diff.hidden = !g || (!g.insertions && !g.deletions && !g.dirty)
569
+ if (!r.diff.hidden) r.diff.textContent = `+${g.insertions} −${g.deletions} · ${g.dirty} files`
570
+ r.ctx.hidden = !s.contextTokens
571
+ r.ctx.textContent = fmt.n(s.contextTokens) + " ctx"
572
+ r.cost.hidden = !s.cost?.usd
573
+ if (s.cost?.usd) {
574
+ r.cost.textContent = (s.cost.estimated ? "~" : "") + fmt.money(s.cost.usd)
575
+ r.cost.title = s.cost.estimated ? "estimated from token counts" : "reported by the provider"
576
+ }
577
+ r.mail.hidden = !s.unread
578
+ if (s.unread) r.mail.textContent = `${s.unread} from @${s.lastPeer ?? "?"}`
579
+ r.grp.hidden = !s.project || s.project === "default"
580
+ r.grp.textContent = s.project ?? ""
581
+ r.tpm.textContent = fmt.n(s.tokPerMin)
582
+ r.tlm.textContent = s.toolPerMin ?? 0
583
+ spark(r.sTok, s.spark.tok, COLOR[s.status] ?? COLOR.idle)
584
+ spark(r.sTool, s.spark.tool, s.status === "busy" ? "#6fa8dc" : COLOR.idle)
585
+ if (s.currentTool) {
586
+ r.say.className = "say tool"
587
+ r.say.textContent = `${s.currentTool.name} · ${fmt.ago(s.currentTool.since)}`
588
+ } else {
589
+ r.say.className = "say"
590
+ r.say.textContent = s.lastText || "No messages yet."
591
+ }
592
+ r.perm.hidden = !s.pendingPermissions
593
+ }
594
+
595
+ async function replyPermission(id, decision) {
596
+ const d = await api(`/api/sessions/${id}`)
597
+ const req = d.permissions?.[0]
598
+ if (!req) return
599
+ await api(`/api/sessions/${id}/permission`, { method: "POST", body: { requestId: req.id, decision } })
600
+ }
601
+
602
+ // ------------------------------------------------------------------ render
603
+ function visibleSessions() {
604
+ return state.sessions.filter((s) => {
605
+ if (view.kind === "running" && s.status === "idle") return false
606
+ if (view.kind === "project" && (s.project ?? "default") !== view.value) return false
607
+ if (view.kind === "room") {
608
+ const room = state.rooms.find((r) => r.id === view.value)
609
+ if (!room || !room.members.some((m) => m.id === s.id)) return false
610
+ }
611
+ if (!filterText) return true
612
+ return [s.handle, s.name, s.directory, s.branch, s.model, s.project].join(" ").toLowerCase().includes(filterText)
613
+ })
614
+ }
615
+
616
+ function renderNav() {
617
+ const rooms = state.rooms ?? []
618
+ const projects = state.projects ?? []
619
+ const sig = JSON.stringify([view, projects, state.totals.busy, state.sessions.length, (state.tasks ?? []).map((t) => t.status)])
620
+ if (sig === navSig) return
621
+ navSig = sig
622
+ const item = (kind, value, label, count, cls = "") =>
623
+ `<button class="navitem ${cls}" data-k="${kind}" data-v="${value ?? ""}" aria-current="${view.kind === kind && String(view.value ?? "") === String(value ?? "")}">
624
+ <span class="n">${label}</span><span class="c">${count}</span></button>`
625
+ $("#nav-views").innerHTML =
626
+ item("all", null, "All", state.sessions.length) + item("running", null, "Running", state.totals.busy ?? 0)
627
+ const open = (state.tasks ?? []).filter((t) => !["done", "cancelled"].includes(t.status)).length
628
+ const mine = (state.tasks ?? []).filter((t) => t.owner === "human" && !["done", "cancelled"].includes(t.status))
629
+ $("#nav-work").innerHTML =
630
+ item("needs", null, `Needs you`, mine.length ? `<b>${mine.length}</b>` : "") +
631
+ item("board", null, "Task board", open) +
632
+ `<button class="navitem navadd" id="run-pipeline">▸ Run a pipeline</button>`
633
+ $("#run-pipeline").onclick = openPipelineDialog
634
+ // One row per project: its work and its conversation are the same thing.
635
+ $("#nav-projects").innerHTML = projects
636
+ .map((p) =>
637
+ item(
638
+ "project",
639
+ p.id,
640
+ `${p.busy ? `<span class="dotm" style="background:var(--busy)"></span>` : ""}${esc(p.name)}`,
641
+ `${p.unread ? `<b>${p.unread}✉</b> ` : ""}${p.open || ""}`,
642
+ ),
643
+ )
644
+ .join("")
645
+ for (const b of $$(".navitem[data-k]")) {
646
+ b.onclick = () => {
647
+ view = { kind: b.dataset.k, value: b.dataset.v || null }
648
+ navSig = null
649
+ render()
650
+ if (b.dataset.k === "room") openRoom(b.dataset.v)
651
+ }
652
+ }
653
+ }
654
+
655
+ function render(fromStream = false) {
656
+ const t = state.totals ?? {}
657
+ $("#s-busy").textContent = t.busy ?? 0
658
+ $("#s-sessions").textContent = t.sessions ?? 0
659
+ $("#s-tpm").textContent = fmt.n(t.tokPerMin)
660
+ $("#s-tools").textContent = fmt.n(t.tools)
661
+ $("#s-cost").textContent = fmt.money(t.usd)
662
+ $("#s-cost-l").textContent = t.unpriced ? `est. spend · ${t.unpriced} unpriced` : "est. spend"
663
+ if (t.spark) spark($("#global-spark"), t.spark.tok, t.busy ? COLOR.busy : COLOR.idle, { w: 170 })
664
+ $("#updated").textContent = new Date(state.at).toLocaleTimeString()
665
+ $("#conn").textContent = state.serverStatus ?? ""
666
+
667
+ const loose = $("#loose")
668
+ loose.hidden = !state.unmanaged
669
+ if (state.unmanaged && !loose.dataset.n) {
670
+ loose.dataset.n = state.unmanaged
671
+ loose.innerHTML = `<span>opencode knows about <b class="num">${state.unmanaged}</b> other session(s) botfarm is not managing.</span>
672
+ <button class="ghost tiny" id="loose-add" style="margin-left:auto">Add some…</button>`
673
+ $("#loose-add").onclick = openAdopt
674
+ }
675
+
676
+ renderRuns()
677
+ const needsView = view.kind === "needs"
678
+ const boardView = view.kind === "board"
679
+ const projectView = view.kind === "project"
680
+ $("#kanban").hidden = !boardView
681
+ $("#grid").hidden = boardView || projectView || needsView
682
+ $("#project-view").hidden = !(projectView || needsView)
683
+ $("#runs").hidden = projectView || needsView
684
+ if (boardView) renderBoard(state.tasks ?? [], "#kanban")
685
+ if (needsView) renderNeeds()
686
+ if (projectView) renderProject()
687
+
688
+ const visible = visibleSessions()
689
+ $("#scope").textContent =
690
+ view.kind === "project" ? `project ${view.value}`
691
+ : view.kind === "room" ? (state.rooms.find((r) => r.id === view.value)?.name ?? "")
692
+ : ""
693
+ $("#empty").hidden = state.sessions.length > 0
694
+ const grid = $("#grid")
695
+ const seen = new Set()
696
+ visible.forEach((s, i) => {
697
+ seen.add(s.id)
698
+ let r = pods.get(s.id)
699
+ if (!r) { r = podEl(s); pods.set(s.id, r) }
700
+ updatePod(r, s)
701
+ const at = grid.children[i]
702
+ if (at !== r.el && !r.el.contains(document.activeElement)) grid.insertBefore(r.el, at ?? null)
703
+ })
704
+ for (const [id, r] of pods) if (!seen.has(id)) { r.el.remove(); pods.delete(id) }
705
+
706
+ renderNav()
707
+ $("#feed").innerHTML = (state.events ?? [])
708
+ .map((e) => `<div class="${e.level}">${new Date(e.at).toLocaleTimeString()} ${esc(e.text)}</div>`)
709
+ .join("")
710
+ if (fromStream && openId) refreshModal()
711
+ if (fromStream && roomId) renderRoom()
712
+ if (fromStream && taskId) renderTask()
713
+ }
714
+
715
+ // ------------------------------------------------------------------ task board
716
+ const COLUMNS = ["backlog", "blocked", "queued", "active", "review", "done", "cancelled"]
717
+ const boardSig = new Map()
718
+ function renderBoard(tasks, target) {
719
+ const cols = COLUMNS.map((status) => ({ status, tasks: tasks.filter((t) => t.status === status) }))
720
+ .filter((c) => c.tasks.length || ["backlog", "queued", "active", "review", "done"].includes(c.status))
721
+ const sig = JSON.stringify(cols.map((c) => [c.status, c.tasks.map((t) => [t.id, t.status, t.title, t.assignee])]))
722
+ if (sig === boardSig.get(target)) return
723
+ boardSig.set(target, sig)
724
+ const byId = Object.fromEntries(state.sessions.map((s) => [s.id, s]))
725
+ $(target).innerHTML = cols.map((c) => `<div class="col ${c.status}">
726
+ <h5>${c.status}<span>${c.tasks.length}</span></h5>
727
+ ${c.tasks.map((t) => {
728
+ const who = byId[t.assignee]
729
+ return `<div class="card ${t.kind === "question" ? "question" : ""}" data-t="${t.id}">
730
+ <div class="t">${esc(t.title)}</div>
731
+ <div class="s">
732
+ ${
733
+ t.owner === "human"
734
+ ? `<span style="color:var(--wait)">you</span>`
735
+ : who
736
+ ? `<img src="${avatarUrl(who)}" alt="" />@${esc(who.handle ?? "")}`
737
+ : t.role
738
+ ? `<span>any ${esc(t.role)}</span>`
739
+ : "unassigned"
740
+ }
741
+ ${t.stage ? `<span class="stage">${esc(t.stage)}</span>` : ""}
742
+ ${t.dependsOn?.length ? `<span>waits on ${t.dependsOn.length}</span>` : ""}
743
+ </div></div>`
744
+ }).join("")}
745
+ </div>`).join("")
746
+ for (const el of $$(`${target} .card`)) el.onclick = () => openTask(el.dataset.t)
747
+ }
748
+
749
+ /**
750
+ * Everything waiting on you, questions first — they are blocking a bot that
751
+ * has stopped to ask rather than guessing.
752
+ */
753
+ let needsSig = null
754
+ function renderNeeds() {
755
+ const mine = (state.tasks ?? [])
756
+ .filter((t) => t.owner === "human" && !["done", "cancelled"].includes(t.status))
757
+ .sort((a, b) => (b.kind === "question") - (a.kind === "question") || a.created - b.created)
758
+ const sig = JSON.stringify(mine.map((t) => [t.id, t.status, t.answer]))
759
+ const el = $("#project-view")
760
+ if (sig === needsSig) return
761
+ needsSig = sig
762
+ const byId = Object.fromEntries(state.sessions.map((x) => [x.id, x]))
763
+ el.innerHTML = `
764
+ <div class="phead"><h2>Needs you</h2><span class="sub">${mine.length} open</span></div>
765
+ ${
766
+ mine.length
767
+ ? mine.map((t) => {
768
+ const who = byId[t.askedBy]
769
+ return `<div class="ask" data-t="${t.id}">
770
+ <div class="asker">${who ? `<img src="${avatarUrl(who)}" alt="" />@${esc(who.handle ?? "")}` : "pipeline"}
771
+ ${t.kind === "question" ? "asks" : "needs you"} · ${esc(t.projectId ?? "")}</div>
772
+ <div class="q">${esc(t.brief || t.title)}</div>
773
+ ${t.options?.length ? `<div class="opts">${t.options.map((o) => `<button class="tiny opt" data-t="${t.id}" data-a="${esc(o)}">${esc(o)}</button>`).join("")}</div>` : ""}
774
+ <div class="reply"><input placeholder="Answer… (⏎)" data-t="${t.id}" />
775
+ <button class="primary tiny send" data-t="${t.id}">Answer</button>
776
+ <button class="ghost tiny open" data-t="${t.id}">Open task</button></div>
777
+ </div>`
778
+ }).join("")
779
+ : `<div class="empty"><b>Nothing waiting on you</b>When a bot needs a decision it will appear here, and the answer goes straight back to it.</div>`
780
+ }`
781
+ const answer = async (id, text) => {
782
+ if (!text?.trim()) return
783
+ await api(`/api/tasks/${id}/answer`, { method: "POST", body: { answer: text.trim() } }).catch((e) => alert(e.message))
784
+ needsSig = null
785
+ render()
786
+ }
787
+ for (const b of $$(".ask .opt", el)) b.onclick = () => answer(b.dataset.t, b.dataset.a)
788
+ for (const b of $$(".ask .send", el)) {
789
+ b.onclick = () => answer(b.dataset.t, $(`.ask .reply input[data-t="${b.dataset.t}"]`, el).value)
790
+ }
791
+ for (const i of $$(".ask .reply input", el)) {
792
+ i.onkeydown = (e) => { if (e.key === "Enter") answer(i.dataset.t, i.value) }
793
+ }
794
+ for (const b of $$(".ask .open", el)) b.onclick = () => openTask(b.dataset.t)
795
+ }
796
+
797
+ /**
798
+ * A project in one screen: who is working, what is on the board, and the
799
+ * conversation about it — rather than three places to visit in turn.
800
+ */
801
+ let projectSig = null
802
+ function renderProject() {
803
+ const p = (state.projects ?? []).find((x) => x.id === view.value)
804
+ if (!p) { view = { kind: "all", value: null }; return }
805
+ const tasks = (state.tasks ?? []).filter((t) => t.projectId === p.id)
806
+ const room = (state.rooms ?? []).find((r) => r.id === p.roomId)
807
+ const sig = JSON.stringify([p, tasks.map((t) => [t.id, t.status]), room?.messages?.length, room?.muted])
808
+ const el = $("#project-view")
809
+ const prior = $("#pc-input")
810
+ const keep = prior ? { text: prior.value, focused: document.activeElement === prior } : {}
811
+ if (sig !== projectSig) {
812
+ projectSig = sig
813
+ // The board element is about to be replaced, so its cached signature no
814
+ // longer describes anything on screen.
815
+ boardSig.delete("#p-kanban")
816
+ el.innerHTML = `
817
+ <div class="phead">
818
+ <h2>${esc(p.name)}</h2>
819
+ <span class="sub">${esc(p.branch ?? "")}${p.worktree ? " · " + esc(fmt.path(p.worktree, 2)) : ""}</span>
820
+ <span class="right">
821
+ <button class="ghost tiny" id="p-newtask">New task</button>
822
+ <button class="ghost tiny" id="p-addsession">Add session</button>
823
+ <button class="ghost tiny" id="p-runpipe">Run a pipeline</button>
824
+ </span>
825
+ </div>
826
+ <div class="crew">${
827
+ p.sessions.length
828
+ ? p.sessions.map((s) => `<span class="who" data-s="${s.id}" data-status="${esc(s.status)}">
829
+ <img src="/avatar/${encodeURIComponent(s.id)}.svg?h=${encodeURIComponent(s.handle ?? "")}" alt="" />@${esc(s.handle ?? "")}
830
+ <span style="color:var(--faint)">${esc(s.status)}</span><span class="doing"></span></span>`).join("")
831
+ : `<span class="note">No sessions in this project yet.</span>`
832
+ }</div>
833
+ <div class="project">
834
+ <div class="work"><div class="kanban" id="p-kanban"></div></div>
835
+ <div class="chatpane">
836
+ <div class="ph"><b>${esc(room?.name ?? "#" + p.id)}</b>
837
+ <span class="note" style="margin:0">${room ? `${room.members.length} members` : "chat starts with the second session"}</span>
838
+ ${room ? `<button class="ghost tiny" id="pc-manage" style="margin-left:auto">Manage</button>` : ""}</div>
839
+ <div class="log" id="pc-log">${
840
+ room?.messages?.length
841
+ ? room.messages.map((m) => {
842
+ const member = room.members.find((x) => x.id === m.fromId)
843
+ return `<div class="turn ${m.from === "operator" ? "op" : ""}">
844
+ ${m.from === "operator" ? `<span class="avdot"></span>` : `<img src="/avatar/${encodeURIComponent(m.fromId ?? m.from)}.svg?h=${encodeURIComponent(member?.handle ?? m.from)}" alt="" />`}
845
+ <div class="c"><div class="m">${m.from === "operator" ? "operator" : "@" + esc(m.from)} · ${new Date(m.at).toLocaleTimeString()}</div>
846
+ <div class="b">${esc(m.text)}</div></div></div>`
847
+ }).join("")
848
+ : `<div class="note">Nothing said yet. Anything you post here reaches every session in the project.</div>`
849
+ }</div>
850
+ <div class="composer"><textarea id="pc-input" placeholder="Post to the project… (@handle reaches one)"></textarea>
851
+ <button class="primary" id="pc-send">Post</button></div>
852
+ </div>
853
+ </div>`
854
+ const log = $("#pc-log", el)
855
+ log.scrollTop = log.scrollHeight
856
+ for (const w of $$(".crew .who", el)) w.onclick = () => openSession(w.dataset.s)
857
+ if ($("#pc-manage", el)) $("#pc-manage", el).onclick = () => openRoom(room.id)
858
+ $("#p-newtask", el).onclick = () => openTaskDialog(p.id)
859
+ $("#p-runpipe", el).onclick = () => openPipelineDialog(p)
860
+ $("#p-addsession", el).onclick = () => { groupOptions($("#f-group"), p.id); $("#f-repo").value = p.repo ?? ""; $("#new-dialog").showModal() }
861
+ const send = async () => {
862
+ const message = $("#pc-input", el).value.trim()
863
+ if (!message) return
864
+ $("#pc-input", el).value = ""
865
+ await api(`/api/projects/${p.id}/chat`, { method: "POST", body: { message } }).catch((e) => alert(e.message))
866
+ }
867
+ $("#pc-send", el).onclick = send
868
+ $("#pc-input", el).onkeydown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send() } }
869
+ const input = $("#pc-input", el)
870
+ if (keep.text) input.value = keep.text
871
+ if (keep.focused) input.focus()
872
+ }
873
+ renderBoard(tasks, "#p-kanban")
874
+ // What each bot is doing right now, refreshed on every push.
875
+ const byId = Object.fromEntries(state.sessions.map((s) => [s.id, s]))
876
+ for (const w of $$(".crew .who", el)) {
877
+ const s = byId[w.dataset.s]
878
+ const line = $(".doing", w)
879
+ if (!s || !line) continue
880
+ const doing = s.status === "busy" && s.currentTool ? `▸ ${s.currentTool.name}` : s.lastText || ""
881
+ line.textContent = doing
882
+ line.title = doing
883
+ }
884
+ }
885
+
886
+ let runsSig = null
887
+ function renderRuns() {
888
+ const runs = state.runs ?? []
889
+ const sig = JSON.stringify(runs.map((r) => [r.id, r.status, r.tasks.map((t) => t.status)]))
890
+ if (sig === runsSig) return
891
+ runsSig = sig
892
+ $("#runs").innerHTML = runs.map((r) => `<div class="run">
893
+ <div class="rt">${esc(r.title)}</div>
894
+ <div class="flow">${r.tasks.map((t) => `<span class="step ${esc(t.status)}" title="${esc(t.title ?? "")}">${esc(t.stage)}</span>`).join(`<span class="arrow">→</span>`)}</div>
895
+ <div class="s" style="margin-top:6px;font-size:10.5px;color:var(--faint);font-family:var(--mono)">${esc(r.pipeline)} · ${esc(r.branch ?? r.repo ?? "")}
896
+ <a href="/api/runs/${r.id}/export" style="color:var(--dim);margin-left:8px">export yaml</a></div>
897
+ </div>`).join("")
898
+ }
899
+
900
+ async function openTask(id) {
901
+ openId = null; roomId = null; taskId = id; taskSig = null
902
+ await renderTask(true)
903
+ }
904
+
905
+ let taskId = null
906
+ let taskSig = null
907
+ async function renderTask(create = false) {
908
+ let t, files
909
+ try {
910
+ t = await api(`/api/tasks/${taskId}`)
911
+ files = (await api(`/api/tasks/${taskId}/diff`)).files
912
+ } catch { return closeModal() }
913
+ const sig = JSON.stringify([t.status, t.handoff, t.notes.length, files])
914
+ if (!create && sig === taskSig) return
915
+ taskSig = sig
916
+ const el = shell(create)
917
+ const who = state.sessions.find((s) => s.id === t.assignee)
918
+ // The task and the conversation it is being done in, side by side: knowing
919
+ // what was asked is useless one click away from seeing what is happening.
920
+ const chat = who ? await api(`/api/sessions/${who.id}`).catch(() => null) : null
921
+ el.classList.add("split-modal")
922
+ el.innerHTML = `
923
+ <div class="left split">
924
+ <div class="taskside">
925
+ <div class="top"><span class="h">${esc(t.title)}</span><span class="s">${esc(t.stage ?? "task")} · ${esc(t.status)}</span>
926
+ <span class="right"><button class="ghost tiny" id="tk-close">Close</button></span></div>
927
+ <div class="sec" style="margin:0"><h3>Brief</h3><div class="turn"><div class="c"><div class="b">${esc(t.brief) || "—"}</div></div></div></div>
928
+ ${t.acceptance?.length ? `<div class="sec"><h3>Acceptance criteria</h3>${t.acceptance.map((a) => `<div class="row"><span class="g">${esc(a)}</span></div>`).join("")}</div>` : ""}
929
+ ${t.handoff ? `<div class="sec"><h3>Handoff from ${esc(t.handoff.by ?? "")}</h3>
930
+ <div class="turn"><div class="c"><div class="b">${esc(t.handoff.summary)}</div></div></div>
931
+ ${t.handoff.acceptance_criteria?.length ? `<div class="note">Criteria: ${t.handoff.acceptance_criteria.map(esc).join(" · ")}</div>` : ""}
932
+ ${t.handoff.open_questions?.length ? `<div class="note" style="color:var(--busy)">Open: ${t.handoff.open_questions.map(esc).join(" · ")}</div>` : ""}
933
+ </div>` : ""}
934
+ <div class="sec"><h3>Changed files</h3>${
935
+ files.length
936
+ ? files.map((f) => `<div class="row"><span class="g file" data-f="${esc(f.path)}" style="cursor:pointer;text-decoration:underline">${esc(f.path)}</span>
937
+ <span class="st">${f.untracked ? "new" : `+${f.added} −${f.removed}`}</span></div>`).join("")
938
+ : `<div class="note">Nothing changed in this worktree yet.</div>`
939
+ }<div id="tk-diff"></div></div>
940
+ ${t.notes.length ? `<div class="sec"><h3>Notes</h3>${t.notes.map((n) => `<div class="row"><span class="st">@${esc(n.by)}</span><span class="g">${esc(n.text)}</span></div>`).join("")}</div>` : ""}
941
+ </div>
942
+ <div style="display:flex;flex-direction:column;min-width:0">
943
+ <div class="top">
944
+ ${who ? `<img class="avatar" style="width:34px;height:34px" src="${avatarUrl(who)}" alt="" /><span class="h">@${esc(who.handle ?? "")}</span>
945
+ <span class="s">${esc(who.status)}</span>` : `<span class="s">No session assigned — this task is in the backlog.</span>`}
946
+ ${who ? `<span class="right"><button class="ghost tiny" id="tk-full">Open session</button></span>` : ""}
947
+ </div>
948
+ <div class="transcript" id="tk-chat">${
949
+ chat?.transcript?.length
950
+ ? chat.transcript.slice().reverse().map((m) => `<div class="turn ${esc(m.role)}">
951
+ ${m.role === "assistant" ? `<img src="${avatarUrl(who)}" alt="" />` : `<span class="avdot"></span>`}
952
+ <div class="c"><div class="m">${m.role === "assistant" ? "@" + esc(who.handle) : esc(m.role)}</div>
953
+ <div class="b">${esc(m.text)}</div>
954
+ ${m.tools?.length ? `<div class="tools">${m.tools.map((x) => `<span>${esc(x.name)}</span>`).join("")}</div>` : ""}</div></div>`).join("")
955
+ : `<div class="empty">${who ? "Nothing in this session yet." : "Assign this task to see the conversation."}</div>`
956
+ }</div>
957
+ ${who ? `<div class="composer"><textarea id="tk-say" placeholder="Say something to @${esc(who.handle ?? "")}… (⏎)"></textarea>
958
+ <button class="primary" id="tk-send">Send</button></div>` : ""}
959
+ </div>
960
+ </div>
961
+ <div class="side">
962
+ <div class="sec"><h3>Task</h3><table class="kv">
963
+ <tr><td>status</td><td>${esc(t.status)}</td></tr>
964
+ <tr><td>assignee</td><td>${who ? "@" + esc(who.handle) : esc(t.persona ?? "—")}</td></tr>
965
+ <tr><td>raised by</td><td>${esc(t.createdBy)}</td></tr>
966
+ <tr><td>blocked by</td><td>${t.dependsOn.length || "—"}</td></tr>
967
+ </table></div>
968
+ <div class="sec"><h3>Move to</h3>
969
+ <div style="display:flex;flex-wrap:wrap;gap:6px">
970
+ ${["backlog", "queued", "active", "review", "done", "cancelled"].map((c) => `<button class="tiny tk-move ${c === t.status ? "primary" : "ghost"}" data-c="${c}">${c}</button>`).join("")}
971
+ </div>
972
+ <div class="note">Moving to queued offers it to the assignee when they next go idle.</div>
973
+ </div>
974
+ ${who ? `<div class="sec"><h3>Assignee</h3><div class="row"><img src="${avatarUrl(who)}" style="width:28px;height:28px;border-radius:6px" alt="" />
975
+ <span class="g">@${esc(who.handle)}</span><button class="ghost tiny" id="tk-open">Open session</button></div></div>` : ""}
976
+ </div>`
977
+ const chatLog = $("#tk-chat", el)
978
+ if (chatLog) chatLog.scrollTop = chatLog.scrollHeight
979
+ $("#tk-close", el).onclick = closeModal
980
+ if ($("#tk-open", el)) $("#tk-open", el).onclick = () => openSession(who.id)
981
+ if ($("#tk-full", el)) $("#tk-full", el).onclick = () => openSession(who.id)
982
+ const say = $("#tk-say", el)
983
+ if (say) {
984
+ const send = async () => {
985
+ const text = say.value.trim()
986
+ if (!text) return
987
+ say.value = ""
988
+ await api(`/api/sessions/${who.id}/send`, { method: "POST", body: { text } }).catch((e) => alert(e.message))
989
+ taskSig = null
990
+ renderTask()
991
+ }
992
+ $("#tk-send", el).onclick = send
993
+ say.onkeydown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send() } }
994
+ }
995
+ for (const b of $$(".tk-move", el)) {
996
+ b.onclick = async () => { await api(`/api/tasks/${t.id}`, { method: "POST", body: { status: b.dataset.c } }); taskSig = null; renderTask() }
997
+ }
998
+ for (const f of $$(".file", el)) {
999
+ f.onclick = async () => {
1000
+ const { diff } = await api(`/api/tasks/${t.id}/diff?file=${encodeURIComponent(f.dataset.f)}`)
1001
+ $("#tk-diff", el).innerHTML = `<div class="diff">${(diff || "(no diff)").split("\n").slice(0, 600).map((l) =>
1002
+ `<div class="${l.startsWith("+") && !l.startsWith("+++") ? "add" : l.startsWith("-") && !l.startsWith("---") ? "del" : l.startsWith("@@") ? "hunk" : ""}">${esc(l) || " "}</div>`).join("")}</div>`
1003
+ }
1004
+ }
1005
+ }
1006
+
1007
+ // ------------------------------------------------------------------ modal shell
1008
+ function shell(create) {
1009
+ let el = $("#modal")
1010
+ if (!el || create) {
1011
+ $("#scrim")?.remove()
1012
+ $("#modal")?.remove()
1013
+ const scrim = document.createElement("div")
1014
+ scrim.className = "scrim"
1015
+ scrim.id = "scrim"
1016
+ scrim.onclick = closeModal
1017
+ document.body.append(scrim)
1018
+ el = document.createElement("div")
1019
+ el.className = "modal"
1020
+ el.id = "modal"
1021
+ document.body.append(el)
1022
+ document.body.classList.add("modal-open")
1023
+ }
1024
+ return el
1025
+ }
1026
+ function closeModal() {
1027
+ if (openId) wsSend(`unwatch:${openId}`)
1028
+ live = { id: null, msgs: null, status: null, currentTool: null, at: 0 }
1029
+ openId = null; roomId = null; taskId = null; modalSig = null; roomSig = null; taskSig = null
1030
+ $("#scrim")?.remove()
1031
+ $("#modal")?.remove()
1032
+ document.body.classList.remove("modal-open")
1033
+ }
1034
+
1035
+ async function openSession(id) {
1036
+ if (openId && openId !== id) wsSend(`unwatch:${openId}`)
1037
+ live = { id, msgs: null, status: null, currentTool: null, at: 0 }
1038
+ wsSend(`watch:${id}`)
1039
+ openId = id; roomId = null; modalSig = null
1040
+ await refreshModal(true)
1041
+ }
1042
+
1043
+ async function refreshModal(create = false) {
1044
+ $("#modal")?.classList.remove("split-modal")
1045
+ let d
1046
+ try { d = await api(`/api/sessions/${openId}`) } catch { return closeModal() }
1047
+ const prior = $("#m-input")
1048
+ const keep = prior ? { text: prior.value, focused: document.activeElement === prior } : {}
1049
+ const sig = JSON.stringify([d.transcript?.length, d.status, d.currentTool, d.totals, d.permissions?.length, d.policy, d.tools, d.git?.dirty, d.rooms, d.group])
1050
+ if (!create && sig === modalSig) return
1051
+ modalSig = sig
1052
+ const el = shell(create)
1053
+ const old = $(".transcript", el)
1054
+ const atBottom = !old || old.scrollHeight - old.scrollTop - old.clientHeight < 80
1055
+ // The socket keeps the transcript current between refreshes; only fall back
1056
+ // to the detail fetch when no live frame has arrived recently.
1057
+ if (live.id !== d.id || !live.msgs || Date.now() - live.at > 4000) {
1058
+ live = { id: d.id, msgs: (d.transcript ?? []).slice().reverse(), status: d.status, currentTool: d.currentTool, at: live.id === d.id ? live.at : 0 }
1059
+ } else {
1060
+ live.status = d.status
1061
+ live.currentTool = d.currentTool
1062
+ }
1063
+ live.handle = d.handle
1064
+
1065
+ el.innerHTML = `
1066
+ <div class="left">
1067
+ <div class="top">
1068
+ <img class="avatar" style="width:38px;height:38px" src="${avatarUrl(d)}" alt="" />
1069
+ <span class="h">@${esc(d.handle ?? "")}</span><span class="s">${esc(d.name)}</span>
1070
+ <span class="right">
1071
+ <button class="ghost tiny" id="m-compact">Compact</button>
1072
+ <button class="danger tiny" id="m-abort">Abort</button>
1073
+ <button class="ghost tiny" id="m-close">Close</button>
1074
+ </span>
1075
+ </div>
1076
+ <div class="transcript">${transcriptBody(d)}</div>
1077
+ ${d.permissions?.length ? `<div class="perm" style="margin:0 16px 8px"><b>${esc(d.permissions[0].action ?? "permission")}</b>
1078
+ <button class="allow" style="margin-left:auto">Allow once</button><button class="always">Always</button><button class="deny danger">Deny</button></div>` : ""}
1079
+ <div class="composer">
1080
+ <textarea id="m-input" placeholder="Continue this session… (⏎ to send, ⇧⏎ newline)"></textarea>
1081
+ <button class="primary" id="m-send">Send</button>
1082
+ </div>
1083
+ </div>
1084
+ <div class="side">
1085
+ <div class="sec"><h3>Session</h3><table class="kv">
1086
+ <tr><td>status</td><td>${esc(d.status)}${d.currentTool ? " · " + esc(d.currentTool.name) : ""}</td></tr>
1087
+ <tr><td>worktree</td><td>${esc(fmt.path(d.directory))}</td></tr>
1088
+ <tr><td>branch</td><td>${esc(d.branch ?? "—")}</td></tr>
1089
+ <tr><td>model</td><td>${esc(d.model ?? "—")}</td></tr>
1090
+ <tr><td>changes</td><td>${d.git ? `+${d.git.insertions} −${d.git.deletions} · ${d.git.dirty}` : "—"}</td></tr>
1091
+ </table>
1092
+ <div class="row" style="border:0;padding-top:8px">
1093
+ <span class="st">project</span>
1094
+ <select id="m-group" style="flex:1">${(state.projects ?? []).map((p) => `<option value="${p.id}" ${p.id === d.project ? "selected" : ""}>${esc(p.name)}</option>`).join("")}</select>
1095
+ <button class="ghost tiny danger" id="m-release">Remove</button>
1096
+ </div></div>
1097
+
1098
+ <div class="sec"><h3>Cost</h3><table class="kv">
1099
+ <tr><td>input</td><td>${fmt.n(d.totals.input)}</td></tr>
1100
+ <tr><td>output</td><td>${fmt.n(d.totals.output)}</td></tr>
1101
+ <tr><td>cache r/w</td><td>${fmt.n(d.totals.cacheRead)} / ${fmt.n(d.totals.cacheWrite)}</td></tr>
1102
+ <tr><td>context</td><td>${fmt.n(d.contextTokens)}</td></tr>
1103
+ <tr><td>tools</td><td>${d.totals.tools} (${d.totals.toolErrors} err)</td></tr>
1104
+ <tr><td>${d.cost?.estimated ? "estimated" : "reported"}</td><td>${fmt.money(d.cost?.usd)}</td></tr>
1105
+ </table>${
1106
+ d.cost?.unknownModel
1107
+ ? `<div class="note">No price set for <span class="num">${esc(d.cost.unknownModel)}</span>. Add one under "pricing" in ~/.botfarm/config.json.</div>`
1108
+ : d.cost?.estimated
1109
+ ? `<div class="note">Estimated from token counts — the provider reported no cost.</div>`
1110
+ : ""
1111
+ }</div>
1112
+
1113
+ <div class="sec"><h3>Tools</h3>
1114
+ ${(d.tools?.servers ?? []).map((m) => `<div class="row">
1115
+ <span class="g">${esc(m.name)}</span><span class="st">${esc(m.status)}</span>
1116
+ <button class="sw mcp-sw" data-n="${esc(m.name)}" role="switch" aria-checked="${m.connected}"></button></div>`).join("")
1117
+ || `<div class="note">No MCP servers for this worktree.</div>`}
1118
+ ${(d.tools?.builtin ?? []).map((t) => `<div class="row">
1119
+ <span class="g">${esc(t.name)}</span>
1120
+ <button class="sw tool-sw" data-n="${esc(t.name)}" role="switch" aria-checked="${t.enabled}"></button></div>`).join("")}
1121
+ <div class="note">MCP servers toggle live. Built-in tools are written to this worktree's opencode config and apply when the session restarts.</div>
1122
+ </div>
1123
+
1124
+ <div class="sec"><h3>Mesh</h3>
1125
+ <div class="row" style="border:0"><span class="st">peer messages</span>
1126
+ <span class="seg" id="m-talk" style="margin-left:auto">
1127
+ <button data-v="off" aria-pressed="${d.policy.talk === "off"}">Off</button>
1128
+ <button data-v="inbox" aria-pressed="${d.policy.talk === "inbox"}">Receive</button>
1129
+ <button data-v="open" aria-pressed="${d.policy.talk === "open"}">Both</button>
1130
+ </span></div>
1131
+ <div class="row" style="border:0"><span class="st">may open sessions</span>
1132
+ <button class="sw" id="m-spawn" role="switch" aria-checked="${d.policy.spawn}" style="margin-left:auto"></button></div>
1133
+ <div class="row" style="border:0"><span class="st">group chats</span>
1134
+ <span class="seg" id="m-rooms" style="margin-left:auto">
1135
+ <button data-v="off" aria-pressed="${d.policy.rooms === "off"}">Off</button>
1136
+ <button data-v="member" aria-pressed="${d.policy.rooms === "member"}">Member</button>
1137
+ <button data-v="create" aria-pressed="${d.policy.rooms === "create"}">Create</button>
1138
+ </span></div>
1139
+ ${d.rooms?.length ? `<div class="note">In ${d.rooms.map((r) => esc(r.name)).join(", ")}</div>` : ""}
1140
+ ${d.origin ? `<div class="note">Opened by @${esc(d.origin)}</div>` : ""}
1141
+ ${d.mcpUrl && d.policy.talk !== "off" ? `<div class="wire">${esc(d.mcpUrl)}</div>` : ""}
1142
+ </div>
1143
+
1144
+ <div class="sec"><h3>Recent tool calls</h3>${
1145
+ (d.toolLog ?? []).slice(0, 12).map((t) => `<div class="row">
1146
+ <span class="g" style="color:${t.status === "error" ? "var(--error)" : "var(--text)"}">${esc(t.name)}</span>
1147
+ <span class="st">${esc(t.summary ?? "")}</span><span class="st">${fmt.ms(t.ms)}</span></div>`).join("")
1148
+ || `<div class="note">None yet.</div>`
1149
+ }</div>
1150
+ </div>`
1151
+
1152
+ const tr = $(".transcript", el)
1153
+ if (atBottom) tr.scrollTop = tr.scrollHeight
1154
+ tr.addEventListener("toggle", (e) => {
1155
+ const id = e.target.dataset?.p
1156
+ if (id) e.target.open ? openThoughts.add(id) : openThoughts.delete(id)
1157
+ }, true)
1158
+ const input = $("#m-input", el)
1159
+ if (keep.text) input.value = keep.text
1160
+ if (keep.focused) input.focus()
1161
+ const send = async () => {
1162
+ const text = input.value.trim()
1163
+ if (!text) return
1164
+ input.value = ""
1165
+ await api(`/api/sessions/${d.id}/send`, { method: "POST", body: { text } }).catch((e) => alert(e.message))
1166
+ modalSig = null
1167
+ refreshModal()
1168
+ }
1169
+ $("#m-send", el).onclick = send
1170
+ input.onkeydown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send() } }
1171
+ $("#m-close", el).onclick = closeModal
1172
+ $("#m-abort", el).onclick = () => api(`/api/sessions/${d.id}/abort`, { method: "POST" })
1173
+ $("#m-compact", el).onclick = () => api(`/api/sessions/${d.id}/compact`, { method: "POST" })
1174
+ $("#m-group", el).onchange = (e) => api(`/api/sessions/${d.id}/group`, { method: "POST", body: { project: e.target.value } })
1175
+ $("#m-release", el).onclick = async () => {
1176
+ if (!confirm("Remove from the board? The session itself is not deleted.")) return
1177
+ await api(`/api/sessions/${d.id}/release`, { method: "POST" })
1178
+ closeModal()
1179
+ }
1180
+ for (const b of $$("#m-talk button", el)) b.onclick = () => setPolicy(d.id, { talk: b.dataset.v })
1181
+ for (const b of $$("#m-rooms button", el)) b.onclick = () => setPolicy(d.id, { rooms: b.dataset.v })
1182
+ $("#m-spawn", el).onclick = () => setPolicy(d.id, { spawn: !d.policy.spawn })
1183
+ for (const b of $$(".mcp-sw", el)) {
1184
+ b.onclick = () => api(`/api/sessions/${d.id}/mcp`, { method: "POST", body: { name: b.dataset.n, connected: b.getAttribute("aria-checked") !== "true" } }).then(bump).catch((e) => alert(e.message))
1185
+ }
1186
+ for (const b of $$(".tool-sw", el)) {
1187
+ b.onclick = () => api(`/api/sessions/${d.id}/tools`, { method: "POST", body: { name: b.dataset.n, enabled: b.getAttribute("aria-checked") !== "true" } }).then(bump)
1188
+ }
1189
+ for (const [cls, dec] of [[".allow", "once"], [".always", "always"], [".deny", "reject"]]) {
1190
+ const b = $(cls, el)
1191
+ if (b) b.onclick = () => replyPermission(d.id, dec)
1192
+ }
1193
+ }
1194
+ // ------------------------------------------------------------------ live transcript
1195
+ function transcriptBody(d) {
1196
+ const msgs = live.msgs ?? []
1197
+ if (!msgs.length) return `<div class="empty">Nothing in this session yet.</div>`
1198
+ const who = { id: d.id, handle: d.handle }
1199
+ const body = msgs.map((m) => {
1200
+ const bot = m.role === "assistant"
1201
+ const parts = m.parts?.length ? m.parts : [
1202
+ ...(m.text ? [{ id: m.id + ":t", kind: "text", text: m.text }] : []),
1203
+ ...(m.tools ?? []).map((t, i) => ({ id: m.id + ":" + i, kind: "tool", name: t.name, status: t.status })),
1204
+ ]
1205
+ return `<div class="turn ${esc(m.role)}">
1206
+ ${bot ? `<img src="${avatarUrl(who)}" alt="" />` : `<span class="avdot"></span>`}
1207
+ <div class="c">
1208
+ <div class="m">${bot ? "@" + esc(d.handle) : esc(m.role === "user" ? "operator" : m.role)}${m.at ? " · " + new Date(m.at).toLocaleTimeString() : ""}</div>
1209
+ ${parts.map((p) => partHtml(p, bot)).join("") || `<span style="color:var(--faint)">…</span>`}
1210
+ </div></div>`
1211
+ }).join("")
1212
+ const working = live.status === "busy"
1213
+ ? `<div class="working"><i></i>@${esc(d.handle)} is ${live.currentTool ? `running ${esc(live.currentTool.name)}` : "working"}…</div>`
1214
+ : ""
1215
+ return body + working
1216
+ }
1217
+
1218
+ function partHtml(p, bot) {
1219
+ if (p.kind === "text") {
1220
+ const text = p.text ?? ""
1221
+ // Briefs and task prompts are long; show the start and let it expand.
1222
+ if (!bot && text.length > 700) {
1223
+ return `<details class="more" data-p="${esc(p.id)}" ${openThoughts.has(p.id) ? "open" : ""}>
1224
+ <summary><div class="b long">${esc(text.slice(0, 700))}</div><span class="lbl">show all ${fmt.n(text.length)} characters</span><span class="lbl less">show less</span></summary>
1225
+ <div class="b">${esc(text)}</div></details>`
1226
+ }
1227
+ return `<div class="b">${esc(text)}</div>`
1228
+ }
1229
+ if (p.kind === "reasoning") {
1230
+ const words = (p.text ?? "").split(/\s+/).filter(Boolean).length
1231
+ return `<details class="think" data-p="${esc(p.id)}" ${openThoughts.has(p.id) ? "open" : ""}>
1232
+ <summary>thinking · ${words} words</summary><div>${esc(p.text ?? "")}</div></details>`
1233
+ }
1234
+ if (p.kind === "tool") {
1235
+ const st = p.status === "completed" ? (p.ms != null ? fmt.ms(p.ms) : "done") : p.status ?? ""
1236
+ const row = `<div class="call s-${esc(p.status ?? "")}"><span class="nm">${esc(p.name ?? "tool")}</span>
1237
+ <span class="sm">${esc(p.summary ?? "")}</span><span class="st">${esc(st)}</span></div>`
1238
+ if (!p.output) return row
1239
+ return `<details data-p="${esc(p.id)}" ${openThoughts.has(p.id) ? "open" : ""}><summary style="list-style:none;cursor:pointer">${row}</summary>
1240
+ <div class="out">${esc(p.output)}</div></details>`
1241
+ }
1242
+ return ""
1243
+ }
1244
+
1245
+ function onLive(f) {
1246
+ if (!openId || f.sessionId !== openId || live.id !== openId) return
1247
+ live.at = Date.now()
1248
+ if (f.t === "transcript") {
1249
+ // See index.html's onLive: keep the longer text per part so a lagging REST
1250
+ // snapshot can't erase streamed-in text/reasoning while tool parts persist.
1251
+ const prevById = new Map((live.msgs ?? []).map((m) => [m.id, m]))
1252
+ live.msgs = (f.transcript ?? []).slice().reverse().map((m) => {
1253
+ const prev = prevById.get(m.id)
1254
+ if (!prev?.parts?.length || !m.parts?.length) return m
1255
+ const prevParts = new Map(prev.parts.map((p) => [p.id, p]))
1256
+ return { ...m, parts: m.parts.map((p) => {
1257
+ const old = prevParts.get(p.id)
1258
+ if (old && typeof old.text === "string" && typeof p.text === "string" && old.text.length > p.text.length) {
1259
+ return { ...p, text: old.text }
1260
+ }
1261
+ return p
1262
+ }) }
1263
+ })
1264
+ live.status = f.status
1265
+ live.currentTool = f.currentTool
1266
+ } else if (f.t === "part") {
1267
+ if (!live.msgs) return
1268
+ let m = live.msgs.find((x) => x.id === f.messageId)
1269
+ if (!m) {
1270
+ if (!f.messageId) return
1271
+ m = { id: f.messageId, role: "assistant", at: Date.now(), parts: [] }
1272
+ live.msgs.push(m)
1273
+ }
1274
+ m.parts ??= []
1275
+ let part = m.parts.find((x) => x.id === f.part.id)
1276
+ if (!part) { part = { ...f.part }; m.parts.push(part); if (f.delta && !f.part.text) part.text = f.delta }
1277
+ else if (f.delta && f.part.text == null) part.text = (part.text ?? "") + f.delta
1278
+ else Object.assign(part, Object.fromEntries(Object.entries(f.part).filter(([, v]) => v != null)))
1279
+ if (f.part.kind === "tool") live.currentTool = /running|pending/.test(f.part.status) ? { name: f.part.name } : null
1280
+ live.status = "busy"
1281
+ }
1282
+ if (liveFrame) return
1283
+ liveFrame = requestAnimationFrame(() => {
1284
+ liveFrame = 0
1285
+ const tr = $("#modal .transcript")
1286
+ if (!tr || !openId) return
1287
+ const atBottom = tr.scrollHeight - tr.scrollTop - tr.clientHeight < 80
1288
+ tr.innerHTML = transcriptBody({ id: openId, handle: live.handle })
1289
+ if (atBottom) tr.scrollTop = tr.scrollHeight
1290
+ })
1291
+ }
1292
+
1293
+ const bump = () => { modalSig = null; refreshModal() }
1294
+ const setPolicy = (id, body) => api(`/api/sessions/${id}/policy`, { method: "POST", body }).then(bump)
1295
+
1296
+ // ------------------------------------------------------------------ room modal
1297
+ function openRoom(id) { roomId = id; openId = null; roomSig = null; renderRoom(true) }
1298
+
1299
+ function renderRoom(create = false) {
1300
+ const r = (state.rooms ?? []).find((x) => x.id === roomId)
1301
+ if (!r) return closeModal()
1302
+ const sig = JSON.stringify([r.messages.length, r.members.length, r.muted, r.pending, r.stats])
1303
+ if (!create && sig === roomSig) return
1304
+ roomSig = sig
1305
+ const el = shell(create)
1306
+ const prior = $("#r-input")
1307
+ const keep = prior ? { text: prior.value, focused: document.activeElement === prior } : {}
1308
+ const others = state.sessions.filter((s) => !r.members.some((m) => m.id === s.id))
1309
+ el.innerHTML = `
1310
+ <div class="left">
1311
+ <div class="top"><span class="h">${esc(r.name)}</span><span class="s">${r.members.length} members</span>
1312
+ <span class="right">
1313
+ <button class="ghost tiny" id="rm-mute">${r.muted ? "Unmute" : "Mute"}</button>
1314
+ <button class="ghost tiny danger" id="rm-del">Delete</button>
1315
+ <button class="ghost tiny" id="rm-close">Close</button>
1316
+ </span></div>
1317
+ <div class="transcript">${
1318
+ r.messages.length
1319
+ ? r.messages.map((m) => {
1320
+ const member = r.members.find((x) => x.id === m.fromId)
1321
+ return `<div class="turn ${m.from === "operator" ? "op" : ""}">
1322
+ ${m.from === "operator" ? `<span class="avdot"></span>` : `<img src="/avatar/${encodeURIComponent(m.fromId ?? m.from)}.svg?h=${encodeURIComponent(member?.handle ?? m.from)}" alt="" />`}
1323
+ <div class="c"><div class="m">${m.from === "operator" ? "operator" : "@" + esc(m.from)} · ${new Date(m.at).toLocaleTimeString()}</div>
1324
+ <div class="b">${esc(m.text)}</div></div></div>`
1325
+ }).join("")
1326
+ : `<div class="empty">Nothing said yet. Anything you post reaches every member.</div>`
1327
+ }</div>
1328
+ <div class="composer">
1329
+ <textarea id="r-input" placeholder="Post as operator… (@handle reaches one member immediately)"></textarea>
1330
+ <button class="primary" id="rm-send">Post</button>
1331
+ </div>
1332
+ </div>
1333
+ <div class="side">
1334
+ <div class="sec"><h3>Room</h3>
1335
+ ${r.topic ? `<div class="note" style="margin:0 0 8px">${esc(r.topic)}</div>` : ""}
1336
+ <div class="note">${r.mode === "push"
1337
+ ? "Every message is delivered to every member's next step."
1338
+ : "Mentions are delivered immediately; everyone else gets one digest when they go idle."}</div>
1339
+ ${r.pending ? `<div class="note">${r.pending} message(s) waiting for members to finish their step.</div>` : ""}
1340
+ ${r.muted ? `<div class="note" style="color:var(--busy)">Muted after talking in circles.</div>` : ""}
1341
+ </div>
1342
+ <div class="sec"><h3>Members</h3>
1343
+ ${r.members.map((m) => `<div class="row">
1344
+ <img src="/avatar/${encodeURIComponent(m.id)}.svg?h=${encodeURIComponent(m.handle ?? "")}" style="width:28px;height:28px;border-radius:6px" alt="" />
1345
+ <span class="g">@${esc(m.handle)}</span><span class="st">${esc(m.status)}</span>
1346
+ <button class="ghost tiny rm-kick" data-s="${m.id}">Remove</button></div>`).join("")}
1347
+ ${others.length ? `<select id="rm-add" style="width:100%;margin-top:8px"><option value="">Add a session…</option>
1348
+ ${others.map((o) => `<option value="${o.id}">@${esc(o.handle ?? o.id)} — ${esc(o.name)}</option>`).join("")}</select>` : ""}
1349
+ </div>
1350
+ <div class="sec"><h3>Delivery cost</h3><table class="kv">
1351
+ <tr><td>messages</td><td>${r.stats.posts}</td></tr>
1352
+ <tr><td>deliveries</td><td>${r.stats.deliveries}</td></tr>
1353
+ <tr><td>characters</td><td>${fmt.n(r.stats.charsDelivered)} (~${fmt.n(Math.round(r.stats.charsDelivered / 4))} tok)</td></tr>
1354
+ </table></div>
1355
+ </div>`
1356
+ const tr = $(".transcript", el)
1357
+ tr.scrollTop = tr.scrollHeight
1358
+ const input = $("#r-input", el)
1359
+ if (keep.text) input.value = keep.text
1360
+ if (keep.focused) input.focus()
1361
+ const post = async () => {
1362
+ const message = input.value.trim()
1363
+ if (!message) return
1364
+ input.value = ""
1365
+ await api(`/api/rooms/${r.id}/post`, { method: "POST", body: { message } }).catch((e) => alert(e.message))
1366
+ }
1367
+ $("#rm-send", el).onclick = post
1368
+ input.onkeydown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); post() } }
1369
+ $("#rm-close", el).onclick = closeModal
1370
+ $("#rm-mute", el).onclick = () => api(`/api/rooms/${r.id}/mute`, { method: "POST", body: { muted: !r.muted } })
1371
+ $("#rm-del", el).onclick = async () => {
1372
+ if (!confirm(`Delete ${r.name}?`)) return
1373
+ await api(`/api/rooms/${r.id}`, { method: "DELETE" })
1374
+ closeModal()
1375
+ }
1376
+ for (const b of $$(".rm-kick", el)) b.onclick = () => api(`/api/rooms/${r.id}/members`, { method: "POST", body: { sessionId: b.dataset.s, remove: true } })
1377
+ const add = $("#rm-add", el)
1378
+ if (add) add.onchange = () => add.value && api(`/api/rooms/${r.id}/members`, { method: "POST", body: { sessionId: add.value } })
1379
+ }
1380
+
1381
+ // ------------------------------------------------------------------ dialogs
1382
+ /**
1383
+ * Probe a typed path before anything is created. "~/workspace/thing" is what
1384
+ * people type, and a repo with services cloned into a gitignored folder needs
1385
+ * a worktree per service — neither is something to discover after the fact.
1386
+ */
1387
+ async function probeRepo(prefix) {
1388
+ const input = $(`#${prefix}-repo`)
1389
+ const info = $(`#${prefix}-repo-info`)
1390
+ const list = $(`#${prefix}-services`)
1391
+ const path = input.value.trim()
1392
+ list.hidden = true
1393
+ list.innerHTML = ""
1394
+ if (!path) { info.textContent = ""; return }
1395
+ info.textContent = "checking…"
1396
+ let r
1397
+ try { r = await api("/api/repo?path=" + encodeURIComponent(path)) } catch { info.textContent = ""; return }
1398
+ if (!r.isRepo) {
1399
+ info.innerHTML = `<span style="color:var(--error)">${esc(r.path)} — ${esc(r.reason)}</span>`
1400
+ return
1401
+ }
1402
+ info.innerHTML = `<span class="num">${esc(r.path)}</span>${r.branch ? ` on ${esc(r.branch)}` : ""}` +
1403
+ (r.nested.length ? ` · ${r.nested.length} repositories inside it` : "")
1404
+ if (r.nested.length) {
1405
+ list.hidden = false
1406
+ list.innerHTML =
1407
+ `<label style="color:var(--dim)"><input type="checkbox" id="${prefix}-all" /><span>Give every service its own worktree</span></label>` +
1408
+ r.nested.map((n) => `<label><input type="checkbox" class="svc" value="${esc(n.rel)}" />
1409
+ <span class="num">${esc(n.rel)}</span><span class="d">${esc(n.branch ?? "")}</span></label>`).join("")
1410
+ $(`#${prefix}-all`).onchange = (e) => {
1411
+ for (const c of $$(`#${prefix}-services .svc`)) c.checked = e.target.checked
1412
+ }
1413
+ }
1414
+ }
1415
+ const chosenServices = (prefix) => $$(`#${prefix}-services .svc:checked`).map((c) => c.value)
1416
+
1417
+ const groupOptions = (sel, selected) => {
1418
+ sel.innerHTML = (state.projects ?? []).map((p) => `<option value="${p.id}" ${p.id === selected ? "selected" : ""}>${esc(p.name)}</option>`).join("")
1419
+ }
1420
+
1421
+ function openTaskDialog(projectId) {
1422
+ groupOptions($("#t-project"), projectId ?? (view.kind === "project" ? view.value : "default"))
1423
+ const fill = () => {
1424
+ const chosen = $("#t-project").value
1425
+ const inProject = state.sessions.filter((s) => (s.project ?? "default") === chosen)
1426
+ $("#t-assignee").innerHTML =
1427
+ `<option value="">Nobody yet — leave in the backlog</option>` +
1428
+ inProject.map((s) => `<option value="${s.id}">@${esc(s.handle ?? s.id)} — ${esc(s.name)}</option>`).join("")
1429
+ }
1430
+ $("#t-project").onchange = fill
1431
+ fill()
1432
+ $("#t-error").textContent = ""
1433
+ $("#task-dialog").showModal()
1434
+ }
1435
+ $("#t-cancel").onclick = () => $("#task-dialog").close()
1436
+ $("#t-create").onclick = async () => {
1437
+ const body = {
1438
+ title: $("#t-title").value.trim(),
1439
+ brief: $("#t-brief").value.trim(),
1440
+ projectId: $("#t-project").value,
1441
+ assignee: $("#t-assignee").value || null,
1442
+ }
1443
+ if (!body.title) return ($("#t-error").textContent = "A title is required.")
1444
+ try {
1445
+ await api("/api/tasks", { method: "POST", body })
1446
+ $("#task-dialog").close()
1447
+ $("#t-title").value = ""
1448
+ $("#t-brief").value = ""
1449
+ } catch (e) { $("#t-error").textContent = e.message }
1450
+ }
1451
+ $("#new-task").onclick = () => openTaskDialog()
1452
+
1453
+ $("#add-project").onclick = () => {
1454
+ $("#pj-sessions").innerHTML = state.sessions
1455
+ .map((s) => `<label><input type="checkbox" value="${s.id}" />
1456
+ <img src="${avatarUrl(s)}" style="width:28px;height:28px;border-radius:6px" alt="" />
1457
+ <span class="num">@${esc(s.handle ?? "")}</span><span class="d">${esc(s.name)}</span></label>`)
1458
+ .join("")
1459
+ $("#pj-error").textContent = ""
1460
+ $("#project-dialog").showModal()
1461
+ }
1462
+ $("#pj-cancel").onclick = () => $("#project-dialog").close()
1463
+ $("#pj-create").onclick = async () => {
1464
+ const name = $("#pj-name").value.trim()
1465
+ if (!name) return ($("#pj-error").textContent = "A name is required.")
1466
+ try {
1467
+ const p = await api("/api/projects", {
1468
+ method: "POST",
1469
+ body: { name, repo: $("#pj-repo").value.trim() || null, sessions: $$("#pj-sessions input:checked").map((i) => i.value) },
1470
+ })
1471
+ $("#project-dialog").close()
1472
+ $("#pj-name").value = ""
1473
+ $("#pj-repo").value = ""
1474
+ view = { kind: "project", value: p.id }
1475
+ navSig = null
1476
+ render()
1477
+ } catch (e) { $("#pj-error").textContent = e.message }
1478
+ }
1479
+
1480
+ async function openAdopt() {
1481
+ groupOptions($("#a-group"))
1482
+ await loadLoose("")
1483
+ $("#adopt-dialog").showModal()
1484
+ }
1485
+ async function loadLoose(q) {
1486
+ const { sessions, total } = await api("/api/unmanaged?q=" + encodeURIComponent(q))
1487
+ $("#a-list").innerHTML = sessions.length
1488
+ ? sessions.map((s) => `<label><input type="checkbox" value="${s.id}" />
1489
+ <span>${esc(s.title)}</span><span class="d">${esc(fmt.path(s.directory, 2))} · ${fmt.ago(s.updated)}</span></label>`).join("")
1490
+ : `<div class="note" style="padding:10px">Nothing matches. ${total} session(s) known to opencode.</div>`
1491
+ }
1492
+ $("#a-search").oninput = (e) => loadLoose(e.target.value)
1493
+ $("#a-cancel").onclick = () => $("#adopt-dialog").close()
1494
+ $("#a-add").onclick = async () => {
1495
+ const ids = $$("#a-list input:checked").map((i) => i.value)
1496
+ if (!ids.length) return
1497
+ await api("/api/adopt", { method: "POST", body: { ids, group: $("#a-group").value } })
1498
+ $("#adopt-dialog").close()
1499
+ $("#loose").dataset.n = ""
1500
+ }
1501
+ $("#add-existing").onclick = openAdopt
1502
+
1503
+ $("#new-pod").onclick = () => { groupOptions($("#f-group")); $("#new-dialog").showModal() }
1504
+ $("#f-repo").onchange = () => probeRepo("f")
1505
+ $("#f-repo").onblur = () => probeRepo("f")
1506
+ $("#p-repo").onchange = () => probeRepo("p")
1507
+ $("#p-repo").onblur = () => probeRepo("p")
1508
+ $("#f-cancel").onclick = () => $("#new-dialog").close()
1509
+ $("#f-create").onclick = async () => {
1510
+ const body = {
1511
+ repo: $("#f-repo").value.trim(),
1512
+ branch: $("#f-branch").value.trim() || null,
1513
+ task: $("#f-task").value.trim() || null,
1514
+ group: $("#f-group").value,
1515
+ services: chosenServices("f"),
1516
+ }
1517
+ if (!body.repo) return ($("#f-error").textContent = "A repository path is required.")
1518
+ $("#f-create").disabled = true
1519
+ try {
1520
+ await api("/api/sessions", { method: "POST", body })
1521
+ $("#new-dialog").close()
1522
+ $("#f-task").value = ""
1523
+ $("#f-branch").value = ""
1524
+ $("#f-error").textContent = ""
1525
+ } catch (e) {
1526
+ $("#f-error").textContent = e.message
1527
+ }
1528
+ $("#f-create").disabled = false
1529
+ }
1530
+
1531
+
1532
+ let pipelineDefs = { pipelines: [], personas: [], problems: [] }
1533
+ async function openPipelineDialog(project) {
1534
+ pipelineDefs = await api("/api/pipelines")
1535
+ if (project) {
1536
+ $("#p-repo").value = project.repo ?? ""
1537
+ $("#p-branch").value = project.branch ?? ""
1538
+ probeRepo("p")
1539
+ }
1540
+ $("#pipe-dialog").dataset.project = project?.id ?? ""
1541
+ $("#p-pipeline").innerHTML = pipelineDefs.pipelines.map((p) => `<option value="${p.id}">${esc(p.title)}</option>`).join("")
1542
+ showStages()
1543
+ $("#p-error").textContent = pipelineDefs.problems.length
1544
+ ? pipelineDefs.problems.map((p) => `${p.file}: ${p.problems.join("; ")}`).join("\n")
1545
+ : ""
1546
+ $("#pipe-dialog").showModal()
1547
+ }
1548
+ function showStages() {
1549
+ const def = pipelineDefs.pipelines.find((p) => p.id === $("#p-pipeline").value)
1550
+ $("#p-stages").textContent = def
1551
+ ? def.stages.map((s) => s.persona).join(" → ") + (def.description ? ` — ${def.description}` : "")
1552
+ : ""
1553
+ }
1554
+ $("#p-pipeline").onchange = showStages
1555
+ $("#p-edit").onclick = () => openEditor("pipelines", $("#p-pipeline").value)
1556
+ $("#p-personas").onclick = () => {
1557
+ const id = prompt("Which persona?\n\n" + pipelineDefs.personas.map((x) => x.id).join(", "), pipelineDefs.personas[0]?.id)
1558
+ if (id) openEditor("personas", id)
1559
+ }
1560
+
1561
+ /**
1562
+ * Editing a pipeline in the app and editing the YAML by hand are the same
1563
+ * file. The structured view is for the common edits — add a phase, move it,
1564
+ * change who does it, change what it is handed — and the YAML tab is there
1565
+ * for everything the form does not cover, so the form never becomes a ceiling.
1566
+ */
1567
+ let editing = null
1568
+ async function openEditor(kind, id) {
1569
+ const def = await api(`/api/${kind}/${id}`)
1570
+ editing = { kind, id, doc: def.doc }
1571
+ $("#ed-title").textContent = `${kind === "personas" ? "Persona" : "Pipeline"}: ${id}`
1572
+ $("#ed-file").textContent = def.file ?? ""
1573
+ $("#ed-error").textContent = ""
1574
+ $("#ed-yaml").hidden = true
1575
+ $("#ed-body").hidden = false
1576
+ $("#ed-yaml-toggle").textContent = "Edit as YAML"
1577
+ renderEditor()
1578
+ $("#edit-dialog").showModal()
1579
+ }
1580
+
1581
+ function renderEditor() {
1582
+ const { kind, doc } = editing
1583
+ const models = [...new Set(state.sessions.map((s) => s.model).filter(Boolean))]
1584
+ if (kind === "personas") {
1585
+ $("#ed-body").innerHTML = `
1586
+ <div class="stagebox">
1587
+ <label>Title</label><input id="e-title" value="${esc(doc.title ?? "")}" style="width:100%" />
1588
+ <label>Role — decides the vocabulary its handle is drawn from (qa, dev, product, reviewer, ops, research, writer)</label>
1589
+ <input id="e-role" value="${esc(doc.role ?? doc.id ?? "")}" style="width:100%" />
1590
+ <label>Model — blank uses the session default</label>
1591
+ <input id="e-model" list="e-models" value="${esc(doc.model ?? "")}" style="width:100%" />
1592
+ <datalist id="e-models">${models.map((m) => `<option value="${esc(m)}">`).join("")}</datalist>
1593
+ <label>Tools it may use — comma separated, blank means everything the session has</label>
1594
+ <input id="e-tools" value="${esc((doc.tools ?? []).join(", "))}" style="width:100%" />
1595
+ <label>Handle adjectives — comma separated, blank uses the role's</label>
1596
+ <input id="e-adj" value="${esc((doc.adjectives ?? []).join(", "))}" style="width:100%" />
1597
+ <div class="recv" style="margin-top:10px">
1598
+ <label><input type="checkbox" id="e-spawn" ${doc.may_spawn ? "checked" : ""} /> may open new sessions</label>
1599
+ <label>peer messages
1600
+ <select id="e-talk">${["open", "inbox", "off"].map((v) => `<option ${(doc.talk ?? "open") === v ? "selected" : ""}>${v}</option>`).join("")}</select></label>
1601
+ <label>group chats
1602
+ <select id="e-rooms">${["member", "create", "off"].map((v) => `<option ${(doc.rooms ?? "member") === v ? "selected" : ""}>${v}</option>`).join("")}</select></label>
1603
+ </div>
1604
+ <label>Brief — the first thing it is told, before any task</label>
1605
+ <textarea id="e-prompt" style="min-height:200px">${esc(doc.prompt ?? "")}</textarea>
1606
+ </div>`
1607
+ return
1608
+ }
1609
+
1610
+ const personas = pipelineDefs.personas.map((p) => p.id)
1611
+ const stages = doc.stages ?? []
1612
+ $("#ed-body").innerHTML = `
1613
+ <div class="stagebox">
1614
+ <label>Title</label><input id="e-title" value="${esc(doc.title ?? "")}" style="width:100%" />
1615
+ <label>Description</label><input id="e-desc" value="${esc(doc.description ?? "")}" style="width:100%" />
1616
+ <label>Shared context — prepended to every bot's brief in this pipeline</label>
1617
+ <textarea id="e-context" style="min-height:60px">${esc(doc.context ?? "")}</textarea>
1618
+ </div>
1619
+ ${stages.map((st, i) => `
1620
+ <div class="stagebox" data-i="${i}">
1621
+ <div class="hd">
1622
+ <input class="sid" value="${esc(st.id ?? "")}" data-f="id" />
1623
+ <select data-f="persona" ${st.human ? "disabled" : ""}>
1624
+ ${personas.map((x) => `<option ${x === st.persona ? "selected" : ""}>${esc(x)}</option>`).join("")}
1625
+ </select>
1626
+ <label style="margin:0;color:var(--dim);font-size:11.5px;white-space:nowrap;display:inline-flex;align-items:center;gap:5px"><input type="checkbox" data-f="human" style="width:auto" ${st.human ? "checked" : ""} /> yours, not a bot's</label>
1627
+ <span class="sp">
1628
+ <button class="ghost tiny" data-move="-1" ${i === 0 ? "disabled" : ""}>↑</button>
1629
+ <button class="ghost tiny" data-move="1" ${i === stages.length - 1 ? "disabled" : ""}>↓</button>
1630
+ <button class="ghost tiny danger" data-del="1">Remove</button>
1631
+ </span>
1632
+ </div>
1633
+ <label>Phase title</label><input value="${esc(st.title ?? "")}" data-f="title" style="width:100%" />
1634
+ <label>Receives — which earlier phases' handoffs this one is given</label>
1635
+ <div class="recv">${
1636
+ stages.filter((o, j) => j < i).map((o) => `<label><input type="checkbox" data-recv="${esc(o.id)}" ${(st.receives ?? []).includes(o.id) ? "checked" : ""} /> ${esc(o.id)}</label>`).join("")
1637
+ || `<span class="note" style="margin:0">nothing before it</span>`
1638
+ }</div>
1639
+ <label>Prompt — mustache over story, acceptance_criteria and handoffs.&lt;phase&gt;</label>
1640
+ <textarea data-f="prompt">${esc(st.prompt ?? "")}</textarea>
1641
+ </div>`).join("")}
1642
+ <button class="ghost" id="e-add">+ Add a phase</button>`
1643
+
1644
+ const read = () => {
1645
+ doc.title = $("#e-title").value
1646
+ doc.description = $("#e-desc").value
1647
+ doc.context = $("#e-context").value || undefined
1648
+ doc.stages = $$("#ed-body .stagebox[data-i]").map((box) => {
1649
+ const g = (f) => $(`[data-f="${f}"]`, box)
1650
+ return {
1651
+ id: g("id").value.trim(),
1652
+ persona: g("human").checked ? undefined : g("persona").value,
1653
+ human: g("human").checked || undefined,
1654
+ title: g("title").value.trim() || undefined,
1655
+ receives: $$("[data-recv]", box).filter((c) => c.checked).map((c) => c.dataset.recv),
1656
+ prompt: g("prompt").value || undefined,
1657
+ }
1658
+ })
1659
+ }
1660
+ editing.read = read
1661
+ for (const box of $$("#ed-body .stagebox[data-i]")) {
1662
+ const i = Number(box.dataset.i)
1663
+ $("[data-move='-1']", box).onclick = () => { read(); const [x] = doc.stages.splice(i, 1); doc.stages.splice(i - 1, 0, x); renderEditor() }
1664
+ $("[data-move='1']", box).onclick = () => { read(); const [x] = doc.stages.splice(i, 1); doc.stages.splice(i + 1, 0, x); renderEditor() }
1665
+ $("[data-del]", box).onclick = () => { read(); doc.stages.splice(i, 1); renderEditor() }
1666
+ $("[data-f='human']", box).onchange = () => { read(); renderEditor() }
1667
+ }
1668
+ $("#e-add").onclick = () => {
1669
+ read()
1670
+ doc.stages.push({ id: "phase" + (doc.stages.length + 1), persona: personas[0], receives: doc.stages.map((x) => x.id), prompt: "{{story}}" })
1671
+ renderEditor()
1672
+ }
1673
+ }
1674
+
1675
+ $("#ed-yaml-toggle").onclick = async () => {
1676
+ const showingYaml = !$("#ed-yaml").hidden
1677
+ if (showingYaml) {
1678
+ $("#ed-yaml").hidden = true
1679
+ $("#ed-body").hidden = false
1680
+ $("#ed-yaml-toggle").textContent = "Edit as YAML"
1681
+ } else {
1682
+ editing.read?.()
1683
+ if (editing.kind === "personas") readPersona()
1684
+ const def = await api(`/api/${editing.kind}/${editing.id}`)
1685
+ $("#ed-yaml").value = def.yaml
1686
+ $("#ed-yaml").hidden = false
1687
+ $("#ed-body").hidden = true
1688
+ $("#ed-yaml-toggle").textContent = "Back to the form"
1689
+ }
1690
+ }
1691
+ function readPersona() {
1692
+ const d = editing.doc
1693
+ d.title = $("#e-title").value
1694
+ d.role = $("#e-role").value.trim() || undefined
1695
+ d.model = $("#e-model").value.trim() || undefined
1696
+ d.tools = $("#e-tools").value.split(",").map((x) => x.trim()).filter(Boolean)
1697
+ d.adjectives = $("#e-adj").value.split(",").map((x) => x.trim()).filter(Boolean)
1698
+ if (!d.tools.length) delete d.tools
1699
+ if (!d.adjectives.length) delete d.adjectives
1700
+ d.may_spawn = $("#e-spawn").checked || undefined
1701
+ d.talk = $("#e-talk").value
1702
+ d.rooms = $("#e-rooms").value
1703
+ d.prompt = $("#e-prompt").value
1704
+ }
1705
+ $("#ed-cancel").onclick = () => $("#edit-dialog").close()
1706
+ $("#ed-save").onclick = async () => {
1707
+ try {
1708
+ let body
1709
+ if (!$("#ed-yaml").hidden) body = { yaml: $("#ed-yaml").value }
1710
+ else {
1711
+ if (editing.kind === "personas") readPersona()
1712
+ else editing.read?.()
1713
+ body = { doc: editing.doc }
1714
+ }
1715
+ const saved = await api(`/api/${editing.kind}/${editing.id}`, { method: "PUT", body })
1716
+ $("#ed-error").textContent = saved.problems?.length
1717
+ ? "Saved, but: " + saved.problems.join("\n")
1718
+ : ""
1719
+ pipelineDefs = await api("/api/pipelines")
1720
+ showStages()
1721
+ if (!saved.problems?.length) $("#edit-dialog").close()
1722
+ } catch (e) {
1723
+ $("#ed-error").textContent = e.message
1724
+ }
1725
+ }
1726
+ $("#p-cancel").onclick = () => $("#pipe-dialog").close()
1727
+ $("#p-reload").onclick = async () => { await api("/api/pipelines/reload", { method: "POST" }); openPipelineDialog() }
1728
+ $("#p-run").onclick = async () => {
1729
+ const body = {
1730
+ pipeline: $("#p-pipeline").value,
1731
+ repo: $("#p-repo").value.trim(),
1732
+ branch: $("#p-branch").value.trim() || null,
1733
+ story: $("#p-story").value.trim(),
1734
+ services: chosenServices("p"),
1735
+ project: $("#pipe-dialog").dataset.project || undefined,
1736
+ }
1737
+ if (!body.repo || !body.story) return ($("#p-error").textContent = "A repository and a story are both required.")
1738
+ $("#p-run").disabled = true
1739
+ try {
1740
+ await api("/api/runs", { method: "POST", body })
1741
+ $("#pipe-dialog").close()
1742
+ $("#p-story").value = ""
1743
+ view = { kind: "board", value: null }
1744
+ navSig = null
1745
+ render()
1746
+ } catch (e) { $("#p-error").textContent = e.message }
1747
+ $("#p-run").disabled = false
1748
+ }
1749
+
1750
+ $("#filter").oninput = (e) => { filterText = e.target.value.toLowerCase(); render() }
1751
+ $("#abort-all").onclick = async () => {
1752
+ const running = visibleSessions().filter((s) => s.status === "busy")
1753
+ if (!running.length || !confirm(`Abort ${running.length} running session(s)?`)) return
1754
+ await Promise.all(running.map((s) => api(`/api/sessions/${s.id}/abort`, { method: "POST" })))
1755
+ }
1756
+ document.addEventListener("keydown", (e) => {
1757
+ if (e.key === "Escape" && (openId || roomId)) closeModal()
1758
+ if (e.key === "/" && !["INPUT", "TEXTAREA"].includes(document.activeElement.tagName)) { e.preventDefault(); $("#filter").focus() }
1759
+ })
1760
+
1761
+ /**
1762
+ * Websocket first, EventSource if it will not connect. Both are pushes — the
1763
+ * dashboard has never polled — but the socket is cheaper per frame and lets us
1764
+ * ask for a resync after a reconnect instead of waiting for the next change.
1765
+ */
1766
+ function connect() {
1767
+ let ws
1768
+ let fellBack = false
1769
+ const fallback = () => {
1770
+ if (fellBack) return
1771
+ fellBack = true
1772
+ const es = new EventSource("/api/stream")
1773
+ es.onmessage = (e) => { state = JSON.parse(e.data); render(true) }
1774
+ es.onerror = () => { $("#conn").textContent = "dashboard offline" }
1775
+ }
1776
+ const open = () => {
1777
+ try {
1778
+ ws = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/api/socket`)
1779
+ } catch {
1780
+ return fallback()
1781
+ }
1782
+ const guard = setTimeout(() => { if (ws.readyState !== 1) { try { ws.close() } catch {} ; fallback() } }, 2500)
1783
+ ws.onopen = () => {
1784
+ clearTimeout(guard)
1785
+ sock = ws
1786
+ ws.send("resync")
1787
+ if (openId) ws.send(`watch:${openId}`)
1788
+ }
1789
+ ws.onmessage = (e) => {
1790
+ const msg = JSON.parse(e.data)
1791
+ if (msg.t) return onLive(msg)
1792
+ state = msg
1793
+ render(true)
1794
+ }
1795
+ ws.onclose = () => {
1796
+ clearTimeout(guard)
1797
+ if (fellBack) return
1798
+ $("#conn").textContent = "reconnecting…"
1799
+ setTimeout(open, 1500)
1800
+ }
1801
+ ws.onerror = () => { if (ws.readyState !== 1) fallback() }
1802
+ }
1803
+ open()
1804
+ }
1805
+ connect()
1806
+ // Re-render on a tick only to keep the relative clocks honest; no network.
1807
+ setInterval(() => render(), 1000)
1808
+ </script>
1809
+ </body>
1810
+ </html>