@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,3284 @@
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 amber / violet / blue, never red-versus-green; red is only
13
+ failure. Every state is also written in words. */
14
+ /* Neutral graphite rather than navy, with brighter text and edges: every
15
+ pair here clears WCAG AA against the surface it sits on. */
16
+ --ink: #141619; --ink-2: #1a1d21; --panel: #22262b; --panel-2: #2b3036; --line: #3d434b; --line-2: #545c66;
17
+ --text: #f7f8fa; --dim: #cfd5dd; --faint: #9aa3ae;
18
+ --busy: #ffb547; --wait: #c3a6ff; --error: #ff7b6e; --idle: #98a4b3; --ok: #5ccfff;
19
+ --accent: #3d8bff; --accent-2: #2f74e0; --hover: #353b43;
20
+ --radius: 6px;
21
+ --mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
22
+ --sans: "IBM Plex Sans", ui-sans-serif, system-ui, -apple-system, sans-serif;
23
+ }
24
+ * { box-sizing: border-box; }
25
+ [hidden] { display: none !important; }
26
+ html, body { margin: 0; height: 100%; }
27
+ body { background: var(--ink); color: var(--text); font-family: var(--sans); font-size: 14px; line-height: 1.45; -webkit-font-smoothing: antialiased; overflow: hidden; }
28
+ .mono, .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-2); border-radius: var(--radius); padding: 6px 10px; }
30
+ button { cursor: pointer; transition: background .12s, border-color .12s, color .12s; }
31
+ button:hover { background: var(--hover); }
32
+ button:disabled { opacity: .5; cursor: default; }
33
+ :focus-visible { outline: 2px solid var(--wait); outline-offset: 1px; }
34
+ input::placeholder, textarea::placeholder { color: var(--faint); }
35
+ textarea { resize: vertical; }
36
+ .ghost { background: transparent; border-color: transparent; color: var(--dim); }
37
+ .ghost:hover { color: var(--text); background: var(--panel-2); }
38
+ .primary { background: var(--accent-2); border-color: var(--accent); color: #fff; }
39
+ .primary:hover { background: var(--accent); }
40
+ .danger:hover { background: #3a1f20; border-color: #6b2f2c; color: #ffb3ab; }
41
+ .sm { font-size: 12px; padding: 3px 9px; }
42
+ .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--idle); flex: none; }
43
+ .dot.busy { background: var(--busy); } .dot.waiting { background: var(--wait); } .dot.error { background: var(--error); } .dot.done { background: var(--ok); }
44
+ .note { color: var(--faint); font-size: 12.5px; }
45
+ .err { color: var(--error); font-size: 12.5px; white-space: pre-wrap; }
46
+ .chip { display: inline-flex; align-items: center; gap: 5px; font-family: var(--mono); font-size: 11px; color: var(--dim); border: 1px solid var(--line-2); border-radius: 20px; padding: 1px 8px; white-space: nowrap; }
47
+
48
+ /* ---------- layout ---------- */
49
+ .app { display: grid; grid-template-columns: 250px 1fr; height: 100vh; }
50
+ aside.side { background: var(--ink-2); border-right: 1px solid var(--line); display: flex; flex-direction: column; min-height: 0; }
51
+ .brand { display: flex; align-items: center; gap: 8px; padding: 14px 16px 10px; }
52
+ .brand b { font-family: var(--mono); font-size: 15px; letter-spacing: .02em; }
53
+ .brand .conn { margin-left: auto; font-family: var(--mono); font-size: 10.5px; color: var(--faint); }
54
+ .wspick { margin: 0 10px 8px; display: flex; flex-direction: column; align-items: stretch; text-align: left; background: var(--panel); border: 1px solid var(--line); padding: 8px 10px; }
55
+ .wspick .n { font-weight: 600; display: flex; align-items: center; gap: 6px; }
56
+ .wspick .n::after { content: "▾"; margin-left: auto; color: var(--faint); font-size: 11px; }
57
+ .wspick .p { font-family: var(--mono); font-size: 11px; color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; }
58
+ .wsmenu { margin: -4px 10px 8px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 4px; }
59
+ .wsmenu button { display: block; width: 100%; text-align: left; border: 0; background: transparent; padding: 6px 8px; }
60
+ .wsmenu button:hover { background: var(--panel-2); }
61
+ .wsmenu button[aria-current="true"] { color: var(--ok); }
62
+ .nav { flex: 1; overflow-y: auto; padding: 4px 0 12px; }
63
+ .nav h4 { display: flex; align-items: center; margin: 14px 16px 4px; font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--faint); }
64
+ .nav h4 button { margin-left: auto; padding: 0 6px; font-size: 15px; line-height: 18px; }
65
+ .item { display: flex; align-items: center; gap: 8px; width: calc(100% - 16px); margin: 1px 8px; padding: 5px 8px; border: 0; background: transparent; text-align: left; border-radius: var(--radius); color: var(--dim); min-width: 0; }
66
+ .item:hover { background: var(--panel); color: var(--text); }
67
+ .item[aria-current="true"] { background: var(--panel-2); color: var(--text); }
68
+ .item .t { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
69
+ .item .hash { color: var(--faint); font-family: var(--mono); }
70
+ .item .badge { font-family: var(--mono); font-size: 10.5px; background: var(--wait); color: #140d2a; border-radius: 10px; padding: 0 6px; font-weight: 600; }
71
+ .item .badge.msg { background: var(--ok); color: #061825; }
72
+ .item.archived { opacity: .55; }
73
+ .foot { border-top: 1px solid var(--line); padding: 10px 16px; display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px 4px; }
74
+ .foot .spend { display: flex; flex-direction: column; align-items: flex-start; background: none; border: 0; padding: 0; text-align: left; }
75
+ .foot .spend i.warn { color: var(--busy); text-decoration: underline dotted; }
76
+ .foot div { display: flex; flex-direction: column; }
77
+ .foot b { font-family: var(--mono); font-size: 13px; font-weight: 500; }
78
+ .foot i { font-style: normal; font-size: 10.5px; color: var(--faint); }
79
+ .foot a { grid-column: 1 / -1; font-size: 11px; color: var(--faint); margin-top: 6px; }
80
+ main { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
81
+
82
+ /* ---------- workstream ---------- */
83
+ .whead { padding: 14px 20px 12px; border-bottom: 1px solid var(--line); display: flex; flex-direction: column; gap: 10px; }
84
+ .whead .row1 { display: flex; align-items: center; gap: 10px; min-width: 0; }
85
+ .whead h1 { font-size: 18px; font-weight: 600; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
86
+ .whead .acts { margin-left: auto; display: flex; gap: 6px; flex: none; }
87
+ .whead .row2 { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
88
+ .steps { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
89
+ .step { font-family: var(--mono); font-size: 11.5px; padding: 2px 9px; border-radius: 20px; border: 1px solid var(--line-2); color: var(--faint); }
90
+ .step.done { color: var(--ok); border-color: #2c6a8c; }
91
+ .step.active { color: var(--busy); border-color: var(--busy); }
92
+ .step.needs { color: var(--wait); border-color: var(--wait); }
93
+ .steps .arr { color: var(--line-2); font-size: 11px; }
94
+ .team { display: flex; gap: 6px; flex-wrap: wrap; }
95
+ .bot { display: flex; align-items: center; gap: 10px; padding: 4px 14px 4px 4px; border-radius: 30px; border: 1px solid var(--line); background: var(--panel); cursor: pointer; max-width: 300px; min-width: 0; }
96
+ .bot:hover { border-color: var(--line-2); }
97
+ .bot img { width: 40px; height: 40px; border-radius: 50%; flex: none; }
98
+ .bot .who { display: flex; flex-direction: column; min-width: 0; line-height: 1.2; }
99
+ .bot .h { font-family: var(--mono); font-size: 13px; }
100
+ .bot .d { font-size: 11px; color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
101
+ .bot { max-width: 420px; align-items: flex-start; border-radius: 22px; padding: 6px 14px 6px 6px; }
102
+ .bot .top { display: flex; align-items: baseline; gap: 7px; min-width: 0; }
103
+ .bot .kind { font-size: 11px; color: var(--fg); border: 1px solid color-mix(in srgb, var(--fg) 45%, transparent); border-radius: 10px; padding: 0 7px; white-space: nowrap; }
104
+ .bot .model { font-family: var(--mono); font-size: 11px; color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
105
+ .bot .stats { display: flex; gap: 10px; font-family: var(--mono); font-size: 10.5px; color: var(--faint); margin-top: 2px; flex-wrap: wrap; }
106
+ .bot .stats b { color: var(--dim); font-weight: 500; }
107
+ .bot .stats .bad { color: var(--error); }
108
+ .bot.error .d { color: var(--error); white-space: normal; }
109
+ .bot.busy { border-color: var(--busy); } .bot.busy .h { color: var(--busy); }
110
+ .bot.waiting { border-color: var(--wait); } .bot.error { border-color: var(--error); }
111
+ /* Board above, conversation below, full width; drag the bar between them. */
112
+ .wbody { flex: 1; min-height: 0; display: grid; grid-template-rows: var(--split, 44%) 8px 1fr; }
113
+ .board { display: grid; grid-template-columns: repeat(4, minmax(200px, 1fr)); gap: 10px; padding: 14px; overflow: auto; align-content: start; min-height: 0; }
114
+ .splitter { cursor: row-resize; background: var(--ink-2); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); display: grid; place-items: center; }
115
+ .splitter::after { content: ""; width: 44px; height: 3px; border-radius: 2px; background: var(--line-2); }
116
+ .splitter:hover::after, .splitter.drag::after { background: var(--ok); }
117
+ .col { background: var(--ink-2); border: 1px solid var(--line); border-radius: 8px; display: flex; flex-direction: column; min-height: 120px; }
118
+ .col h5 { margin: 0; padding: 9px 12px; display: flex; align-items: center; font-size: 11px; text-transform: uppercase; letter-spacing: .07em; color: var(--dim); }
119
+ .col h5 span { margin-left: auto; font-family: var(--mono); color: var(--faint); }
120
+ .col.needs h5 { color: var(--wait); } .col.doing h5 { color: var(--busy); }
121
+ .col .cards { display: flex; flex-direction: column; gap: 7px; padding: 0 8px 10px; flex: 1; }
122
+ .col.over { border-color: var(--ok); background: #1d2a35; }
123
+ .card { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 9px 10px; cursor: pointer; display: flex; flex-direction: column; gap: 6px; }
124
+ .card:hover { border-color: var(--line-2); }
125
+ .card.dragging { opacity: .4; }
126
+ .card .ttl { font-size: 13px; }
127
+ .card .meta { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--faint); min-width: 0; }
128
+ .card .meta img { width: 30px; height: 30px; border-radius: 50%; flex: none; }
129
+ .card .meta { gap: 8px; }
130
+ .card .meta .h { font-size: 12px; color: var(--dim); }
131
+ .card .meta .h { font-family: var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
132
+ .card .meta .who { display: flex; flex-direction: column; min-width: 0; line-height: 1.3; }
133
+ .card .meta .st { font-family: var(--mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
134
+ .card.active { border-left: 3px solid var(--busy); }
135
+ .card.question, .card.review { border-left: 3px solid var(--wait); }
136
+ .card .stage { font-family: var(--mono); font-size: 10.5px; color: var(--wait); }
137
+ .addcard { border: 1px dashed var(--line-2); background: transparent; color: var(--faint); font-size: 12px; }
138
+ .chat { display: flex; flex-direction: column; min-height: 0; background: var(--ink-2); }
139
+ .chat .ch { padding: 10px 14px; border-bottom: 1px solid var(--line); display: flex; align-items: center; gap: 8px; }
140
+ .chat .ch b { font-family: var(--mono); font-size: 13px; }
141
+ .log { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 12px 20px; display: flex; flex-direction: column; gap: 12px; }
142
+ /* Each bot's messages wear its avatar's colours; yours sit on the right. */
143
+ .log { gap: 4px; }
144
+ .msg { display: flex; gap: 12px; align-items: flex-start; margin-top: 10px; --fg: var(--dim); --bg: var(--panel-2); }
145
+ .msg.cont { margin-top: 0; }
146
+ .msg .av { width: 44px; height: 44px; border-radius: 10px; flex: none; }
147
+ .msg.cont .av { visibility: hidden; height: 0; }
148
+ .msg .c { min-width: 0; max-width: min(900px, 85%); display: flex; flex-direction: column; align-items: flex-start; }
149
+ .msg .b.long { max-height: 11em; overflow: hidden; -webkit-mask-image: linear-gradient(#000 65%, transparent); mask-image: linear-gradient(#000 65%, transparent); }
150
+ .msg .more { font-family: var(--mono); font-size: 11px; color: var(--faint); background: none; border: 0; padding: 2px 4px; }
151
+ .msg .more:hover { color: var(--text); background: none; }
152
+ .msg .m { display: flex; align-items: baseline; gap: 6px; font-size: 11px; color: var(--faint); margin: 0 0 3px 2px; }
153
+ .msg .m b { font-family: var(--mono); font-weight: 600; font-size: 12px; color: var(--fg); }
154
+ .msg .m .role { font-family: var(--mono); font-size: 10.5px; color: var(--faint); }
155
+ .msg.cont .m { display: none; }
156
+ .msg .b { font-size: 13.5px; white-space: pre-wrap; word-break: break-word; padding: 7px 11px; border-radius: 4px 12px 12px 12px;
157
+ background: color-mix(in srgb, var(--bg) 62%, var(--panel)); border: 1px solid color-mix(in srgb, var(--fg) 38%, transparent); }
158
+ .msg.cont .b { border-radius: 12px; }
159
+ .msg .b .at { font-family: var(--mono); font-size: 12.5px; font-weight: 600; color: var(--atc, var(--ok)); }
160
+ .msg.op { flex-direction: row-reverse; --fg: var(--ok); --bg: #1c4461; }
161
+ .msg.op .c { align-items: flex-end; }
162
+ .msg.op .m { margin: 0 2px 3px 0; }
163
+ .msg.op .b { border-radius: 12px 4px 12px 12px; }
164
+ .msg.op .av { display: grid; place-items: center; background: #1c4461; border: 1px solid #3a7aa6; font-size: 11px; font-family: var(--mono); color: var(--ok); }
165
+ .msg .b .tag { display: inline-block; font-family: var(--mono); font-size: 10.5px; color: var(--fg); border: 1px solid color-mix(in srgb, var(--fg) 45%, transparent); border-radius: 10px; padding: 0 7px; margin-bottom: 4px; }
166
+ .ask { background: var(--ink-2); border: 1px solid var(--wait); border-radius: 10px; padding: 10px 12px; display: flex; flex-direction: column; gap: 12px; }
167
+ .ask .askh { font-size: 12.5px; color: var(--dim); }
168
+ .ask .askh b { color: var(--wait); font-family: var(--mono); font-weight: 600; }
169
+ .q { display: flex; flex-direction: column; gap: 6px; }
170
+ .q .qh { align-self: flex-start; font-family: var(--mono); font-size: 10.5px; color: var(--wait); border: 1px solid #4a3d7a; border-radius: 10px; padding: 0 7px; }
171
+ .q .qt { font-size: 13.5px; white-space: pre-wrap; display: flex; gap: 7px; }
172
+ .q .qn { font-family: var(--mono); font-size: 11px; color: #140d2a; background: var(--wait); border-radius: 50%; width: 18px; height: 18px; flex: none; display: grid; place-items: center; margin-top: 1px; }
173
+ .opts { display: flex; flex-direction: column; gap: 4px; }
174
+ .opt { display: flex; gap: 9px; align-items: flex-start; text-align: left; background: var(--panel); border-color: var(--line); padding: 6px 10px; }
175
+ .opt:hover { border-color: var(--line-2); }
176
+ .opt .box { width: 14px; height: 14px; border: 1.5px solid var(--faint); border-radius: 50%; flex: none; margin-top: 3px; }
177
+ .opt[role=checkbox] .box { border-radius: 3px; }
178
+ .opt[aria-checked=true] { border-color: var(--wait); background: #221a3d; }
179
+ .opt[aria-checked=true] .box { border-color: var(--wait); background: var(--wait); box-shadow: inset 0 0 0 2.5px #221a3d; }
180
+ .opt .ol { display: flex; flex-direction: column; }
181
+ .opt .ol b { font-weight: 500; font-size: 13px; }
182
+ .opt .ol i { font-style: normal; font-size: 12px; color: var(--faint); }
183
+ .q .qa, .ask .qnote { width: 100%; font-size: 13px; resize: none; }
184
+ .ask .askf { display: flex; align-items: center; gap: 8px; }
185
+ .ask .askf .note { margin-right: auto; }
186
+ .log .ask { margin: 12px 0 4px 56px; max-width: 760px; }
187
+ .bot { --fg: var(--line-2); }
188
+ .bot img { box-shadow: 0 0 0 2px var(--fg); }
189
+ .sys { font-family: var(--mono); font-size: 11px; color: var(--faint); padding-left: 56px; margin: 2px 0; }
190
+ .sys.warn { color: var(--busy); } .sys.error { color: var(--error); }
191
+ .composer { border-top: 1px solid var(--line); padding: 10px; display: flex; gap: 8px; position: relative; }
192
+ .composer textarea { flex: 1; min-height: 40px; max-height: 160px; resize: none; font-size: 13px; }
193
+ .mentions { position: absolute; bottom: 100%; left: 10px; background: var(--panel); border: 1px solid var(--line-2); border-radius: var(--radius); padding: 4px; min-width: 200px; }
194
+ .mentions button { display: flex; gap: 8px; align-items: center; width: 100%; border: 0; background: transparent; text-align: left; font-family: var(--mono); font-size: 12px; }
195
+ .mentions button[aria-selected="true"], .mentions button:hover { background: var(--panel-2); }
196
+ .mentions img { width: 24px; height: 24px; border-radius: 50%; }
197
+
198
+ /* ---------- home / editors ---------- */
199
+ .page { flex: 1; overflow-y: auto; padding: 22px 26px 60px; }
200
+ .page h1 { font-size: 20px; margin: 0 0 4px; }
201
+ .page .lead { color: var(--dim); margin: 0 0 18px; }
202
+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
203
+ .tile { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; cursor: pointer; display: flex; flex-direction: column; gap: 8px; text-align: left; }
204
+ .tile:hover { border-color: var(--line-2); background: var(--panel); }
205
+ .tile .top { display: flex; align-items: center; gap: 8px; }
206
+ .tile .top b { font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
207
+ .tile .last { font-size: 12.5px; color: var(--dim); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
208
+ .tile .avs { display: flex; gap: 3px; }
209
+ .tile .avs img { width: 32px; height: 32px; border-radius: 50%; }
210
+ .hero { border: 1px dashed var(--line-2); border-radius: 10px; padding: 26px; text-align: center; color: var(--dim); margin-bottom: 18px; }
211
+ .hero .primary { margin-top: 10px; font-size: 14px; padding: 8px 16px; }
212
+ .edhead { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; flex-wrap: wrap; }
213
+ .edhead .acts { margin-left: auto; display: flex; gap: 6px; }
214
+ .field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
215
+ .field label { font-size: 11.5px; color: var(--dim); font-weight: 500; }
216
+ .field .hint { font-size: 11.5px; color: var(--faint); }
217
+ .row { display: flex; gap: 10px; }
218
+ .row > .field { flex: 1; min-width: 0; }
219
+ textarea.code, .code { font-family: var(--mono); font-size: 12.5px; line-height: 1.5; }
220
+ .stages { display: flex; flex-direction: column; gap: 0; max-width: 900px; }
221
+ .stagecard { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; }
222
+ .stagecard.human { border-color: #4a3d7a; }
223
+ .stagecard .sh { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
224
+ .stagecard .sh .n { font-family: var(--mono); color: var(--faint); font-size: 12px; }
225
+ .stagecard .sh .tools { margin-left: auto; display: flex; gap: 4px; }
226
+ .link { align-self: center; color: var(--line-2); font-size: 16px; line-height: 22px; }
227
+ .toggles { display: flex; gap: 6px; flex-wrap: wrap; }
228
+ .toggle { font-family: var(--mono); font-size: 11.5px; padding: 2px 9px; border-radius: 20px; }
229
+ .toggle[aria-pressed="true"] { background: #1c3a55; border-color: var(--ok); color: var(--ok); }
230
+ .problems { background: #2a1d24; border: 1px solid #5c2f36; border-radius: 6px; padding: 8px 12px; margin: 10px 0; font-size: 12.5px; color: #ffc2bb; max-width: 900px; }
231
+ .tabs { display: flex; gap: 4px; margin: 12px 0 14px; }
232
+ /* difficulty, XP, levels, hats */
233
+ .card .tags { display: flex; gap: 6px; flex-wrap: wrap; }
234
+ .diff { font-family: var(--mono); font-size: 10.5px; border-radius: 10px; padding: 0 7px; border: 1px solid var(--line-2); color: var(--dim); }
235
+ .diff.d-easy { color: #8fe0a0; border-color: #3d7a4d; } .diff.d-hard { color: #ffb0a6; border-color: #8a4038; }
236
+ .xp { font-family: var(--mono); font-size: 10.5px; color: #ffd66b; }
237
+ .rbot .ava .hat { position: absolute; top: -13px; left: 50%; transform: translateX(-50%) rotate(-8deg); font-size: 20px; line-height: 1; filter: drop-shadow(0 1px 1px rgba(0,0,0,.6)); pointer-events: none; }
238
+ .rbot .lvl { font-family: var(--mono); font-size: 10px; color: #ffd66b; border: 1px solid #6b5a2a; border-radius: 10px; padding: 0 5px; flex: none; }
239
+ .rbot .h.gold, .farm .fbot.gold .nm { color: #ffd66b; }
240
+ .farm .fbot .hat { position: absolute; top: -15px; left: 50%; transform: translateX(-50%) rotate(-8deg); font-size: 21px; line-height: 1; z-index: 5; pointer-events: none; filter: drop-shadow(0 1px 1px rgba(0,0,0,.6)); }
241
+ .farm .fbot.gold img { box-shadow: 0 0 0 3px #ffd66b, 0 0 14px rgba(255,214,107,.6), 0 6px 10px rgba(0,0,0,.45); }
242
+ pre.packet { white-space: pre-wrap; font-family: var(--mono); font-size: 12.5px; line-height: 1.5; margin: 0; }
243
+ .btnlink { display: inline-flex; align-items: center; text-decoration: none; color: var(--dim); border: 1px solid transparent; border-radius: var(--radius); padding: 3px 9px; font-size: 12px; }
244
+ .btnlink:hover { background: var(--panel-2); color: var(--text); }
245
+ .scrub { display: flex; align-items: center; gap: 8px; }
246
+ .scrub input[type=range] { flex: 1; accent-color: var(--accent); }
247
+ /* harvest */
248
+ .harvest { position: fixed; inset: 0; z-index: 60; background: rgba(8,9,11,.55); display: grid; place-items: center; overflow: hidden; animation: rise .25s ease-out; }
249
+ .harvest .fall { position: absolute; top: -40px; font-size: 28px; animation: fall linear forwards; pointer-events: none; }
250
+ @keyframes fall { to { transform: translateY(110vh) rotate(540deg); opacity: .9 } }
251
+ .harvest .hcard { position: relative; background: var(--panel); border: 1px solid var(--line-2); border-radius: 16px; padding: 22px 26px; width: min(560px, 92vw); text-align: center; box-shadow: 0 30px 80px rgba(0,0,0,.55); animation: pop .45s cubic-bezier(.2,.8,.3,1.2); }
252
+ .harvest .hbig { font-size: 54px; line-height: 1; animation: bob 1.6s ease-in-out infinite; }
253
+ .harvest h2 { margin: 8px 0 14px; font-size: 18px; }
254
+ .hstats { display: flex; justify-content: center; gap: 18px; flex-wrap: wrap; }
255
+ .hstats div { display: flex; flex-direction: column; min-width: 70px; }
256
+ .hstats b { font-family: var(--mono); font-size: 20px; }
257
+ .hstats.small b { font-size: 16px; }
258
+ .hstats i { font-style: normal; font-size: 11px; color: var(--faint); }
259
+ .hstats .good b { color: #8fe0a0; }
260
+ .lvups { margin-top: 14px; display: flex; flex-direction: column; gap: 6px; }
261
+ .lvup { font-size: 13.5px; color: #ffd66b; animation: rise .5s ease-out both; }
262
+ .hacts { margin-top: 18px; display: flex; gap: 8px; justify-content: center; }
263
+ .hpanel { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 12px; margin-top: 18px; }
264
+ .hlist { display: flex; flex-direction: column; gap: 4px; margin-top: 10px; }
265
+ .hrow { display: flex; gap: 8px; align-items: center; font-size: 13px; flex-wrap: wrap; padding: 3px 0; border-bottom: 1px solid var(--line); }
266
+ .hrow .note { flex: 1 1 180px; }
267
+ .hrow b { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 220px; }
268
+ .hrow button { margin-left: auto; }
269
+ .lrow { display: grid; grid-template-columns: 28px 90px 1fr 100px auto; gap: 8px; align-items: center; padding: 4px 0; font-size: 13px; }
270
+ .lrow .lhat { font-size: 20px; }
271
+ .lbar { height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; }
272
+ .lbar i { display: block; height: 100%; background: #ffd66b; }
273
+ @media (prefers-reduced-motion: reduce) { .harvest .hbig, .harvest .hcard, .lvup { animation: none } }
274
+ .page a, .tierbox a, .drawer a { color: var(--ok); }
275
+ .tiers { display: flex; flex-direction: column; gap: 10px; }
276
+ .tier { display: grid; grid-template-columns: 70px minmax(220px, 1fr) 150px minmax(160px, 1fr); gap: 12px; align-items: end; }
277
+ .tier .diff { align-self: center; justify-self: start; font-size: 12px; padding: 2px 10px; }
278
+ .tier .field { margin-bottom: 0; }
279
+ .tier .tierhelp { align-self: center; }
280
+ @media (max-width: 900px) { .tier { grid-template-columns: 70px 1fr; } .tier .tierhelp { grid-column: 1 / -1; } }
281
+ .switch { display: flex; align-items: center; gap: 8px; margin: 12px 0 4px; }
282
+ .switch input { width: 18px; height: 18px; accent-color: var(--accent); }
283
+ .tier .quota { grid-column: 2 / -1; font-size: 12.5px; }
284
+ .tier .quota summary { cursor: pointer; color: var(--dim); }
285
+ .tier .quota .qsum { font-family: var(--mono); font-size: 11.5px; color: var(--wait); }
286
+ .qgrid { display: grid; grid-template-columns: repeat(4, minmax(90px, 1fr)) 1.4fr 1.4fr 130px; gap: 8px; margin: 8px 0 4px; align-items: end; }
287
+ .qgrid .field { margin-bottom: 0; }
288
+ @media (max-width: 1100px) { .qgrid { grid-template-columns: repeat(4, 1fr); } }
289
+ .qmeter { display: inline-flex; align-items: center; gap: 6px; margin-left: 10px; font-family: var(--mono); font-size: 11px; color: var(--dim); }
290
+ .qmeter .bar { width: 60px; height: 5px; border-radius: 3px; background: var(--line); overflow: hidden; display: inline-block; }
291
+ .qmeter .bar i { display: block; height: 100%; background: var(--ok); }
292
+ .qmeter.over { color: var(--busy); } .qmeter.over .bar i { background: var(--busy); }
293
+ .tierbox { border: 1px solid var(--line); border-radius: 8px; padding: 10px 14px; margin: 4px 0 14px; background: var(--ink-2); }
294
+ .tierbox summary { cursor: pointer; }
295
+ ul.plain { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; }
296
+ /* card completion events */
297
+ .cardev { margin: 12px 0 4px 56px; max-width: 900px; border: 1px solid color-mix(in srgb, var(--fg, var(--ok)) 45%, var(--line)); border-left: 3px solid var(--fg, var(--ok)); border-radius: 10px; background: var(--panel); padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; }
298
+ .cardev.sysev { --fg: #8fe0a0; }
299
+ .cardev .ceh { display: flex; gap: 10px; align-items: center; margin-left: -56px; }
300
+ .cardev .ceh .av { width: 44px; height: 44px; border-radius: 10px; flex: none; }
301
+ .cardev .ceh .cebig { width: 44px; text-align: center; font-size: 26px; flex: none; }
302
+ .cardev .cet { display: flex; flex-direction: column; min-width: 0; font-size: 12.5px; color: var(--dim); }
303
+ .cardev .cet .who { font-family: var(--mono); color: var(--fg, var(--text)); }
304
+ .cardev .cet .stage { font-family: var(--mono); font-size: 11px; color: var(--wait); border: 1px solid #4a3d7a; border-radius: 10px; padding: 0 7px; }
305
+ .cardev .cettl { font-size: 14px; color: var(--text); font-weight: 500; }
306
+ .cechips { display: flex; flex-wrap: wrap; gap: 6px; }
307
+ .cechips > span { font-family: var(--mono); font-size: 11px; color: var(--dim); background: var(--ink-2); border: 1px solid var(--line); border-radius: 12px; padding: 1px 8px; }
308
+ .cechips .warnc { color: var(--wait); border-color: #4a3d7a; }
309
+ .add { color: #8fe0a0; } .del { color: #ffb0a6; }
310
+ .cesum { font-size: 13px; white-space: pre-wrap; color: var(--dim); }
311
+ .cesum.long { max-height: 6.5em; overflow: hidden; -webkit-mask-image: linear-gradient(#000 60%, transparent); mask-image: linear-gradient(#000 60%, transparent); }
312
+ .shots { display: flex; gap: 8px; flex-wrap: wrap; }
313
+ .shots img { height: 96px; max-width: 180px; object-fit: cover; border-radius: 6px; border: 1px solid var(--line-2); cursor: zoom-in; background: var(--ink); }
314
+ .cefiles summary { cursor: pointer; font-size: 12.5px; color: var(--dim); }
315
+ .difflist { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; }
316
+ .dfile { border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
317
+ .dfile summary { cursor: pointer; padding: 4px 8px; background: var(--ink-2); font-size: 12px; display: flex; gap: 8px; align-items: baseline; }
318
+ .fbadge { font-family: var(--mono); font-size: 10px; border: 1px solid var(--line-2); border-radius: 8px; padding: 0 6px; color: #8fe0a0; }
319
+ .fbadge.del { color: #ffb0a6; } .fbadge.warn { color: var(--busy); border-color: #6b5a2a; }
320
+ pre.diff { margin: 0; padding: 8px 10px; font-family: var(--mono); font-size: 11.5px; line-height: 1.45; max-height: 50vh; overflow: auto; background: var(--ink); white-space: pre; }
321
+ pre.diff .a { color: #8fe0a0; background: rgba(80,160,100,.12); display: inline-block; min-width: 100%; }
322
+ pre.diff .r { color: #ffb0a6; background: rgba(200,80,70,.12); display: inline-block; min-width: 100%; }
323
+ pre.diff .at { color: var(--ok); } pre.diff .h { color: var(--faint); }
324
+ .lightbox { position: fixed; inset: 0; z-index: 70; background: rgba(0,0,0,.8); display: grid; place-items: center; cursor: zoom-out; animation: rise .15s ease-out; }
325
+ .lightbox img { max-width: 94vw; max-height: 88vh; border-radius: 8px; box-shadow: 0 20px 60px rgba(0,0,0,.6); }
326
+ .lightbox button { position: absolute; top: 16px; right: 16px; }
327
+ .benchgrid { display: grid; grid-template-columns: minmax(320px, 1fr) minmax(380px, 1.2fr); gap: 16px; margin-top: 12px; align-items: start; }
328
+ @media (max-width: 1100px) { .benchgrid { grid-template-columns: 1fr; } }
329
+ .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; min-width: 0; }
330
+ .panel h3 { margin: 0 0 10px; font-size: 13px; }
331
+ .benchout { margin-top: 12px; border: 1px solid var(--line-2); border-radius: 8px; overflow: hidden; animation: rise .2s ease-out; }
332
+ .benchout.ok { border-color: color-mix(in srgb, var(--ok) 60%, var(--line)); }
333
+ .benchout.bad { border-color: var(--error); }
334
+ .benchout .bh { display: flex; gap: 10px; align-items: baseline; padding: 6px 10px; background: var(--ink-2); font-size: 12px; }
335
+ .benchout.bad .bh b { color: var(--error); }
336
+ .benchout pre { margin: 0; padding: 10px; font-family: var(--mono); font-size: 12px; white-space: pre-wrap; word-break: break-word; max-height: 45vh; overflow: auto; }
337
+ .benchout pre.log { color: var(--dim); border-top: 1px solid var(--line); }
338
+ .tabs button[aria-pressed="true"] { background: var(--panel-2); border-color: var(--line-2); color: var(--text); }
339
+
340
+ /* ---------- drawer + dialogs ---------- */
341
+ .scrim { position: fixed; inset: 0; background: rgba(8,9,11,.55); z-index: 30; }
342
+ .drawer { position: fixed; top: 0; right: 0; bottom: 0; width: min(760px, 96vw); z-index: 31; background: var(--panel); border-left: 1px solid var(--line-2); display: flex; flex-direction: column; box-shadow: -20px 0 60px rgba(0,0,0,.45); animation: slide .16s ease-out; }
343
+ @keyframes slide { from { transform: translateX(24px); opacity: .6 } }
344
+ @media (prefers-reduced-motion: reduce) { .drawer { animation: none } }
345
+ .drawer .dh { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--line); }
346
+ .drawer .dh img { width: 48px; height: 48px; border-radius: 10px; }
347
+ .drawer .dh .h { font-family: var(--mono); font-size: 15px; }
348
+ .drawer .dh .s { font-size: 12px; color: var(--dim); }
349
+ .drawer .dh .acts { margin-left: auto; display: flex; gap: 6px; }
350
+ .drawer .kv { display: flex; gap: 14px; flex-wrap: wrap; padding: 8px 16px; border-bottom: 1px solid var(--line); font-family: var(--mono); font-size: 11.5px; color: var(--dim); }
351
+ .drawer .kv b { color: var(--text); font-weight: 500; }
352
+ .drawer .body { flex: 1; overflow-y: auto; padding: 16px; }
353
+ .tune { padding: 10px 16px; border-bottom: 1px solid var(--line); background: var(--ink-2); animation: rise .2s ease-out; }
354
+ .tune .tuneh { display: flex; align-items: baseline; gap: 10px; margin-bottom: 8px; }
355
+ .tune .askf { display: flex; align-items: center; gap: 8px; }
356
+ .tune .askf .note { margin-right: auto; }
357
+ .rbot .ovr { color: var(--wait); }
358
+ .transcript { flex: 1; overflow-y: auto; padding: 14px 16px; display: flex; flex-direction: column; gap: 12px; }
359
+ .turn { display: flex; gap: 10px; }
360
+ .turn img, .turn .avdot { width: 40px; height: 40px; border-radius: 9px; flex: none; }
361
+ .turn .avdot { background: #1c4461; border: 1px solid #3a7aa6; }
362
+ .turn .c { min-width: 0; flex: 1; }
363
+ .turn .m { font-family: var(--mono); font-size: 11px; color: var(--faint); margin-bottom: 2px; }
364
+ .turn .b { font-size: 13.5px; white-space: pre-wrap; word-break: break-word; }
365
+ .turn .b.long { max-height: 8.5em; overflow: hidden; -webkit-mask-image: linear-gradient(#000 70%, transparent); mask-image: linear-gradient(#000 70%, transparent); }
366
+ .turn details > summary { cursor: pointer; list-style: none; }
367
+ .turn details > summary::-webkit-details-marker { display: none; }
368
+ .turn .lbl { display: block; font-family: var(--mono); font-size: 11px; color: var(--faint); margin-top: 3px; }
369
+ .turn details.more > summary .less, .turn details.more[open] > summary .b, .turn details.more[open] > summary .lbl:not(.less) { display: none; }
370
+ .turn details.more[open] > summary .less { display: block; }
371
+ .turn details.think > div { font-size: 12.5px; color: var(--dim); font-style: italic; white-space: pre-wrap; border-left: 2px solid var(--line-2); padding-left: 8px; margin-top: 3px; }
372
+ .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; }
373
+ .turn .call .nm { color: var(--busy); flex: none; }
374
+ .turn .call .sm { color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; flex: 1; }
375
+ .turn .call .st { color: var(--faint); flex: none; }
376
+ .turn .call.s-running .st, .turn .call.s-pending .st { color: var(--busy); }
377
+ .turn .call.s-error { border-color: var(--error); } .turn .call.s-error .st { color: var(--error); }
378
+ .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; }
379
+ .working { font-family: var(--mono); font-size: 12px; color: var(--busy); padding-left: 50px; }
380
+ .working i { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--busy); margin-right: 7px; animation: pulse 1.2s ease-in-out infinite; }
381
+ @keyframes pulse { 50% { opacity: .25 } }
382
+ @media (prefers-reduced-motion: reduce) { .working i { animation: none } }
383
+ .cardbar { border-bottom: 1px solid var(--line); background: var(--ink-2); padding: 8px 16px; display: flex; flex-direction: column; gap: 8px; max-height: 50vh; overflow-y: auto; }
384
+ .cardbar .cb1 { display: flex; align-items: center; gap: 10px; min-width: 0; flex-wrap: wrap; }
385
+ .cardbar .stage { font-family: var(--mono); font-size: 11px; color: var(--wait); }
386
+ .cardbar .ct { font-weight: 600; font-size: 13.5px; min-width: 0; }
387
+ .cardbar .acts { margin-left: auto; display: flex; gap: 6px; align-items: center; }
388
+ .cardbar select { font-size: 12px; padding: 3px 6px; }
389
+ .cardbar .chip.st-doing { color: var(--busy); border-color: var(--busy); }
390
+ .cardbar .chip.st-needs { color: var(--wait); border-color: var(--wait); }
391
+ .cardbar .chip.st-done { color: var(--ok); border-color: #2c6a8c; }
392
+ .cardbar .cb-details { border-top: 1px dashed var(--line); padding-top: 10px; }
393
+ .x-asks { display: flex; flex-direction: column; gap: 8px; padding: 0 16px; }
394
+ .x-asks:not(:empty) { padding: 10px 16px; border-top: 1px solid var(--line); max-height: 45vh; overflow-y: auto; }
395
+ .perm-ask { border-color: var(--busy); }
396
+ .perm-ask .askh b { color: var(--busy); }
397
+ .perm-detail { margin: 0; font-family: var(--mono); font-size: 12px; background: var(--ink); border: 1px solid var(--line); border-radius: 4px; padding: 6px 8px; white-space: pre-wrap; word-break: break-all; max-height: 10em; overflow: auto; }
398
+ .sec { margin-bottom: 18px; }
399
+ .sec h3 { font-size: 11px; text-transform: uppercase; letter-spacing: .07em; color: var(--faint); margin: 0 0 6px; font-weight: 600; }
400
+ .sec .pre { white-space: pre-wrap; font-size: 13px; }
401
+ .sec ul { margin: 0; padding-left: 18px; }
402
+ dialog { background: var(--panel); color: var(--text); border: 1px solid var(--line-2); border-radius: 10px; padding: 0; width: min(620px, 94vw); box-shadow: 0 30px 80px rgba(0,0,0,.55); }
403
+ dialog::backdrop { background: rgba(4,8,14,.6); }
404
+ dialog .dhd { padding: 14px 18px 0; display: flex; align-items: center; }
405
+ dialog .dhd h2 { font-size: 16px; margin: 0; }
406
+ dialog .dbd { padding: 14px 18px; }
407
+ dialog .dft { padding: 12px 18px 16px; display: flex; gap: 8px; justify-content: flex-end; align-items: center; }
408
+ dialog .dft .err { margin-right: auto; }
409
+ .fs { border: 1px solid var(--line); border-radius: 6px; height: 320px; overflow-y: auto; background: var(--ink-2); }
410
+ .fs button { display: flex; width: 100%; gap: 8px; align-items: center; border: 0; border-radius: 0; background: transparent; text-align: left; padding: 6px 10px; font-family: var(--mono); font-size: 12.5px; }
411
+ .fs button:hover { background: var(--panel-2); }
412
+ .fs .git { margin-left: auto; font-size: 10.5px; color: var(--ok); }
413
+ .pathbar { display: flex; gap: 6px; margin-bottom: 8px; }
414
+ .pathbar input { flex: 1; font-family: var(--mono); font-size: 12.5px; }
415
+ .svcs { display: flex; flex-direction: column; gap: 4px; max-height: 150px; overflow-y: auto; border: 1px solid var(--line); border-radius: 6px; padding: 6px 10px; background: var(--ink-2); }
416
+ .svcs label { display: flex; gap: 8px; align-items: center; font-family: var(--mono); font-size: 12px; }
417
+ .svcs label [data-unlink] { margin-left: auto; padding: 0 6px; }
418
+ .addrepo { display: flex; gap: 6px; margin-top: 6px; }
419
+ .aptab { width: 100%; border-collapse: collapse; font-size: 13px; }
420
+ .aptab th, .aptab td { text-align: left; vertical-align: top; padding: 7px 6px; border-top: 1px solid var(--line); }
421
+ .aptab summary { cursor: pointer; }
422
+ .apb { font-size: 12px; white-space: nowrap; }
423
+ .apb.ok { color: var(--ok); } .apb.busy { color: var(--busy); } .apb.err { color: var(--error); } .apb.dim { color: var(--faint); }
424
+ .warnbox { border: 1px solid var(--busy); color: var(--busy); border-radius: 6px; padding: 6px 10px; margin-bottom: 8px; font-size: 13px; }
425
+ .addrepo input { flex: 1; }
426
+ .stageprev { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 6px; }
427
+ .empty { color: var(--faint); font-size: 13px; padding: 10px 2px; }
428
+ @media (max-width: 760px) { .app { grid-template-columns: 1fr; } aside.side { display: none; } .board { grid-template-columns: repeat(4, 80vw); } }
429
+
430
+ /* ---------- workstream: bots down the left, like a DM list ---------- */
431
+ .wlayout { flex: 1; min-height: 0; display: grid; grid-template-columns: 272px 1fr; }
432
+ .wmain { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
433
+ .rail { border-right: 1px solid var(--line); background: var(--ink-2); display: flex; flex-direction: column; min-height: 0; }
434
+ .rail .rh { display: flex; align-items: center; gap: 8px; padding: 12px 14px 8px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--faint); }
435
+ .rail .rh button { margin-left: auto; }
436
+ .rail .list { flex: 1; overflow-y: auto; padding: 0 8px 12px; display: flex; flex-direction: column; gap: 2px; }
437
+ .rail .grp { font-size: 10.5px; color: var(--faint); font-family: var(--mono); margin: 10px 8px 4px; display: flex; align-items: center; gap: 6px; }
438
+ .rail .grp::after { content: ""; flex: 1; height: 1px; background: var(--line); }
439
+ .rbot { display: grid; grid-template-columns: 44px 1fr; gap: 10px; align-items: center; padding: 7px 8px; border-radius: 10px; cursor: pointer; border: 1px solid transparent; position: relative; --fg: var(--line-2); transition: background .15s, border-color .15s, transform .15s; }
440
+ .rbot:hover { background: var(--panel); border-color: var(--line); }
441
+ .rbot .ava { position: relative; width: 44px; height: 44px; }
442
+ .rbot .ava img { width: 44px; height: 44px; border-radius: 50%; display: block; box-shadow: 0 0 0 2px var(--fg); }
443
+ .rbot .ava .pip { position: absolute; right: -1px; bottom: -1px; width: 13px; height: 13px; border-radius: 50%; border: 2.5px solid var(--ink-2); background: var(--idle); }
444
+ .rbot.busy .ava .pip { background: var(--busy); } .rbot.waiting .ava .pip { background: var(--wait); } .rbot.error .ava .pip { background: var(--error); }
445
+ .rbot.busy .ava::before { content: ""; position: absolute; inset: -5px; border-radius: 50%; background: conic-gradient(var(--busy) 0 25%, transparent 25% 50%, var(--busy) 50% 75%, transparent 75%); -webkit-mask: radial-gradient(circle, transparent 25px, #000 26px); mask: radial-gradient(circle, transparent 25px, #000 26px); animation: spin 2.4s linear infinite; }
446
+ .rbot.waiting .ava::before { content: ""; position: absolute; inset: -4px; border-radius: 50%; border: 2px solid var(--wait); animation: pulse 1.4s ease-in-out infinite; }
447
+ @keyframes spin { to { transform: rotate(360deg) } }
448
+ .rbot .who { min-width: 0; display: flex; flex-direction: column; line-height: 1.25; }
449
+ .rbot .top { display: flex; align-items: baseline; gap: 6px; min-width: 0; }
450
+ .rbot .h { font-family: var(--mono); font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
451
+ .rbot.busy .h { color: var(--busy); }
452
+ .rbot .kind { font-size: 10.5px; color: var(--fg); border: 1px solid color-mix(in srgb, var(--fg) 55%, transparent); border-radius: 10px; padding: 0 6px; white-space: nowrap; flex: none; }
453
+ .rbot .d { font-size: 12px; color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
454
+ .rbot.error .d { color: var(--error); }
455
+ .rbot.waiting .d { color: var(--wait); }
456
+ .rbot .stats { display: flex; gap: 8px; font-family: var(--mono); font-size: 10.5px; color: var(--faint); margin-top: 1px; flex-wrap: wrap; }
457
+ .rbot .stats b { color: var(--dim); font-weight: 500; }
458
+ .rbot .stats .bad { color: var(--error); }
459
+ .rbot .model { font-family: var(--mono); font-size: 10.5px; color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
460
+ .rbot .unread { position: absolute; right: 8px; top: 8px; min-width: 8px; height: 8px; border-radius: 50%; background: var(--wait); }
461
+ .viewtabs { display: flex; gap: 2px; background: var(--ink); border: 1px solid var(--line); border-radius: 8px; padding: 2px; }
462
+ .viewtabs button { border: 0; background: transparent; padding: 3px 12px; font-size: 12px; color: var(--dim); border-radius: 6px; }
463
+ .viewtabs button[aria-pressed="true"] { background: var(--panel-2); color: var(--text); box-shadow: 0 1px 0 rgba(255,255,255,.06) inset; }
464
+
465
+ /* budgets */
466
+ .budget { display: inline-flex; align-items: center; gap: 10px; font-family: var(--mono); font-size: 11px; color: var(--dim); border: 1px solid var(--line-2); border-radius: 20px; padding: 2px 4px 2px 10px; }
467
+ .budget .m { display: inline-flex; align-items: center; gap: 5px; }
468
+ .budget .bar { width: 44px; height: 5px; border-radius: 3px; background: var(--line); overflow: hidden; }
469
+ .budget .bar i { display: block; height: 100%; background: var(--ok); border-radius: 3px; transition: width .6s ease; }
470
+ .budget .bar i.warn { background: var(--busy); } .budget .bar i.over { background: var(--error); }
471
+ .budget button { padding: 1px 8px; font-size: 11px; border-radius: 12px; }
472
+ .paused { margin: 0 20px; padding: 9px 12px; border: 1px solid var(--busy); border-radius: 8px; background: color-mix(in srgb, var(--busy) 12%, var(--panel)); display: flex; align-items: center; gap: 10px; font-size: 13px; animation: rise .25s ease-out; }
473
+ .paused b { color: var(--busy); }
474
+ .paused .acts { margin-left: auto; display: flex; gap: 6px; }
475
+ .card .usage { height: 4px; border-radius: 2px; background: var(--line); overflow: hidden; }
476
+ .card .usage i { display: block; height: 100%; background: var(--busy); transition: width .6s ease; }
477
+ .card .usage i.over { background: var(--error); }
478
+ .card .pieces { display: flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: 11px; color: var(--dim); }
479
+ .card .pieces .track { flex: 1; height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; display: flex; }
480
+ .card .pieces .track i { height: 100%; background: var(--ok); transition: width .6s ease; }
481
+ .card .pieces .track i.run { background: var(--busy); }
482
+ .card .part { font-family: var(--mono); font-size: 10.5px; color: var(--faint); }
483
+ .card.group { border-style: dashed; }
484
+ .card .avs { display: flex; margin-left: auto; }
485
+ .card .avs img { width: 22px; height: 22px; border-radius: 50%; margin-left: -6px; box-shadow: 0 0 0 2px var(--panel); }
486
+ .ask.budget-ask { border-color: var(--busy); }
487
+ .ask.budget-ask .askh b { color: var(--busy); }
488
+ .limits { display: flex; gap: 8px; }
489
+ .limits input { width: 100%; font-family: var(--mono); font-size: 12.5px; }
490
+
491
+ /* motion: cards glide between columns, new things arrive, nothing jumps */
492
+ @keyframes rise { from { opacity: 0; transform: translateY(6px) } }
493
+ @keyframes pop { 0% { opacity: 0; transform: scale(.92) } 70% { transform: scale(1.02) } }
494
+ .card.enter { animation: pop .35s cubic-bezier(.2,.8,.3,1.2); }
495
+ .msg.enter { animation: rise .3s ease-out; }
496
+ .rbot.enter { animation: rise .35s ease-out; }
497
+ .card.flash { box-shadow: 0 0 0 2px var(--ok); transition: box-shadow .8s; }
498
+ .step { transition: color .3s, border-color .3s, background .3s; }
499
+ .step.active { background: color-mix(in srgb, var(--busy) 12%, transparent); }
500
+
501
+ /* ---------- the farm: the board as a place ---------- */
502
+ .farm { position: relative; overflow: hidden; min-height: 0; border-radius: 0; user-select: none;
503
+ background: linear-gradient(#26384a 0%, #2d4658 38%, #32503b 38.2%, #2c4633 100%); }
504
+ .farm .sun { position: absolute; top: 14px; right: 60px; width: 42px; height: 42px; border-radius: 50%; background: radial-gradient(#ffe7a3, #ffc75a 60%, transparent 62%); box-shadow: 0 0 40px 10px rgba(255,200,90,.25); animation: bob 6s ease-in-out infinite; }
505
+ .farm .cloud { position: absolute; top: 22px; width: 90px; height: 22px; border-radius: 20px; background: rgba(255,255,255,.14); animation: drift 60s linear infinite; }
506
+ .farm .cloud::before { content: ""; position: absolute; left: 18px; top: -12px; width: 40px; height: 28px; border-radius: 50%; background: inherit; }
507
+ @keyframes drift { from { transform: translateX(-120px) } to { transform: translateX(110vw) } }
508
+ @keyframes bob { 50% { transform: translateY(-4px) } }
509
+ .farm .zone { position: absolute; border-radius: 12px; display: flex; flex-direction: column; }
510
+ .farm .zone .zl { position: absolute; left: 10px; top: 8px; font-family: var(--mono); font-size: 11px; color: rgba(255,255,255,.85); text-shadow: 0 1px 2px rgba(0,0,0,.6); display: flex; gap: 6px; align-items: center; z-index: 1; }
511
+ .farm .zone .zl i { font-style: normal; font-size: 16px; }
512
+ .farm .shed { background: rgba(120,84,52,.35); border: 2px solid rgba(160,112,70,.6); }
513
+ .farm .field { background: repeating-linear-gradient(90deg, rgba(92,64,38,.55) 0 18px, rgba(80,55,32,.55) 18px 22px); border: 2px solid rgba(140,100,60,.5); }
514
+ .farm .barn { background: rgba(150,52,52,.32); border: 2px solid rgba(200,90,80,.6); }
515
+ .farm .silo { background: rgba(170,170,160,.18); border: 2px solid rgba(210,210,200,.45); }
516
+ .farm .house { background: rgba(60,80,110,.35); border: 2px dashed rgba(150,180,220,.45); }
517
+ .farm .crop { position: absolute; width: 58px; text-align: center; transition: left 1.1s cubic-bezier(.4,.1,.2,1), top 1.1s cubic-bezier(.4,.1,.2,1), opacity .4s; cursor: pointer; z-index: 2; }
518
+ .farm .crop .g { font-size: 26px; line-height: 30px; display: block; filter: drop-shadow(0 2px 2px rgba(0,0,0,.4)); transition: transform .3s; }
519
+ .farm .crop:hover .g { transform: scale(1.15); }
520
+ .farm .crop .t { display: block; font-size: 9.5px; color: #fff; text-shadow: 0 1px 2px #000; line-height: 1.15; max-height: 2.3em; overflow: hidden; }
521
+ .farm .crop .grow { display: block; margin: 2px auto 0; width: 40px; height: 4px; border-radius: 2px; background: rgba(0,0,0,.35); overflow: hidden; }
522
+ .farm .crop .grow i { display: block; height: 100%; background: #9be15d; transition: width .8s; }
523
+ .farm .crop.active .g { animation: sway 2.4s ease-in-out infinite; transform-origin: bottom center; }
524
+ .farm .crop.needs .g::after { content: "!"; }
525
+ @keyframes sway { 50% { transform: rotate(6deg) } }
526
+ .farm .fbot { position: absolute; width: 42px; height: 42px; z-index: 4; transition: left 1.6s cubic-bezier(.45,.05,.25,1), top 1.6s cubic-bezier(.45,.05,.25,1); cursor: pointer; }
527
+ .farm .fbot img { width: 42px; height: 42px; border-radius: 50%; box-shadow: 0 0 0 3px var(--fg, #fff), 0 6px 10px rgba(0,0,0,.45); display: block; background: var(--panel); }
528
+ .farm .fbot .nm { position: absolute; top: 45px; left: 50%; transform: translateX(-50%); font-family: var(--mono); font-size: 10px; color: #fff; white-space: nowrap; text-shadow: 0 1px 2px #000; background: rgba(0,0,0,.55); padding: 0 5px; border-radius: 6px; opacity: 0; transition: opacity .15s; pointer-events: none; }
529
+ .farm .fbot:hover { z-index: 6; } .farm .fbot:hover .nm { opacity: 1; }
530
+ .farm .fbot.busy { animation: hop .7s ease-in-out infinite; }
531
+ .farm .fbot.walking img { animation: waddle .35s ease-in-out infinite; }
532
+ @keyframes hop { 50% { margin-top: -5px } }
533
+ @keyframes waddle { 25% { transform: rotate(-7deg) } 75% { transform: rotate(7deg) } }
534
+ .farm .fbot .say { position: absolute; top: -8px; left: 36px; background: #fff; color: #222; font-family: var(--mono); font-size: 9.5px; border-radius: 8px; padding: 1px 6px; white-space: nowrap; max-width: 110px; overflow: hidden; text-overflow: ellipsis; box-shadow: 0 2px 6px rgba(0,0,0,.35); }
535
+ .farm .fbot .say::after { content: ""; position: absolute; left: -3px; bottom: 1px; border: 4px solid transparent; border-right-color: #fff; border-left: 0; }
536
+ .farm .fbot.waiting .say { background: var(--wait); color: #1a1030; }
537
+ .farm .fbot.waiting .say::after { border-right-color: var(--wait); }
538
+ .farm .spark { position: absolute; width: 5px; height: 5px; border-radius: 50%; background: #ffd66b; box-shadow: 0 0 6px #ffd66b; animation: spark 1.4s ease-out forwards; pointer-events: none; z-index: 3; }
539
+ @keyframes spark { to { transform: translate(var(--dx), -34px); opacity: 0 } }
540
+ .farm .legend { position: absolute; left: 12px; bottom: 8px; font-family: var(--mono); font-size: 10.5px; color: rgba(255,255,255,.8); text-shadow: 0 1px 2px #000; }
541
+ @media (prefers-reduced-motion: reduce) {
542
+ .rbot.busy .ava::before, .farm .fbot.busy, .farm .fbot.walking img, .farm .crop.active .g, .farm .sun, .farm .cloud, .card.enter, .msg.enter, .rbot.enter { animation: none }
543
+ .farm .fbot, .farm .crop { transition: none }
544
+ }
545
+ .matrix{width:100%;border-collapse:collapse;font-size:13px}.matrix th,.matrix td{border-top:1px solid var(--line);padding:8px;text-align:left;vertical-align:top}.matrix td.own{background:color-mix(in srgb,var(--accent) 10%,transparent)}.matrix .chip{margin-top:4px}.matrix .chip.own{border-color:var(--accent);color:var(--accent)}.matrix .chip.dim{opacity:.6}
546
+ .tags .round{font-family:var(--mono);font-size:11px;color:var(--wait);border:1px solid var(--wait);border-radius:10px;padding:0 6px}.tags .round.chk{color:var(--busy);border-color:var(--busy)}
547
+ </style>
548
+ </head>
549
+ <body>
550
+ <div class="app">
551
+ <aside class="side">
552
+ <div class="brand"><b>BotFarm</b><span class="conn" id="conn">connecting…</span></div>
553
+ <button class="wspick" id="wspick" aria-expanded="false"><span class="n" id="ws-name">No folder</span><span class="p" id="ws-path"></span></button>
554
+ <div class="wsmenu" id="wsmenu" hidden></div>
555
+ <nav class="nav">
556
+ <h4>Workstreams <button class="ghost" id="new-stream" title="Start a workstream">+</button></h4>
557
+ <div id="nav-streams"></div>
558
+ <h4>Pipelines <button class="ghost" id="new-pipeline" title="New pipeline">+</button></h4>
559
+ <div id="nav-pipelines"></div>
560
+ <h4>Agents <button class="ghost" id="new-agent" title="New agent">+</button></h4>
561
+ <div id="nav-agents"></div>
562
+ <h4>Tools <button class="ghost" id="new-tool" title="New tool in botfarm/mcps/">+</button></h4>
563
+ <div id="nav-tools"></div>
564
+ <button class="item" id="nav-routing" data-k="routing"><span class="hash">⇅</span><span class="t">Model routing</span><span class="note mono" id="nav-routing-state"></span></button>
565
+ </nav>
566
+ <div class="foot">
567
+ <div><b id="f-busy">0</b><i>working</i></div>
568
+ <div><b id="f-tpm">0</b><i>tokens/min</i></div>
569
+ <div><b id="f-tok">0</b><i>tokens total</i></div>
570
+ <button class="spend" id="f-spend" title="Prices"><b id="f-cost">$0</b><i id="f-cost-l">spend</i></button>
571
+ <a href="/classic">Classic view — every session, adopt, rooms</a>
572
+ </div>
573
+ </aside>
574
+ <main id="main"></main>
575
+ </div>
576
+
577
+ <dialog id="d-folder">
578
+ <div class="dhd"><h2>Open a folder</h2></div>
579
+ <div class="dbd">
580
+ <div class="pathbar"><button class="sm" id="fs-up" title="Up one folder">↑</button><input id="fs-path" spellcheck="false" /><button class="sm" id="fs-go">Go</button></div>
581
+ <div class="fs" id="fs-list"></div>
582
+ <div class="note" id="fs-note" style="margin-top:8px">A workspace is a folder with its opencode config. BotFarm keeps its agents and pipelines in a <span class="mono">botfarm/</span> folder at its root, one file each (<span class="mono">&lt;id&gt;.agent.botfarm.yml</span>, <span class="mono">&lt;id&gt;.pipeline.botfarm.yml</span>), and its own tools in <span class="mono">botfarm/mcps/</span>. They are created if missing.</div>
583
+ </div>
584
+ <div class="dft"><span class="err" id="fs-err"></span><button id="fs-cancel">Cancel</button><button class="primary" id="fs-use">Use this folder</button></div>
585
+ </dialog>
586
+
587
+ <dialog id="d-stream">
588
+ <div class="dhd"><h2>Start a workstream</h2></div>
589
+ <div class="dbd">
590
+ <div class="field"><label for="ns-pipeline">Pipeline</label><select id="ns-pipeline"></select><div class="stageprev" id="ns-stages"></div></div>
591
+ <div class="field"><label for="ns-story">Story, defect or task</label>
592
+ <textarea id="ns-story" rows="7" placeholder="Paste the story with its acceptance criteria. Every bot gets all of it."></textarea></div>
593
+ <div class="row">
594
+ <div class="field"><label for="ns-title">Name</label><input id="ns-title" placeholder="DE238 no cc cancel after docs" /></div>
595
+ <div class="field"><label for="ns-branch">Branch (its own worktree)</label><input id="ns-branch" class="mono" placeholder="botfarm/de238-…" spellcheck="false" /></div>
596
+ </div>
597
+ <div class="field" id="ns-svc-field"><label>Repos to include in the worktree <span class="note">(each gets its own worktree on the same branch)</span></label><div class="svcs" id="ns-svcs"></div>
598
+ <div class="addrepo"><input id="ns-add-path" class="mono" placeholder="~/workspace/spt — a repo from anywhere" spellcheck="false" /><input id="ns-add-as" class="mono" placeholder="as (folder name)" spellcheck="false" style="max-width:150px" /><button class="sm" id="ns-add">Add repo</button></div></div>
599
+ <div class="field" id="ns-par-field" hidden><label>Bots working at once on split stages</label><div class="limits" id="ns-par"></div>
600
+ <div class="hint">A split stage turns the list the stage before it hands over into one card per item. This many bots of that kind take them at the same time, in the same worktree; the next stage waits for all of them.</div></div>
601
+ <div class="field"><label>Limits for the whole workstream <span class="note">(optional — it stops and asks you when it reaches one)</span></label><div id="ns-limits"></div></div>
602
+ </div>
603
+ <div class="dft"><span class="err" id="ns-err"></span><button id="ns-cancel">Cancel</button><button class="primary" id="ns-go">Start</button></div>
604
+ </dialog>
605
+
606
+ <dialog id="d-repos">
607
+ <div class="dhd"><h2>Add a repo to this workstream</h2></div>
608
+ <div class="dbd">
609
+ <p class="note" style="margin-top:0">It gets its own worktree on this workstream's branch, inside the workstream's worktree, and the bots are told where it is. A repo from outside the workspace is mounted under its folder name.</p>
610
+ <div class="field" id="ar-known-field"><label>From this workspace</label><div class="svcs" id="ar-known"></div></div>
611
+ <div class="field"><label for="ar-path">Or any repo</label><div class="addrepo"><input id="ar-path" class="mono" placeholder="~/workspace/spt" spellcheck="false" /><input id="ar-as" class="mono" placeholder="as (folder name)" spellcheck="false" style="max-width:150px" /></div>
612
+ <label class="switch" style="margin-top:6px"><input type="checkbox" id="ar-remember" checked /> also offer it for new workstreams in this workspace</label></div>
613
+ </div>
614
+ <div class="dft"><span class="err" id="ar-err"></span><button id="ar-cancel">Cancel</button><button class="primary" id="ar-go">Add</button></div>
615
+ </dialog>
616
+
617
+ <dialog id="d-card">
618
+ <div class="dhd"><h2>New card</h2></div>
619
+ <div class="dbd">
620
+ <div class="field"><label for="nc-title">Title</label><input id="nc-title" /></div>
621
+ <div class="field"><label for="nc-brief">What needs doing</label><textarea id="nc-brief" rows="5"></textarea></div>
622
+ <div class="field"><label for="nc-who">Who</label><select id="nc-who"></select></div>
623
+ </div>
624
+ <div class="dft"><span class="err" id="nc-err"></span><button id="nc-cancel">Cancel</button><button class="primary" id="nc-go">Add</button></div>
625
+ </dialog>
626
+
627
+ <dialog id="d-price">
628
+ <div class="dhd"><h2>Model prices</h2></div>
629
+ <div class="dbd">
630
+ <div class="note" style="margin-bottom:10px">opencode reports a cost only when the provider returns one (Bedrock does not), so BotFarm prices tokens itself. USD per million tokens; cache reads are charged at a tenth of input, cache writes at 1.25×. Saved in <span class="mono">~/.botfarm/config.json</span>.</div>
631
+ <div id="pr-rows"></div>
632
+ </div>
633
+ <div class="dft"><span class="err" id="pr-err"></span><button id="pr-cancel">Close</button><button class="primary" id="pr-save">Save</button></div>
634
+ </dialog>
635
+
636
+ <dialog id="d-restart">
637
+ <div class="dhd"><h2>Restart this workstream</h2></div>
638
+ <div class="dbd">
639
+ <div class="field"><label for="rs-stage">Start again from</label><select id="rs-stage"></select>
640
+ <div class="hint">Stages before it keep their handoffs. This one and everything after go back to waiting their turn.</div></div>
641
+ <label style="display:flex;gap:8px;align-items:flex-start"><input type="checkbox" id="rs-fresh" checked style="margin-top:4px" />
642
+ <span>Fresh bots<br><span class="note">New opencode sessions in the same worktree and branch, so changes to opencode.json, MCP servers or the agents take effect. The old sessions leave the board; their transcripts stay in opencode. Unticked, the same bots are told to pick up again.</span></span></label>
643
+ </div>
644
+ <div class="dft"><span class="err" id="rs-err"></span><button id="rs-cancel">Cancel</button><button class="primary" id="rs-go">Restart</button></div>
645
+ </dialog>
646
+
647
+ <dialog id="d-delete">
648
+ <div class="dhd"><h2>Delete <span id="dl-name"></span>?</h2></div>
649
+ <div class="dbd">
650
+ <p class="note" id="dl-what" style="margin-top:0"></p>
651
+ <div id="dl-dirty" hidden style="border:1px solid var(--busy);border-radius:8px;padding:10px 12px;margin:10px 0">
652
+ <b style="color:var(--busy)" id="dl-dirty-head"></b>
653
+ <div class="mono note" id="dl-files" style="margin:6px 0;font-size:12px"></div>
654
+ <label style="display:flex;gap:8px;align-items:flex-start"><input type="checkbox" id="dl-force" style="margin-top:4px" /><span>Delete them anyway</span></label>
655
+ </div>
656
+ <label style="display:flex;gap:8px;align-items:flex-start" id="dl-branch-row"><input type="checkbox" id="dl-branch" style="margin-top:4px" />
657
+ <span>Also delete branch <span class="mono" id="dl-branch-name"></span><br><span class="note" id="dl-branch-note"></span></span></label>
658
+ </div>
659
+ <div class="dft"><span class="err" id="dl-err"></span><button id="dl-cancel">Cancel</button><button class="primary danger" id="dl-go">Delete</button></div>
660
+ </dialog>
661
+
662
+ <dialog id="d-apply" style="width:min(820px,96vw)">
663
+ <div class="dhd"><h2>Apply the work to your repos</h2></div>
664
+ <div class="dbd">
665
+ <p class="note" style="margin-top:0">Each repo's changes (edits, new files, and anything committed on the branch) become a patch, applied to your real checkout on whatever branch it is on — <b>unstaged and uncommitted</b>, ready to review in your IDE. BotFarm's own files are left out. The .patch files are kept, so you can do this again later.</p>
666
+ <div id="ap-body"><div class="note">Working out the patches…</div></div>
667
+ </div>
668
+ <div class="dft"><span class="err" id="ap-err"></span><button id="ap-undo" hidden>Undo last apply</button><button id="ap-cancel">Close</button><button class="primary" id="ap-go" disabled>Apply</button></div>
669
+ </dialog>
670
+
671
+ <dialog id="d-limits">
672
+ <div class="dhd"><h2>Workstream limits</h2></div>
673
+ <div class="dbd">
674
+ <p class="note" style="margin-top:0">When the workstream reaches any of these, every bot on it stops and it waits for you. Leave a box empty for no limit. Cards can have their own limits too (set per stage in the pipeline).</p>
675
+ <div id="lm-body"></div>
676
+ <div class="note mono" id="lm-used"></div>
677
+ </div>
678
+ <div class="dft"><span class="err" id="lm-err"></span><button id="lm-cancel">Cancel</button><button class="primary" id="lm-go">Save</button></div>
679
+ </dialog>
680
+
681
+ <dialog id="d-bot">
682
+ <div class="dhd"><h2>Add a bot to this workstream</h2></div>
683
+ <div class="dbd"><div class="field"><label for="nb-agent">Agent</label><select id="nb-agent"></select>
684
+ <div class="hint">It joins the team, the chat and the worktree. Give it work with a card.</div></div></div>
685
+ <div class="dft"><span class="err" id="nb-err"></span><button id="nb-cancel">Cancel</button><button class="primary" id="nb-go">Add</button></div>
686
+ </dialog>
687
+ <script>
688
+ // ------------------------------------------------------------------ basics
689
+ const $ = (s, el = document) => el.querySelector(s)
690
+ const $$ = (s, el = document) => [...el.querySelectorAll(s)]
691
+ const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c])
692
+ const fmt = {
693
+ n(v) { if (!v) return "0"; if (v >= 1e6) return (v / 1e6).toFixed(1) + "M"; if (v >= 1e3) return (v / 1e3).toFixed(v >= 1e4 ? 0 : 1) + "k"; return String(Math.round(v)) },
694
+ money(v) { return "$" + (v ?? 0).toFixed(v && v < 1 ? 3 : 2) },
695
+ ago(ts) { if (!ts) return ""; const s = Math.max(0, Math.round((Date.now() - ts) / 1000)); if (s < 60) return s + "s"; if (s < 3600) return Math.floor(s / 60) + "m"; if (s < 86400) return Math.floor(s / 3600) + "h"; return Math.floor(s / 86400) + "d" },
696
+ ms(v) { return v == null ? "" : v < 1000 ? v + "ms" : (v / 1000).toFixed(1) + "s" },
697
+ time(ts) { return ts ? new Date(ts).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) : "" },
698
+ path(p) { return (p ?? "").replace(/^\/(Users|home)\/[^/]+/, "~") },
699
+ }
700
+ const avatarUrl = (s) => `/avatar/${encodeURIComponent(s.id)}.svg?h=${encodeURIComponent(s.handle ?? "")}`
701
+ /** "anthropic/claude-sonnet-4-5" + "high" → "claude-sonnet-4-5 · high". */
702
+ function modelName(s) {
703
+ const m = s.model ?? s.configuredModel
704
+ const short = m ? String(m).split("/").pop().replace(/^(us|eu|apac|global)\.(anthropic\.)?/, "").replace(/-\d{8}(-v\d+(:\d+)?)?$/, "") : "opencode default model"
705
+ return short + (s.variant ? ` · ${s.variant}` : "")
706
+ }
707
+ const slug = (s, max = 48) => {
708
+ const full = String(s ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
709
+ return full.length <= max ? full : full.slice(0, max).replace(/-[^-]*$/, "")
710
+ }
711
+ async function api(path, opts = {}) {
712
+ const res = await fetch(path, {
713
+ method: opts.method ?? "GET",
714
+ headers: opts.body ? { "content-type": "application/json" } : undefined,
715
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
716
+ })
717
+ const out = await res.json().catch(() => ({}))
718
+ if (!res.ok) throw new Error(out.error ?? res.statusText)
719
+ return out
720
+ }
721
+ const store = {
722
+ get(k, d) { try { return JSON.parse(localStorage.getItem("bf:" + k)) ?? d } catch { return d } },
723
+ set(k, v) { try { localStorage.setItem("bf:" + k, JSON.stringify(v)) } catch {} },
724
+ }
725
+
726
+ // ------------------------------------------------------------------ state
727
+ let state = { sessions: [], rooms: [], projects: [], tasks: [], workspaces: [], events: [], totals: {} }
728
+ let ui = { ws: null, kind: "home", id: null }
729
+ const libs = new Map() // workspace id -> library summary
730
+ let seen = store.get("seen", {}) // room id -> last message time the operator saw
731
+
732
+ function route() {
733
+ const [, ws, kind, id] = (location.hash.slice(1) || "").split("/").map((x) => decodeURIComponent(x || ""))
734
+ ui = { ws: ws || store.get("ws", null), kind: kind || "home", id: id || null }
735
+ try { if (drawer?.kind === "packet") closeDrawer(true) } catch {} // drawer is declared further down
736
+ const known = state.workspaces ?? []
737
+ if (known.length && !known.some((w) => w.id === ui.ws)) ui.ws = known[0].id
738
+ if (ui.ws) store.set("ws", ui.ws)
739
+ mainKey = null
740
+ render()
741
+ }
742
+ const go = (kind, id = null, ws = ui.ws) => { location.hash = `/${encodeURIComponent(ws ?? "")}/${kind}${id ? "/" + encodeURIComponent(id) : ""}` }
743
+ window.addEventListener("hashchange", route)
744
+
745
+ const currentWs = () => (state.workspaces ?? []).find((w) => w.id === ui.ws) ?? null
746
+ const streamsOf = (wsId) => (state.projects ?? [])
747
+ .filter((p) => p.id !== "default" && p.workspaceId === wsId)
748
+ .sort((a, b) => (a.status === "archived") - (b.status === "archived") || (b.created ?? 0) - (a.created ?? 0))
749
+ const tasksOf = (p) => (state.tasks ?? []).filter((t) => t.projectId === p.id && t.status !== "cancelled").sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
750
+ const roomOf = (p) => (state.rooms ?? []).find((r) => r.id === p.roomId) ?? null
751
+ const teamOf = (p) => (state.sessions ?? []).filter((s) => (s.project ?? "default") === p.id)
752
+ const sessionById = (id) => (state.sessions ?? []).find((s) => s.id === id)
753
+ const needsYou = (t) => !["done", "cancelled", "blocked"].includes(t.status) && (t.owner === "human" || t.status === "review" || t.status === "waiting")
754
+ const unreadOf = (p) => {
755
+ const room = roomOf(p)
756
+ if (!room) return 0
757
+ return room.messages.filter((m) => m.from !== "operator" && m.at > (seen[room.id] ?? 0)).length
758
+ }
759
+
760
+ async function library(wsId, fresh = false) {
761
+ if (!wsId) return null
762
+ if (!fresh && libs.has(wsId)) return libs.get(wsId)
763
+ const lib = await api(`/api/workspaces/${encodeURIComponent(wsId)}/library`)
764
+ libs.set(wsId, lib)
765
+ return lib
766
+ }
767
+
768
+ // ------------------------------------------------------------------ sidebar
769
+ let navSig = null
770
+ function renderSide() {
771
+ const ws = currentWs()
772
+ $("#ws-name").textContent = ws?.name ?? "Open a folder"
773
+ $("#ws-path").textContent = ws ? fmt.path(ws.path) : ""
774
+ const t = state.totals ?? {}
775
+ $("#f-busy").textContent = t.busy ?? 0
776
+ $("#f-tpm").textContent = fmt.n(t.tokPerMin)
777
+ $("#f-cost").textContent = fmt.money(t.usd)
778
+ $("#f-tok").textContent = fmt.n(t.tokens)
779
+ $("#f-cost-l").textContent = t.unpriced ? `spend · ${t.unknownModels?.length ?? 1} model(s) unpriced` : "spend"
780
+ $("#f-cost-l").className = t.unpriced ? "warn" : ""
781
+ $("#conn").textContent = state.serverStatus ?? ""
782
+ const lib = ws ? libs.get(ws.id) : null
783
+ const streams = ws ? streamsOf(ws.id) : []
784
+ const wt = ws ? state.tools?.[ws.id] : null
785
+ const sig = JSON.stringify([ui, ws?.id, streams.map((p) => [p.id, p.name, p.status, p.busy, unreadOf(p), tasksOf(p).filter(needsYou).length]), lib?.pipelines?.map((p) => [p.id, p.title]), lib?.agents?.map((a) => [a.id, a.title]), wt?.tools?.map((t) => t.file + t.name), wt?.errors?.map((e) => e.file)])
786
+ if (sig === navSig) return
787
+ navSig = sig
788
+ const cur = (kind, id) => ui.kind === kind && ui.id === id
789
+ $("#nav-streams").innerHTML = streams.length
790
+ ? streams.map((p) => {
791
+ const needs = tasksOf(p).filter(needsYou).length
792
+ const unread = unreadOf(p)
793
+ const dot = p.status === "archived" ? "" : p.busy ? "busy" : needs ? "waiting" : p.status === "done" ? "done" : ""
794
+ return `<button class="item ${p.status === "archived" ? "archived" : ""}" data-k="stream" data-id="${esc(p.id)}" aria-current="${cur("stream", p.id)}">
795
+ <span class="dot ${dot}"></span><span class="t">${esc(p.name)}</span>
796
+ ${needs ? `<span class="badge" title="needs you">${needs}</span>` : ""}${unread ? `<span class="badge msg" title="unread">${unread}</span>` : ""}</button>`
797
+ }).join("")
798
+ : `<div class="note" style="padding:2px 16px">None yet.</div>`
799
+ $("#nav-pipelines").innerHTML = (lib?.pipelines ?? []).map((p) =>
800
+ `<button class="item" data-k="pipeline" data-id="${esc(p.id)}" aria-current="${cur("pipeline", p.id)}"><span class="hash">▸</span><span class="t">${esc(p.title ?? p.id)}</span></button>`).join("")
801
+ || `<div class="note" style="padding:2px 16px">${ws ? "Loading…" : ""}</div>`
802
+ $("#nav-agents").innerHTML = (lib?.agents ?? []).map((a) =>
803
+ `<button class="item" data-k="agent" data-id="${esc(a.id)}" aria-current="${cur("agent", a.id)}"><span class="hash">@</span><span class="t">${esc(a.title ?? a.id)}</span></button>`).join("")
804
+ $("#nav-tools").innerHTML = [
805
+ ...(wt?.tools ?? []).map((t) => `<button class="item" data-k="tool" data-id="${esc(t.file)}" aria-current="${cur("tool", t.file)}" title="${esc(t.description)}"><span class="hash">ƒ</span><span class="t mono">${esc(t.name)}</span></button>`),
806
+ ...(wt?.errors ?? []).map((e) => `<button class="item" data-k="tool" data-id="${esc(e.file)}" aria-current="${cur("tool", e.file)}" title="${esc(e.error)}"><span class="hash" style="color:var(--error)">!</span><span class="t mono" style="color:var(--error)">${esc(e.file)}</span></button>`),
807
+ ].join("") || `<div class="note" style="padding:2px 16px">${ws ? "Drop .js files in botfarm/mcps/" : ""}</div>`
808
+ for (const b of $$(".nav .item")) b.onclick = () => go(b.dataset.k, b.dataset.id)
809
+ $("#nav-routing").setAttribute("aria-current", String(ui.kind === "routing"))
810
+ $("#nav-routing").onclick = () => go("routing")
811
+ }
812
+ $("#wspick").onclick = () => {
813
+ const menu = $("#wsmenu")
814
+ menu.hidden = !menu.hidden
815
+ $("#wspick").setAttribute("aria-expanded", String(!menu.hidden))
816
+ if (menu.hidden) return
817
+ menu.innerHTML = (state.workspaces ?? []).map((w) =>
818
+ `<button data-ws="${esc(w.id)}" aria-current="${w.id === ui.ws}">${esc(w.name)} <span class="note mono">${esc(fmt.path(w.path))}</span></button>`).join("") +
819
+ `<button id="ws-open">Open folder…</button>` +
820
+ (currentWs() ? `<button id="ws-forget" class="note">Remove ${esc(currentWs().name)} from this list</button>` : "")
821
+ for (const b of $$("[data-ws]", menu)) b.onclick = () => { menu.hidden = true; go("home", null, b.dataset.ws) }
822
+ $("#ws-open").onclick = () => { menu.hidden = true; openFolder() }
823
+ if ($("#ws-forget")) $("#ws-forget").onclick = async () => {
824
+ if (!confirm("Remove this folder from BotFarm? Nothing on disk is deleted.")) return
825
+ menu.hidden = true
826
+ await api(`/api/workspaces/${encodeURIComponent(ui.ws)}`, { method: "DELETE" })
827
+ store.set("ws", null)
828
+ go("home", null, "")
829
+ }
830
+ }
831
+ $("#new-stream").onclick = () => openNewStream()
832
+ $("#new-pipeline").onclick = () => go("pipeline", "+new")
833
+ $("#new-agent").onclick = () => go("agent", "+new")
834
+ $("#new-tool").onclick = async () => {
835
+ if (!ui.ws) return
836
+ const name = prompt("Tool name (letters, digits, _ and -). Bots will see it as botfarm_<name>.", "my_tool")
837
+ if (!name) return
838
+ try {
839
+ const out = await api(`/api/workspaces/${encodeURIComponent(ui.ws)}/tools`, { method: "POST", body: { name: name.trim() } })
840
+ go("tool", out.file)
841
+ } catch (e) { alert(e.message) }
842
+ }
843
+
844
+ // ------------------------------------------------------------------ main
845
+ let mainKey = null
846
+ function render() {
847
+ renderSide()
848
+ checkHarvests()
849
+ if (ui.kind !== "replay" && rp) { clearInterval(rp.timer); rp = null }
850
+ const ws = currentWs()
851
+ const key = `${ui.ws}|${ui.kind}|${ui.id}`
852
+ const main = $("#main")
853
+ if (!ws) {
854
+ if (mainKey !== "none") {
855
+ mainKey = "none"
856
+ main.innerHTML = `<div class="page"><div class="hero"><h1>Open a folder to start</h1>
857
+ <p class="lead">A workspace is a folder and its opencode config. Its agents and pipelines live in two YAML files at its root.</p>
858
+ <button class="primary" id="hero-open">Open a folder…</button></div></div>`
859
+ $("#hero-open").onclick = openFolder
860
+ }
861
+ return
862
+ }
863
+ if (!libs.has(ws.id)) library(ws.id).then(() => { mainKey = null; navSig = null; render() }).catch(() => {})
864
+ if (ui.kind === "stream") return renderStream(key)
865
+ if (ui.kind === "pipeline") return mainKey === key ? null : renderPipeline(key)
866
+ if (ui.kind === "agent") return mainKey === key ? null : renderAgent(key)
867
+ if (ui.kind === "tool") return renderTool(key)
868
+ if (ui.kind === "replay") return renderReplay(key)
869
+ if (ui.kind === "routing") return mainKey === key ? null : renderRouting(key)
870
+ return renderHome(key)
871
+ }
872
+
873
+ // ------------------------------------------------------------------ home
874
+ let homeSig = null
875
+ function renderHome(key) {
876
+ const ws = currentWs()
877
+ const streams = streamsOf(ws.id)
878
+ const lib = libs.get(ws.id)
879
+ const sig = JSON.stringify([key, streams.map((p) => [p.id, p.status, p.busy, roomOf(p)?.messages?.length, tasksOf(p).map((t) => t.status)]), lib?.problems?.length, lib?.missing])
880
+ if (mainKey === key && sig === homeSig) return
881
+ mainKey = key
882
+ homeSig = sig
883
+ const open = streams.filter((p) => p.status !== "archived")
884
+ const archived = streams.filter((p) => p.status === "archived")
885
+ const tile = (p) => {
886
+ const tasks = tasksOf(p)
887
+ const done = tasks.filter((t) => t.status === "done").length
888
+ const needs = tasks.filter(needsYou).length
889
+ const last = roomOf(p)?.messages?.at(-1)
890
+ return `<button class="tile" data-id="${esc(p.id)}">
891
+ <div class="top"><span class="dot ${p.busy ? "busy" : needs ? "waiting" : p.status === "done" ? "done" : ""}"></span><b>${esc(p.name)}</b>
892
+ <span class="chip">${done}/${tasks.length}</span></div>
893
+ <div class="note mono">${esc(p.pipelineTitle ?? p.pipeline ?? "no pipeline")} · ${esc(p.branch ?? "")}</div>
894
+ ${last ? `<div class="last"><b class="mono">${last.from === "operator" ? "you" : "@" + esc(last.from)}</b> ${esc(last.text)}</div>` : ""}
895
+ <div class="avs">${teamOf(p).map((s) => `<img src="${avatarUrl(s)}" alt="@${esc(s.handle)}" title="@${esc(s.handle)} · ${esc(s.status)}" />`).join("")}
896
+ ${needs ? `<span class="chip" style="color:var(--wait);border-color:var(--wait);margin-left:auto">${needs} need${needs > 1 ? "" : "s"} you</span>` : ""}</div>
897
+ </button>`
898
+ }
899
+ $("#main").innerHTML = `<div class="page">
900
+ <h1>${esc(ws.name)}</h1>
901
+ <p class="lead mono" style="font-size:12.5px">${esc(fmt.path(ws.path))}</p>
902
+ ${lib?.problems?.length ? `<div class="problems">${lib.problems.map((p) => `${esc(p.file)}${p.id ? " · " + esc(p.id) : ""}: ${esc(p.problems.join("; "))}`).join("<br>")}</div>` : ""}
903
+ ${lib?.opencode ? `<p class="note" style="margin:-8px 0 16px">${lib.opencode.found
904
+ ? `opencode.json: model <span class="mono">${esc(lib.opencode.model ?? "—")}</span> · ${lib.opencode.agents.length} agents · MCP ${lib.opencode.mcp.filter((m) => m.enabled).map((m) => esc(m.name)).join(", ") || "none enabled"}`
905
+ : `<span style="color:var(--busy)">No opencode.json in this folder — bots get opencode's defaults.</span>`}</p>` : ""}
906
+ <div class="hero"><div>Each workstream is one story on its own branch, with its own bots, chat and board.</div>
907
+ <button class="primary" id="home-new">Start a workstream</button></div>
908
+ <div class="grid">${open.map(tile).join("")}</div>
909
+ <div id="home-harvest"></div>
910
+ ${archived.length ? `<h3 class="note" style="margin:24px 0 8px">Archived</h3><div class="grid">${archived.map(tile).join("")}</div>` : ""}
911
+ </div>`
912
+ harvestPanel(ws.id)
913
+ $("#home-new").onclick = () => openNewStream()
914
+ for (const t of $$(".tile[data-id]")) t.onclick = () => go("stream", t.dataset.id)
915
+ }
916
+
917
+ // ------------------------------------------------------------------ workstream
918
+ const COLS = [
919
+ { id: "todo", label: "To do" },
920
+ { id: "doing", label: "Doing" },
921
+ { id: "needs", label: "Needs you" },
922
+ { id: "done", label: "Done" },
923
+ ]
924
+ function colOf(t) {
925
+ if (t.status === "done") return "done"
926
+ if (needsYou(t)) return "needs"
927
+ if (t.status === "active") return "doing"
928
+ return "todo"
929
+ }
930
+ const canSendBack = (t) => !!t?.runId && t.status === "done" && t.owner !== "human" && t.kind !== "question"
931
+ async function sendBackPrompt(t) {
932
+ const reason = prompt(`Send "${t.title}" back to its bot?\n\nWhat needs fixing? (They get this card back with your words, and whatever was waiting on it waits for the fix.)`)
933
+ if (!reason?.trim()) return false
934
+ await api(`/api/tasks/${encodeURIComponent(t.id)}/send-back`, { method: "POST", body: { reason } }).catch((e) => alert(e.message))
935
+ return true
936
+ }
937
+ function cardStatus(t, tasks) {
938
+ if (t.status === "blocked" && t.recheck) return `waits on the fix to ${t.recheck.to} · fix ${t.recheck.round}`
939
+ if (t.status === "queued" && t.rework && !t.rework.early) return `sent back by @${t.rework.by}${t.prefer ? ` · for @${sessionById(t.prefer)?.handle ?? "its bot"}` : ""}`
940
+ if (t.status === "blocked") {
941
+ const dep = tasks.find((x) => t.dependsOn?.includes(x.id))
942
+ return `waits on ${dep?.stage ?? "another card"}`
943
+ }
944
+ if (t.status === "active") return `working ${fmt.ago(t.startedAt ?? t.updated)}`
945
+ if (t.kind === "question" && t.status !== "done") return "question for you"
946
+ if (t.status === "review") return t.handoff?.open_questions?.length ? `${t.handoff.open_questions.length} open question(s)` : "review"
947
+ if (t.status === "done") return t.owner === "human" ? "answered" : "handed off"
948
+ return t.status
949
+ }
950
+
951
+ let streamSig = {}
952
+ function renderStream(key) {
953
+ const p = (state.projects ?? []).find((x) => x.id === ui.id)
954
+ if (!p) { if (mainKey !== key + "missing") { mainKey = key + "missing"; $("#main").innerHTML = `<div class="page"><div class="empty">That workstream is gone.</div></div>` } return }
955
+ if (mainKey !== key) {
956
+ mainKey = key
957
+ streamSig = {}
958
+ $("#main").innerHTML = `
959
+ <div class="whead">
960
+ <div class="row1"><span class="dot" id="w-dot"></span><h1 id="w-title"></h1><span class="chip" id="w-branch"></span>
961
+ <div class="acts">
962
+ <button class="sm" id="w-card">+ Card</button>
963
+ <button class="sm ghost" id="w-story">Story</button>
964
+ <button class="sm ghost" id="w-repo" title="Put another repo in this workstream's worktree, on the same branch">+ Repo</button>
965
+ <button class="sm ghost" id="w-pr" title="A PR description from the handoffs: criteria with their tests, files, cost">PR packet</button>
966
+ <button class="sm ghost" id="w-apply" title="Turn the work into patches and apply them to your real repos, uncommitted">Apply to repos…</button>
967
+ <button class="sm ghost" id="w-replay" title="Scrub through everything that happened; save it as a file">Replay</button>
968
+ <button class="sm" id="w-pause" title="Stop every bot on this workstream until you resume it" hidden>Pause</button>
969
+ <button class="sm ghost" id="w-limits" title="Time, token and dollar limits for this workstream">Limits…</button>
970
+ <button class="sm ghost" id="w-refresh" title="Re-read opencode.json and the botfarm/ agents, reconnect MCP servers and fetch their current tools">Refresh tools</button>
971
+ <button class="sm ghost" id="w-restart">Restart…</button>
972
+ <button class="sm ghost danger" id="w-archive">Archive</button>
973
+ <button class="sm ghost danger" id="w-delete" title="Delete this workstream for good">Delete…</button>
974
+ </div></div>
975
+ <div class="row2"><div class="steps" id="w-steps"></div><span id="w-budget"></span>
976
+ <div class="viewtabs" style="margin-left:auto" role="group" aria-label="View"><button data-view="board" aria-pressed="true">Board</button><button data-view="farm" aria-pressed="false">Farm</button></div></div>
977
+ </div>
978
+ <div id="w-paused"></div>
979
+ <div class="wlayout">
980
+ <aside class="rail" aria-label="Team">
981
+ <div class="rh">Team <span class="mono" id="w-teamn"></span><button class="sm ghost" id="w-bot" title="Add a bot to this workstream">+ Bot</button></div>
982
+ <div class="list" id="w-team"></div>
983
+ </aside>
984
+ <div class="wmain">
985
+ <div class="wbody">
986
+ <div class="board" id="w-board"></div>
987
+ <div class="farm" id="w-farm" hidden></div>
988
+ <div class="splitter" id="w-split" role="separator" aria-orientation="horizontal" title="Drag to resize"></div>
989
+ <section class="chat">
990
+ <div class="ch"><b id="w-room"></b><span class="note" id="w-members"></span></div>
991
+ <div class="log" id="w-log"></div>
992
+ <div class="composer">
993
+ <div class="mentions" id="w-mentions" hidden></div>
994
+ <textarea id="w-input" rows="1" placeholder="Message the team… @handle to reach one bot"></textarea>
995
+ <button class="primary" id="w-send">Post</button>
996
+ </div>
997
+ </section>
998
+ </div>
999
+ </div>
1000
+ </div>`
1001
+ $("#w-card").onclick = () => openNewCard(p.id)
1002
+ $("#w-bot").onclick = () => openAddBot(p.id)
1003
+ $("#w-story").onclick = () => openStory(p.id)
1004
+ $("#w-restart").onclick = () => openRestart(p.id)
1005
+ $("#w-archive").onclick = async () => {
1006
+ if (!confirm(`Archive "${p.name}"? Its bots are stopped and its open cards cancelled. The chat and board stay readable.`)) return
1007
+ const drop = confirm("Also delete its opencode sessions and its worktree? Cancel keeps them.")
1008
+ await api(`/api/projects/${encodeURIComponent(p.id)}/archive`, { method: "POST", body: { removeSessions: drop, removeWorktree: drop } }).catch((e) => alert(e.message))
1009
+ }
1010
+ $("#w-delete").onclick = () => openDelete(p.id)
1011
+ $("#w-limits").onclick = () => openLimits(p.id)
1012
+ $("#w-pause").onclick = async () => {
1013
+ const cur = (state.projects ?? []).find((x) => x.id === p.id)
1014
+ const paused = !!cur?.budget?.paused
1015
+ $("#w-pause").disabled = true
1016
+ await api(`/api/projects/${encodeURIComponent(p.id)}/${paused ? "resume" : "pause"}`, { method: "POST", body: {} }).catch((e) => alert(e.message))
1017
+ $("#w-pause").disabled = false
1018
+ }
1019
+ $("#w-pr").onclick = () => openPacket(p.id)
1020
+ $("#w-apply").onclick = () => openApply(p.id)
1021
+ $("#w-replay").onclick = () => go("replay", "p:" + p.id)
1022
+ $("#w-repo").onclick = () => openAddRepo(p.id)
1023
+ for (const b of $$("[data-view]")) b.onclick = () => { store.set("view", b.dataset.view); applyView(); streamSig = {}; renderStream(mainKey) }
1024
+ applyView()
1025
+ $("#w-refresh").onclick = async () => {
1026
+ const b = $("#w-refresh")
1027
+ b.disabled = true
1028
+ b.textContent = "Refreshing…"
1029
+ try {
1030
+ const out = await api(`/api/projects/${encodeURIComponent(p.id)}/refresh-config`, { method: "POST", body: {} })
1031
+ b.textContent = out.pending ? "Refresh when idle" : "Refreshed"
1032
+ } catch (e) { alert(e.message); b.textContent = "Refresh tools" }
1033
+ setTimeout(() => { b.disabled = false; b.textContent = "Refresh tools" }, 2500)
1034
+ }
1035
+ wireComposer(p)
1036
+ wireSplit()
1037
+ }
1038
+ const tasks = tasksOf(p)
1039
+ const team = teamOf(p)
1040
+ const room = roomOf(p)
1041
+
1042
+ // header
1043
+ const hsig = JSON.stringify([p.name, p.branch, p.status, p.busy, tasks.map((t) => [t.id, t.status, t.stage, t.children?.length])])
1044
+ if (hsig !== streamSig.h) {
1045
+ streamSig.h = hsig
1046
+ $("#w-title").textContent = p.name
1047
+ $("#w-branch").textContent = p.branch ?? "no branch"
1048
+ $("#w-branch").title = p.worktree ?? ""
1049
+ $("#w-dot").className = `dot ${p.busy ? "busy" : tasks.some(needsYou) ? "waiting" : p.status === "done" ? "done" : ""}`
1050
+ const stages = tasks.filter((t) => t.stage && !t.parentId)
1051
+ const pieces = (t) => { const k = t.children?.map((id) => tasks.find((x) => x.id === id)).filter(Boolean) ?? []; return k.length ? ` ${k.filter((x) => x.status === "done").length}/${k.length}` : "" }
1052
+ $("#w-steps").innerHTML = stages.length
1053
+ ? stages.map((t, i) => {
1054
+ // Stages that start together (after:) sit side by side, joined by ∥ instead of →.
1055
+ const sameStart = i && JSON.stringify(t.dependsOn ?? []) === JSON.stringify(stages[i - 1].dependsOn ?? []) && (t.dependsOn ?? []).length
1056
+ return `${i ? `<span class="arr">${sameStart ? "∥" : "→"}</span>` : ""}<span class="step ${t.status === "done" ? "done" : t.status === "active" ? "active" : needsYou(t) ? "needs" : ""}" title="${esc(t.title)} — ${esc(t.status)}">${esc(t.stage)}${pieces(t)}</span>`
1057
+ }).join("")
1058
+ + `<span class="note" style="margin-left:8px">${esc(p.pipelineTitle ?? "")}</span>`
1059
+ : `<span class="note">No pipeline — add cards and bots by hand.</span>`
1060
+ $("#w-archive").hidden = p.status === "archived"
1061
+ $("#w-restart").hidden = !p.runId
1062
+ }
1063
+ // team, down the left
1064
+ const tsig = JSON.stringify(team.map((s) => [s.id, s.status, s.currentTool?.name, s.lastText, s.permissions?.length, s.questions?.length, s.model, s.variant, s.tokens, s.cost?.usd, s.invocations, s.totals?.tools, s.mcp?.connected, s.mcp?.total, s.mcp?.botfarm, s.lastError, s.override?.model, s.override?.variant, levelOfBot(s)?.level, levelOfBot(s)?.xp]))
1065
+ if (tsig !== streamSig.t) {
1066
+ streamSig.t = tsig
1067
+ renderRail(team, tasks)
1068
+ }
1069
+ // budget
1070
+ const gsig = JSON.stringify([p.budget?.limits, p.budget?.paused, p.budget ? Math.floor(p.budget.used.minutes) : 0, p.budget ? Math.round(p.budget.used.usd * 100) : 0, p.budget ? Math.round(p.budget.used.tokens / 1000) : 0])
1071
+ if (gsig !== streamSig.g) {
1072
+ streamSig.g = gsig
1073
+ renderBudget(p)
1074
+ }
1075
+ // board
1076
+ const bsig = JSON.stringify([view(), tasks.map((t) => [t.id, t.status, t.title, t.assignee, t.owner, t.startedAt ? Math.floor((Date.now() - t.startedAt) / 60000) : 0, t.budget?.kind, t.limits, t.usage ? Math.round(usageFrac(t) * 50) : 0, t.difficulty?.level, t.xp?.gained])])
1077
+ if (bsig !== streamSig.b) {
1078
+ streamSig.b = bsig
1079
+ if (view() === "board") renderBoard(p, tasks)
1080
+ }
1081
+ if (view() === "farm") renderFarm(p, tasks, team)
1082
+ // chat
1083
+ const events = (state.events ?? []).filter((e) => e.sessionId && team.some((s) => s.id === e.sessionId))
1084
+ const pending = tasks.filter(asks)
1085
+ const botAsks = team.filter((s) => s.permissions?.length || s.questions?.length)
1086
+ const csig = JSON.stringify([room?.id, room?.messages?.length, room?.messages?.at(-1)?.id, events.length, events[0]?.at, pending.map((t) => [t.id, t.status]), botAsks.map((s) => [s.id, s.permissions?.map((x) => x.id), s.questions?.map((x) => x.id)])])
1087
+ // Never repaint under someone answering a question; catch up when they leave it.
1088
+ const typing = document.activeElement?.closest?.("#w-log .ask")
1089
+ if (csig !== streamSig.c && !typing) {
1090
+ streamSig.c = csig
1091
+ renderLog(p, room, events, team, pending)
1092
+ }
1093
+ if (room && document.visibilityState === "visible") {
1094
+ const lastAt = room.messages.at(-1)?.at ?? 0
1095
+ if (lastAt > (seen[room.id] ?? 0)) { seen[room.id] = lastAt; store.set("seen", seen); navSig = null }
1096
+ }
1097
+ }
1098
+
1099
+
1100
+ // --- board or farm
1101
+ const view = () => (store.get("view", "board") === "farm" ? "farm" : "board")
1102
+ function applyView() {
1103
+ const v = view()
1104
+ for (const b of $$("[data-view]")) b.setAttribute("aria-pressed", String(b.dataset.view === v))
1105
+ if ($("#w-board")) $("#w-board").hidden = v !== "board"
1106
+ if ($("#w-farm")) $("#w-farm").hidden = v !== "farm"
1107
+ farmState.built = false
1108
+ }
1109
+
1110
+ // --- the team, down the left: one row per bot, grouped when there are several of a kind
1111
+ const seenBots = new Set()
1112
+ function botDoing(s) {
1113
+ if (s.status === "busy") return s.currentTool ? `running ${s.currentTool.name}` : "thinking…"
1114
+ if (s.status === "waiting") return s.permissions?.length ? `⏸ wants to ${permissionWhat(s.permissions[0]).action}` : s.questions?.length ? "⏸ has a question for you" : "needs a permission"
1115
+ if (s.status === "error") return `⚠ ${s.lastError ?? "error"}`
1116
+ return s.lastText || "idle"
1117
+ }
1118
+ function renderRail(team, tasks) {
1119
+ team = [...team].sort((a, b) => (a.created ?? 0) - (b.created ?? 0))
1120
+ $("#w-teamn").textContent = team.length ? String(team.length) : ""
1121
+ const kinds = new Map()
1122
+ for (const s of team) {
1123
+ const k = s.personaTitle ?? s.persona ?? "Other"
1124
+ kinds.set(k, [...(kinds.get(k) ?? []), s])
1125
+ }
1126
+ const row = (s) => {
1127
+ const card = tasks.find((t) => t.assignee === s.id && t.status === "active")
1128
+ const mcp = s.mcp
1129
+ const fresh = !seenBots.has(s.id)
1130
+ seenBots.add(s.id)
1131
+ return `<div class="rbot ${esc(s.status)} ${fresh && seenBots.size > team.length ? "enter" : ""}" data-s="${esc(s.id)}" style="--fg:${esc(s.color?.fg ?? "")}" title="${esc(botDoing(s))}">
1132
+ <span class="ava"><img src="${avatarUrl(s)}" alt="" /><span class="pip"></span>${levelOfBot(s)?.hat ? `<span class="hat" title="${esc(levelOfBot(s).title)}">${levelOfBot(s).hat}</span>` : ""}</span>
1133
+ <div class="who">
1134
+ <span class="top"><span class="h ${levelOfBot(s)?.level >= 6 ? "gold" : ""}">@${esc(s.handle)}</span>${s.personaTitle || s.persona ? `<span class="kind">${esc(s.personaTitle ?? s.persona)}</span>` : ""}${levelOfBot(s) ? `<span class="lvl" title="${esc(levelOfBot(s).title)} · ${levelOfBot(s).xp} XP${levelOfBot(s).next ? ` · ${levelOfBot(s).next - levelOfBot(s).xp} to the next level` : ""}${(levelOfBot(s).perks ?? []).length ? "\n" + levelOfBot(s).perks.map((x) => "• " + x.label).join("\n") : ""}">Lv ${levelOfBot(s).level}</span>` : ""}</span>
1135
+ <span class="d">${card ? `${esc(card.item ? `${card.item.index}/${card.item.total} ` : "")}${esc(card.title)} · ` : ""}${esc(botDoing(s))}</span>
1136
+ <span class="model ${s.override ? "ovr" : ""}" title="${esc([s.model && `ran on ${s.model}`, s.configuredModel && `asks for ${s.configuredModel}`, s.override && "changed here (Model… in its panel)"].filter(Boolean).join("\n"))}">${s.override ? "⇅ " : ""}${esc(modelName(s))}${s.agentName ? ` · ${esc(s.agentName)}` : ""}</span>
1137
+ <span class="stats">
1138
+ <span title="tokens used"><b>${fmt.n(s.tokens)}</b> tok</span>
1139
+ <span title="${s.cost?.unknownModel ? "no price for " + esc(s.cost.unknownModel) : s.cost?.estimated ? "estimated from tokens" : "reported by the provider"}"><b>${s.cost?.unknownModel ? "$?" : fmt.money(s.cost?.usd)}</b></span>
1140
+ <span title="times it was handed something"><b>${s.invocations ?? 0}</b>×</span>
1141
+ <span title="tool calls"><b>${s.totals?.tools ?? 0}</b> tools</span>
1142
+ ${mcp ? `<span class="${mcp.botfarm ? "" : "bad"}" title="${esc(mcp.servers.map((m) => `${m.name}: ${m.status}${m.error ? " — " + m.error : ""}`).join("\n"))}"><b>${mcp.connected}/${mcp.total}</b> MCP</span>` : ""}
1143
+ </span></div></div>`
1144
+ }
1145
+ $("#w-team").innerHTML = team.length
1146
+ ? [...kinds.entries()].map(([k, list]) => (kinds.size > 1 || list.length > 1 ? `<div class="grp">${esc(k)}${list.length > 1 ? ` ×${list.length}` : ""}</div>` : "") + list.map(row).join("")).join("")
1147
+ : `<span class="note" style="padding:8px">No bots yet.</span>`
1148
+ for (const b of $$("#w-team .rbot")) b.onclick = () => openSession(b.dataset.s)
1149
+ }
1150
+
1151
+ // --- card completion events in the chat: who, how long, tokens, cost, the diff, screenshots
1152
+ const reports = new Map() // task id -> report (diffs), fetched when opened
1153
+ const openCards = new Set() // task ids whose files are open in the chat
1154
+ const openFiles = new Set() // `${taskId}|${file}` open diffs
1155
+ function diffHtml(d) {
1156
+ return `<pre class="diff">${esc(d).split("\n").map((l) => {
1157
+ const c = l.startsWith("+++") || l.startsWith("---") ? "h" : l.startsWith("+") ? "a" : l.startsWith("-") ? "r" : l.startsWith("@@") ? "at" : ""
1158
+ return c ? `<span class="${c}">${l}</span>` : l
1159
+ }).join("\n")}</pre>`
1160
+ }
1161
+ function cardFilesHtml(taskId) {
1162
+ const r = reports.get(taskId)
1163
+ if (!r) return `<div class="note">Loading…</div>`
1164
+ if (!r.diffs?.length) return `<div class="note">${r.files?.length ? "No diff — the files may be committed or unchanged now." : "It did not list any files."}</div>`
1165
+ return r.diffs.map((d) => `<details class="dfile" data-dfile="${esc(taskId)}|${esc(d.file)}" ${openFiles.has(taskId + "|" + d.file) ? "open" : ""}>
1166
+ <summary><span class="mono">${esc(d.file)}</span>${d.created ? ` <span class="fbadge">new</span>` : ""}${d.deleted ? ` <span class="fbadge del">deleted</span>` : ""} <span class="add">+${d.added}</span> <span class="del">−${d.removed}</span>${d.binary ? ` <span class="note">(binary)</span>` : d.truncated ? ` <span class="note">(cut short)</span>` : ""}${d.unlisted ? ` <span class="fbadge warn" title="Changed on disk while this card ran, but not listed in its handoff — by a tool, an MCP server, a script, or another bot in the same worktree">not in its handoff</span>` : ""}</summary>${d.binary ? "" : diffHtml(d.diff)}</details>`).join("") + (r.tracked ? `<div class="note" style="margin-top:4px">Every file that changed on disk while the card ran, whatever changed it.</div>` : "")
1167
+ }
1168
+ function cardEventHtml(m, who, text, isNew) {
1169
+ const e = m.meta
1170
+ text = String(m.text ?? "").replace(/^Finished [^:]*(\([^)]*\))?[^:]*:\s*/, "")
1171
+ const op = m.from === "operator"
1172
+ const added = e.files.reduce((a, f) => a + (f.added ?? 0), 0), removed = e.files.reduce((a, f) => a + (f.removed ?? 0), 0)
1173
+ const style = !op && who?.color ? `style="--fg:${esc(who.color.fg)};--bg:${esc(who.color.bg)}"` : ""
1174
+ const mins = e.minutes < 1 ? `${Math.max(1, Math.round(e.minutes * 60))}s` : e.minutes < 90 ? `${Math.round(e.minutes)}m` : `${(e.minutes / 60).toFixed(1)}h`
1175
+ return `<div class="cardev ${isNew ? "enter" : ""} ${op ? "sysev" : ""}" ${style} data-cardev="${esc(e.taskId)}">
1176
+ <div class="ceh">${op ? `<span class="cebig">🧺</span>` : `<img class="av" src="${avatarUrl({ id: m.fromId ?? m.from, handle: who?.handle ?? m.from })}" alt="" />`}
1177
+ <div class="cet"><div><b class="who">${op ? (e.bots?.length ? e.bots.map((h) => "@" + esc(h)).join(", ") : "The team") : "@" + esc(m.from)}</b> finished ${e.stage ? `<span class="stage">${esc(e.stage)}</span>` : ""}${e.piece ? ` <span class="note">piece ${esc(e.piece)}</span>` : ""}${e.pieces ? ` <span class="note">${e.pieces} pieces</span>` : ""} <span class="note">${fmt.time(m.at)}</span></div>
1178
+ <div class="cettl">✓ ${esc(e.title)}</div></div></div>
1179
+ <div class="cechips">
1180
+ <span title="time on the card">⏱ ${mins}</span>
1181
+ <span title="tokens">${fmt.n(e.tokens)} tok</span>
1182
+ <span title="cost (estimated)">${fmt.money(e.usd)}</span>
1183
+ ${e.model ? `<span class="mono" title="${esc(e.model)}">${esc(shortModel(e.model))}</span>` : ""}
1184
+ ${e.xp ? `<span class="xp">+${e.xp} XP</span>` : ""}
1185
+ ${e.files.length ? `<span>${e.files.length} file${e.files.length > 1 ? "s" : ""}${added || removed ? ` <span class="add">+${added}</span> <span class="del">−${removed}</span>` : ""}</span>` : ""}
1186
+ ${e.images ? `<span>🖼 ${e.images}</span>` : ""}
1187
+ ${e.open ? `<span class="warnc">${e.open} open question${e.open > 1 ? "s" : ""}</span>` : ""}
1188
+ </div>
1189
+ ${text && !op ? `<div class="cesum ${text.length > 400 && !expanded.has(m.id) ? "long" : ""}">${esc(text)}</div>${text.length > 400 ? `<button class="more" data-more="${esc(m.id)}">${expanded.has(m.id) ? "show less" : "show all"}</button>` : ""}` : ""}
1190
+ ${e.images ? `<div class="shots">${Array.from({ length: Math.min(e.images, 8) }, (_, i) => `<img src="/api/tasks/${encodeURIComponent(e.taskId)}/image/${i}" alt="screenshot ${i + 1}" loading="lazy" data-shot="/api/tasks/${encodeURIComponent(e.taskId)}/image/${i}" />`).join("")}</div>` : ""}
1191
+ ${e.files.length ? `<details class="cefiles" data-cefiles="${esc(e.taskId)}" ${openCards.has(e.taskId) ? "open" : ""}><summary>Changes</summary><div class="difflist">${openCards.has(e.taskId) ? cardFilesHtml(e.taskId) : ""}</div></details>` : ""}
1192
+ </div>`
1193
+ }
1194
+ function wireCardEvents(root) {
1195
+ for (const d of $$("[data-cefiles]", root)) d.ontoggle = async () => {
1196
+ const id = d.dataset.cefiles
1197
+ if (!d.open) { openCards.delete(id); return }
1198
+ openCards.add(id)
1199
+ const box = $(".difflist", d)
1200
+ if (!reports.has(id)) {
1201
+ box.innerHTML = `<div class="note">Loading…</div>`
1202
+ try { reports.set(id, await api(`/api/tasks/${encodeURIComponent(id)}/report`)) } catch (e) { box.innerHTML = `<div class="err">${esc(e.message)}</div>`; return }
1203
+ }
1204
+ box.innerHTML = cardFilesHtml(id)
1205
+ wireDiffFiles(box)
1206
+ }
1207
+ for (const d of $$("[data-cefiles][open] .difflist", root)) wireDiffFiles(d)
1208
+ for (const im of $$("[data-shot]", root)) im.onclick = () => lightbox(im.dataset.shot)
1209
+ // Reports opened before a repaint come back open without a refetch.
1210
+ for (const d of $$("[data-cefiles][open]", root)) if (!reports.has(d.dataset.cefiles)) d.ontoggle()
1211
+ }
1212
+ function wireDiffFiles(box) {
1213
+ for (const f of $$("[data-dfile]", box)) f.ontoggle = () => (f.open ? openFiles.add(f.dataset.dfile) : openFiles.delete(f.dataset.dfile))
1214
+ }
1215
+ function lightbox(src) {
1216
+ const el = document.createElement("div")
1217
+ el.className = "lightbox"
1218
+ el.innerHTML = `<img src="${esc(src)}" alt="" /><button class="sm" aria-label="Close">Close</button>`
1219
+ el.onclick = () => el.remove()
1220
+ document.body.appendChild(el)
1221
+ }
1222
+
1223
+ // --- limits: time, tokens and dollars, for the workstream and for each card
1224
+ const LIMIT_KINDS = [["usd", "$", (v) => fmt.money(v)], ["tokens", "tok", (v) => fmt.n(v)], ["minutes", "min", (v) => `${Math.round(v)}m`]]
1225
+ function usageFrac(t) {
1226
+ if (!t.limits || !t.usage) return 0
1227
+ return Math.max(0, ...Object.entries(t.limits).map(([k, v]) => (t.usage[k] ?? 0) / v))
1228
+ }
1229
+ function renderBudget(p) {
1230
+ const b = p.budget
1231
+ const el = $("#w-budget")
1232
+ if (!b) { el.innerHTML = ""; $("#w-paused").innerHTML = ""; $("#w-limits").hidden = true; return }
1233
+ $("#w-limits").hidden = false
1234
+ const set = LIMIT_KINDS.filter(([k]) => b.limits?.[k])
1235
+ el.innerHTML = `<span class="budget" title="What this workstream has used${set.length ? " against its limits" : ""}">
1236
+ ${(set.length ? set : LIMIT_KINDS).map(([k, unit, f]) => {
1237
+ const lim = b.limits?.[k]
1238
+ const frac = lim ? Math.min(1, (b.used[k] ?? 0) / lim) : 0
1239
+ return `<span class="m">${f(b.used[k] ?? 0)}${lim ? ` / ${f(lim)}` : k === "tokens" ? " tok" : ""}${lim ? `<span class="bar"><i class="${frac >= 1 ? "over" : frac > .8 ? "warn" : ""}" style="width:${Math.round(frac * 100)}%"></i></span>` : ""}</span>`
1240
+ }).join("")}
1241
+ <button class="ghost" data-limits>${set.length ? "Limits" : "Set limits"}</button></span>`
1242
+ $("[data-limits]", el).onclick = () => openLimits(p.id)
1243
+ const pz = b.paused
1244
+ const pb = $("#w-pause")
1245
+ if (pb) { pb.hidden = b.status !== "running"; pb.textContent = pz ? "▶ Resume" : "⏸ Pause"; pb.className = pz ? "sm primary" : "sm" }
1246
+ if (pz?.kind === "manual") {
1247
+ $("#w-paused").innerHTML = `<div class="paused" style="margin-top:10px">⏸ <span><b>Paused by you</b> ${fmt.ago(pz.at)} ago — ${pz.interrupted?.length ?? 0} bot(s) stopped mid-card; nothing new starts. Resume picks them up where they were.</span>
1248
+ <span class="acts"><button class="primary sm" data-resume>▶ Resume</button></span></div>`
1249
+ $("[data-resume]", $("#w-paused")).onclick = () => $("#w-pause").click()
1250
+ return
1251
+ }
1252
+ $("#w-paused").innerHTML = pz ? `<div class="paused" style="margin-top:10px">⏸ <span><b>Paused:</b> reached its ${pz.kind === "usd" ? "dollar" : pz.kind === "tokens" ? "token" : "time"} limit
1253
+ (${LIMIT_KINDS.find(([k]) => k === pz.kind)[2](pz.used)} of ${LIMIT_KINDS.find(([k]) => k === pz.kind)[2](pz.limit)}). Every bot is stopped until you raise it.</span>
1254
+ <span class="acts"><button class="primary sm" data-raise>Raise limit and resume…</button><button class="sm danger" data-stop>Stop workstream</button></span></div>` : ""
1255
+ if (pz) {
1256
+ $("[data-raise]", $("#w-paused")).onclick = () => openLimits(p.id)
1257
+ $("[data-stop]", $("#w-paused")).onclick = async () => {
1258
+ if (!confirm("Stop this workstream? Open cards are cancelled; the chat, board and branch stay.")) return
1259
+ await api(`/api/projects/${encodeURIComponent(p.id)}/budget`, { method: "POST", body: { action: "stop" } }).catch((e) => alert(e.message))
1260
+ }
1261
+ }
1262
+ }
1263
+ function limitInputs(prefix, l = {}) {
1264
+ return `<div class="limits">
1265
+ <div class="field"><label for="${prefix}-usd">Dollars</label><input id="${prefix}-usd" type="number" min="0" step="0.5" placeholder="no limit" value="${l?.usd ?? ""}" /></div>
1266
+ <div class="field"><label for="${prefix}-tokens">Tokens</label><input id="${prefix}-tokens" type="number" min="0" step="10000" placeholder="no limit" value="${l?.tokens ?? ""}" /></div>
1267
+ <div class="field"><label for="${prefix}-minutes">Minutes</label><input id="${prefix}-minutes" type="number" min="0" step="5" placeholder="no limit" value="${l?.minutes ?? ""}" /></div></div>`
1268
+ }
1269
+ function readLimits(prefix) {
1270
+ const out = {}
1271
+ for (const k of ["usd", "tokens", "minutes"]) { const v = Number($(`#${prefix}-${k}`)?.value); if (v > 0) out[k] = v }
1272
+ return Object.keys(out).length ? out : null
1273
+ }
1274
+ const dLimits = wireDialog("#d-limits", "#lm-cancel")
1275
+ function openLimits(pid) {
1276
+ const p = (state.projects ?? []).find((x) => x.id === pid)
1277
+ if (!p?.budget) return
1278
+ dLimits.dataset.p = pid
1279
+ const u = p.budget.used
1280
+ $("#lm-used").textContent = `Used so far: ${fmt.money(u.usd)} · ${fmt.n(u.tokens)} tokens · ${Math.round(u.minutes)} minutes`
1281
+ $("#lm-body").innerHTML = limitInputs("lm", p.budget.limits ?? {})
1282
+ $("#lm-go").textContent = p.budget.paused ? "Save and resume" : "Save"
1283
+ $("#lm-err").textContent = ""
1284
+ dLimits.showModal()
1285
+ }
1286
+ $("#lm-go").onclick = async () => {
1287
+ try {
1288
+ await api(`/api/projects/${encodeURIComponent(dLimits.dataset.p)}/budget`, { method: "POST", body: { action: "continue", limits: readLimits("lm") } })
1289
+ dLimits.close()
1290
+ } catch (e) { $("#lm-err").textContent = e.message }
1291
+ }
1292
+ function budgetAskBlock(t) {
1293
+ const b = t.budget
1294
+ const who = sessionById(t.assignee)
1295
+ if (b.kind === "rounds") return `<div class="ask budget-ask" data-ask="${esc(t.id)}">
1296
+ <div class="askh">@${esc(who?.handle ?? "its bot")} wants to send <b>${esc(b.to)}</b> back again, but ${esc(b.why)}. <b>${esc(t.title)}</b> is waiting for you.</div>
1297
+ <div class="askf" style="flex-wrap:wrap"><span class="note">Read the last fix in the chat. One more round sends it back; Accept makes the checker finish and list what still fails.</span>
1298
+ ${who ? `<button class="sm" data-bopen>Open @${esc(who.handle)}</button>` : ""}
1299
+ <button class="sm danger" data-bstop>Stop this card</button>
1300
+ <button class="sm" data-baccept>Accept as is</button>
1301
+ <button class="primary sm" data-bgo>One more round</button></div>
1302
+ </div>`
1303
+ if (b.kind === "loop") return `<div class="ask budget-ask" data-ask="${esc(t.id)}">
1304
+ <div class="askh">@${esc(who?.handle ?? "its bot")} looks stuck on <b>${esc(t.title)}</b>: ${esc(b.why ?? "")}. It is stopped.</div>
1305
+ <div class="askf" style="flex-wrap:wrap"><span class="note">Continue tells it to try another way; or open its chat and steer it.</span>
1306
+ ${who ? `<button class="sm" data-bopen>Open @${esc(who.handle)}</button>` : ""}
1307
+ <button class="sm danger" data-bstop>Stop this card</button>
1308
+ <button class="primary sm" data-bgo>Continue</button></div>
1309
+ </div>`
1310
+ const f = LIMIT_KINDS.find(([k]) => k === b.kind)[2]
1311
+ return `<div class="ask budget-ask" data-ask="${esc(t.id)}">
1312
+ <div class="askh"><b>${esc(t.title)}</b> reached its ${b.kind === "usd" ? "dollar" : b.kind === "tokens" ? "token" : "time"} limit — ${f(b.used)} of ${f(b.limit)}. @${esc(who?.handle ?? "its bot")} is stopped.</div>
1313
+ <div class="askf" style="flex-wrap:wrap"><span class="note">Continue gives it half as much again; or set exact limits.</span>
1314
+ <button class="sm" data-bset>Set limits…</button>
1315
+ <button class="sm danger" data-bstop>Stop this card</button>
1316
+ <button class="primary sm" data-bgo>Continue (+50%)</button></div>
1317
+ <div data-bform hidden>${limitInputs("bl-" + t.id.replace(/\W/g, ""), t.limits)}<div class="askf"><span class="note">New limits for this card.</span><button class="primary sm" data-bsave>Save and continue</button></div></div>
1318
+ </div>`
1319
+ }
1320
+ function wireBudgetAsk(root, t, done) {
1321
+ const box = $(`[data-ask="${CSS.escape(t.id)}"]`, root)
1322
+ if (!box) return
1323
+ const send = async (body) => { try { await api(`/api/tasks/${encodeURIComponent(t.id)}/budget`, { method: "POST", body }); done() } catch (e) { alert(e.message) } }
1324
+ $("[data-bgo]", box).onclick = () => send({ action: "continue" })
1325
+ $("[data-bstop]", box).onclick = () => confirm("Stop this card? The stage after it goes on without it.") && send({ action: "stop" })
1326
+ if ($("[data-baccept]", box)) $("[data-baccept]", box).onclick = () => send({ action: "accept" })
1327
+ if ($("[data-bopen]", box)) $("[data-bopen]", box).onclick = () => openSession(t.assignee, { taskId: t.id })
1328
+ if (!$("[data-bset]", box)) return
1329
+ $("[data-bset]", box).onclick = () => { $("[data-bform]", box).hidden = false }
1330
+ $("[data-bsave]", box).onclick = () => send({ action: "continue", limits: readLimits("bl-" + t.id.replace(/\W/g, "")) })
1331
+ }
1332
+
1333
+ // --- the farm: the same board, as a place. Cards are crops that move from the
1334
+ // shed to the field to the silo; bots walk to the crop they are working on,
1335
+ // wait at the barn when they need you, and rest at the farmhouse.
1336
+ const farmState = { built: false, crops: new Map(), bots: new Map(), timer: null, observer: null, pid: null }
1337
+ const FARM_ZONES = [
1338
+ { id: "house", label: "Farmhouse", icon: "🏠", x: .012, w: .125 },
1339
+ { id: "shed", label: "To do", icon: "🌱", x: .147, w: .165 },
1340
+ { id: "field", label: "Doing", icon: "🌾", x: .322, w: .335 },
1341
+ { id: "barn", label: "Needs you", icon: "🔔", x: .667, w: .145 },
1342
+ { id: "silo", label: "Done", icon: "🌽", x: .822, w: .166 },
1343
+ ]
1344
+ const GROUND = [0.27, 0.97]
1345
+ const cropZone = (t) => ({ todo: "shed", doing: "field", needs: "barn", done: "silo" })[colOf(t)]
1346
+ function cropGlyph(t) {
1347
+ if (t.children) return "🚜"
1348
+ if (t.status === "blocked") return "🌰"
1349
+ const c = colOf(t)
1350
+ if (c === "needs") return t.budget ? "⏳" : t.kind === "question" ? "❓" : "🔔"
1351
+ return { todo: "🌱", doing: "🌾", done: "🌽" }[c]
1352
+ }
1353
+ function cropGrowth(t, tasks) {
1354
+ if (t.status === "done") return 1
1355
+ if (t.children) {
1356
+ const kids = t.children.map((id) => tasks.find((x) => x.id === id)).filter(Boolean)
1357
+ return kids.length ? kids.filter((k) => ["done", "cancelled"].includes(k.status)).length / kids.length : 0
1358
+ }
1359
+ if (t.status !== "active") return 0
1360
+ if (t.limits) return Math.min(1, usageFrac(t))
1361
+ return Math.min(.95, (Date.now() - (t.startedAt ?? Date.now())) / (20 * 60_000))
1362
+ }
1363
+ function renderFarm(p, tasks, team) {
1364
+ const farm = $("#w-farm")
1365
+ if (!farm || farm.hidden) return
1366
+ if (!farmState.built || farmState.pid !== p.id) {
1367
+ farm.innerHTML = `<div class="sun"></div><div class="cloud" style="animation-delay:-12s"></div><div class="cloud" style="top:52px;animation-delay:-40s;opacity:.7"></div>
1368
+ ${FARM_ZONES.map((z) => `<div class="zone ${z.id}" data-z="${z.id}"><span class="zl"><i>${z.icon}</i>${z.label} <b data-n></b></span></div>`).join("")}
1369
+ <div class="legend">Bots walk to the card they are working on · click a bot or a crop to open it</div>`
1370
+ farmState.crops.clear()
1371
+ farmState.bots.clear()
1372
+ farmState.built = true
1373
+ farmState.pid = p.id
1374
+ farmState.observer?.disconnect()
1375
+ farmState.observer = new ResizeObserver(() => { const q = (state.projects ?? []).find((x) => x.id === farmState.pid); if (q && view() === "farm") renderFarm(q, tasksOf(q), teamOf(q)) })
1376
+ farmState.observer.observe(farm)
1377
+ clearInterval(farmState.timer)
1378
+ farmState.timer = setInterval(farmSparks, 650)
1379
+ }
1380
+ const W = farm.clientWidth, H = farm.clientHeight
1381
+ if (!W || !H) return
1382
+ const rect = {}
1383
+ for (const z of FARM_ZONES) {
1384
+ const r = { x: z.x * W, y: GROUND[0] * H, w: z.w * W, h: (GROUND[1] - GROUND[0]) * H }
1385
+ rect[z.id] = r
1386
+ const el = $(`[data-z="${z.id}"]`, farm)
1387
+ Object.assign(el.style, { left: r.x + "px", top: r.y + "px", width: r.w + "px", height: r.h + "px" })
1388
+ }
1389
+ // crops
1390
+ const byZone = { shed: [], field: [], barn: [], silo: [] }
1391
+ for (const t of tasks) byZone[cropZone(t)]?.push(t)
1392
+ const place = new Map()
1393
+ for (const [zid, list] of Object.entries(byZone)) {
1394
+ const r = rect[zid]
1395
+ $(`[data-z="${zid}"] [data-n]`, farm).textContent = list.length ? `(${list.length})` : ""
1396
+ // In the field each crop has room below it for the bots working it.
1397
+ const field = zid === "field"
1398
+ const cw = field ? 84 : 62
1399
+ const cols = Math.max(1, Math.floor((r.w - 12) / cw))
1400
+ const rows = Math.ceil(list.length / cols)
1401
+ const ch = Math.min(field ? 132 : 66, (r.h - 30) / Math.max(1, rows))
1402
+ const gap = cols > 1 ? Math.min(cw + 10, (r.w - 12 - 58) / (cols - 1)) : 0
1403
+ list.forEach((t, i) => place.set(t.id, { x: r.x + 6 + (i % cols) * gap, y: r.y + 28 + Math.floor(i / cols) * ch }))
1404
+ }
1405
+ const live = new Set()
1406
+ for (const t of tasks) {
1407
+ const pos = place.get(t.id)
1408
+ if (!pos) continue
1409
+ live.add(t.id)
1410
+ let el = farmState.crops.get(t.id)
1411
+ if (!el) {
1412
+ el = document.createElement("div")
1413
+ el.className = "crop"
1414
+ el.onclick = () => openTask(t.id)
1415
+ farm.appendChild(el)
1416
+ farmState.crops.set(t.id, el)
1417
+ el.style.left = pos.x + "px"; el.style.top = (pos.y - 20) + "px"; el.style.opacity = "0"
1418
+ requestAnimationFrame(() => { el.style.opacity = "1" })
1419
+ }
1420
+ const g = cropGrowth(t, tasks)
1421
+ el.className = `crop ${colOf(t) === "doing" ? "active" : ""}`
1422
+ el.title = `${t.stage ? t.stage + " · " : ""}${t.title} — ${t.children ? "split into " + t.children.length : cardStatus(t, tasks)}`
1423
+ el.innerHTML = `<span class="g">${cropGlyph(t)}</span><span class="t">${esc(t.item ? `${t.item.index}/${t.item.total} ` : "")}${esc(t.title)}</span>${colOf(t) === "doing" ? `<span class="grow"><i style="width:${Math.round(g * 100)}%"></i></span>` : ""}`
1424
+ el.style.left = pos.x + "px"
1425
+ el.style.top = pos.y + "px"
1426
+ }
1427
+ for (const [id, el] of farmState.crops) if (!live.has(id)) { el.style.opacity = "0"; setTimeout(() => el.remove(), 400); farmState.crops.delete(id) }
1428
+ // bots
1429
+ const houseSlots = [], barnSlots = []
1430
+ const where = (s) => {
1431
+ const card = tasks.find((t) => t.assignee === s.id && t.status === "active")
1432
+ if (card && place.get(card.id) && s.status !== "waiting") return { kind: "crop", id: card.id }
1433
+ if (s.status === "waiting" || s.status === "error") return { kind: "barn" }
1434
+ return { kind: "house" }
1435
+ }
1436
+ const atCrop = new Map()
1437
+ const liveBots = new Set()
1438
+ for (const s of team) {
1439
+ liveBots.add(s.id)
1440
+ const w = where(s)
1441
+ let x, y
1442
+ if (w.kind === "crop") {
1443
+ const c = place.get(w.id)
1444
+ const n = atCrop.get(w.id) ?? 0
1445
+ atCrop.set(w.id, n + 1)
1446
+ x = c.x + 6 + n * 24; y = c.y + 62 + n * 4
1447
+ } else {
1448
+ const list = w.kind === "barn" ? barnSlots : houseSlots
1449
+ const r = rect[w.kind === "barn" ? "barn" : "house"]
1450
+ const i = list.length
1451
+ list.push(s.id)
1452
+ const per = Math.max(1, Math.floor((r.w - 10) / 54))
1453
+ x = r.x + 8 + (i % per) * 54; y = r.y + 40 + Math.floor(i / per) * 60
1454
+ }
1455
+ let el = farmState.bots.get(s.id)
1456
+ if (!el) {
1457
+ el = document.createElement("div")
1458
+ el.className = "fbot"
1459
+ el.innerHTML = `<img alt="" /><span class="hat"></span><span class="nm"></span><span class="say" hidden></span>`
1460
+ el.onclick = () => openSession(s.id)
1461
+ farm.appendChild(el)
1462
+ farmState.bots.set(s.id, el)
1463
+ el.style.left = (rect.house.x + 8) + "px"; el.style.top = (rect.house.y + 34) + "px"
1464
+ el.dataset.x = el.style.left; el.dataset.y = el.style.top
1465
+ }
1466
+ $("img", el).src = avatarUrl(s)
1467
+ $(".nm", el).textContent = "@" + s.handle
1468
+ const lv = levelOfBot(s)
1469
+ $(".hat", el).textContent = lv?.hat ?? ""
1470
+ el.style.setProperty("--fg", s.color?.fg ?? "#fff")
1471
+ const say = s.status === "busy" ? (s.currentTool ? s.currentTool.name.replace(/^[\w-]+?_(?=\w)/, "") : "…") : s.status === "waiting" ? "needs you" : s.status === "error" ? "⚠" : ""
1472
+ const sayEl = $(".say", el)
1473
+ sayEl.hidden = !say
1474
+ sayEl.textContent = say
1475
+ const moved = el.dataset.x !== x + "px" || el.dataset.y !== y + "px"
1476
+ el.className = `fbot ${esc(s.status)} ${(lv?.level ?? 0) >= 6 ? "gold" : ""} ${moved ? "walking" : el.classList.contains("walking") ? "walking" : ""}`
1477
+ if (moved) {
1478
+ el.dataset.x = x + "px"; el.dataset.y = y + "px"
1479
+ el.style.left = x + "px"; el.style.top = y + "px"
1480
+ clearTimeout(el._walk)
1481
+ el._walk = setTimeout(() => el.classList.remove("walking"), 1600)
1482
+ }
1483
+ el.title = `@${s.handle} · ${s.personaTitle ?? s.persona ?? ""} — ${botDoing(s)}`
1484
+ }
1485
+ for (const [id, el] of farmState.bots) if (!liveBots.has(id)) { el.remove(); farmState.bots.delete(id) }
1486
+ }
1487
+ /** Busy bots throw off sparks: tokens being spent, roughly. */
1488
+ function farmSparks() {
1489
+ const farm = $("#w-farm")
1490
+ if (!farm || farm.hidden || document.visibilityState !== "visible" || matchMedia("(prefers-reduced-motion: reduce)").matches) return
1491
+ for (const el of farm.querySelectorAll(".fbot.busy")) {
1492
+ const sp = document.createElement("span")
1493
+ sp.className = "spark"
1494
+ sp.style.left = (parseFloat(el.style.left) + 20 + Math.random() * 10) + "px"
1495
+ sp.style.top = (parseFloat(el.style.top) + 4) + "px"
1496
+ sp.style.setProperty("--dx", `${Math.round(Math.random() * 30 - 15)}px`)
1497
+ farm.appendChild(sp)
1498
+ setTimeout(() => sp.remove(), 1500)
1499
+ }
1500
+ }
1501
+
1502
+ // --- levels: XP per kind of bot per workspace (from the server's farmhands)
1503
+ function levelOfBot(s) {
1504
+ if (!s?.persona) return null
1505
+ const p = (state.projects ?? []).find((x) => x.id === s.project)
1506
+ const wsId = p?.workspaceId ?? ui.ws
1507
+ const l = (state.levels?.[wsId] ?? []).find((x) => x.persona === s.persona)
1508
+ return l && l.xp > 0 ? l : null
1509
+ }
1510
+ const shortModel = (m) => String(m ?? "").split("/").pop().replace(/^(us|eu|apac|global)\./, "").replace(/^anthropic\./, "").replace(/^claude-/, "").replace(/-\d{8}.*$/, "")
1511
+
1512
+ // --- PR packet: written from the handoffs and git; copy it, or open the PR with gh
1513
+ async function openPacket(pid) {
1514
+ const el = openDrawer("packet", pid, `<div class="dh"><div><div class="s">PR packet</div><div style="font-size:16px;font-weight:600" id="pk-title">Writing…</div></div>
1515
+ <div class="acts"><button class="sm" id="pk-copy">Copy</button><button class="sm primary" id="pk-open" title="Push the branch and run gh pr create">Open PR…</button><button class="sm ghost" id="pk-close">Close</button></div></div>
1516
+ <div class="kv" id="pk-kv"></div>
1517
+ <div class="body"><pre class="packet" id="pk-body">…</pre></div>`)
1518
+ $("#pk-close", el).onclick = () => closeDrawer()
1519
+ let pk
1520
+ try { pk = await api(`/api/projects/${encodeURIComponent(pid)}/pr`) } catch (e) { $("#pk-body", el).textContent = e.message; return }
1521
+ $("#pk-title", el).textContent = pk.title
1522
+ $("#pk-kv", el).innerHTML = `<span>saved as <b>${esc(fmt.path(pk.path))}</b></span>${pk.base ? `<span>against <b>${esc(pk.base)}</b></span>` : ""}<span>tests found <b>${pk.tests?.length ?? 0}</b></span><span>files <b>${pk.changed?.length ?? 0}</b></span>`
1523
+ $("#pk-body", el).textContent = pk.markdown
1524
+ $("#pk-copy", el).onclick = async () => { await navigator.clipboard.writeText(pk.markdown).catch(() => {}); $("#pk-copy", el).textContent = "Copied" }
1525
+ $("#pk-open", el).onclick = async () => {
1526
+ if (!confirm(`Push ${pk.branch} and open a pull request with this description (gh pr create)?`)) return
1527
+ $("#pk-open", el).disabled = true
1528
+ try { const out = await api(`/api/projects/${encodeURIComponent(pid)}/pr`, { method: "POST", body: {} }); $("#pk-kv", el).insertAdjacentHTML("beforeend", `<span>opened <b><a href="${esc(out.url)}" target="_blank" rel="noopener">${esc(out.url)}</a></b></span>`) }
1529
+ catch (e) { alert(e.message) }
1530
+ $("#pk-open", el).disabled = false
1531
+ }
1532
+ }
1533
+
1534
+ // --- replays: a workstream's timeline, scrubbable; saved as one file, opened anywhere
1535
+ let rp = null
1536
+ async function renderReplay(key) {
1537
+ if (mainKey === key && rp) return
1538
+ mainKey = key
1539
+ const [src, ...rest] = String(ui.id).split(":")
1540
+ const id = rest.join(":")
1541
+ $("#main").innerHTML = `<div class="page"><div class="note">Loading the replay…</div></div>`
1542
+ let doc
1543
+ try {
1544
+ doc = src === "p" ? await (await fetch(`/api/projects/${encodeURIComponent(id)}/replay`)).json() : await api(`/api/replays/${encodeURIComponent(id)}`)
1545
+ if (doc.error) throw new Error(doc.error)
1546
+ } catch (e) { $("#main").innerHTML = `<div class="page"><div class="err">${esc(e.message)}</div></div>`; return }
1547
+ rp = { doc, t: doc.start, playing: false, speed: 60, timer: null, src, id }
1548
+ const len = Math.max(1, doc.end - doc.start)
1549
+ $("#main").innerHTML = `
1550
+ <div class="whead"><div class="row1"><span class="dot done"></span><h1>${esc(doc.project.name)}</h1><span class="chip">replay</span>${doc.project.branch ? `<span class="chip">${esc(doc.project.branch)}</span>` : ""}
1551
+ <div class="acts">${src === "p" ? `<a class="sm ghost btnlink" href="/api/projects/${encodeURIComponent(id)}/replay?download=1" download>Save replay file</a><button class="sm ghost" id="rp-back">Back to the workstream</button>` : `<span class="note">imported ${esc(doc.exportedAt?.slice(0, 10) ?? "")}</span>`}</div></div>
1552
+ <div class="scrub">
1553
+ <button class="sm primary" id="rp-play">▶ Play</button>
1554
+ <select id="rp-speed" title="Speed">${[1, 10, 60, 300, 1800].map((x) => `<option value="${x}" ${x === 60 ? "selected" : ""}>${x}×</option>`).join("")}</select>
1555
+ <button class="sm ghost" id="rp-next" title="Jump to the next thing that happened">Next ›</button>
1556
+ <input type="range" id="rp-range" min="0" max="${len}" value="0" step="1000" />
1557
+ <span class="mono note" id="rp-time"></span>
1558
+ </div></div>
1559
+ <div class="wlayout">
1560
+ <aside class="rail"><div class="rh">Team</div><div class="list" id="rp-team"></div></aside>
1561
+ <div class="wmain"><div class="wbody"><div class="farm" id="w-farm"></div><div class="splitter"></div>
1562
+ <section class="chat"><div class="ch"><b>Chat</b><span class="note" id="rp-count"></span></div><div class="log" id="rp-log"></div></section></div></div>
1563
+ </div>`
1564
+ farmState.built = false
1565
+ $("#rp-back")?.addEventListener("click", () => go("stream", id))
1566
+ $("#rp-range").oninput = (e) => { rp.t = doc.start + Number(e.target.value); paintReplay() }
1567
+ $("#rp-speed").onchange = (e) => { rp.speed = Number(e.target.value) }
1568
+ $("#rp-next").onclick = () => { const n = doc.events.find((ev) => ev.t > rp.t + 500); if (n) { rp.t = n.t; paintReplay() } }
1569
+ $("#rp-play").onclick = () => {
1570
+ rp.playing = !rp.playing
1571
+ $("#rp-play").textContent = rp.playing ? "❚❚ Pause" : "▶ Play"
1572
+ clearInterval(rp.timer)
1573
+ if (rp.playing) rp.timer = setInterval(() => {
1574
+ if (!document.body.contains($("#rp-range"))) return clearInterval(rp.timer)
1575
+ rp.t = Math.min(doc.end, rp.t + 250 * rp.speed)
1576
+ if (rp.t >= doc.end) { rp.playing = false; clearInterval(rp.timer); $("#rp-play").textContent = "▶ Play" }
1577
+ paintReplay()
1578
+ }, 250)
1579
+ }
1580
+ paintReplay()
1581
+ }
1582
+ /** The workstream as it was at rp.t: fold the events up to then. */
1583
+ function replayStateAt(doc, t) {
1584
+ const bots = new Map(), tasks = new Map(), msgs = []
1585
+ let harvest = null
1586
+ for (const ev of doc.events) {
1587
+ if (ev.t > t) break
1588
+ if (ev.k === "bot") bots.set(ev.bot.id, { ...(bots.get(ev.bot.id) ?? {}), ...ev.bot, currentTool: ev.bot.tool ? { name: ev.bot.tool } : null, project: "replay" })
1589
+ else if (ev.k === "task") tasks.set(ev.task.id, { ...ev.task, projectId: "replay" })
1590
+ else if (ev.k === "msg") msgs.push({ ...ev.msg, at: ev.t })
1591
+ else if (ev.k === "note") msgs.push({ id: "n" + ev.t, sys: true, text: ev.text, level: ev.level, at: ev.t })
1592
+ else if (ev.k === "harvest") harvest = ev.harvest
1593
+ }
1594
+ const known = new Map(doc.team.map((s) => [s.id, s]))
1595
+ const team = [...bots.values()].map((b) => ({ ...known.get(b.id), ...b }))
1596
+ return { team, tasks: [...tasks.values()].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)), msgs, harvest }
1597
+ }
1598
+ function paintReplay() {
1599
+ if (!rp) return
1600
+ const { doc } = rp
1601
+ const st = replayStateAt(doc, rp.t)
1602
+ $("#rp-range").value = String(rp.t - doc.start)
1603
+ $("#rp-time").textContent = `${new Date(rp.t).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })} · ${fmt.ago(doc.start) ? Math.round((rp.t - doc.start) / 60000) + " min in" : ""}`
1604
+ $("#rp-team").innerHTML = st.team.map((s) => `<div class="rbot ${esc(s.status)}"><span class="ava"><img src="${avatarUrl(s)}" alt="" /><span class="pip"></span></span><div class="who"><span class="top"><span class="h">@${esc(s.handle)}</span>${s.personaTitle ? `<span class="kind">${esc(s.personaTitle)}</span>` : ""}</span><span class="d">${esc(s.status === "busy" ? (s.tool ? "running " + s.tool : "thinking…") : s.status)}</span></div></div>`).join("")
1605
+ renderFarm({ id: "replay:" + rp.id }, st.tasks, st.team)
1606
+ const log = $("#rp-log")
1607
+ $("#rp-count").textContent = `${st.msgs.length} messages`
1608
+ log.innerHTML = st.msgs.slice(-80).map((m) => m.sys
1609
+ ? `<div class="sys ${esc(m.level ?? "")}">${fmt.time(m.at)} ${esc(m.text)}</div>`
1610
+ : `<div class="msg ${m.from === "operator" ? "op" : ""}">${m.from === "operator" ? `<span class="av">you</span>` : `<img class="av" src="${avatarUrl({ id: m.fromId ?? m.from, handle: m.from })}" alt="" />`}<div class="c"><div class="m"><b>${m.from === "operator" ? "you" : "@" + esc(m.from)}</b><span>${fmt.time(m.at)}</span></div><div class="b">${esc(m.text)}</div></div></div>`).join("")
1611
+ log.scrollTop = log.scrollHeight
1612
+ if (st.harvest && !rp.celebrated) { rp.celebrated = true; celebrate(st.harvest, { replay: true }) }
1613
+ }
1614
+ async function importReplayFile() {
1615
+ const input = document.createElement("input")
1616
+ input.type = "file"
1617
+ input.accept = ".json,application/json"
1618
+ input.onchange = async () => {
1619
+ const f = input.files?.[0]
1620
+ if (!f) return
1621
+ try {
1622
+ const doc = JSON.parse(await f.text())
1623
+ const out = await api("/api/replays", { method: "POST", body: doc })
1624
+ go("replay", "s:" + out.id)
1625
+ } catch (e) { alert(e.message) }
1626
+ }
1627
+ input.click()
1628
+ }
1629
+
1630
+ // --- harvest: a workstream that finished gets a moment, and a line in the log
1631
+ const seenHarvests = new Set(store.get("harvests", []))
1632
+ function checkHarvests() {
1633
+ for (const p of state.projects ?? []) {
1634
+ const h = p.harvest
1635
+ if (!h || seenHarvests.has(h.id)) continue
1636
+ seenHarvests.add(h.id)
1637
+ store.set("harvests", [...seenHarvests].slice(-200))
1638
+ // Only celebrate what finished while you were here, not old ones on first load.
1639
+ if (Date.now() - h.at < 10 * 60_000 && p.workspaceId === ui.ws) celebrate(h)
1640
+ }
1641
+ }
1642
+ function celebrate(h, { replay = false } = {}) {
1643
+ document.querySelector(".harvest")?.remove()
1644
+ const crops = ["🌽", "🌾", "🥕", "🍅", "🍎", "🌻", "🥔", "🍓"]
1645
+ const still = matchMedia("(prefers-reduced-motion: reduce)").matches
1646
+ const el = document.createElement("div")
1647
+ el.className = "harvest"
1648
+ el.innerHTML = `${still ? "" : Array.from({ length: 36 }, (_, i) => `<span class="fall" style="left:${Math.random() * 100}%;animation-delay:${(Math.random() * 1.2).toFixed(2)}s;animation-duration:${(2.2 + Math.random() * 1.8).toFixed(2)}s">${crops[i % crops.length]}</span>`).join("")}
1649
+ <div class="hcard" role="dialog" aria-label="Harvest">
1650
+ <div class="hbig">🧺</div>
1651
+ <h2>Harvest${replay ? " (replay)" : ""}: ${esc(h.name)}</h2>
1652
+ <div class="hstats">
1653
+ <div><b>${h.cards}</b><i>cards</i></div>
1654
+ <div><b>${Math.round(h.minutes)}m</b><i>start to finish</i></div>
1655
+ <div><b>${fmt.money(h.usd)}</b><i>${h.budget ? `of ${fmt.money(h.budget)}` : "spent"}</i></div>
1656
+ ${h.saved > 0 ? `<div class="good"><b>${fmt.money(h.saved)}</b><i>under budget</i></div>` : ""}
1657
+ <div><b>+${h.xp}</b><i>XP</i></div>
1658
+ </div>
1659
+ ${(h.levelUps ?? []).length ? `<div class="lvups">${h.levelUps.map((l) => `<span class="lvup">${l.hat ?? "⭐"} ${esc(l.persona)} reached level ${l.level} — ${esc(l.title)}</span>`).join("")}</div>` : ""}
1660
+ <div class="hacts">${!replay ? `<button class="sm" data-h="packet">PR packet</button><button class="sm" data-h="apply" title="Turn the work into patches and apply them to your real repos, uncommitted">Apply to my repos…</button>` : ""}<button class="primary" data-h="close">Nice</button></div>
1661
+ </div>`
1662
+ document.body.appendChild(el)
1663
+ const close = () => el.remove()
1664
+ el.onclick = (e) => { if (e.target === el) close() }
1665
+ $("[data-h=close]", el).onclick = close
1666
+ $("[data-h=packet]", el)?.addEventListener("click", () => { close(); openPacket(h.project) })
1667
+ $("[data-h=apply]", el)?.addEventListener("click", () => { close(); openApply(h.project) })
1668
+ setTimeout(() => el.classList.add("settled"), 3500)
1669
+ }
1670
+ async function harvestPanel(wsId) {
1671
+ const box = $("#home-harvest")
1672
+ if (!box) return
1673
+ let d
1674
+ try { d = await api(`/api/workspaces/${encodeURIComponent(wsId)}/harvest`) } catch { return }
1675
+ const reps = await api("/api/replays").catch(() => [])
1676
+ const w = d.week
1677
+ box.innerHTML = `<div class="hpanel">
1678
+ <section class="panel"><h3>Harvest log <span class="note">this week</span></h3>
1679
+ <div class="hstats small"><div><b>${w.stories}</b><i>stories</i></div><div><b>${w.cards}</b><i>cards</i></div><div><b>${fmt.money(w.usd)}</b><i>spent</i></div><div class="good"><b>${fmt.money(w.saved)}</b><i>under budget</i></div><div><b>${(w.botMinutes / 60).toFixed(1)}h</b><i>bot work</i></div></div>
1680
+ ${d.recent.length ? `<div class="hlist">${d.recent.map((h) => `<div class="hrow"><span>🧺</span><b>${esc(h.name)}</b><span class="note mono">${fmt.ago(h.at)} ago · ${h.cards} cards · ${fmt.money(h.usd)}${h.saved > 0 ? ` · <span style="color:var(--ok)">${fmt.money(h.saved)} under</span>` : ""} · +${h.xp} XP</span>${(h.levelUps ?? []).map((l) => `<span title="${esc(l.persona)} level ${l.level}">${l.hat ?? "⭐"}</span>`).join("")}<button class="sm ghost" data-rp="${esc(h.project)}">Replay</button></div>`).join("")}</div>` : `<div class="note">Nothing harvested yet — finish a workstream.</div>`}
1681
+ </section>
1682
+ <section class="panel"><h3>The farmhands</h3>
1683
+ ${d.levels.length ? d.levels.sort((a, b) => b.xp - a.xp).map((l) => `<div class="lrow" title="${esc((l.perks ?? []).map((x) => "• " + x.label).join("\\n"))}"><span class="lhat">${l.hat ?? "🌱"}</span><b>${esc(l.persona)}</b><span class="note">Lv ${l.level} ${esc(l.title)}</span><span class="lbar"><i style="width:${Math.round((l.progress ?? 0) * 100)}%"></i></span><span class="mono note">${l.xp} XP</span></div>`).join("") : `<div class="note">Bots earn XP for clean handoffs: no pauses, under budget, nothing left open.</div>`}
1684
+ </section>
1685
+ <section class="panel"><h3>Replays <button class="sm ghost" id="rp-import" style="float:right">Open a replay file…</button></h3>
1686
+ ${reps.length ? reps.slice(0, 8).map((r) => `<div class="hrow"><span>🎞️</span><b>${esc(r.name)}</b><span class="note mono">${r.events} events · ${new Date(r.end ?? 0).toLocaleDateString()}</span><button class="sm ghost" data-rps="${esc(r.id)}">Open</button></div>`).join("") : `<div class="note">Save one from a workstream (Replay → Save replay file) and open it here or on another computer.</div>`}
1687
+ </section></div>`
1688
+ for (const b of $$("[data-rp]", box)) b.onclick = () => go("replay", "p:" + b.dataset.rp)
1689
+ for (const b of $$("[data-rps]", box)) b.onclick = () => go("replay", "s:" + b.dataset.rps)
1690
+ $("#rp-import", box).onclick = importReplayFile
1691
+ }
1692
+
1693
+ function wireSplit() {
1694
+ const body = $(".wbody")
1695
+ const bar = $("#w-split")
1696
+ const set = (pct) => body.style.setProperty("--split", `${Math.min(80, Math.max(15, pct))}%`)
1697
+ set(store.get("split", 44))
1698
+ bar.onpointerdown = (e) => {
1699
+ bar.setPointerCapture(e.pointerId)
1700
+ bar.classList.add("drag")
1701
+ const top = body.getBoundingClientRect().top
1702
+ const h = body.getBoundingClientRect().height
1703
+ bar.onpointermove = (m) => { const pct = ((m.clientY - top) / h) * 100; set(pct); store.set("split", Math.round(Math.min(80, Math.max(15, pct)))) }
1704
+ bar.onpointerup = () => { bar.onpointermove = null; bar.classList.remove("drag") }
1705
+ }
1706
+ bar.ondblclick = () => { set(44); store.set("split", 44) }
1707
+ }
1708
+
1709
+ let dragId = null
1710
+ const seenCards = new Set()
1711
+ function cardHtml(t, tasks) {
1712
+ const who = sessionById(t.assignee)
1713
+ const kids = t.children ? t.children.map((id) => tasks.find((x) => x.id === id)).filter(Boolean) : null
1714
+ const frac = usageFrac(t)
1715
+ const kidDone = kids ? kids.filter((k) => ["done", "cancelled"].includes(k.status)).length : 0
1716
+ const kidRun = kids ? kids.filter((k) => k.status === "active").length : 0
1717
+ const status = kids ? `${kidDone}/${kids.length} pieces done${kidRun ? ` · ${kidRun} in progress` : ""}` : t.budget ? (t.budget.kind === "loop" ? `paused: looks stuck` : t.budget.kind === "rounds" ? `paused: ${t.budget.round} fix rounds` : `paused: ${t.budget.kind === "usd" ? "dollar" : t.budget.kind === "tokens" ? "token" : "time"} limit reached`) : cardStatus(t, tasks)
1718
+ return `<div class="card ${esc(t.status)} ${t.kind === "question" ? "question" : ""} ${kids ? "group" : ""}" draggable="${kids ? "false" : "true"}" data-t="${esc(t.id)}">
1719
+ ${t.stage ? `<span class="stage">${esc(t.stage)}${t.item ? ` <span class="part">· piece ${t.item.index}/${t.item.total}</span>` : ""}${t.split && !kids ? ` <span class="part">· splits for up to ${t.split.parallel}</span>` : ""}</span>` : ""}
1720
+ <span class="ttl">${esc(t.title)}</span>
1721
+ ${t.difficulty || t.xp || t.round > 1 || t.recheck ? `<span class="tags">${t.round > 1 ? `<span class="round" title="${esc((t.history ?? []).map((h) => `round ${h.round}${h.by ? " by @" + h.by : ""}`).join("\n"))}">↩ round ${t.round}</span>` : ""}${t.recheck ? `<span class="round chk">↻ verify ${t.recheck.round + 1}</span>` : ""}${t.difficulty ? `<span class="diff d-${esc(t.difficulty.level)}" title="${esc(t.difficulty.why)}${t.difficulty.route ? " → " + esc([t.difficulty.route.model?.split("/").pop(), t.difficulty.route.variant].filter(Boolean).join(" · ")) : ""}${t.difficulty.route?.fellBack ? " — fallback: " + esc(t.difficulty.route.fellBack.why) : ""}">${esc(t.difficulty.level)}${t.difficulty.route?.model ? " · " + esc(shortModel(t.difficulty.route.model)) : t.difficulty.route?.variant ? " · " + esc(t.difficulty.route.variant) : ""}${t.difficulty.route?.fellBack ? " ↓" : ""}</span>` : ""}${t.xp ? `<span class="xp" title="${esc(t.xp.reasons.join("\n"))}">+${t.xp.gained} XP</span>` : ""}</span>` : ""}
1722
+ ${kids ? `<div class="pieces"><span class="track"><i style="width:${(kidDone / kids.length) * 100}%"></i><i class="run" style="width:${(kidRun / kids.length) * 100}%"></i></span>
1723
+ <span class="avs">${[...new Set(kids.map((k) => k.assignee).filter(Boolean))].map((id) => sessionById(id)).filter(Boolean).map((b) => `<img src="${avatarUrl(b)}" alt="@${esc(b.handle)}" title="@${esc(b.handle)}" />`).join("")}</span></div>` : ""}
1724
+ ${t.limits && t.status === "active" && !kids ? `<div class="usage" title="${esc(Object.entries(t.limits).map(([k, v]) => `${k}: ${LIMIT_KINDS.find(([x]) => x === k)[2](t.usage?.[k] ?? 0)} of ${LIMIT_KINDS.find(([x]) => x === k)[2](v)}`).join("\n"))}"><i class="${frac >= 1 ? "over" : ""}" style="width:${Math.min(100, Math.round(frac * 100))}%"></i></div>` : ""}
1725
+ <div class="meta">${who ? `<img src="${avatarUrl(who)}" alt="" />` : ""}
1726
+ <span class="who"><span class="h" ${who?.color ? `style="color:${esc(who.color.fg)}"` : ""}>${who ? "@" + esc(who.handle) : t.owner === "human" ? "you" : kids ? `${kids.length} cards` : esc(t.role ?? "unassigned")}</span>
1727
+ <span class="st">${esc(status)}</span></span></div></div>`
1728
+ }
1729
+ function renderBoard(p, tasks) {
1730
+ const board = $("#w-board")
1731
+ // FLIP: remember where every card was, render, then slide each from there.
1732
+ const before = new Map($$(".card[data-t]", board).map((c) => [c.dataset.t, c.getBoundingClientRect()]))
1733
+ const first = !before.size
1734
+ board.innerHTML = COLS.map((c) => {
1735
+ const cards = tasks.filter((t) => colOf(t) === c.id)
1736
+ return `<div class="col ${c.id}" data-col="${c.id}"><h5>${c.label}<span>${cards.length}</span></h5><div class="cards">
1737
+ ${cards.map((t) => cardHtml(t, tasks)).join("")}
1738
+ ${c.id === "todo" && p.status !== "archived" ? `<button class="addcard" data-add="1">+ Add a card</button>` : ""}
1739
+ </div></div>`
1740
+ }).join("")
1741
+ const still = !matchMedia("(prefers-reduced-motion: reduce)").matches
1742
+ for (const c of $$(".card[data-t]", board)) {
1743
+ const was = before.get(c.dataset.t)
1744
+ if (!still) continue
1745
+ if (was) {
1746
+ const now = c.getBoundingClientRect()
1747
+ const dx = was.left - now.left, dy = was.top - now.top
1748
+ if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
1749
+ c.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "none" }], { duration: 450, easing: "cubic-bezier(.2,.8,.2,1)" })
1750
+ if (Math.abs(dx) > 40) { c.classList.add("flash"); setTimeout(() => c.classList.remove("flash"), 900) }
1751
+ }
1752
+ } else if (!first && !seenCards.has(c.dataset.t)) c.classList.add("enter")
1753
+ seenCards.add(c.dataset.t)
1754
+ }
1755
+ for (const c of $$(".card", board)) {
1756
+ c.onclick = () => openTask(c.dataset.t)
1757
+ c.ondragstart = (e) => { dragId = c.dataset.t; c.classList.add("dragging"); e.dataTransfer.effectAllowed = "move" }
1758
+ c.ondragend = () => { dragId = null; c.classList.remove("dragging") }
1759
+ }
1760
+ const add = $("[data-add]", board)
1761
+ if (add) add.onclick = () => openNewCard(p.id)
1762
+ for (const col of $$(".col", board)) {
1763
+ if (col.dataset.col === "doing") continue // work starts when a bot picks it up, not by dragging
1764
+ col.ondragover = (e) => { if (dragId) { e.preventDefault(); col.classList.add("over") } }
1765
+ col.ondragleave = () => col.classList.remove("over")
1766
+ col.ondrop = async (e) => {
1767
+ e.preventDefault()
1768
+ col.classList.remove("over")
1769
+ const t = (state.tasks ?? []).find((x) => x.id === dragId)
1770
+ if (!t) return
1771
+ if (col.dataset.col === "todo" && canSendBack(t)) { await sendBackPrompt(t); return }
1772
+ const status = col.dataset.col === "done" ? "done" : col.dataset.col === "needs" ? "review" : t.dependsOn?.length && t.status === "blocked" ? "blocked" : t.assignee || t.role ? "queued" : "backlog"
1773
+ if (status === t.status) return
1774
+ await api(`/api/tasks/${encodeURIComponent(t.id)}`, { method: "POST", body: { status, queuedAt: Date.now() } }).catch((err) => alert(err.message))
1775
+ }
1776
+ }
1777
+ }
1778
+
1779
+ const expanded = new Set() // long chat messages opened in full
1780
+ const seenMsgs = new Set() // messages already shown, so only new ones animate in
1781
+ function renderLog(p, room, events, team, pending = []) {
1782
+ const log = $("#w-log")
1783
+ const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 60
1784
+ $("#w-room").textContent = room?.name ? "#" + room.name.replace(/^#/, "") : "#" + p.id
1785
+ $("#w-members").textContent = room ? `${room.members.length} members${room.muted ? " · muted" : ""}` : ""
1786
+ const handles = new Set(team.map((s) => s.handle))
1787
+ const items = [
1788
+ ...(room?.messages ?? []).map((m) => ({ at: m.at, m })),
1789
+ // Only the moments that change what the team is doing; the rest is in the activity of each bot.
1790
+ ...events.filter((e) => e.level === "error" || /picked up|went idle|is stuck|needs you|needs a permission|aborted|reminded/.test(e.text)).slice(0, 30).map((e) => ({ at: e.at, e })),
1791
+ ].sort((a, b) => a.at - b.at)
1792
+ const byHandle = Object.fromEntries(team.map((s) => [s.handle, s]))
1793
+ const linkify = (text) => esc(text).replace(/@([a-z]+-[a-z]+)/g, (m, h) => {
1794
+ const s = byHandle[h]
1795
+ return s ? `<span class="at" style="--atc:${esc(s.color?.fg ?? "")}">@${h}</span>` : m
1796
+ })
1797
+ let prev = null
1798
+ const logPrimed = seenMsgs.size > 0
1799
+ log.innerHTML = items.length
1800
+ ? items.map(({ m, e }) => {
1801
+ if (e) { prev = null; return `<div class="sys ${esc(e.level)}">${fmt.time(e.at)} ${esc(e.text)}</div>` }
1802
+ const op = m.from === "operator"
1803
+ const who = team.find((s) => s.id === m.fromId) ?? byHandle[m.from]
1804
+ // Consecutive messages from one author within a few minutes read as one turn.
1805
+ const cont = prev && prev.from === m.from && m.at - prev.at < 180_000
1806
+ prev = m
1807
+ const style = !op && who?.color ? `style="--fg:${esc(who.color.fg)};--bg:${esc(who.color.bg)}"` : ""
1808
+ const finished = /^Finished (\S+): /.exec(m.text)
1809
+ const text = finished ? m.text.slice(finished[0].length) : m.text
1810
+ const isNew = logPrimed && !seenMsgs.has(m.id)
1811
+ seenMsgs.add(m.id)
1812
+ if (m.meta?.kind === "card") { prev = null; return cardEventHtml(m, who, text, isNew) }
1813
+ return `<div class="msg ${op ? "op" : ""} ${cont ? "cont" : ""} ${isNew ? "enter" : ""}" ${style}>
1814
+ ${op ? `<span class="av">you</span>` : `<img class="av" src="${avatarUrl({ id: m.fromId ?? m.from, handle: who?.handle ?? m.from })}" alt="" />`}
1815
+ <div class="c"><div class="m"><b>${op ? "you" : "@" + esc(m.from)}</b>${!op && who ? `<span class="role">${esc(who.role ?? who.persona ?? "")}</span>` : ""}<span>${fmt.time(m.at)}</span></div>
1816
+ <div class="b ${text.length > 700 && !expanded.has(m.id) ? "long" : ""}">${finished ? `<span class="tag">handed off ${esc(finished[1])}</span>\n` : ""}${linkify(text)}</div>
1817
+ ${text.length > 700 ? `<button class="more" data-more="${esc(m.id)}">${expanded.has(m.id) ? "show less" : `show all ${fmt.n(text.length)} characters`}</button>` : ""}</div></div>`
1818
+ }).join("")
1819
+ : `<div class="empty">Nothing said yet. What you post here reaches the whole team; @handle reaches one bot.</div>`
1820
+ wireCardEvents(log)
1821
+ for (const b of $$("[data-more]", log)) b.onclick = () => {
1822
+ expanded.has(b.dataset.more) ? expanded.delete(b.dataset.more) : expanded.add(b.dataset.more)
1823
+ streamSig.c = null
1824
+ const keep = log.scrollTop
1825
+ renderStream(mainKey)
1826
+ log.scrollTop = keep
1827
+ }
1828
+ // Questions waiting on you sit at the bottom of the conversation, answerable in place.
1829
+ log.insertAdjacentHTML("beforeend", pending.map((t) => askBlock(t)).join(""))
1830
+ for (const t of pending) wireAsk(log, t)
1831
+ // What opencode itself is waiting on: tool permissions and its own questions.
1832
+ for (const s of team.filter((x) => x.permissions?.length || x.questions?.length)) {
1833
+ log.insertAdjacentHTML("beforeend", botAsksHtml(s))
1834
+ wireBotAsks(log, s)
1835
+ }
1836
+ if (atBottom || !streamSig.scrolled) { log.scrollTop = log.scrollHeight; streamSig.scrolled = true }
1837
+ }
1838
+
1839
+ function wireComposer(p) {
1840
+ const input = $("#w-input")
1841
+ const box = $("#w-mentions")
1842
+ let picks = []
1843
+ let sel = 0
1844
+ const close = () => { box.hidden = true; picks = [] }
1845
+ const show = () => {
1846
+ const before = input.value.slice(0, input.selectionStart)
1847
+ const m = /@([a-z-]*)$/.exec(before)
1848
+ if (!m) return close()
1849
+ picks = teamOf(p).filter((s) => s.handle?.startsWith(m[1]))
1850
+ if (!picks.length) return close()
1851
+ sel = Math.min(sel, picks.length - 1)
1852
+ box.hidden = false
1853
+ box.innerHTML = picks.map((s, i) => `<button data-i="${i}" aria-selected="${i === sel}"><img src="${avatarUrl(s)}" alt="" />@${esc(s.handle)} <span class="note">${esc(s.role ?? s.persona ?? "")}</span></button>`).join("")
1854
+ for (const b of $$("button", box)) b.onmousedown = (e) => { e.preventDefault(); pick(Number(b.dataset.i)) }
1855
+ }
1856
+ const pick = (i) => {
1857
+ const s = picks[i]
1858
+ if (!s) return
1859
+ const pos = input.selectionStart
1860
+ const before = input.value.slice(0, pos).replace(/@([a-z-]*)$/, `@${s.handle} `)
1861
+ input.value = before + input.value.slice(pos)
1862
+ input.selectionStart = input.selectionEnd = before.length
1863
+ close()
1864
+ input.focus()
1865
+ }
1866
+ const send = async () => {
1867
+ const message = input.value.trim()
1868
+ if (!message) return
1869
+ input.value = ""
1870
+ close()
1871
+ await api(`/api/projects/${encodeURIComponent(p.id)}/chat`, { method: "POST", body: { message } }).catch((e) => { alert(e.message); input.value = message })
1872
+ }
1873
+ input.oninput = () => { input.style.height = "auto"; input.style.height = Math.min(160, input.scrollHeight) + "px"; show() }
1874
+ input.onkeydown = (e) => {
1875
+ if (!box.hidden) {
1876
+ if (e.key === "ArrowDown") { e.preventDefault(); sel = (sel + 1) % picks.length; return show() }
1877
+ if (e.key === "ArrowUp") { e.preventDefault(); sel = (sel - 1 + picks.length) % picks.length; return show() }
1878
+ if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); return pick(sel) }
1879
+ if (e.key === "Escape") return close()
1880
+ }
1881
+ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send() }
1882
+ }
1883
+ input.onblur = () => setTimeout(close, 100)
1884
+ $("#w-send").onclick = send
1885
+ }
1886
+
1887
+ // ------------------------------------------------------------------ drawers
1888
+ let drawer = null // { kind, id, render }
1889
+ function openDrawer(kind, id, html) {
1890
+ closeDrawer(true)
1891
+ const scrim = document.createElement("div")
1892
+ scrim.className = "scrim"
1893
+ scrim.id = "scrim"
1894
+ scrim.onclick = () => closeDrawer()
1895
+ const el = document.createElement("section")
1896
+ el.className = "drawer"
1897
+ el.id = "drawer"
1898
+ el.innerHTML = html
1899
+ document.body.append(scrim, el)
1900
+ drawer = { kind, id }
1901
+ return el
1902
+ }
1903
+ function closeDrawer(silent = false) {
1904
+ if (drawer?.kind === "session") wsSend(`unwatch:${drawer.id}`)
1905
+ drawer = null
1906
+ live = { id: null, msgs: null, status: null, currentTool: null, at: 0 }
1907
+ $("#scrim")?.remove()
1908
+ $("#drawer")?.remove()
1909
+ }
1910
+ document.addEventListener("keydown", (e) => { if (e.key === "Escape" && drawer && !document.querySelector("dialog[open]")) closeDrawer() })
1911
+
1912
+ function openTask(id) {
1913
+ const t = (state.tasks ?? []).find((x) => x.id === id)
1914
+ if (!t) return
1915
+ const who = sessionById(t.assignee)
1916
+ // A card a bot works on opens that bot's conversation, with the card
1917
+ // pinned on top. Only cards with nobody behind them (your questions,
1918
+ // sign-offs, unassigned work) open on their own.
1919
+ if (who && t.kind !== "question") return openSession(who.id, { taskId: t.id })
1920
+ const h = t.handoff
1921
+ const el = openDrawer("task", id, `
1922
+ <div class="dh"><div><div class="s mono">${esc(t.stage ?? t.kind)} · ${esc(t.status)}</div><div style="font-size:16px;font-weight:600">${esc(t.title)}</div></div>
1923
+ <div class="acts"><button class="sm ghost" id="t-close">Close</button></div></div>
1924
+ <div class="kv"><span>owner <b>${who ? "@" + esc(who.handle) : t.owner === "human" ? "you" : esc(t.role ?? "—")}</b></span>
1925
+ <span>created <b>${fmt.ago(t.created)} ago</b></span>${t.startedAt ? `<span>started <b>${fmt.ago(t.startedAt)} ago</b></span>` : ""}</div>
1926
+ <div class="body">
1927
+ ${asks(t) ? `<div class="sec">${askBlock(t)}</div>` : ""}
1928
+ ${h?.operator_notes ? `<div class="sec"><h3>You said</h3><div class="pre">${esc(h.operator_notes)}</div></div>` : ""}
1929
+ ${t.answer ? `<div class="sec"><h3>Your answer</h3><div class="pre">${esc(t.answer)}</div></div>` : ""}
1930
+ ${h ? `<div class="sec"><h3>Handoff from @${esc(sessionById(t.assignee)?.handle ?? h.by ?? "")}</h3><div class="pre">${esc(h.summary)}</div></div>
1931
+ ${h.acceptance_criteria?.length ? `<div class="sec"><h3>Acceptance criteria</h3><ul>${h.acceptance_criteria.map((a) => `<li>${esc(a)}</li>`).join("")}</ul></div>` : ""}
1932
+ ${h.artifacts?.length ? `<div class="sec"><h3>Files</h3><ul class="mono" style="font-size:12px">${h.artifacts.map((a) => `<li>${esc(a)}</li>`).join("")}</ul></div>` : ""}
1933
+ ${h.open_questions?.length ? `<div class="sec"><h3>Open questions</h3><ul>${h.open_questions.map((a) => `<li>${esc(a)}</li>`).join("")}</ul></div>` : ""}` : ""}
1934
+ ${t.kind !== "question" ? `<div class="sec"><h3>Brief</h3><div class="pre">${esc(t.brief || "—")}</div></div>` : ""}
1935
+ ${t.notes?.length ? `<div class="sec"><h3>Notes</h3>${t.notes.map((n) => `<div class="note">${fmt.time(n.at)} ${esc(n.by)}: ${esc(n.text)}</div>`).join("")}</div>` : ""}
1936
+ <div class="sec"><h3>Move</h3><div class="toggles">
1937
+ <button class="sm" data-move="queued">To do</button><button class="sm" data-move="review">Needs you</button>
1938
+ <button class="sm" data-move="done">Done — let the next stage start</button><button class="sm danger" data-move="cancelled">Cancel card</button>
1939
+ ${who ? `<button class="sm ghost" id="t-bot">Open @${esc(who.handle)}</button>` : ""}
1940
+ ${canSendBack(t) ? `<button class="sm" id="t-sendback">Send back…</button>` : ""}
1941
+ ${t.stage && t.runId ? `<button class="sm ghost" id="t-restart">Restart from here…</button>` : ""}</div></div>
1942
+ </div>`)
1943
+ $("#t-close", el).onclick = () => closeDrawer()
1944
+ wireAsk(el, t, () => closeDrawer())
1945
+ for (const b of $$("[data-move]", el)) b.onclick = async () => {
1946
+ await api(`/api/tasks/${encodeURIComponent(t.id)}`, { method: "POST", body: { status: b.dataset.move, queuedAt: Date.now() } }).catch((e) => alert(e.message))
1947
+ closeDrawer()
1948
+ }
1949
+ if ($("#t-bot", el)) $("#t-bot", el).onclick = () => openSession(who.id)
1950
+ if ($("#t-restart", el)) $("#t-restart", el).onclick = () => { closeDrawer(); openRestart(t.projectId, t.stage) }
1951
+ if ($("#t-sendback", el)) $("#t-sendback", el).onclick = async () => { if (await sendBackPrompt(t)) closeDrawer() }
1952
+ }
1953
+
1954
+ // --- what opencode is waiting on for a bot: permissions and its own questions
1955
+ function permissionWhat(p) {
1956
+ const m = p.metadata ?? {}
1957
+ const detail = m.command ?? m.filepath ?? m.filePath ?? m.path ?? m.url ?? (p.patterns ?? []).join(", ")
1958
+ return { action: p.action ?? "permission", detail: detail ? String(detail) : "" }
1959
+ }
1960
+ function botAsksHtml(s) {
1961
+ const perms = (s.permissions ?? []).map((p) => {
1962
+ const w = permissionWhat(p)
1963
+ return `<div class="ask perm-ask" data-perm="${esc(p.id)}" data-s="${esc(s.id)}" style="--fg:${esc(s.color?.fg ?? "")}">
1964
+ <div class="askh"><b>@${esc(s.handle)}</b> wants permission to <b>${esc(w.action)}</b></div>
1965
+ ${w.detail ? `<pre class="perm-detail">${esc(w.detail)}</pre>` : ""}
1966
+ ${(p.always ?? []).length ? `<div class="note">"Always" allows: <span class="mono">${esc(p.always.join(", "))}</span></div>` : ""}
1967
+ <div class="askf"><span class="note">opencode is paused until you answer.</span>
1968
+ <button class="sm danger" data-reply="reject">Deny</button><button class="sm" data-reply="always">Always allow</button><button class="primary sm" data-reply="once">Allow once</button></div>
1969
+ </div>`
1970
+ })
1971
+ const qs = (s.questions ?? []).map((req) => {
1972
+ const d = drafts.get("oq:" + req.id) ?? []
1973
+ return `<div class="ask" data-oq="${esc(req.id)}" data-s="${esc(s.id)}">
1974
+ <div class="askh"><b>@${esc(s.handle)}</b> asks</div>
1975
+ ${req.questions.map((q, i) => {
1976
+ const a = d[i] ?? { picked: [], text: "" }
1977
+ return `<div class="q" data-i="${i}" data-multi="${q.multiple ? 1 : ""}">
1978
+ ${q.header ? `<span class="qh">${esc(q.header)}</span>` : ""}
1979
+ <div class="qt">${req.questions.length > 1 ? `<span class="qn">${i + 1}</span>` : ""}${esc(q.question)}</div>
1980
+ ${q.options?.length ? `<div class="opts">${q.options.map((o) => `<button type="button" class="opt" role="${q.multiple ? "checkbox" : "radio"}" aria-checked="${a.picked.includes(o.label)}" data-l="${esc(o.label)}">
1981
+ <span class="box"></span><span class="ol"><b>${esc(o.label)}</b>${o.description ? `<i>${esc(o.description)}</i>` : ""}</span></button>`).join("")}</div>` : ""}
1982
+ ${q.custom === false ? "" : `<textarea class="qa" rows="1" placeholder="${q.options?.length ? "Or say it in your own words…" : "Your answer…"}">${esc(a.text)}</textarea>`}
1983
+ </div>`
1984
+ }).join("")}
1985
+ <div class="askf"><span class="note">Goes straight back to @${esc(s.handle)}.</span><button class="sm ghost" data-dismiss>Dismiss</button><button class="primary sm" data-send>Send answer</button></div>
1986
+ </div>`
1987
+ })
1988
+ return perms.join("") + qs.join("")
1989
+ }
1990
+ function wireBotAsks(root, s) {
1991
+ for (const box of $$(`.perm-ask[data-s="${CSS.escape(s.id)}"]`, root)) {
1992
+ const p = (s.permissions ?? []).find((x) => x.id === box.dataset.perm)
1993
+ for (const b of $$("[data-reply]", box)) b.onclick = async () => {
1994
+ for (const x of $$("button", box)) x.disabled = true
1995
+ const w = p ? permissionWhat(p) : {}
1996
+ await api(`/api/sessions/${encodeURIComponent(s.id)}/permission`, { method: "POST", body: { requestId: box.dataset.perm, decision: b.dataset.reply, what: `${w.action ?? ""} ${w.detail ?? ""}`.trim().slice(0, 120) } })
1997
+ .catch((e) => { alert(e.message); for (const x of $$("button", box)) x.disabled = false })
1998
+ box.remove()
1999
+ }
2000
+ }
2001
+ for (const box of $$(`[data-oq][data-s="${CSS.escape(s.id)}"]`, root)) {
2002
+ const id = box.dataset.oq
2003
+ const read = () => $$(".q", box).map((q) => ({ picked: $$(".opt[aria-checked=true]", q).map((o) => o.dataset.l), text: $(".qa", q)?.value.trim() ?? "" }))
2004
+ for (const q of $$(".q", box)) {
2005
+ for (const o of $$(".opt", q)) o.onclick = () => {
2006
+ const on = o.getAttribute("aria-checked") !== "true"
2007
+ if (!q.dataset.multi) for (const x of $$(".opt", q)) x.setAttribute("aria-checked", "false")
2008
+ o.setAttribute("aria-checked", String(on))
2009
+ drafts.set("oq:" + id, read())
2010
+ }
2011
+ if ($(".qa", q)) $(".qa", q).oninput = () => drafts.set("oq:" + id, read())
2012
+ }
2013
+ $("[data-send]", box).onclick = async (e) => {
2014
+ const answers = read().map((a) => [...a.picked, ...(a.text ? [a.text] : [])])
2015
+ if (!answers.some((a) => a.length)) return $(".qa", box)?.focus()
2016
+ e.target.disabled = true
2017
+ await api(`/api/sessions/${encodeURIComponent(s.id)}/question`, { method: "POST", body: { requestId: id, answers } }).catch((err) => alert(err.message))
2018
+ drafts.delete("oq:" + id)
2019
+ box.remove()
2020
+ }
2021
+ $("[data-dismiss]", box).onclick = async () => {
2022
+ await api(`/api/sessions/${encodeURIComponent(s.id)}/question`, { method: "POST", body: { requestId: id, reject: true } }).catch((err) => alert(err.message))
2023
+ box.remove()
2024
+ }
2025
+ }
2026
+ }
2027
+
2028
+ // --- the card pinned on top of a bot's conversation
2029
+ function cardDetailsHtml(t) {
2030
+ const h = t.handoff
2031
+ const r = t.rework
2032
+ return [
2033
+ r ? `<div class="sec"><h3>${r.early ? "Found while it was being built" : `Sent back by @${esc(r.by)} — round ${r.round}`}</h3><div class="pre">${esc([r.reason, ...(r.failures ?? []).map((f) => "• " + f), r.files?.length ? "files: " + r.files.join(", ") : ""].filter(Boolean).join("\n"))}</div></div>` : "",
2034
+ t.history?.length ? `<div class="sec"><h3>Earlier rounds</h3>${t.history.map((x) => `<details><summary>Round ${x.round}${x.by ? ` · @${esc(x.by)}` : ""}${x.usd != null ? ` · $${Number(x.usd).toFixed(2)}` : ""}</summary><div class="pre">${esc(x.summary || "—")}</div></details>`).join("")}</div>` : "",
2035
+ h?.operator_notes ? `<div class="sec"><h3>You said</h3><div class="pre">${esc(h.operator_notes)}</div></div>` : "",
2036
+ h ? `<div class="sec"><h3>Handoff</h3><div class="pre">${esc(h.summary)}</div></div>` : "",
2037
+ h?.acceptance_criteria?.length ? `<div class="sec"><h3>Acceptance criteria</h3><ul>${h.acceptance_criteria.map((a) => `<li>${esc(a)}</li>`).join("")}</ul></div>` : "",
2038
+ h?.artifacts?.length ? `<div class="sec"><h3>Files</h3><ul class="mono" style="font-size:12px">${h.artifacts.map((a) => `<li>${esc(a)}</li>`).join("")}</ul></div>` : "",
2039
+ h?.open_questions?.length && t.status !== "review" ? `<div class="sec"><h3>Open questions</h3><ul>${h.open_questions.map((a) => `<li>${esc(a)}</li>`).join("")}</ul></div>` : "",
2040
+ t.notes?.length ? `<div class="sec"><h3>Notes</h3>${t.notes.map((n) => `<div class="note">${fmt.time(n.at)} ${esc(n.by)}: ${esc(n.text)}</div>`).join("")}</div>` : "",
2041
+ `<div class="sec"><h3>Brief</h3><div class="pre">${esc(t.brief || "—")}</div></div>`,
2042
+ ].join("")
2043
+ }
2044
+ let cardSig = null
2045
+ function renderCardBar() {
2046
+ if (drawer?.kind !== "session") return
2047
+ const bar = $("#x-card")
2048
+ if (!bar) return
2049
+ const mine = (state.tasks ?? []).filter((t) => t.assignee === drawer.id && t.status !== "cancelled").sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
2050
+ let t = mine.find((x) => x.id === drawer.taskId)
2051
+ ?? mine.find((x) => x.status === "active")
2052
+ ?? mine.find((x) => !["done", "cancelled"].includes(x.status))
2053
+ ?? mine.at(-1)
2054
+ const sig = JSON.stringify([t?.id, t?.status, t?.handoff?.at, t?.notes?.length, mine.map((x) => [x.id, x.status]), drawer.details])
2055
+ if (sig === cardSig) return
2056
+ if (bar.contains(document.activeElement) && document.activeElement.closest(".ask")) return // answering: do not repaint
2057
+ cardSig = sig
2058
+ if (!t) { bar.hidden = true; return }
2059
+ drawer.taskId = t.id
2060
+ bar.hidden = false
2061
+ const col = { done: "done", active: "doing", review: "needs", waiting: "needs" }[t.status] ?? "todo"
2062
+ bar.innerHTML = `
2063
+ <div class="cb1">
2064
+ ${mine.length > 1 ? `<select id="cb-pick" title="This bot's cards">${mine.map((x) => `<option value="${esc(x.id)}" ${x.id === t.id ? "selected" : ""}>${esc(x.stage ?? "card")} · ${esc(x.status)}</option>`).join("")}</select>` : t.stage ? `<span class="stage">${esc(t.stage)}</span>` : ""}
2065
+ <b class="ct">${esc(t.title)}</b>
2066
+ <span class="chip st-${col}">${esc(cardStatus(t, state.tasks ?? []))}</span>
2067
+ <span class="acts">
2068
+ <select id="cb-move" title="Move this card">
2069
+ <option value="">Move…</option>
2070
+ <option value="queued">To do</option><option value="review">Needs you</option>
2071
+ <option value="done">Done — next stage starts</option><option value="cancelled">Cancel card</option>
2072
+ ${canSendBack(t) ? `<option value="sendback">Send back to its bot…</option>` : ""}
2073
+ </select>
2074
+ ${t.stage && t.runId ? `<button class="sm ghost" id="cb-restart">Restart from here…</button>` : ""}
2075
+ <button class="sm ghost" id="cb-more" aria-expanded="${!!drawer.details}">${drawer.details ? "Hide details" : "Details"}</button>
2076
+ </span>
2077
+ </div>
2078
+ ${asks(t) ? `<div class="cb-ask">${askBlock(t)}</div>` : ""}
2079
+ ${drawer.details ? `<div class="cb-details">${cardDetailsHtml(t)}</div>` : ""}`
2080
+ wireAsk(bar, t, () => { cardSig = null; renderCardBar() })
2081
+ if ($("#cb-pick", bar)) $("#cb-pick", bar).onchange = (e) => { drawer.taskId = e.target.value; cardSig = null; renderCardBar() }
2082
+ $("#cb-more", bar).onclick = () => { drawer.details = !drawer.details; cardSig = null; renderCardBar() }
2083
+ $("#cb-move", bar).onchange = async (e) => {
2084
+ const status = e.target.value
2085
+ if (!status) return
2086
+ if (status === "sendback") { await sendBackPrompt(t); e.target.value = ""; return }
2087
+ if (status === "cancelled" && !confirm("Cancel this card?")) { e.target.value = ""; return }
2088
+ await api(`/api/tasks/${encodeURIComponent(t.id)}`, { method: "POST", body: { status, queuedAt: Date.now() } }).catch((err) => alert(err.message))
2089
+ }
2090
+ if ($("#cb-restart", bar)) $("#cb-restart", bar).onclick = () => openRestart(t.projectId, t.stage)
2091
+ }
2092
+
2093
+ // --- questions for you: choices where the bot offered them, your own words always
2094
+ const drafts = new Map() // task id -> [{ picked: [labels], text }], shared by the card and the chat
2095
+ const asks = (t) => (t.kind === "question" && !["done", "cancelled", "blocked"].includes(t.status)) || t.status === "review"
2096
+ function questionsOf(t) {
2097
+ // A sign-off stage in a pipeline: the question is the stage itself.
2098
+ if (t.kind === "question" && t.stage && !t.questions?.length) {
2099
+ return [{ question: t.title, options: [{ label: "Looks good — ship it" }, { label: "Needs changes", description: "Say what in your own words" }], multiple: false }]
2100
+ }
2101
+ if (t.kind === "question") return t.questions?.length ? t.questions : [{ question: t.brief, options: (t.options ?? []).map((l) => ({ label: l })), multiple: false }]
2102
+ const h = t.handoff
2103
+ return h?.questions?.length ? h.questions : (h?.open_questions ?? []).map((q) => ({ question: q, options: [], multiple: false }))
2104
+ }
2105
+ function askBlock(t) {
2106
+ if (t.budget) return budgetAskBlock(t)
2107
+ const qs = questionsOf(t)
2108
+ const d = drafts.get(t.id) ?? []
2109
+ const review = t.kind !== "question"
2110
+ const who = sessionById(t.askedBy ?? t.assignee)
2111
+ return `<div class="ask" data-ask="${esc(t.id)}">
2112
+ <div class="askh">${review ? `<b>${esc(t.stage ?? t.title)}</b> is waiting for you before the next stage` : t.stage ? `<b>${esc(t.stage)}</b> is yours` : `<b>@${esc(who?.handle ?? "a bot")}</b> asks`}</div>
2113
+ ${t.stage && t.kind === "question" ? (() => {
2114
+ const prev = (state.tasks ?? []).filter((x) => t.dependsOn?.includes(x.id) && x.handoff?.summary)
2115
+ return prev.map((x) => `<div class="note" style="white-space:pre-wrap">${esc(x.stage)}: ${esc(x.handoff.summary)}</div>`).join("")
2116
+ })() : ""}
2117
+ ${qs.map((q, i) => {
2118
+ const a = d[i] ?? { picked: [], text: "" }
2119
+ return `<div class="q" data-i="${i}" data-multi="${q.multiple ? 1 : ""}">
2120
+ ${q.header ? `<span class="qh">${esc(q.header)}</span>` : ""}
2121
+ <div class="qt">${qs.length > 1 ? `<span class="qn">${i + 1}</span>` : ""}${esc(q.question)}</div>
2122
+ ${q.options?.length ? `<div class="opts">${q.options.map((o) => `<button type="button" class="opt" role="${q.multiple ? "checkbox" : "radio"}" aria-checked="${a.picked.includes(o.label)}" data-l="${esc(o.label)}">
2123
+ <span class="box"></span><span class="ol"><b>${esc(o.label)}</b>${o.description ? `<i>${esc(o.description)}</i>` : ""}</span></button>`).join("")}</div>` : ""}
2124
+ <textarea class="qa" rows="${q.options?.length ? 1 : 2}" placeholder="${q.options?.length ? "Or say it in your own words…" : "Your answer…"}">${esc(a.text)}</textarea>
2125
+ </div>`
2126
+ }).join("")}
2127
+ ${review && !qs.length ? `<textarea class="qnote" rows="2" placeholder="Anything the next bots should know (optional)"></textarea>` : ""}
2128
+ <div class="askf"><span class="note">${review ? "Your answers go to every later stage and the chat." : "Goes straight back to the bot that asked."}</span>
2129
+ <button class="primary sm" data-send>${review ? "Continue to the next stage" : "Send answer"}</button></div>
2130
+ </div>`
2131
+ }
2132
+ function wireAsk(root, t, done = () => {}) {
2133
+ if (t.budget) return wireBudgetAsk(root, t, done)
2134
+ const box = $(`[data-ask="${CSS.escape(t.id)}"]`, root)
2135
+ if (!box) return
2136
+ const qs = questionsOf(t)
2137
+ const read = () => $$(".q", box).map((q) => ({
2138
+ picked: $$(".opt[aria-checked=true]", q).map((o) => o.dataset.l),
2139
+ text: $(".qa", q).value.trim(),
2140
+ }))
2141
+ const save = () => drafts.set(t.id, read())
2142
+ for (const q of $$(".q", box)) {
2143
+ for (const o of $$(".opt", q)) o.onclick = () => {
2144
+ const on = o.getAttribute("aria-checked") !== "true"
2145
+ if (!q.dataset.multi) for (const x of $$(".opt", q)) x.setAttribute("aria-checked", "false")
2146
+ o.setAttribute("aria-checked", String(on))
2147
+ save()
2148
+ }
2149
+ $(".qa", q).oninput = (e) => { save(); e.target.style.height = "auto"; e.target.style.height = Math.min(200, e.target.scrollHeight) + "px" }
2150
+ }
2151
+ $("[data-send]", box).onclick = async (e) => {
2152
+ const answers = read()
2153
+ const note = $(".qnote", box)?.value.trim() ?? ""
2154
+ const review = t.kind !== "question"
2155
+ if (!review && !answers.some((a) => a.picked.length || a.text)) return $(".qa", box).focus()
2156
+ e.target.disabled = true
2157
+ try {
2158
+ if (review) await api(`/api/tasks/${encodeURIComponent(t.id)}/continue`, { method: "POST", body: { answers: qs.length ? answers : null, note } })
2159
+ else await api(`/api/tasks/${encodeURIComponent(t.id)}/answer`, { method: "POST", body: { answers } })
2160
+ drafts.delete(t.id)
2161
+ done()
2162
+ } catch (err) { alert(err.message); e.target.disabled = false }
2163
+ }
2164
+ }
2165
+
2166
+ function openStory(pid) {
2167
+ const p = (state.projects ?? []).find((x) => x.id === pid)
2168
+ const el = openDrawer("story", pid, `
2169
+ <div class="dh"><div><div class="s">Story</div><div style="font-size:16px;font-weight:600">${esc(p.name)}</div></div>
2170
+ <div class="acts"><button class="sm ghost" id="s-close">Close</button></div></div>
2171
+ <div class="kv"><span>pipeline <b>${esc(p.pipelineTitle ?? "—")}</b></span><span>branch <b>${esc(p.branch ?? "—")}</b></span><span>worktree <b>${esc(fmt.path(p.worktree ?? p.repo))}</b></span>
2172
+ <span>bots listed in <b>.botfarm/workstreams/${esc(p.id)}.yml</b></span></div>
2173
+ <div class="body"><div class="pre" style="white-space:pre-wrap">${esc(p.story ?? "No story recorded for this workstream.")}</div></div>`)
2174
+ $("#s-close", el).onclick = () => closeDrawer()
2175
+ }
2176
+
2177
+ // --- a bot's live transcript
2178
+ let live = { id: null, msgs: null, status: null, currentTool: null, at: 0 }
2179
+ const openThoughts = new Set()
2180
+ let liveFrame = 0
2181
+ async function openSession(id, { taskId = null } = {}) {
2182
+ const s = sessionById(id)
2183
+ if (!s) return
2184
+ const el = openDrawer("session", id, `
2185
+ <div class="dh"><img src="${avatarUrl(s)}" alt="" /><div><div class="h">@${esc(s.handle)}</div><div class="s">${esc(s.personaTitle ?? s.role ?? s.persona ?? "")}${s.project ? ` · ${esc((state.projects ?? []).find((p) => p.id === s.project)?.name ?? "")}` : ""}</div></div>
2186
+ <div class="acts"><button class="sm" id="x-tune" title="Switch this bot to another model or reasoning level">Model…</button><button class="sm danger" id="x-abort">Stop</button><button class="sm ghost" id="x-close">Close</button></div></div>
2187
+ <div class="kv" id="x-kv"></div>
2188
+ <div class="tune" id="x-tunebox" hidden></div>
2189
+ <div class="cardbar" id="x-card" hidden></div>
2190
+ <div class="transcript" id="x-tr"><div class="empty">Loading…</div></div>
2191
+ <div class="x-asks" id="x-asks"></div>
2192
+ <div class="composer"><textarea id="x-input" rows="1" placeholder="Talk to @${esc(s.handle)} directly…"></textarea><button class="primary" id="x-send">Send</button></div>`)
2193
+ live = { id, msgs: null, status: s.status, currentTool: s.currentTool, at: 0, handle: s.handle }
2194
+ drawer.taskId = taskId
2195
+ cardSig = null
2196
+ xAsksSig = null
2197
+ renderCardBar()
2198
+ wsSend(`watch:${id}`)
2199
+ $("#x-close", el).onclick = () => closeDrawer()
2200
+ $("#x-tune", el).onclick = () => openTune(id)
2201
+ $("#x-abort", el).onclick = () => api(`/api/sessions/${encodeURIComponent(id)}/abort`, { method: "POST" })
2202
+ const input = $("#x-input", el)
2203
+ const send = async () => {
2204
+ const text = input.value.trim()
2205
+ if (!text) return
2206
+ input.value = ""
2207
+ await api(`/api/sessions/${encodeURIComponent(id)}/send`, { method: "POST", body: { text } }).catch((e) => alert(e.message))
2208
+ }
2209
+ $("#x-send", el).onclick = send
2210
+ input.onkeydown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send() } }
2211
+ $("#x-tr", el).addEventListener("toggle", (e) => {
2212
+ const pid = e.target.dataset?.p
2213
+ if (pid) e.target.open ? openThoughts.add(pid) : openThoughts.delete(pid)
2214
+ }, true)
2215
+ renderKv()
2216
+ // The socket sends the transcript as soon as we start watching; this is
2217
+ // only for when the socket is not there.
2218
+ const d = await api(`/api/sessions/${encodeURIComponent(id)}`).catch(() => null)
2219
+ if (d && live.id === id && !live.msgs) { live.msgs = (d.transcript ?? []).slice().reverse(); paintTranscript() }
2220
+ }
2221
+ // --- upgrade / downgrade a running bot: model and reasoning level, from its next turn
2222
+ async function openTune(id) {
2223
+ const box = $("#x-tunebox")
2224
+ if (!box) return
2225
+ if (!box.hidden) { box.hidden = true; return }
2226
+ box.hidden = false
2227
+ box.innerHTML = `<div class="note">Loading models…</div>`
2228
+ let info
2229
+ try { info = await api(`/api/sessions/${encodeURIComponent(id)}/models`) } catch (e) { box.innerHTML = `<div class="err">${esc(e.message)}</div>`; return }
2230
+ const cur = info.current ?? {}
2231
+ const variantsFor = (mid) => { const m = info.models.find((x) => x.id === mid); return m?.variants?.length ? m.variants : ["low", "medium", "high"] }
2232
+ const quick = ["opus", "sonnet", "haiku"].map((k) => info.models.find((m) => new RegExp(k, "i").test(m.id) && m.provider === (cur.model ?? "").split("/")[0]) ?? info.models.find((m) => new RegExp(k, "i").test(m.id))).filter(Boolean)
2233
+ const paint = () => {
2234
+ const mid = $("#tu-model")?.value ?? cur.model ?? ""
2235
+ $("#tu-variant").innerHTML = [`<option value="default">default${info.fromAgent.variant ? ` (${esc(info.fromAgent.variant)})` : ""}</option>`, ...variantsFor(mid).map((v) => `<option value="${esc(v)}" ${v === cur.variant ? "selected" : ""}>${esc(v)}</option>`)].join("")
2236
+ }
2237
+ box.innerHTML = `<div class="tuneh"><b>Model and reasoning for this bot</b><span class="note">Applies from its next turn. The agent file is not changed.</span></div>
2238
+ <div class="row">
2239
+ <div class="field"><label for="tu-model">Model</label><input id="tu-model" class="mono" list="tu-models" value="${esc(cur.model ?? "")}" placeholder="opencode default" />
2240
+ <datalist id="tu-models">${info.models.map((m) => `<option value="${esc(m.id)}">${esc(m.provider)} · ${esc(m.name)}${m.variants?.length ? " · " + esc(m.variants.join("/")) : ""}</option>`).join("")}</datalist></div>
2241
+ <div class="field" style="max-width:170px"><label for="tu-variant">Reasoning</label><select id="tu-variant"></select></div>
2242
+ </div>
2243
+ ${quick.length ? `<div class="toggles" style="margin:-4px 0 10px">${quick.map((m) => `<button class="toggle" data-q="${esc(m.id)}" aria-pressed="${m.id === cur.model}">${esc(m.name)}</button>`).join("")}</div>` : ""}
2244
+ <div class="askf"><span class="note">${info.override ? `Overridden here — the agent says ${esc(info.fromAgent.model ?? "default")}${info.fromAgent.variant ? " · " + esc(info.fromAgent.variant) : ""}.` : `From the agent definition.`}</span>
2245
+ ${info.override ? `<button class="sm ghost" id="tu-reset">Back to the agent's</button>` : ""}
2246
+ <button class="sm ghost" id="tu-cancel">Cancel</button><button class="primary sm" id="tu-apply">Apply</button></div>`
2247
+ paint()
2248
+ $("#tu-model").oninput = paint
2249
+ for (const b of $$("[data-q]", box)) b.onclick = () => { $("#tu-model").value = b.dataset.q; for (const x of $$("[data-q]", box)) x.setAttribute("aria-pressed", String(x === b)); paint() }
2250
+ const send = async (body) => {
2251
+ try { await api(`/api/sessions/${encodeURIComponent(id)}/settings`, { method: "POST", body }); box.hidden = true } catch (e) { alert(e.message) }
2252
+ }
2253
+ $("#tu-apply").onclick = () => send({ model: $("#tu-model").value.trim() || null, variant: $("#tu-variant").value })
2254
+ $("#tu-cancel").onclick = () => { box.hidden = true }
2255
+ if ($("#tu-reset")) $("#tu-reset").onclick = () => send({ model: null, variant: null })
2256
+ }
2257
+
2258
+ let xAsksSig = null
2259
+ function renderDrawerAsks() {
2260
+ const box = $("#x-asks")
2261
+ const s = sessionById(drawer?.id)
2262
+ if (!box || !s) return
2263
+ const sig = JSON.stringify([s.permissions?.map((x) => x.id), s.questions?.map((x) => x.id)])
2264
+ if (sig === xAsksSig || box.contains(document.activeElement)) return
2265
+ xAsksSig = sig
2266
+ box.innerHTML = botAsksHtml(s)
2267
+ wireBotAsks(box, s)
2268
+ }
2269
+ function renderKv() {
2270
+ if (drawer?.kind !== "session") return
2271
+ renderCardBar()
2272
+ renderDrawerAsks()
2273
+ const s = sessionById(drawer.id)
2274
+ const kv = $("#x-kv")
2275
+ if (!s || !kv) return
2276
+ kv.innerHTML = `<span>status <b>${esc(s.status)}</b></span><span>model <b>${esc(s.model ?? s.configuredModel ?? "opencode default")}</b>${s.variant ? ` · <b>${esc(s.variant)}</b>` : ""}</span>
2277
+ ${s.mode ? `<span>agent <b>${esc(s.mode)}</b></span>` : ""}
2278
+ <span>tokens <b>${fmt.n(s.tokens)}</b> (in ${fmt.n(s.totals?.input)} · out ${fmt.n(s.totals?.output)} · cache ${fmt.n(s.totals?.cacheRead)})</span>
2279
+ <span>${s.cost?.estimated ? "est." : ""} <b>${s.cost?.unknownModel ? "$? — no price set" : fmt.money(s.cost?.usd)}</b></span>
2280
+ <span>runs <b>${s.invocations ?? 0}</b></span><span>tools <b>${s.totals?.tools ?? 0}</b>${s.totals?.toolErrors ? ` (${s.totals.toolErrors} failed)` : ""}</span>
2281
+ <span>MCP <b>${s.mcp ? s.mcp.servers.map((m) => `<span title="${esc(m.error ?? m.status)}">${esc(m.name)}${m.connected ? "" : " (" + esc(m.status) + ")"}</span>`).join(", ") || "none" : "—"}</b>
2282
+ ${s.mcp?.servers?.some((m) => ["failed", "error", "disconnected"].includes(m.status)) ? `<button class="sm ghost" data-mcp-reconnect="${esc(s.id)}">Reconnect</button>` : ""}</span>
2283
+ <span>worktree <b>${esc(fmt.path(s.directory))}</b></span>`
2284
+ const rb = kv.querySelector("[data-mcp-reconnect]")
2285
+ if (rb) rb.onclick = async () => {
2286
+ rb.disabled = true
2287
+ rb.textContent = "Reconnecting…"
2288
+ await api(`/api/sessions/${encodeURIComponent(s.id)}/mcp/reconnect`, { method: "POST", body: {} }).catch((e) => alert(e.message))
2289
+ }
2290
+ if (live.id === s.id && live.status !== s.status) { live.status = s.status; paintTranscript() }
2291
+ }
2292
+ function transcriptBody() {
2293
+ const msgs = live.msgs ?? []
2294
+ if (!msgs.length) return `<div class="empty">Nothing in this session yet.</div>`
2295
+ const who = { id: live.id, handle: live.handle }
2296
+ const body = msgs.map((m) => {
2297
+ const bot = m.role === "assistant"
2298
+ const parts = m.parts?.length ? m.parts : [
2299
+ ...(m.text ? [{ id: m.id + ":t", kind: "text", text: m.text }] : []),
2300
+ ...(m.tools ?? []).map((t, i) => ({ id: m.id + ":" + i, kind: "tool", name: t.name, status: t.status })),
2301
+ ]
2302
+ return `<div class="turn ${esc(m.role)}">
2303
+ ${bot ? `<img src="${avatarUrl(who)}" alt="" />` : `<span class="avdot"></span>`}
2304
+ <div class="c"><div class="m">${bot ? "@" + esc(live.handle) : m.role === "user" ? "you / BotFarm" : esc(m.role)}${m.at ? " · " + fmt.time(m.at) : ""}</div>
2305
+ ${parts.map((p) => partHtml(p, bot)).join("") || `<span class="note">…</span>`}</div></div>`
2306
+ }).join("")
2307
+ const working = live.status === "busy" ? `<div class="working"><i></i>@${esc(live.handle)} is ${live.currentTool ? `running ${esc(live.currentTool.name)}` : "working"}…</div>` : ""
2308
+ return body + working
2309
+ }
2310
+ function partHtml(p, bot) {
2311
+ const open = openThoughts.has(p.id) ? "open" : ""
2312
+ if (p.kind === "text") {
2313
+ const text = p.text ?? ""
2314
+ if (!bot && text.length > 600) {
2315
+ return `<details class="more" data-p="${esc(p.id)}" ${open}><summary><div class="b long">${esc(text.slice(0, 600))}</div>
2316
+ <span class="lbl">show all ${fmt.n(text.length)} characters</span><span class="lbl less">show less</span></summary><div class="b">${esc(text)}</div></details>`
2317
+ }
2318
+ return `<div class="b">${esc(text)}</div>`
2319
+ }
2320
+ if (p.kind === "reasoning") {
2321
+ const words = (p.text ?? "").split(/\s+/).filter(Boolean).length
2322
+ return `<details class="think" data-p="${esc(p.id)}" ${open}><summary class="lbl">thinking · ${words} words</summary><div>${esc(p.text ?? "")}</div></details>`
2323
+ }
2324
+ if (p.kind === "tool") {
2325
+ const st = p.status === "completed" ? (p.ms != null ? fmt.ms(p.ms) : "done") : p.status ?? ""
2326
+ const row = `<div class="call s-${esc(p.status ?? "")}"><span class="nm">${esc(p.name ?? "tool")}</span><span class="sm">${esc(p.summary ?? "")}</span><span class="st">${esc(st)}</span></div>`
2327
+ return p.output ? `<details data-p="${esc(p.id)}" ${open}><summary>${row}</summary><div class="out">${esc(p.output)}</div></details>` : row
2328
+ }
2329
+ return ""
2330
+ }
2331
+ function paintTranscript() {
2332
+ if (liveFrame) return
2333
+ liveFrame = requestAnimationFrame(() => {
2334
+ liveFrame = 0
2335
+ const tr = $("#x-tr")
2336
+ if (!tr || !live.msgs) return
2337
+ const atBottom = tr.scrollHeight - tr.scrollTop - tr.clientHeight < 80 || !tr.dataset.painted
2338
+ tr.innerHTML = transcriptBody()
2339
+ tr.dataset.painted = "1"
2340
+ if (atBottom) tr.scrollTop = tr.scrollHeight
2341
+ })
2342
+ }
2343
+ function onLive(f) {
2344
+ if (drawer?.kind !== "session" || f.sessionId !== drawer.id || live.id !== drawer.id) return
2345
+ live.at = Date.now()
2346
+ if (f.t === "transcript") {
2347
+ // The REST snapshot behind this frame can lag the streaming deltas we already
2348
+ // painted (a still-in-flight assistant turn's text/reasoning parts are not
2349
+ // always fully flushed server-side yet), so a blind overwrite here made text
2350
+ // visibly vanish and reappear on every poll while tool parts (whose status the
2351
+ // server does track live) stayed put. Keep whichever text is longer per part.
2352
+ const prevById = new Map((live.msgs ?? []).map((m) => [m.id, m]))
2353
+ live.msgs = (f.transcript ?? []).slice().reverse().map((m) => {
2354
+ const prev = prevById.get(m.id)
2355
+ if (!prev?.parts?.length || !m.parts?.length) return m
2356
+ const prevParts = new Map(prev.parts.map((p) => [p.id, p]))
2357
+ return { ...m, parts: m.parts.map((p) => {
2358
+ const old = prevParts.get(p.id)
2359
+ if (old && typeof old.text === "string" && typeof p.text === "string" && old.text.length > p.text.length) {
2360
+ return { ...p, text: old.text }
2361
+ }
2362
+ return p
2363
+ }) }
2364
+ })
2365
+ live.status = f.status
2366
+ live.currentTool = f.currentTool
2367
+ } else if (f.t === "part") {
2368
+ if (!live.msgs) return
2369
+ let m = live.msgs.find((x) => x.id === f.messageId)
2370
+ if (!m) {
2371
+ if (!f.messageId) return
2372
+ m = { id: f.messageId, role: "assistant", at: Date.now(), parts: [] }
2373
+ live.msgs.push(m)
2374
+ }
2375
+ m.parts ??= []
2376
+ let part = m.parts.find((x) => x.id === f.part.id)
2377
+ if (!part) { part = { ...f.part }; m.parts.push(part); if (f.delta && !f.part.text) part.text = f.delta }
2378
+ else if (f.delta && f.part.text == null) part.text = (part.text ?? "") + f.delta
2379
+ else Object.assign(part, Object.fromEntries(Object.entries(f.part).filter(([, v]) => v != null)))
2380
+ if (f.part.kind === "tool") live.currentTool = /running|pending/.test(f.part.status) ? { name: f.part.name } : null
2381
+ live.status = "busy"
2382
+ }
2383
+ paintTranscript()
2384
+ }
2385
+
2386
+ // ------------------------------------------------------------------ dialogs
2387
+ function wireDialog(id, cancelId) {
2388
+ const d = $(id)
2389
+ $(cancelId).onclick = () => d.close()
2390
+ return d
2391
+ }
2392
+
2393
+ // --- folder browser: a page cannot ask the OS for a path, so the server lists folders
2394
+ const dFolder = wireDialog("#d-folder", "#fs-cancel")
2395
+ let fsHere = null
2396
+ async function browse(path) {
2397
+ $("#fs-err").textContent = ""
2398
+ const r = await api("/api/fs?path=" + encodeURIComponent(path)).catch((e) => ({ error: e.message, dirs: [] }))
2399
+ fsHere = r
2400
+ $("#fs-path").value = fmt.path(r.path ?? path)
2401
+ $("#fs-up").disabled = !r.parent
2402
+ $("#fs-err").textContent = r.error ?? ""
2403
+ $("#fs-list").innerHTML = r.dirs.map((d) => `<button data-p="${esc(d.path)}">📁 ${esc(d.name)}${d.isRepo ? `<span class="git">git</span>` : ""}</button>`).join("")
2404
+ || `<div class="empty" style="padding:10px">No folders in here.</div>`
2405
+ $("#fs-use").textContent = r.isRepo ? "Use this repository" : "Use this folder"
2406
+ for (const b of $$("#fs-list button")) b.onclick = () => browse(b.dataset.p)
2407
+ }
2408
+ function openFolder() {
2409
+ dFolder.showModal()
2410
+ browse(currentWs() ? currentWs().path.replace(/\/[^/]+$/, "") : "~/workspace")
2411
+ }
2412
+ $("#fs-up").onclick = () => fsHere?.parent && browse(fsHere.parent)
2413
+ $("#fs-go").onclick = () => browse($("#fs-path").value)
2414
+ $("#fs-path").onkeydown = (e) => { if (e.key === "Enter") { e.preventDefault(); browse($("#fs-path").value) } }
2415
+ $("#fs-use").onclick = async () => {
2416
+ try {
2417
+ const ws = await api("/api/workspaces", { method: "POST", body: { path: fsHere?.path ?? $("#fs-path").value } })
2418
+ dFolder.close()
2419
+ libs.delete(ws.id)
2420
+ go("home", null, ws.id)
2421
+ } catch (e) { $("#fs-err").textContent = e.message }
2422
+ }
2423
+
2424
+ // --- the work as patches, applied to the real repos
2425
+ const dApply = wireDialog("#d-apply", "#ap-cancel")
2426
+ let apProject = null
2427
+ const AP_HOW = {
2428
+ clean: ["✓ applies cleanly", "ok"], "3way": ["↔ needs a 3-way merge (no conflicts)", "busy"], conflict: ["⚠ will conflict — markers to resolve", "err"],
2429
+ nothing: ["no changes", "dim"], present: ["✓ already in your repo", "ok"], conflicts: ["⚠ applied with conflicts", "err"], partial: ["⚠ partly applied — see .rej files", "err"], failed: ["✗ not applied", "err"],
2430
+ }
2431
+ const apBadge = (how, done = false) => { const [t, c] = done && how === "clean" ? ["✓ applied", "ok"] : AP_HOW[how] ?? [how, "dim"]; return `<span class="apb ${c}">${esc(t)}</span>` }
2432
+ async function openApply(projectId) {
2433
+ apProject = projectId
2434
+ $("#ap-body").innerHTML = `<div class="note">Working out the patches…</div>`
2435
+ $("#ap-err").textContent = ""
2436
+ $("#ap-go").disabled = true
2437
+ $("#ap-undo").hidden = true
2438
+ dApply.showModal()
2439
+ let v
2440
+ try { v = await api(`/api/projects/${encodeURIComponent(projectId)}/patches`) } catch (e) { $("#ap-body").innerHTML = `<div class="err">${esc(e.message)}</div>`; return }
2441
+ const withWork = v.repos.filter((r) => r.files?.length)
2442
+ const lastClean = (v.applied?.results ?? []).some((r) => r.how === "clean") && !v.applied?.undone
2443
+ $("#ap-undo").hidden = !lastClean
2444
+ $("#ap-body").innerHTML = `
2445
+ ${v.busy.length ? `<div class="warnbox">@${v.busy.map(esc).join(", @")} ${v.busy.length > 1 ? "are" : "is"} still working — you get what is in the worktree right now.</div>` : ""}
2446
+ ${v.applied ? `<div class="note">Last applied ${fmt.ago(v.applied.at)} ago${v.applied.undone ? " (undone)" : ""}: ${v.applied.results.filter((r) => r.how !== "nothing").map((r) => `${esc(r.rel || "parent")} ${apBadge(r.how, true)}`).join(" · ") || "nothing"}</div>` : ""}
2447
+ <table class="aptab"><thead><tr><th></th><th>Repo</th><th>Goes into</th><th>Changes</th><th></th></tr></thead><tbody>
2448
+ ${v.repos.map((r) => `<tr>
2449
+ <td><input type="checkbox" data-aprel="${esc(r.rel)}" ${r.files?.length && r.check?.how !== "present" ? "checked" : r.files?.length ? "" : "disabled"} /></td>
2450
+ <td class="mono">${esc(r.rel ? r.rel + "/" : r.name + " (parent)")}</td>
2451
+ <td><div class="mono" style="font-size:12px">${esc(r.real)}</div><div class="note">${r.realBranch ? `on <b>${esc(r.realBranch)}</b>` : ""}${r.realDirty ? ` · ${r.realDirty} uncommitted file${r.realDirty > 1 ? "s" : ""} already there` : ""}</div>
2452
+ ${r.overlap?.length ? `<div class="note" style="color:var(--busy)">you have uncommitted edits to ${r.overlap.map(esc).join(", ")}</div>` : ""}</td>
2453
+ <td>${r.error ? `<span class="err">${esc(r.error)}</span>` : r.files?.length ? `<details><summary>${r.files.length} file${r.files.length > 1 ? "s" : ""} <span class="mono" style="color:var(--ok)">+${r.files.reduce((a, f) => a + (f.added ?? 0), 0)}</span> <span class="mono" style="color:var(--error)">−${r.files.reduce((a, f) => a + (f.removed ?? 0), 0)}</span>${r.commits ? ` · ${r.commits} commit${r.commits > 1 ? "s" : ""}` : ""}</summary><div class="mono" style="font-size:12px">${r.files.map((f) => esc(f.path)).join("<br>")}</div></details>` : `<span class="note">no changes</span>`}</td>
2454
+ <td>${r.check ? apBadge(r.check.how) : ""}${r.file ? `<br><a class="note" href="/api/projects/${encodeURIComponent(projectId)}/patches/file?rel=${encodeURIComponent(r.rel)}" download>download .patch</a>` : ""}</td></tr>`).join("")}
2455
+ </tbody></table>
2456
+ <div class="note mono" style="margin-top:6px">Patches: ${esc(v.dir)}</div>`
2457
+ const count = () => $$("[data-aprel]:checked", $("#ap-body")).length
2458
+ const label = () => { const n = count(); $("#ap-go").disabled = !n; $("#ap-go").textContent = n ? `Apply to ${n} repo${n > 1 ? "s" : ""}` : "Apply" }
2459
+ for (const c of $$("[data-aprel]", $("#ap-body"))) c.onchange = label
2460
+ label()
2461
+ if (!withWork.length) $("#ap-err").textContent = "Nothing to apply — the worktrees match your repos."
2462
+ }
2463
+ $("#ap-go").onclick = async () => {
2464
+ const repos = $$("[data-aprel]:checked", $("#ap-body")).map((c) => c.dataset.aprel)
2465
+ const risky = repos.length && $$("[data-aprel]:checked", $("#ap-body")).some((c) => c.closest("tr").querySelector(".apb.err"))
2466
+ if (risky && !confirm("Some of these will conflict. They are applied as far as they go, with conflict markers (or .rej files) to sort out. Go ahead?")) return
2467
+ $("#ap-go").disabled = true
2468
+ $("#ap-err").textContent = "Applying…"
2469
+ try {
2470
+ const out = await api(`/api/projects/${encodeURIComponent(apProject)}/patches/apply`, { method: "POST", body: { repos } })
2471
+ await openApply(apProject)
2472
+ $("#ap-err").innerHTML = `<span style="color:var(--ok)">Done: ${out.results.filter((r) => r.how !== "nothing").map((r) => `${esc(r.rel || "parent")} ${apBadge(r.how, true)}${r.conflicts?.length ? ` (${r.conflicts.map(esc).join(", ")})` : ""}`).join(" · ")}. Review and commit in your repos.</span>`
2473
+ } catch (e) { $("#ap-err").textContent = e.message; $("#ap-go").disabled = false }
2474
+ }
2475
+ $("#ap-undo").onclick = async () => {
2476
+ if (!confirm("Take the cleanly applied changes back out of your repos? Anything you edited on top of them since stops the undo for that repo.")) return
2477
+ try {
2478
+ const out = await api(`/api/projects/${encodeURIComponent(apProject)}/patches/undo`, { method: "POST", body: {} })
2479
+ await openApply(apProject)
2480
+ $("#ap-err").textContent = out.map((r) => `${r.rel || "parent"}: ${r.undone ? "undone" : "kept — " + (r.note ?? "")}`).join(" · ")
2481
+ } catch (e) { $("#ap-err").textContent = e.message }
2482
+ }
2483
+
2484
+ // --- a repo into a running workstream
2485
+ const dRepos = wireDialog("#d-repos", "#ar-cancel")
2486
+ let arProject = null
2487
+ async function openAddRepo(projectId) {
2488
+ arProject = (state.projects ?? []).find((x) => x.id === projectId)
2489
+ if (!arProject) return
2490
+ const ws = currentWs()
2491
+ const lib = ws ? await library(ws.id, true).catch(() => null) : null
2492
+ const inIt = new Set((arProject.services ?? []).map((x) => (typeof x === "string" ? x : x.path)))
2493
+ const known = [...(lib?.nested ?? []).map((n) => ({ value: n.rel, label: n.rel, note: n.branch ?? "" })), ...(lib?.linked ?? []).map((l) => ({ value: l.path, label: l.as + "/", note: l.path, as: l.as }))].filter((k) => !inIt.has(k.value))
2494
+ $("#ar-known-field").hidden = !known.length
2495
+ $("#ar-known").innerHTML = known.map((k) => `<label><input type="radio" name="ar-k" value="${esc(k.value)}" data-as="${esc(k.as ?? "")}" /> ${esc(k.label)} <span class="note">${esc(k.note)}</span></label>`).join("")
2496
+ $("#ar-path").value = ""; $("#ar-as").value = ""; $("#ar-err").textContent = ""
2497
+ dRepos.showModal()
2498
+ }
2499
+ $("#ar-go").onclick = async () => {
2500
+ const pick = $("#ar-known input:checked")
2501
+ const path = $("#ar-path").value.trim() || pick?.value
2502
+ const as = $("#ar-as").value.trim() || (!$("#ar-path").value.trim() ? pick?.dataset.as : "") || undefined
2503
+ if (!path) { $("#ar-err").textContent = "Pick a repo or give a path."; return }
2504
+ $("#ar-go").disabled = true
2505
+ $("#ar-err").textContent = "Making its worktree…"
2506
+ try {
2507
+ const out = await api(`/api/projects/${encodeURIComponent(arProject.id)}/repos`, { method: "POST", body: { path, as } })
2508
+ if ($("#ar-remember").checked && (path.startsWith("/") || path.startsWith("~")) && arProject.workspaceId) {
2509
+ await api(`/api/workspaces/${encodeURIComponent(arProject.workspaceId)}/repos`, { method: "POST", body: { path, as } }).catch(() => {})
2510
+ libs.delete(arProject.workspaceId)
2511
+ }
2512
+ dRepos.close()
2513
+ if (out.warning) alert(out.warning)
2514
+ } catch (err) { $("#ar-err").textContent = err.message }
2515
+ $("#ar-go").disabled = false
2516
+ }
2517
+
2518
+ // --- new workstream
2519
+ const dStream = wireDialog("#d-stream", "#ns-cancel")
2520
+ let branchTouched = false
2521
+ async function openNewStream(pipelineId) {
2522
+ const ws = currentWs()
2523
+ if (!ws) return openFolder()
2524
+ const lib = await library(ws.id, true).catch((e) => { alert(e.message); return null })
2525
+ if (!lib) return
2526
+ $("#ns-err").textContent = lib.problems?.length ? lib.problems.map((p) => `${p.file}${p.id ? " · " + p.id : ""}: ${p.problems.join("; ")}`).join("\n") : ""
2527
+ $("#ns-pipeline").innerHTML = lib.pipelines.map((p) => `<option value="${esc(p.id)}">${esc(p.title ?? p.id)}</option>`).join("")
2528
+ if (pipelineId) $("#ns-pipeline").value = pipelineId
2529
+ showStagePreview()
2530
+ $("#ns-story").value = ""
2531
+ $("#ns-title").value = ""
2532
+ $("#ns-branch").value = ""
2533
+ branchTouched = false
2534
+ paintRepoPicks(ws, lib)
2535
+ $("#ns-add-path").value = ""
2536
+ $("#ns-add-as").value = ""
2537
+ dStream.showModal()
2538
+ $("#ns-story").focus()
2539
+ }
2540
+ function paintRepoPicks(ws, lib) {
2541
+ const nested = lib.nested ?? []
2542
+ const linked = lib.linked ?? ws.linked ?? []
2543
+ $("#ns-svcs").innerHTML = [
2544
+ ...nested.map((n) => `<label><input type="checkbox" value="${esc(n.rel)}" ${ws.services?.includes(n.rel) ? "checked" : ""} /> ${esc(n.rel)} <span class="note">${esc(n.branch ?? "")}</span></label>`),
2545
+ ...linked.map((l) => `<label><input type="checkbox" value="${esc(l.path)}" ${ws.services?.includes(l.path) ? "checked" : ""} /> ${esc(l.as)}/ <span class="note">${esc(l.path)}</span><button class="sm ghost" data-unlink="${esc(l.path)}" title="Stop offering this repo">×</button></label>`),
2546
+ ].join("") || `<span class="note">Only the workspace repo. Add one from anywhere below.</span>`
2547
+ for (const b of $$("[data-unlink]", $("#ns-svcs"))) b.onclick = async (e) => {
2548
+ e.preventDefault()
2549
+ const out = await api(`/api/workspaces/${encodeURIComponent(ws.id)}/repos?path=${encodeURIComponent(b.dataset.unlink)}`, { method: "DELETE" }).catch((err) => { alert(err.message); return null })
2550
+ if (out) { Object.assign(ws, out); paintRepoPicks(ws, { ...lib, linked: out.linked }) }
2551
+ }
2552
+ }
2553
+ $("#ns-add").onclick = async (e) => {
2554
+ e.preventDefault()
2555
+ const ws = currentWs()
2556
+ const path = $("#ns-add-path").value.trim()
2557
+ if (!ws || !path) return
2558
+ $("#ns-add").disabled = true
2559
+ try {
2560
+ const out = await api(`/api/workspaces/${encodeURIComponent(ws.id)}/repos`, { method: "POST", body: { path, as: $("#ns-add-as").value.trim() || undefined } })
2561
+ Object.assign(ws, out)
2562
+ const lib = libs.get(ws.id) ?? {}
2563
+ lib.linked = out.linked
2564
+ paintRepoPicks(ws, lib)
2565
+ $("#ns-add-path").value = ""; $("#ns-add-as").value = ""
2566
+ $("#ns-err").textContent = ""
2567
+ } catch (err) { $("#ns-err").textContent = err.message }
2568
+ $("#ns-add").disabled = false
2569
+ }
2570
+ function showStagePreview() {
2571
+ const lib = libs.get(ui.ws)
2572
+ const def = lib?.pipelines.find((p) => p.id === $("#ns-pipeline").value)
2573
+ $("#ns-stages").innerHTML = def
2574
+ ? def.stages.map((s) => `<span class="chip" title="${esc(s.after ? "starts after " + s.after.join(", ") : "")}">${esc(s.id)} · ${s.human ? "you" : "@" + esc(s.persona)}${s.split ? ` ×${esc(s.parallel ?? 3)}` : ""}${s.after ? " ⇉" : ""}</span>`).join("") + (def.description ? `<div class="note" style="width:100%">${esc(def.description)}</div>` : "")
2575
+ : ""
2576
+ const splits = (def?.stages ?? []).filter((s) => s.split && !s.human)
2577
+ $("#ns-par-field").hidden = !splits.length
2578
+ $("#ns-par").innerHTML = splits.map((s) => `<div class="field"><label for="ns-par-${esc(s.id)}">${esc(s.id)} — max @${esc(s.persona)} bots</label><input id="ns-par-${esc(s.id)}" data-stage="${esc(s.id)}" type="number" min="1" max="10" value="${esc(s.parallel ?? 3)}" /></div>`).join("")
2579
+ $("#ns-limits").innerHTML = limitInputs("nsl", def?.limits ?? {})
2580
+ }
2581
+ $("#ns-pipeline").onchange = showStagePreview
2582
+ const autoBranch = () => { if (!branchTouched) $("#ns-branch").placeholder = $("#ns-branch").value = ($("#ns-title").value.trim() ? "botfarm/" + slug($("#ns-title").value, 36) : "") }
2583
+ $("#ns-story").oninput = () => {
2584
+ if ($("#ns-title").dataset.touched) return
2585
+ const first = $("#ns-story").value.split("\n").map((l) => l.trim()).find(Boolean) ?? ""
2586
+ $("#ns-title").value = first.length > 48 ? first.slice(0, 48).replace(/\s+\S*$/, "") : first
2587
+ autoBranch()
2588
+ }
2589
+ $("#ns-title").oninput = () => { $("#ns-title").dataset.touched = $("#ns-title").value ? "1" : ""; autoBranch() }
2590
+ $("#ns-branch").oninput = () => { branchTouched = !!$("#ns-branch").value }
2591
+ $("#ns-go").onclick = async () => {
2592
+ const services = $$("#ns-svcs input:checked").map((c) => c.value)
2593
+ const parallel = Object.fromEntries($$("#ns-par input").map((i) => [i.dataset.stage, Number(i.value) || 1]))
2594
+ const body = { pipeline: $("#ns-pipeline").value, story: $("#ns-story").value, title: $("#ns-title").value.trim() || undefined, branch: $("#ns-branch").value.trim() || undefined, services, parallel, limits: readLimits("nsl") }
2595
+ $("#ns-go").disabled = true
2596
+ $("#ns-err").textContent = "Creating the worktree and the team…"
2597
+ try {
2598
+ await api(`/api/workspaces/${encodeURIComponent(ui.ws)}`, { method: "POST", body: { services } }).catch(() => {})
2599
+ const run = await api(`/api/workspaces/${encodeURIComponent(ui.ws)}/workstreams`, { method: "POST", body })
2600
+ dStream.close()
2601
+ $("#ns-title").dataset.touched = ""
2602
+ go("stream", run.projectId)
2603
+ } catch (e) { $("#ns-err").textContent = e.message }
2604
+ $("#ns-go").disabled = false
2605
+ }
2606
+
2607
+ // --- new card
2608
+ const dCard = wireDialog("#d-card", "#nc-cancel")
2609
+ function openNewCard(pid) {
2610
+ const p = (state.projects ?? []).find((x) => x.id === pid)
2611
+ const team = teamOf(p)
2612
+ $("#nc-title").value = ""
2613
+ $("#nc-brief").value = ""
2614
+ $("#nc-err").textContent = ""
2615
+ $("#nc-who").innerHTML = team.map((s) => `<option value="s:${esc(s.id)}">@${esc(s.handle)} (${esc(s.role ?? s.persona ?? "bot")})</option>`).join("") +
2616
+ [...new Set(team.map((s) => s.role).filter(Boolean))].map((r) => `<option value="r:${esc(r)}">whoever plays ${esc(r)}</option>`).join("") +
2617
+ `<option value="h:">me</option>`
2618
+ dCard.dataset.p = pid
2619
+ dCard.showModal()
2620
+ $("#nc-title").focus()
2621
+ }
2622
+ $("#nc-go").onclick = async () => {
2623
+ const [kind, v] = $("#nc-who").value.split(/:(.*)/)
2624
+ const body = { title: $("#nc-title").value, brief: $("#nc-brief").value, projectId: dCard.dataset.p }
2625
+ if (kind === "s") body.assignee = v
2626
+ if (kind === "r") { body.role = v; body.owner = "role" }
2627
+ if (kind === "h") body.owner = "human"
2628
+ try { await api("/api/tasks", { method: "POST", body }); dCard.close() } catch (e) { $("#nc-err").textContent = e.message }
2629
+ }
2630
+
2631
+ // --- prices for models the table does not know
2632
+ const dPrice = wireDialog("#d-price", "#pr-cancel")
2633
+ $("#f-spend").onclick = async () => {
2634
+ const v = await api("/api/pricing").catch(() => null)
2635
+ if (!v) return
2636
+ const rows = [...new Set([...(v.unknown ?? []), ...Object.keys(v.overrides ?? {})])]
2637
+ $("#pr-err").textContent = ""
2638
+ $("#pr-rows").innerHTML = rows.length
2639
+ ? rows.map((m) => {
2640
+ const o = v.overrides?.[m] ?? {}
2641
+ return `<div class="row" data-m="${esc(m)}" style="align-items:flex-end">
2642
+ <div class="field"><label>${v.unknown?.includes(m) ? `<span style="color:var(--busy)">no price</span> · ` : ""}model</label><input class="mono" value="${esc(m)}" disabled /></div>
2643
+ <div class="field" style="max-width:110px"><label>input $/M</label><input data-k="input" type="number" min="0" step="0.01" value="${o.input ?? ""}" /></div>
2644
+ <div class="field" style="max-width:110px"><label>output $/M</label><input data-k="output" type="number" min="0" step="0.01" value="${o.output ?? ""}" /></div></div>`
2645
+ }).join("")
2646
+ : `<div class="empty">Every model in use has a price.</div>`
2647
+ dPrice.showModal()
2648
+ }
2649
+ $("#pr-save").onclick = async () => {
2650
+ try {
2651
+ for (const row of $$("#pr-rows [data-m]")) {
2652
+ const input = $("[data-k=input]", row).value, output = $("[data-k=output]", row).value
2653
+ if (input === "" || output === "") continue
2654
+ await api("/api/pricing", { method: "PUT", body: { model: row.dataset.m, input: Number(input), output: Number(output) } })
2655
+ }
2656
+ dPrice.close()
2657
+ } catch (e) { $("#pr-err").textContent = e.message }
2658
+ }
2659
+
2660
+ // --- restart a workstream from a stage
2661
+ const dRestart = wireDialog("#d-restart", "#rs-cancel")
2662
+ function openRestart(pid, stage = null) {
2663
+ const p = (state.projects ?? []).find((x) => x.id === pid)
2664
+ if (!p) return
2665
+ const tasks = tasksOf(p).filter((t) => t.stage)
2666
+ const firstOpen = tasks.find((t) => t.status !== "done")?.stage ?? tasks[0]?.stage
2667
+ $("#rs-stage").innerHTML = tasks.map((t) => `<option value="${esc(t.stage)}" ${t.stage === (stage ?? firstOpen) ? "selected" : ""}>${esc(t.stage)} — ${esc(t.title)} (${esc(t.status)})</option>`).join("")
2668
+ $("#rs-err").textContent = ""
2669
+ dRestart.dataset.p = pid
2670
+ dRestart.showModal()
2671
+ }
2672
+ $("#rs-go").onclick = async () => {
2673
+ $("#rs-go").disabled = true
2674
+ $("#rs-err").textContent = $("#rs-fresh").checked ? "Starting fresh bots…" : ""
2675
+ try {
2676
+ await api(`/api/projects/${encodeURIComponent(dRestart.dataset.p)}/restart`, { method: "POST", body: { stage: $("#rs-stage").value, fresh: $("#rs-fresh").checked } })
2677
+ dRestart.close()
2678
+ } catch (e) { $("#rs-err").textContent = e.message }
2679
+ $("#rs-go").disabled = false
2680
+ }
2681
+
2682
+ // --- delete workstream
2683
+ const dDelete = wireDialog("#d-delete", "#dl-cancel")
2684
+ async function openDelete(pid) {
2685
+ let pv
2686
+ try { pv = await api(`/api/projects/${encodeURIComponent(pid)}/delete`) } catch (e) { return alert(e.message) }
2687
+ dDelete.dataset.p = pid
2688
+ $("#dl-name").textContent = pv.name
2689
+ const parts = [
2690
+ `${pv.bots} bot${pv.bots === 1 ? "" : "s"} and their opencode sessions`,
2691
+ `${pv.cards} card${pv.cards === 1 ? "" : "s"}`,
2692
+ `${pv.messages} chat message${pv.messages === 1 ? "" : "s"}`,
2693
+ pv.worktree ? `the worktree at ${fmt.path(pv.worktree)}` : null,
2694
+ ].filter(Boolean)
2695
+ $("#dl-what").textContent = `This removes ${parts.join(", ")}. It cannot be undone.`
2696
+ $("#dl-dirty").hidden = !pv.changed
2697
+ $("#dl-force").checked = false
2698
+ if (pv.changed) {
2699
+ $("#dl-dirty-head").textContent = `${pv.changed} uncommitted file${pv.changed > 1 ? "s" : ""} in the worktree will be lost`
2700
+ $("#dl-files").innerHTML = pv.files.map(esc).join("<br>") + (pv.changed > pv.files.length ? `<br>… and ${pv.changed - pv.files.length} more` : "")
2701
+ }
2702
+ $("#dl-branch-row").hidden = !pv.branch
2703
+ $("#dl-branch-name").textContent = pv.branch ?? ""
2704
+ $("#dl-branch").checked = false
2705
+ $("#dl-branch").disabled = !pv.branchOwned
2706
+ $("#dl-branch-note").textContent = !pv.branchOwned
2707
+ ? "Kept: BotFarm only deletes branches it named (botfarm/…)."
2708
+ : pv.commits
2709
+ ? `Careful: ${pv.commits} commit${pv.commits > 1 ? "s" : ""} on it are on no other branch or remote, and would be gone.`
2710
+ : "Everything on it is already on another branch or a remote. Unticked, the branch stays for a PR."
2711
+ $("#dl-err").textContent = ""
2712
+ const sync = () => { $("#dl-go").disabled = !!pv.changed && !$("#dl-force").checked }
2713
+ $("#dl-force").onchange = sync
2714
+ sync()
2715
+ dDelete.showModal()
2716
+ }
2717
+ $("#dl-go").onclick = async () => {
2718
+ $("#dl-go").disabled = true
2719
+ $("#dl-err").textContent = "Deleting…"
2720
+ try {
2721
+ await api(`/api/projects/${encodeURIComponent(dDelete.dataset.p)}/delete`, { method: "POST", body: { force: $("#dl-force").checked, deleteBranch: $("#dl-branch").checked } })
2722
+ dDelete.close()
2723
+ go("home")
2724
+ } catch (e) { $("#dl-err").textContent = e.message }
2725
+ $("#dl-go").disabled = false
2726
+ }
2727
+
2728
+ // --- add bot
2729
+ const dBot = wireDialog("#d-bot", "#nb-cancel")
2730
+ async function openAddBot(pid) {
2731
+ const lib = await library(ui.ws)
2732
+ $("#nb-agent").innerHTML = (lib?.agents ?? []).map((a) => `<option value="${esc(a.id)}">${esc(a.title ?? a.id)}</option>`).join("")
2733
+ $("#nb-err").textContent = ""
2734
+ dBot.dataset.p = pid
2735
+ dBot.showModal()
2736
+ }
2737
+ $("#nb-go").onclick = async () => {
2738
+ $("#nb-go").disabled = true
2739
+ try { await api(`/api/projects/${encodeURIComponent(dBot.dataset.p)}/bots`, { method: "POST", body: { agent: $("#nb-agent").value } }); dBot.close() }
2740
+ catch (e) { $("#nb-err").textContent = e.message }
2741
+ $("#nb-go").disabled = false
2742
+ }
2743
+
2744
+ // ------------------------------------------------------------------ pipeline editor
2745
+ const NEW_STAGE_PROMPT = `Original story:
2746
+
2747
+ {{story}}
2748
+
2749
+ {{#received}}
2750
+ --- {{persona}} ({{stage}}) ---
2751
+ {{summary}}
2752
+ {{#acceptance_criteria}} - {{.}}
2753
+ {{/acceptance_criteria}}
2754
+ {{/received}}
2755
+ `
2756
+ let ed = null // { kind, id, doc, isNew, tab }
2757
+ async function renderPipeline(key) {
2758
+ mainKey = key
2759
+ const lib = await library(ui.ws, true).catch(() => null)
2760
+ if (!lib) return
2761
+ const isNew = ui.id === "+new"
2762
+ const found = lib.pipelines.find((p) => p.id === ui.id)
2763
+ if (!isNew && !found) { $("#main").innerHTML = `<div class="page"><div class="empty">No pipeline "${esc(ui.id)}" in this workspace.</div></div>`; return }
2764
+ const doc = isNew
2765
+ ? { id: "", title: "", description: "", stages: [{ id: "build", persona: lib.agents[0]?.id ?? "", title: "", receives: [], prompt: "{{story}}\n" }] }
2766
+ : JSON.parse(JSON.stringify(found))
2767
+ ed = { kind: "pipelines", id: isNew ? null : found.id, doc, isNew, tab: "form", lib }
2768
+ paintPipeline()
2769
+ }
2770
+ function paintPipeline() {
2771
+ const { doc, lib, isNew } = ed
2772
+ const problems = (lib.problems ?? []).filter((p) => /\.pipeline\.botfarm\.ya?ml$/.test(p.file) && (!p.id || p.id === ed.id))
2773
+ $("#main").innerHTML = `<div class="page">
2774
+ <div class="edhead"><h1>${isNew ? "New pipeline" : esc(doc.title || doc.id)}</h1>
2775
+ <div class="acts">${!isNew ? `<button class="sm" id="pe-run">Start a workstream with it</button><button class="sm danger" id="pe-del">Delete</button>` : ""}
2776
+ <button class="primary sm" id="pe-save">Save</button></div></div>
2777
+ <div class="note mono">${esc(isNew ? "botfarm/<id>.pipeline.botfarm.yml" : doc.file ?? "")}</div>
2778
+ <div class="tabs"><button class="sm ghost" data-tab="form" aria-pressed="${ed.tab === "form"}">Stages</button><button class="sm ghost" data-tab="yaml" aria-pressed="${ed.tab === "yaml"}">This file (YAML)</button></div>
2779
+ ${problems.length ? `<div class="problems">${problems.map((p) => esc(p.problems.join("; "))).join("<br>")}</div>` : ""}
2780
+ <div class="err" id="pe-err"></div>
2781
+ <div id="pe-body"></div></div>`
2782
+ for (const b of $$("[data-tab]")) b.onclick = () => { if (ed.tab === "form") readPipelineForm(); ed.tab = b.dataset.tab; paintPipeline() }
2783
+ $("#pe-save").onclick = savePipeline
2784
+ if ($("#pe-run")) $("#pe-run").onclick = () => openNewStream(ed.id)
2785
+ if ($("#pe-del")) $("#pe-del").onclick = async () => {
2786
+ if (!confirm(`Delete pipeline "${ed.id}"? This deletes botfarm/${ed.id}.pipeline.botfarm.yml.`)) return
2787
+ await api(`/api/workspaces/${encodeURIComponent(ui.ws)}/pipelines/${encodeURIComponent(ed.id)}`, { method: "DELETE" })
2788
+ libs.delete(ui.ws)
2789
+ go("home")
2790
+ }
2791
+ if (ed.tab === "yaml") return paintRaw("pipelines")
2792
+ const agents = lib.agents ?? []
2793
+ $("#pe-body").innerHTML = `
2794
+ <div class="row" style="max-width:900px">
2795
+ <div class="field"><label>Name</label><input id="pe-title" value="${esc(doc.title ?? "")}" placeholder="Do a user story" /></div>
2796
+ <div class="field" style="max-width:220px"><label>Id</label><input id="pe-id" class="mono" value="${esc(doc.id ?? "")}" ${isNew ? "" : "disabled"} placeholder="do-user-story" /></div>
2797
+ </div>
2798
+ <div class="field" style="max-width:900px"><label>Description</label><input id="pe-desc" value="${esc(doc.description ?? "")}" /></div>
2799
+ <div class="note" style="margin:4px 0 10px;max-width:900px">Each stage is one card on the board, handed to one agent (or to you). A stage starts when the one before it hands off — or when the stages it is set to start after have. A split stage becomes one card per item of the list handed to it, worked by several bots at once; the stages after it wait for every piece.
2800
+ In prompts: <span class="mono">{{story}}</span>, <span class="mono">{{#handoffs.analyse}}…{{/handoffs.analyse}}</span> for one earlier stage,
2801
+ <span class="mono">{{#received}}…{{/received}}</span> for all it receives, with <span class="mono">{{summary}}</span>, <span class="mono">{{#acceptance_criteria}}{{.}}{{/acceptance_criteria}}</span>, <span class="mono">{{#artifacts}}</span>.</div>
2802
+ <div class="stages">${doc.stages.map((s, i) => `
2803
+ ${i ? `<div class="link">↓</div>` : ""}
2804
+ <div class="stagecard ${s.human ? "human" : ""}" data-i="${i}">
2805
+ <div class="sh"><span class="n">${i + 1}</span>
2806
+ <input data-f="id" class="mono" value="${esc(s.id ?? "")}" placeholder="stage id" style="width:150px" />
2807
+ <input data-f="title" value="${esc(s.title ?? "")}" placeholder="What the card says" style="flex:1" />
2808
+ <span class="tools"><button class="sm ghost" data-up ${i ? "" : "disabled"} title="Move up">↑</button><button class="sm ghost" data-down ${i < doc.stages.length - 1 ? "" : "disabled"} title="Move down">↓</button><button class="sm ghost danger" data-del title="Remove stage">✕</button></span></div>
2809
+ <div class="row">
2810
+ <div class="field" style="max-width:260px"><label>Done by</label><select data-f="persona">
2811
+ ${agents.map((a) => `<option value="${esc(a.id)}" ${!s.human && s.persona === a.id ? "selected" : ""}>@${esc(a.id)} — ${esc(a.title ?? "")}</option>`).join("")}
2812
+ <option value="__human" ${s.human ? "selected" : ""}>you (sign-off / decision)</option></select></div>
2813
+ ${s.human ? "" : `<div class="field" style="max-width:190px"><label>If it has open questions</label><select data-f="hold">
2814
+ <option value="">hand off anyway</option><option value="1" ${s.hold_on_questions ? "selected" : ""}>wait for me</option></select></div>
2815
+ <div class="field" style="max-width:170px"><label title="Which model tier this stage's cards get, when model routing is on">Difficulty</label><select data-f="difficulty">
2816
+ ${[["", "auto"], ["easy", "easy"], ["normal", "normal"], ["hard", "hard"]].map(([v, l]) => `<option value="${v}" ${(s.difficulty ?? "") === v ? "selected" : ""}>${l}</option>`).join("")}</select></div>
2817
+ ${i ? `<div class="field" style="max-width:190px"><label title="How many times this stage may send earlier work back to be fixed before it asks you. 0: never.">Fix rounds (send back)</label><select data-f="rounds">
2818
+ ${[["", "default (3)"], ["0", "never send back"], ["1", "1"], ["2", "2"], ["3", "3"], ["5", "5"], ["8", "8"]].map(([v, l]) => `<option value="${v}" ${String(s.send_back === false ? 0 : s.max_rounds ?? "") === v ? "selected" : ""}>${l}</option>`).join("")}</select></div>` : ""}`}
2819
+ <div class="field"><label>Receives handoffs from</label><div class="toggles">${doc.stages.slice(0, i).map((e) => `<button class="toggle" data-recv="${esc(e.id)}" aria-pressed="${(s.receives ?? []).includes(e.id)}">${esc(e.id)}</button>`).join("") || `<span class="note">first stage — gets the story</span>`}</div></div>
2820
+ </div>
2821
+ ${i ? `<div class="row">
2822
+ <div class="field"><label>Starts after <span class="note">(nothing picked: the stage before it)</span></label><div class="toggles">${doc.stages.slice(0, i).map((e) => `<button class="toggle" data-after="${esc(e.id)}" aria-pressed="${(s.after ?? []).includes(e.id)}">${esc(e.id)}</button>`).join("")}</div>
2823
+ <div class="hint">Pick an earlier stage to run side by side with the ones in between — e.g. QA writing tests while dev builds.</div></div>
2824
+ ${s.human ? "" : `<div class="field" style="max-width:250px"><label>Split into parallel cards</label><select data-f="split">
2825
+ <option value="">no — one card</option><option value="tasks" ${s.split ? "selected" : ""}>yes — one per item in the "tasks" list</option></select>
2826
+ <div class="hint">The stage it waits on is asked for the list.</div></div>
2827
+ <div class="field" style="max-width:130px"><label>Max bots at once</label><input data-f="parallel" type="number" min="1" max="10" value="${esc(s.parallel ?? (s.split ? 3 : ""))}" ${s.split ? "" : "disabled"} placeholder="3" /></div>`}
2828
+ </div>` : ""}
2829
+ ${s.human ? "" : `<div class="field"><label>Limits per card <span class="note">(optional — the card stops and asks you when it reaches one)</span></label>${limitInputs("pl" + i, s.limits ?? {})}</div>`}
2830
+ <div class="field"><label>Prompt</label><textarea data-f="prompt" class="code" rows="${Math.min(14, Math.max(4, (s.prompt ?? "").split("\n").length + 1))}">${esc(s.prompt ?? "")}</textarea></div>
2831
+ </div>`).join("")}
2832
+ <div class="link">↓</div>
2833
+ <button id="pe-add" style="align-self:flex-start">+ Add a stage</button>
2834
+ </div>`
2835
+ for (const card of $$(".stagecard")) {
2836
+ const i = Number(card.dataset.i)
2837
+ const move = (d) => { readPipelineForm(); const [x] = doc.stages.splice(i, 1); doc.stages.splice(i + d, 0, x); paintPipeline() }
2838
+ $("[data-up]", card).onclick = () => move(-1)
2839
+ $("[data-down]", card).onclick = () => move(1)
2840
+ $("[data-del]", card).onclick = () => { readPipelineForm(); const [gone] = doc.stages.splice(i, 1); for (const s of doc.stages) s.receives = (s.receives ?? []).filter((r) => r !== gone.id); paintPipeline() }
2841
+ for (const b of $$("[data-recv], [data-after]", card)) b.onclick = () => b.setAttribute("aria-pressed", String(b.getAttribute("aria-pressed") !== "true"))
2842
+ if ($("[data-f=split]", card)) $("[data-f=split]", card).onchange = () => { readPipelineForm(); paintPipeline() }
2843
+ $("[data-f=persona]", card).onchange = () => { readPipelineForm(); paintPipeline() }
2844
+ }
2845
+ $("#pe-add").onclick = () => {
2846
+ readPipelineForm()
2847
+ const prev = doc.stages.map((s) => s.id).filter(Boolean)
2848
+ doc.stages.push({ id: `stage${doc.stages.length + 1}`, persona: agents[0]?.id ?? "", title: "", receives: prev.slice(-1), prompt: NEW_STAGE_PROMPT })
2849
+ paintPipeline()
2850
+ }
2851
+ }
2852
+ function readPipelineForm() {
2853
+ if (!$("#pe-title")) return
2854
+ const d = ed.doc
2855
+ d.title = $("#pe-title").value.trim()
2856
+ if (ed.isNew) d.id = $("#pe-id").value.trim() || slug(d.title)
2857
+ d.description = $("#pe-desc").value.trim()
2858
+ for (const card of $$(".stagecard")) {
2859
+ const s = d.stages[Number(card.dataset.i)]
2860
+ s.id = $("[data-f=id]", card).value.trim()
2861
+ s.title = $("[data-f=title]", card).value.trim() || undefined
2862
+ const who = $("[data-f=persona]", card).value
2863
+ if (who === "__human") { s.human = true; delete s.persona } else { s.persona = who; delete s.human }
2864
+ const hold = $("[data-f=hold]", card)
2865
+ if (hold?.value) s.hold_on_questions = true
2866
+ else delete s.hold_on_questions
2867
+ const diff = $("[data-f=difficulty]", card)?.value
2868
+ if (diff && !s.human) s.difficulty = diff
2869
+ else delete s.difficulty
2870
+ const rounds = $("[data-f=rounds]", card)?.value
2871
+ if (rounds !== undefined) { if (s.send_back === false) delete s.send_back; if (rounds === "" || s.human) delete s.max_rounds; else s.max_rounds = Number(rounds) }
2872
+ s.receives = $$("[data-recv]", card).filter((b) => b.getAttribute("aria-pressed") === "true").map((b) => b.dataset.recv)
2873
+ const after = $$("[data-after]", card).filter((b) => b.getAttribute("aria-pressed") === "true").map((b) => b.dataset.after)
2874
+ if (after.length) s.after = after; else delete s.after
2875
+ const split = $("[data-f=split]", card)?.value
2876
+ if (split && !s.human) s.split = split; else delete s.split
2877
+ const par = Number($("[data-f=parallel]", card)?.value)
2878
+ if (s.split && par >= 1) s.parallel = Math.min(10, par); else delete s.parallel
2879
+ const lim = s.human ? null : readLimits("pl" + card.dataset.i)
2880
+ if (lim) s.limits = lim; else delete s.limits
2881
+ s.prompt = $("[data-f=prompt]", card).value
2882
+ }
2883
+ }
2884
+ async function savePipeline() {
2885
+ if (ed.tab === "yaml") return saveRaw("pipelines")
2886
+ readPipelineForm()
2887
+ const d = ed.doc
2888
+ if (!d.id) return ($("#pe-err").textContent = "Give it a name or an id.")
2889
+ const clean = { ...d, stages: d.stages.map((s) => Object.fromEntries(Object.entries(s).filter(([, v]) => v !== undefined && v !== ""))) }
2890
+ if (!clean.description) delete clean.description
2891
+ try {
2892
+ const lib = await api(`/api/workspaces/${encodeURIComponent(ui.ws)}/pipelines/${encodeURIComponent(d.id)}`, { method: "PUT", body: { doc: clean } })
2893
+ libs.set(ui.ws, { ...libs.get(ui.ws), ...lib })
2894
+ navSig = null
2895
+ if (ed.isNew) return go("pipeline", d.id)
2896
+ ed.lib = libs.get(ui.ws)
2897
+ paintPipeline()
2898
+ $("#pe-err").innerHTML = `<span style="color:var(--ok)">Saved to ${esc(ed.doc.file ?? "botfarm/" + d.id + ".pipeline.botfarm.yml")}.</span>`
2899
+ } catch (e) { $("#pe-err").textContent = e.message }
2900
+ }
2901
+
2902
+ function paintRaw(kind) { return paintRawInto("#pe-body", kind) }
2903
+ async function saveRaw(kind) {
2904
+ const errEl = $("#pe-err") ?? $("#ae-err")
2905
+ try {
2906
+ if (!ed.id) throw new Error("Save it from the form first — the file is named after its id.")
2907
+ const lib = await api(`/api/workspaces/${encodeURIComponent(ui.ws)}/file/${kind}?entry=${encodeURIComponent(ed.id)}`, { method: "PUT", body: { text: $("#raw").value } })
2908
+ libs.set(ui.ws, { ...libs.get(ui.ws), ...lib })
2909
+ navSig = null
2910
+ renderSide()
2911
+ errEl.innerHTML = `<span style="color:var(--ok)">Saved.</span>`
2912
+ } catch (e) { errEl.textContent = e.message }
2913
+ }
2914
+
2915
+ // ------------------------------------------------------------------ tool bench
2916
+ // A workspace tool (botfarm/mcps/<file>): its form, run it where a bot would,
2917
+ // see the result, and edit the source — saving reloads it for every bot.
2918
+ let bench = null
2919
+ function schemaField(key, sch, req, val) {
2920
+ const id = `tb-a-${key}`
2921
+ const label = `<label for="${id}">${esc(key)}${req ? " *" : ""} <span class="note">${esc(sch.type ?? "any")}</span></label>`
2922
+ const hint = sch.description ? `<div class="hint">${esc(sch.description)}</div>` : ""
2923
+ if (Array.isArray(sch.enum)) return `<div class="field">${label}<select id="${id}" data-arg="${esc(key)}" data-t="enum"><option value="">—</option>${sch.enum.map((v) => `<option ${String(val) === String(v) ? "selected" : ""}>${esc(v)}</option>`).join("")}</select>${hint}</div>`
2924
+ if (sch.type === "boolean") return `<div class="field"><label style="display:flex;gap:8px;align-items:center"><input type="checkbox" id="${id}" data-arg="${esc(key)}" data-t="boolean" ${val ? "checked" : ""} /> ${esc(key)}</label>${hint}</div>`
2925
+ if (sch.type === "number" || sch.type === "integer") return `<div class="field">${label}<input id="${id}" type="number" data-arg="${esc(key)}" data-t="number" value="${esc(val ?? sch.default ?? "")}" />${hint}</div>`
2926
+ if (sch.type === "object" || sch.type === "array") return `<div class="field">${label}<textarea id="${id}" class="code" rows="3" data-arg="${esc(key)}" data-t="json" placeholder="${sch.type === "array" ? "[ ]" : "{ }"}">${val !== undefined ? esc(JSON.stringify(val, null, 2)) : ""}</textarea>${hint}</div>`
2927
+ return `<div class="field">${label}<input id="${id}" class="mono" data-arg="${esc(key)}" data-t="string" value="${esc(val ?? sch.default ?? "")}" />${hint}</div>`
2928
+ }
2929
+ function readBenchArgs() {
2930
+ if (bench.raw) return JSON.parse($("#tb-raw").value || "{}")
2931
+ const out = {}
2932
+ for (const el of $$("[data-arg]")) {
2933
+ const k = el.dataset.arg, t = el.dataset.t
2934
+ if (t === "boolean") { if (el.checked) out[k] = true; continue }
2935
+ const v = el.value.trim()
2936
+ if (!v) continue
2937
+ out[k] = t === "number" ? Number(v) : t === "json" ? JSON.parse(v) : v
2938
+ }
2939
+ return out
2940
+ }
2941
+ async function renderTool(key) {
2942
+ const wt = state.tools?.[ui.ws]
2943
+ const tool = wt?.tools?.find((t) => t.file === ui.id)
2944
+ const broken = wt?.errors?.find((e) => e.file === ui.id)
2945
+ const sig = JSON.stringify([key, tool?.name, tool?.description, tool?.inputSchema, broken?.error])
2946
+ if (mainKey === key && bench?.sig === sig) return
2947
+ const fresh = mainKey !== key
2948
+ mainKey = key
2949
+ if (fresh) bench = { file: ui.id, raw: false, args: {}, out: null, source: null }
2950
+ bench.sig = sig
2951
+ if (fresh || bench.source === null) {
2952
+ const src = await api(`/api/workspaces/${encodeURIComponent(ui.ws)}/tools/${encodeURIComponent(ui.id)}/source`).catch(() => ({ text: "", path: "" }))
2953
+ bench.source = src.text
2954
+ bench.path = src.path
2955
+ }
2956
+ const ws = currentWs()
2957
+ const dirs = [{ dir: ws.path, label: `workspace — ${fmt.path(ws.path)}` }, ...streamsOf(ws.id).filter((p) => p.worktree && p.status !== "archived").map((p) => ({ dir: p.worktree, label: `${p.name} — ${fmt.path(p.worktree)}` }))]
2958
+ const props = tool?.inputSchema?.properties ?? {}
2959
+ const req = new Set(tool?.inputSchema?.required ?? [])
2960
+ $("#main").innerHTML = `<div class="page bench">
2961
+ <div class="edhead"><h1 class="mono">${esc(tool ? "botfarm_" + tool.name : ui.id)}</h1>
2962
+ <div class="acts"><button class="sm danger" id="tb-del">Delete file</button></div></div>
2963
+ <div class="note mono">${esc(fmt.path(bench.path || ""))}</div>
2964
+ ${broken ? `<div class="problems">This file does not load: ${esc(broken.error)}</div>` : ""}
2965
+ <div class="benchgrid">
2966
+ <section class="panel">
2967
+ <h3>Try it</h3>
2968
+ ${tool ? `<p class="lead" style="margin:0 0 12px">${esc(tool.description)}</p>
2969
+ <div class="field"><label for="tb-dir">Run in</label><select id="tb-dir">${dirs.map((d) => `<option value="${esc(d.dir)}" ${bench.dir === d.dir ? "selected" : ""}>${esc(d.label)}</option>`).join("")}</select>
2970
+ <div class="hint">ctx.repoRoot — where a bot's call runs is its own worktree.</div></div>
2971
+ <div class="tabs"><button class="sm ghost" data-mode="form" aria-pressed="${!bench.raw}">Form</button><button class="sm ghost" data-mode="raw" aria-pressed="${bench.raw}">JSON</button></div>
2972
+ <div id="tb-args">${bench.raw
2973
+ ? `<textarea id="tb-raw" class="code" rows="8" style="width:100%">${esc(JSON.stringify(bench.args ?? {}, null, 2))}</textarea>`
2974
+ : Object.keys(props).length ? Object.entries(props).map(([k, sch]) => schemaField(k, sch, req.has(k), bench.args?.[k])).join("") : `<div class="note">No arguments.</div>`}</div>
2975
+ <div class="askf" style="display:flex;gap:8px;align-items:center"><span class="note" id="tb-note" style="margin-right:auto"></span><button class="primary" id="tb-run">Run</button></div>
2976
+ <div id="tb-out">${benchOut()}</div>` : `<div class="note">Fix the file and save to try it.</div>`}
2977
+ </section>
2978
+ <section class="panel">
2979
+ <h3>Source <span class="note">— saving reloads it for every bot in this workspace</span></h3>
2980
+ <textarea id="tb-src" class="code" spellcheck="false" style="width:100%;min-height:52vh">${esc(bench.source ?? "")}</textarea>
2981
+ <div style="display:flex;gap:8px;align-items:center;margin-top:8px"><span class="err" id="tb-err" style="margin-right:auto"></span><button class="primary sm" id="tb-save">Save</button></div>
2982
+ </section>
2983
+ </div></div>`
2984
+ for (const b of $$("[data-mode]")) b.onclick = () => { try { bench.args = readBenchArgs() } catch {} bench.raw = b.dataset.mode === "raw"; mainKey = null; renderTool(key) }
2985
+ if ($("#tb-dir")) $("#tb-dir").onchange = (e) => { bench.dir = e.target.value }
2986
+ if ($("#tb-run")) $("#tb-run").onclick = async () => {
2987
+ let args
2988
+ try { args = readBenchArgs() } catch (e) { $("#tb-note").textContent = "Arguments are not valid JSON: " + e.message; return }
2989
+ bench.args = args
2990
+ $("#tb-run").disabled = true
2991
+ $("#tb-note").textContent = "Running…"
2992
+ try { bench.out = await api(`/api/workspaces/${encodeURIComponent(ui.ws)}/tools/${encodeURIComponent(tool.name)}/run`, { method: "POST", body: { args, directory: $("#tb-dir").value } }) }
2993
+ catch (e) { bench.out = { ok: false, error: e.message, ms: 0, logs: [] } }
2994
+ $("#tb-run").disabled = false
2995
+ $("#tb-note").textContent = ""
2996
+ $("#tb-out").innerHTML = benchOut()
2997
+ }
2998
+ $("#tb-save").onclick = async () => {
2999
+ bench.source = $("#tb-src").value
3000
+ try {
3001
+ await api(`/api/workspaces/${encodeURIComponent(ui.ws)}/tools/${encodeURIComponent(ui.id)}/source`, { method: "PUT", body: { text: bench.source } })
3002
+ $("#tb-err").innerHTML = `<span style="color:var(--ok)">Saved and reloaded.</span>`
3003
+ } catch (e) { $("#tb-err").textContent = e.message }
3004
+ }
3005
+ $("#tb-src").onkeydown = (e) => { if ((e.metaKey || e.ctrlKey) && e.key === "s") { e.preventDefault(); $("#tb-save").click() } }
3006
+ $("#tb-del").onclick = async () => {
3007
+ if (!confirm(`Delete botfarm/mcps/${ui.id}?`)) return
3008
+ await api(`/api/workspaces/${encodeURIComponent(ui.ws)}/tools/${encodeURIComponent(ui.id)}`, { method: "DELETE" }).catch((e) => alert(e.message))
3009
+ go("home")
3010
+ }
3011
+ }
3012
+ function benchOut() {
3013
+ const o = bench?.out
3014
+ if (!o) return ""
3015
+ const body = o.ok ? (typeof o.result === "string" ? o.result : JSON.stringify(o.result, null, 2)) : `${o.error}${o.stack ? "\n\n" + o.stack : ""}`
3016
+ return `<div class="benchout ${o.ok ? "ok" : "bad"}"><div class="bh"><b>${o.ok ? "Result" : "Error"}</b><span class="note mono">${fmt.ms(o.ms)}${o.directory ? " · in " + esc(fmt.path(o.directory)) : ""}</span></div>
3017
+ <pre>${esc(body)}</pre>${o.logs?.length ? `<div class="bh"><b>Log</b></div><pre class="log">${esc(o.logs.join("\n"))}</pre>` : ""}</div>`
3018
+ }
3019
+
3020
+ // ------------------------------------------------------------------ model routing
3021
+ // Three rows — easy, normal, hard — each a model and a reasoning level. Used by
3022
+ // the Routing page (workspace-wide) and the agent editor (one agent's own).
3023
+ const TIER_HELP = {
3024
+ easy: "Typos, docs, renames, small config changes, short single-file work.",
3025
+ normal: "Everyday changes. Leave empty to keep each agent's own model.",
3026
+ hard: "Refactors, migrations, security, concurrency, long stories with many criteria.",
3027
+ }
3028
+ function tierRows(prefix, tiers, models, usage = {}) {
3029
+ const list = `<datalist id="${prefix}-models">${models.map((m) => `<option value="${esc(m.id)}">${esc(m.provider ?? "")} · ${esc(m.name ?? m.id)}${m.variants?.length ? " · " + esc(m.variants.join("/")) : ""}</option>`).join("")}</datalist>`
3030
+ return list + `<div class="tiers">${["easy", "normal", "hard"].map((lv) => {
3031
+ const t = tiers?.[lv] ?? {}
3032
+ return `<div class="tier t-${lv}">
3033
+ <span class="diff d-${lv}">${lv}</span>
3034
+ <div class="field"><label for="${prefix}-${lv}-model">Model</label><input id="${prefix}-${lv}-model" class="mono" list="${prefix}-models" value="${esc(t.model ?? "")}" placeholder="the agent's own" data-tier="${lv}" /></div>
3035
+ <div class="field" style="max-width:150px"><label for="${prefix}-${lv}-variant">Reasoning</label><select id="${prefix}-${lv}-variant" data-tierv="${lv}" data-current="${esc(t.variant ?? "")}"></select></div>
3036
+ <span class="note tierhelp">${esc(TIER_HELP[lv])}</span>
3037
+ <details class="quota" ${t.quota ? "open" : ""}><summary>Quota and fallback ${t.quota ? `<span class="qsum">${esc(quotaText(t.quota))} → ${esc(t.fallback?.model ? shortModel(t.fallback.model) : "the agent's own")}${t.fallback?.variant ? " · " + esc(t.fallback.variant) : ""}</span>` : `<span class="note">— cap this model, then fall back to another</span>`}${usage[lv] ? quotaMeter(t.quota, usage[lv]) : ""}</summary>
3038
+ <div class="qgrid">
3039
+ <div class="field"><label>Up to $</label><input id="${prefix}-${lv}-q-usd" type="number" min="0" step="0.5" placeholder="—" value="${esc(t.quota?.usd ?? "")}" /></div>
3040
+ <div class="field"><label>or tokens</label><input id="${prefix}-${lv}-q-tokens" type="number" min="0" step="50000" placeholder="—" value="${esc(t.quota?.tokens ?? "")}" /></div>
3041
+ <div class="field"><label>or minutes</label><input id="${prefix}-${lv}-q-minutes" type="number" min="0" step="10" placeholder="—" value="${esc(t.quota?.minutes ?? "")}" /></div>
3042
+ <div class="field"><label>per</label><select id="${prefix}-${lv}-q-per">${["day", "week", "month", "workstream"].map((p) => `<option value="${p}" ${(t.quota?.per ?? "day") === p ? "selected" : ""}>${p}</option>`).join("")}</select></div>
3043
+ <div class="field" style="grid-column: span 2"><label>then use</label><input id="${prefix}-${lv}-fb-model" class="mono" list="${prefix}-models" value="${esc(t.fallback?.model ?? "")}" placeholder="the agent's own model" data-tier="${lv}-fb" /></div>
3044
+ <div class="field"><label>reasoning</label><select id="${prefix}-${lv}-fb-variant" data-current="${esc(t.fallback?.variant ?? "")}"></select></div>
3045
+ </div>
3046
+ <div class="note">Counted per workspace for this model. A bot already running on it moves to the fallback when the quota runs out.</div>
3047
+ </details>
3048
+ </div>`
3049
+ }).join("")}</div>`
3050
+ }
3051
+ function quotaText(q) {
3052
+ const parts = [q.usd && `$${q.usd}`, q.tokens && `${fmt.n(q.tokens)} tok`, q.minutes && `${q.minutes} min`].filter(Boolean)
3053
+ return `${parts.join(" / ")} per ${q.per ?? "day"}`
3054
+ }
3055
+ function quotaMeter(q, u) {
3056
+ if (!q) return ""
3057
+ const kind = ["usd", "tokens", "minutes"].find((k) => q[k])
3058
+ if (!kind) return ""
3059
+ const frac = Math.min(1, (u.spent?.[kind] ?? 0) / q[kind])
3060
+ const used = kind === "usd" ? fmt.money(u.spent.usd) : kind === "tokens" ? fmt.n(u.spent.tokens) : `${Math.round(u.spent.minutes)}m`
3061
+ return ` <span class="qmeter ${u.over ? "over" : ""}" title="${u.over ? "used up — cards go to the fallback" : "used so far this " + esc(u.per)}"><span class="bar"><i style="width:${Math.round(frac * 100)}%"></i></span>${used}${u.over ? " · using fallback" : ""}</span>`
3062
+ }
3063
+ function wireTierRows(prefix, models) {
3064
+ for (const lv of ["easy", "normal", "hard", "easy-fb", "normal-fb", "hard-fb"]) {
3065
+ const input = $(`#${prefix}-${lv}-model`)
3066
+ const sel = $(`#${prefix}-${lv}-variant`)
3067
+ if (!input || !sel) continue
3068
+ const paint = () => {
3069
+ const m = models.find((x) => x.id === input.value.trim())
3070
+ const opts = m?.variants?.length ? m.variants : ["low", "medium", "high"]
3071
+ const cur = sel.value || sel.dataset.current || ""
3072
+ sel.innerHTML = [`<option value="">default</option>`, ...opts.map((v) => `<option value="${esc(v)}" ${v === cur ? "selected" : ""}>${esc(v)}</option>`)].join("")
3073
+ }
3074
+ input.oninput = paint
3075
+ paint()
3076
+ }
3077
+ }
3078
+ function readTierRows(prefix) {
3079
+ const out = {}
3080
+ for (const lv of ["easy", "normal", "hard"]) {
3081
+ const t = {}
3082
+ const m = $(`#${prefix}-${lv}-model`)?.value.trim()
3083
+ const v = $(`#${prefix}-${lv}-variant`)?.value
3084
+ if (m) t.model = m
3085
+ if (v) t.variant = v
3086
+ const q = {}
3087
+ for (const k of ["usd", "tokens", "minutes"]) { const n = Number($(`#${prefix}-${lv}-q-${k}`)?.value); if (n > 0) q[k] = n }
3088
+ if (m && Object.keys(q).length) {
3089
+ q.per = $(`#${prefix}-${lv}-q-per`)?.value ?? "day"
3090
+ t.quota = q
3091
+ const fm = $(`#${prefix}-${lv}-fb-model`)?.value.trim()
3092
+ const fv = $(`#${prefix}-${lv}-fb-variant`)?.value
3093
+ t.fallback = { ...(fm ? { model: fm } : {}), ...(fv ? { variant: fv } : {}) }
3094
+ }
3095
+ out[lv] = t
3096
+ }
3097
+ return out
3098
+ }
3099
+ function agentMatrix(agents) {
3100
+ if (!agents.length) return ""
3101
+ const short = (m) => (m ? esc(String(m).split("/").pop()) : `<span class="note">agent's model</span>`)
3102
+ const cell = (c) => {
3103
+ const tag = c.source === "agent" ? `<span class="chip own">own rule</span>` : c.source === "workspace" ? `<span class="chip">workspace</span>` : `<span class="chip dim">no routing</span>`
3104
+ const q = c.quota ? `<div class="note">${c.quota.usd ? `$${(c.quota.spent?.usd ?? 0).toFixed(2)} / $${c.quota.usd}` : c.quota.tokens ? `${Math.round((c.quota.spent?.tokens ?? 0) / 1000)}k / ${Math.round(c.quota.tokens / 1000)}k tok` : `${Math.round(c.quota.spent?.minutes ?? 0)} / ${c.quota.minutes} min`} per ${esc(c.quota.per)} · ${esc(c.quota.scope)}${c.quota.over ? ` · <b style="color:var(--busy)">used up → ${short(c.fallback?.model)}</b>` : ""}</div>` : ""
3105
+ return `<td class="${c.source === "agent" ? "own" : ""}"><div class="mono">${short(c.model)}${c.variant ? ` · ${esc(c.variant)}` : ""}</div>${tag}${q}</td>`
3106
+ }
3107
+ return `<section class="panel" style="margin-top:12px"><h3>What each agent gets</h3>
3108
+ <p class="note" style="margin:0 0 8px">Per size, an agent's own rule wins; sizes it leaves out use the workspace rules above. An agent's own quota counts only that agent's spend. Click an agent to give it its own rules.</p>
3109
+ <table class="matrix"><thead><tr><th>Agent</th><th>easy</th><th>normal</th><th>hard</th></tr></thead><tbody>
3110
+ ${agents.map((a) => `<tr><th><a href="#" data-agent="${esc(a.id)}">${esc(a.title)}</a><div class="note mono">@${esc(a.id)}</div></th>${["easy", "normal", "hard"].map((l) => cell(a.levels[l] ?? {})).join("")}</tr>`).join("")}
3111
+ </tbody></table></section>`
3112
+ }
3113
+ async function renderRouting(key) {
3114
+ mainKey = key
3115
+ $("#main").innerHTML = `<div class="page"><div class="note">Loading…</div></div>`
3116
+ const [doc, mods, lib] = await Promise.all([
3117
+ api(`/api/workspaces/${encodeURIComponent(ui.ws)}/routing`).catch(() => ({ enabled: false, tiers: {} })),
3118
+ api(`/api/workspaces/${encodeURIComponent(ui.ws)}/models`).catch(() => ({ models: [] })),
3119
+ library(ui.ws, true).catch(() => null),
3120
+ ])
3121
+ const own = (lib?.agents ?? []).filter((a) => a.tiers)
3122
+ const pinned = (lib?.pipelines ?? []).flatMap((p) => p.stages.filter((s) => s.difficulty).map((s) => `${p.title ?? p.id} → ${s.id}: ${s.difficulty}`))
3123
+ $("#main").innerHTML = `<div class="page" style="max-width:980px">
3124
+ <div class="edhead"><h1>Model routing</h1><div class="acts"><button class="primary sm" id="rt-save">Save</button></div></div>
3125
+ <div class="note mono">botfarm/routing.botfarm.yml</div>
3126
+ <p class="lead" style="margin-top:10px">Each card is sized <b>easy</b>, <b>normal</b> or <b>hard</b> from its words, length and number of criteria — no model decides — and runs on the model you pick for that size. Cheap models for small cards, the big one only where it pays.</p>
3127
+ <label class="switch"><input type="checkbox" id="rt-on" ${doc.enabled ? "checked" : ""} /> <b>Route cards by difficulty</b> <span class="note">off: every bot uses its agent's model</span></label>
3128
+ <div class="field" style="max-width:520px;margin-top:8px"><label for="rt-switch">When a quota runs out mid-card</label><select id="rt-switch">
3129
+ <option value="now" ${doc.switch !== "next-turn" ? "selected" : ""}>switch at once — stop the turn and carry on on the fallback</option>
3130
+ <option value="next-turn" ${doc.switch === "next-turn" ? "selected" : ""}>switch on its next turn — let the current turn finish</option></select></div>
3131
+ <div class="err" id="rt-err"></div>
3132
+ <section class="panel" style="margin-top:12px">${tierRows("rt", doc.tiers ?? {}, mods.models ?? [], doc.usage ?? {})}
3133
+ <p class="note" style="margin:10px 0 0">Empty model: the agent's own model, only the reasoning level changes. Workspace default: <span class="mono">${esc(mods.default ?? "opencode's")}</span>.</p></section>
3134
+ ${agentMatrix(doc.agents ?? [])}
3135
+ <section class="panel" style="margin-top:12px"><h3>Overrides</h3>
3136
+ <ul class="plain">
3137
+ <li>A bot's own <b>Model…</b> choice (in its panel) always wins.</li>
3138
+ <li>A stage can pin its size — <b>Difficulty</b> in the pipeline editor.${pinned.length ? `<br><span class="note mono">${pinned.map(esc).join("<br>")}</span>` : ""}</li>
3139
+ <li>A split piece can say its own size (the product bot is asked to mark obvious ones).</li>
3140
+ <li>An agent can route its own way — <b>Model by difficulty</b> in the agent editor.${own.length ? ` <span class="note">Set on: ${own.map((a) => `<a href="#" data-agent="${esc(a.id)}">${esc(a.title ?? a.id)}</a>`).join(", ")}</span>` : ""}</li>
3141
+ </ul></section>
3142
+ </div>`
3143
+ wireTierRows("rt", mods.models ?? [])
3144
+ for (const a of $$("[data-agent]")) a.onclick = (e) => { e.preventDefault(); go("agent", a.dataset.agent) }
3145
+ $("#rt-save").onclick = async () => {
3146
+ try {
3147
+ await api(`/api/workspaces/${encodeURIComponent(ui.ws)}/routing`, { method: "PUT", body: { enabled: $("#rt-on").checked, switch: $("#rt-switch").value, tiers: readTierRows("rt") } })
3148
+ $("#rt-err").innerHTML = `<span style="color:var(--ok)">Saved. New cards are routed from now on; cards already running keep their model.</span>`
3149
+ } catch (e) { $("#rt-err").textContent = e.message }
3150
+ }
3151
+ }
3152
+
3153
+ // ------------------------------------------------------------------ agent editor
3154
+ async function renderAgent(key) {
3155
+ mainKey = key
3156
+ const lib = await library(ui.ws, true).catch(() => null)
3157
+ if (!lib) return
3158
+ const isNew = ui.id === "+new"
3159
+ const found = lib.agents.find((a) => a.id === ui.id)
3160
+ if (!isNew && !found) { $("#main").innerHTML = `<div class="page"><div class="empty">No agent "${esc(ui.id)}" in this workspace.</div></div>`; return }
3161
+ lib.models = (await api(`/api/workspaces/${encodeURIComponent(ui.ws)}/models`).catch(() => ({ models: [] }))).models
3162
+ ed = { kind: "agents", id: isNew ? null : found.id, doc: isNew ? { id: "", title: "", role: "", prompt: "" } : JSON.parse(JSON.stringify(found)), isNew, tab: "form", lib }
3163
+ paintAgent()
3164
+ }
3165
+ function paintAgent() {
3166
+ const { doc, isNew, lib } = ed
3167
+ const usedBy = (lib.pipelines ?? []).filter((p) => p.stages.some((s) => s.persona === doc.id)).map((p) => p.title ?? p.id)
3168
+ $("#main").innerHTML = `<div class="page">
3169
+ <div class="edhead"><h1>${isNew ? "New agent" : "@" + esc(doc.id)}</h1>
3170
+ <div class="acts">${!isNew ? `<button class="sm danger" id="ae-del">Delete</button>` : ""}<button class="primary sm" id="ae-save">Save</button></div></div>
3171
+ <div class="note mono">${esc(isNew ? "botfarm/<id>.agent.botfarm.yml" : doc.file ?? "")}${usedBy.length ? ` · used by ${esc(usedBy.join(", "))}` : ""}</div>
3172
+ <div class="tabs"><button class="sm ghost" data-tab="form" aria-pressed="${ed.tab === "form"}">Agent</button><button class="sm ghost" data-tab="yaml" aria-pressed="${ed.tab === "yaml"}">This file (YAML)</button></div>
3173
+ <div class="err" id="ae-err"></div>
3174
+ <div id="ae-body"></div></div>`
3175
+ for (const b of $$("[data-tab]")) b.onclick = () => { if (ed.tab === "form") readAgentForm(); ed.tab = b.dataset.tab; paintAgent() }
3176
+ $("#ae-save").onclick = saveAgent
3177
+ if ($("#ae-del")) $("#ae-del").onclick = async () => {
3178
+ if (!confirm(`Delete agent "${ed.id}"? This deletes botfarm/${ed.id}.agent.botfarm.yml.`)) return
3179
+ await api(`/api/workspaces/${encodeURIComponent(ui.ws)}/agents/${encodeURIComponent(ed.id)}`, { method: "DELETE" })
3180
+ libs.delete(ui.ws)
3181
+ go("home")
3182
+ }
3183
+ if (ed.tab === "yaml") return paintRawInto("#ae-body", "agents")
3184
+ $("#ae-body").innerHTML = `<div style="max-width:900px">
3185
+ <div class="row">
3186
+ <div class="field"><label>Name</label><input id="ae-title" value="${esc(doc.title ?? "")}" placeholder="QA Bot" /></div>
3187
+ <div class="field" style="max-width:200px"><label>Id (used in pipelines)</label><input id="ae-id" class="mono" value="${esc(doc.id ?? "")}" ${isNew ? "" : "disabled"} placeholder="qa" /></div>
3188
+ <div class="field" style="max-width:200px"><label>Role</label><input id="ae-role" class="mono" value="${esc(doc.role ?? "")}" placeholder="qa" /><span class="hint">Cards for a role go to whoever plays it.</span></div>
3189
+ </div>
3190
+ <div class="row">
3191
+ <div class="field"><label>opencode agent</label><input id="ae-agent" class="mono" list="ae-agents" value="${esc(doc.agent ?? "")}" placeholder="${lib.opencode?.agents?.some((a) => a.name === "build") ? "build" : "opencode default"}" />
3192
+ <datalist id="ae-agents">${(lib.opencode?.agents ?? []).map((a) => `<option value="${esc(a.name)}">${esc([a.model, a.variant, a.description].filter(Boolean).join(" · "))}</option>`).join("")}</datalist>
3193
+ <span class="hint">From your opencode.json. Its model and reasoning level apply unless set here.</span></div>
3194
+ <div class="field"><label>Model</label><input id="ae-model" class="mono" list="ae-models" value="${esc(doc.model ?? "")}" placeholder="${esc(resolvedModel(lib, doc) ?? "opencode default")}" />
3195
+ <datalist id="ae-models">${[...new Set([lib.opencode?.model, ...(lib.opencode?.agents ?? []).map((a) => a.model)].filter(Boolean))].map((m) => `<option value="${esc(m)}"></option>`).join("")}</datalist></div>
3196
+ <div class="field" style="max-width:150px"><label>Reasoning</label><input id="ae-variant" class="mono" list="ae-variants" value="${esc(doc.variant ?? "")}" placeholder="${esc(resolvedVariant(lib, doc) ?? "default")}" />
3197
+ <datalist id="ae-variants"><option value="low"></option><option value="medium"></option><option value="high"></option></datalist></div>
3198
+ </div>
3199
+ <div class="row">
3200
+ <div class="field"><label>Tools</label><input id="ae-tools" class="mono" value="${esc((doc.tools ?? []).join(", "))}" placeholder="all tools (or e.g. read, grep, glob)" /></div>
3201
+ <div class="field" style="max-width:180px"><label>May open sessions</label><select id="ae-spawn"><option value="">no</option><option value="1" ${doc.may_spawn ? "selected" : ""}>yes</option></select></div>
3202
+ </div>
3203
+ <details class="tierbox" ${doc.tiers ? "open" : ""}><summary><b>Model by difficulty</b> <span class="note">${doc.tiers ? "this agent routes its own way" : "uses the workspace routing"} — optional</span></summary>
3204
+ <p class="note" style="margin:6px 0 10px">Leave empty to follow <a href="#" data-go-routing>the workspace routing</a>. Fill a row to give this agent its own model or reasoning level for cards of that size.</p>
3205
+ ${tierRows("ae-t", doc.tiers ?? {}, lib.models ?? [])}
3206
+ </details>
3207
+ <div class="field"><label>Instructions</label><textarea id="ae-prompt" class="code" rows="18">${esc(doc.prompt ?? "")}</textarea>
3208
+ <span class="hint">Sent once, together with the agent's first card in a workstream.</span></div></div>`
3209
+ wireTierRows("ae-t", lib.models ?? [])
3210
+ $("[data-go-routing]")?.addEventListener("click", (e) => { e.preventDefault(); go("routing") })
3211
+ }
3212
+ async function paintRawInto(sel, kind) {
3213
+ if (!ed.id) { $(sel).innerHTML = `<div class="note">Save it from the form first — the file is named after its id.</div>`; return }
3214
+ const f = await api(`/api/workspaces/${encodeURIComponent(ui.ws)}/file/${kind}?entry=${encodeURIComponent(ed.id)}`)
3215
+ $(sel).innerHTML = `<textarea id="raw" class="code" spellcheck="false" style="width:100%;max-width:1000px;min-height:65vh">${esc(f.text)}</textarea>`
3216
+ }
3217
+ function ocAgent(lib, doc) {
3218
+ const name = doc.agent ?? (lib.opencode?.agents?.some((a) => a.name === "build") ? "build" : null)
3219
+ return (lib.opencode?.agents ?? []).find((a) => a.name === name) ?? null
3220
+ }
3221
+ const resolvedModel = (lib, doc) => doc.model ?? ocAgent(lib, doc)?.model ?? lib.opencode?.model ?? null
3222
+ const resolvedVariant = (lib, doc) => doc.variant ?? ocAgent(lib, doc)?.variant ?? null
3223
+ function readAgentForm() {
3224
+ if (!$("#ae-title")) return
3225
+ const d = ed.doc
3226
+ d.title = $("#ae-title").value.trim()
3227
+ if (ed.isNew) d.id = $("#ae-id").value.trim() || slug(d.title)
3228
+ d.role = $("#ae-role").value.trim() || d.id
3229
+ d.model = $("#ae-model").value.trim() || undefined
3230
+ d.agent = $("#ae-agent").value.trim() || undefined
3231
+ d.variant = $("#ae-variant").value.trim() || undefined
3232
+ const tools = $("#ae-tools").value.split(/[,\s]+/).filter(Boolean)
3233
+ d.tools = tools.length ? tools : undefined
3234
+ d.may_spawn = $("#ae-spawn").value ? true : undefined
3235
+ d.prompt = $("#ae-prompt").value
3236
+ const tiers = readTierRows("ae-t")
3237
+ d.tiers = Object.values(tiers).some((t) => Object.keys(t).length) ? tiers : undefined
3238
+ }
3239
+ async function saveAgent() {
3240
+ if (ed.tab === "yaml") return saveRaw("agents")
3241
+ readAgentForm()
3242
+ const d = ed.doc
3243
+ if (!d.id) return ($("#ae-err").textContent = "Give it a name or an id.")
3244
+ const clean = Object.fromEntries(Object.entries(d).filter(([, v]) => v !== undefined && v !== ""))
3245
+ try {
3246
+ const lib = await api(`/api/workspaces/${encodeURIComponent(ui.ws)}/agents/${encodeURIComponent(d.id)}`, { method: "PUT", body: { doc: clean } })
3247
+ libs.set(ui.ws, { ...libs.get(ui.ws), ...lib })
3248
+ navSig = null
3249
+ if (ed.isNew) return go("agent", d.id)
3250
+ ed.lib = libs.get(ui.ws)
3251
+ paintAgent()
3252
+ $("#ae-err").innerHTML = `<span style="color:var(--ok)">Saved to ${esc(ed.doc.file ?? "botfarm/" + ed.doc.id + ".agent.botfarm.yml")}.</span>`
3253
+ } catch (e) { $("#ae-err").textContent = e.message }
3254
+ }
3255
+
3256
+ // ------------------------------------------------------------------ socket
3257
+ let sock = null
3258
+ const wsSend = (text) => { if (sock?.readyState === 1) sock.send(text) }
3259
+ function connect() {
3260
+ const open = () => {
3261
+ const ws = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/api/socket`)
3262
+ ws.onopen = () => {
3263
+ sock = ws
3264
+ ws.send("resync")
3265
+ if (drawer?.kind === "session") ws.send(`watch:${drawer.id}`)
3266
+ }
3267
+ ws.onmessage = (e) => {
3268
+ const msg = JSON.parse(e.data)
3269
+ if (msg.t) return onLive(msg)
3270
+ const first = !state.at
3271
+ state = msg
3272
+ if (first) route()
3273
+ else render()
3274
+ renderKv()
3275
+ }
3276
+ ws.onclose = () => { sock = null; $("#conn").textContent = "reconnecting…"; setTimeout(open, 1500) }
3277
+ }
3278
+ open()
3279
+ }
3280
+ connect()
3281
+ setInterval(() => { if (state.at && ui.kind === "stream") render() }, 15000) // keep "working 3m" honest
3282
+ </script>
3283
+ </body>
3284
+ </html>