team-chat 0.0.0-stage → 0.3.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 (122) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +458 -2
  3. package/agents/rules.md +52 -0
  4. package/agents/runtimes/codex.md +14 -0
  5. package/bin/bridge +3 -0
  6. package/bin/runner +3 -0
  7. package/cli/lib/agents.js +97 -0
  8. package/cli/lib/docs.js +151 -0
  9. package/cli/lib/local.js +41 -0
  10. package/cli/lib/server.js +52 -0
  11. package/cli/lib/util.js +107 -0
  12. package/cli/team-chat.js +383 -0
  13. package/host/bridge/attempts.js +43 -0
  14. package/host/bridge/bridge.js +326 -0
  15. package/host/bridge/config.js +105 -0
  16. package/host/bridge/knowledge-outbox.js +96 -0
  17. package/host/bridge/launcher.js +126 -0
  18. package/host/bridge/main.js +87 -0
  19. package/host/bridge/progress.js +74 -0
  20. package/host/bridge/prompt.js +97 -0
  21. package/host/bridge/run-archive.js +200 -0
  22. package/host/bridge/thread.js +44 -0
  23. package/host/bridge/triage.js +97 -0
  24. package/host/bridge/workdir.js +41 -0
  25. package/host/knowledge/card.js +43 -0
  26. package/host/knowledge/errors.js +50 -0
  27. package/host/knowledge/lesson.js +153 -0
  28. package/host/knowledge/process.js +160 -0
  29. package/host/knowledge/scrub.js +29 -0
  30. package/host/knowledge/shell.js +74 -0
  31. package/host/knowledge/stack.js +229 -0
  32. package/host/knowledge/transcript.js +82 -0
  33. package/host/lib/chat-api.js +120 -0
  34. package/host/lib/log.js +6 -0
  35. package/host/lib/pid-lock.js +65 -0
  36. package/host/lib/shell-words.js +45 -0
  37. package/host/runner/bridge-process.js +82 -0
  38. package/host/runner/config.js +81 -0
  39. package/host/runner/main.js +69 -0
  40. package/host/runner/retention.js +40 -0
  41. package/host/runner/runner.js +161 -0
  42. package/package.json +67 -5
  43. package/server/dist/src/app.js +25 -0
  44. package/server/dist/src/app.module.js +50 -0
  45. package/server/dist/src/attachments/attachment-store.js +111 -0
  46. package/server/dist/src/attachments/attachments.controller.js +67 -0
  47. package/server/dist/src/attachments/mime.js +84 -0
  48. package/server/dist/src/attachments/uploads.interceptor.js +72 -0
  49. package/server/dist/src/auth/participant.guard.js +53 -0
  50. package/server/dist/src/bridges/bridge-restart.service.js +80 -0
  51. package/server/dist/src/bridges/bridges.controller.js +47 -0
  52. package/server/dist/src/chats/chats.controller.js +83 -0
  53. package/server/dist/src/chats/chats.service.js +128 -0
  54. package/server/dist/src/collector/collector.controller.js +71 -0
  55. package/server/dist/src/collector/collector.module.js +23 -0
  56. package/server/dist/src/collector/collector.service.js +111 -0
  57. package/server/dist/src/common/clock.js +20 -0
  58. package/server/dist/src/common/errors.js +37 -0
  59. package/server/dist/src/common/format.js +14 -0
  60. package/server/dist/src/common/http.js +17 -0
  61. package/server/dist/src/common/slug.js +28 -0
  62. package/server/dist/src/common/tokens.js +19 -0
  63. package/server/dist/src/common/validation.js +41 -0
  64. package/server/dist/src/config/config.js +39 -0
  65. package/server/dist/src/conversation.module.js +41 -0
  66. package/server/dist/src/db/database.js +73 -0
  67. package/server/dist/src/db/database.module.js +51 -0
  68. package/server/dist/src/db/schema.js +212 -0
  69. package/server/dist/src/decisions/decisions.controller.js +55 -0
  70. package/server/dist/src/decisions/decisions.service.js +109 -0
  71. package/server/dist/src/documents/documents.controller.js +87 -0
  72. package/server/dist/src/documents/documents.service.js +189 -0
  73. package/server/dist/src/health/health.controller.js +29 -0
  74. package/server/dist/src/knowledge/knowledge.controller.js +83 -0
  75. package/server/dist/src/knowledge/knowledge.module.js +25 -0
  76. package/server/dist/src/knowledge/knowledge.service.js +189 -0
  77. package/server/dist/src/local/docs-sync.service.js +97 -0
  78. package/server/dist/src/local/local.controller.js +197 -0
  79. package/server/dist/src/local/local.guard.js +55 -0
  80. package/server/dist/src/local/local.module.js +30 -0
  81. package/server/dist/src/local/setup.service.js +154 -0
  82. package/server/dist/src/local/transfer.service.js +151 -0
  83. package/server/dist/src/main.js +33 -0
  84. package/server/dist/src/mcp/chat-tools.js +282 -0
  85. package/server/dist/src/mcp/mcp.controller.js +58 -0
  86. package/server/dist/src/mcp/mcp.module.js +24 -0
  87. package/server/dist/src/mentions/mention-state.js +80 -0
  88. package/server/dist/src/mentions/mentions.controller.js +100 -0
  89. package/server/dist/src/mentions/mentions.service.js +203 -0
  90. package/server/dist/src/messages/message.presenter.js +102 -0
  91. package/server/dist/src/messages/messages.service.js +149 -0
  92. package/server/dist/src/messages/thread.controller.js +179 -0
  93. package/server/dist/src/participants/directory.service.js +68 -0
  94. package/server/dist/src/participants/participants.module.js +24 -0
  95. package/server/dist/src/participants/participants.service.js +84 -0
  96. package/server/dist/src/settings/settings.service.js +42 -0
  97. package/server/dist/src/sharing/consent.js +16 -0
  98. package/server/dist/src/sharing/sharing.module.js +22 -0
  99. package/server/dist/src/sharing/sharing.service.js +175 -0
  100. package/server/dist/src/team/runner.service.js +75 -0
  101. package/server/dist/src/team/team.controller.js +178 -0
  102. package/server/dist/src/team/team.module.js +25 -0
  103. package/server/dist/src/team/team.service.js +280 -0
  104. package/server/dist/src/web/template.js +57 -0
  105. package/server/dist/src/web/web-login.service.js +74 -0
  106. package/server/dist/src/web/web.controller.js +203 -0
  107. package/server/dist/src/web/web.module.js +25 -0
  108. package/server/drizzle/0000_init.sql +169 -0
  109. package/server/drizzle/0001_seed_roles.sql +25 -0
  110. package/server/drizzle/0002_knowledge.sql +17 -0
  111. package/server/drizzle/0003_sharing.sql +30 -0
  112. package/server/drizzle/meta/0000_snapshot.json +1224 -0
  113. package/server/drizzle/meta/0001_snapshot.json +1224 -0
  114. package/server/drizzle/meta/0002_snapshot.json +1359 -0
  115. package/server/drizzle/meta/0003_snapshot.json +1545 -0
  116. package/server/drizzle/meta/_journal.json +34 -0
  117. package/server/package.json +1 -0
  118. package/server/views/chat.html +865 -0
  119. package/server/views/documents.html +204 -0
  120. package/server/views/login.html +31 -0
  121. package/server/views/setup.html +60 -0
  122. package/server/views/team.html +369 -0
@@ -0,0 +1,369 @@
1
+ <!doctype html>
2
+ <html lang="ru">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Команда — агенты и роли</title>
7
+ <style>
8
+ :root{--bg:#f6f7f9;--card:#fff;--line:#e4e7ec;--ink:#14171c;--muted:#6b7280;
9
+ --brand:#0057c2;--ok:#11a36b;--warn:#b45309;--bad:#b91c1c;
10
+ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,sans-serif;}
11
+ *{box-sizing:border-box}
12
+ body{margin:0;background:var(--bg);color:var(--ink);font-size:15px;line-height:1.5}
13
+ header{background:var(--card);border-bottom:1px solid var(--line);padding:12px 20px;
14
+ display:flex;align-items:center;gap:16px;position:sticky;top:0;z-index:5}
15
+ header h1{font-size:15px;margin:0;font-weight:600}
16
+ header a{color:var(--muted);text-decoration:none;font-size:13px}
17
+ header a:hover{color:var(--brand)}
18
+ .layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:16px;
19
+ max-width:1200px;margin:0 auto;padding:16px 20px}
20
+ @media (max-width:900px){.layout{grid-template-columns:1fr}}
21
+ .panel{background:var(--card);border:1px solid var(--line);border-radius:12px;align-self:start}
22
+ .panel h2{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);
23
+ margin:0;padding:14px 16px;border-bottom:1px solid var(--line);display:flex;align-items:center}
24
+ .panel h2 button{margin-left:auto;text-transform:none;letter-spacing:0}
25
+ .row{padding:12px 16px;border-bottom:1px solid var(--line);display:flex;gap:10px;align-items:flex-start}
26
+ .row:last-child{border-bottom:0}
27
+ .row.off{opacity:.55}
28
+ .dot{width:30px;height:30px;border-radius:50%;flex:none;color:#fff;font-size:12px;font-weight:600;
29
+ display:grid;place-items:center}
30
+ .row .main{flex:1;min-width:0}
31
+ .row .t{font-weight:600}
32
+ .row .m{font-size:12px;color:var(--muted)}
33
+ .row .m b{color:var(--ink);font-weight:500}
34
+ .row pre{white-space:pre-wrap;word-wrap:break-word;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;
35
+ color:var(--muted);margin:6px 0 0;max-height:4.5em;overflow:hidden}
36
+ button{border:1px solid var(--line);background:#fff;border-radius:8px;padding:4px 11px;
37
+ font:inherit;font-size:13px;cursor:pointer;color:var(--ink)}
38
+ button:hover{border-color:var(--brand);color:var(--brand)}
39
+ button.primary{background:var(--brand);border-color:var(--brand);color:#fff;font-weight:600}
40
+ button.primary:hover{color:#fff;opacity:.92}
41
+ .live{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:4px;vertical-align:1px}
42
+ .note{padding:12px 16px;font-size:13px;color:var(--muted);border-bottom:1px solid var(--line);background:#fbfcfd}
43
+ .note code{background:#eef1f5;border-radius:4px;padding:0 4px}
44
+ .empty{padding:16px;color:var(--muted);font-size:13px}
45
+ .col{display:flex;flex-direction:column;gap:16px}
46
+ .tokenbox{display:flex;flex-direction:column;gap:8px}
47
+ .tokenbox code{display:block;white-space:pre-wrap;word-break:break-all;background:#f1f3f6;border-radius:8px;
48
+ padding:8px 10px;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--ink)}
49
+ .warn{background:#fef3c7;border:1px solid #fcd34d;border-radius:10px;padding:12px 16px;margin:16px auto;max-width:1160px}
50
+ dialog{border:1px solid var(--line);border-radius:12px;padding:0;width:min(640px,calc(100vw - 32px))}
51
+ dialog::backdrop{background:rgba(20,23,28,.35)}
52
+ dialog form{display:flex;flex-direction:column;gap:10px;padding:18px}
53
+ dialog h3{margin:0 0 4px;font-size:16px}
54
+ dialog label{display:flex;flex-direction:column;gap:4px;font-size:13px;color:var(--muted)}
55
+ dialog input,dialog select,dialog textarea{font:inherit;color:var(--ink);border:1px solid var(--line);
56
+ border-radius:8px;padding:8px 10px;outline:none;background:#fff}
57
+ dialog input:focus,dialog select:focus,dialog textarea:focus{border-color:var(--brand)}
58
+ dialog input:disabled{background:#f6f7f9;color:var(--muted)}
59
+ dialog textarea{min-height:260px;resize:vertical;font:13px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace}
60
+ dialog .two{display:grid;grid-template-columns:1fr 1fr;gap:10px}
61
+ dialog .check{flex-direction:row;align-items:center;gap:8px;color:var(--ink)}
62
+ dialog .actions{display:flex;gap:8px;justify-content:flex-end;margin-top:4px}
63
+ dialog .actions .left{margin-right:auto}
64
+ dialog .err{color:var(--bad);font-size:13px;min-height:1em}
65
+ </style>
66
+ </head>
67
+ <body>
68
+ <header>
69
+ <h1>Команда</h1>
70
+ <a href="{{ back }}">← в чат</a>
71
+ <span style="margin-left:auto;color:var(--muted);font-size:13px">{{ me.name }} · {{ me.role }}</span>
72
+ </header>
73
+
74
+ <div class="layout">
75
+ <div class="col">
76
+ <section class="panel">
77
+ <h2>Агенты {{#owner}}<button type="button" class="primary" id="addagent">+ агент</button>{{/owner}}</h2>
78
+ <div class="note" id="runtimesnote"></div>
79
+ <div id="agents"><div class="empty">Загрузка…</div></div>
80
+ </section>
81
+ <section class="panel">
82
+ <h2>Машины {{#owner}}<button type="button" id="addrunner">+ машина</button>{{/owner}}</h2>
83
+ <div class="note">Машина — компьютер, где работают агенты: на нём запущен раннер
84
+ (<code>team-chat start</code> у владельца, <code>team-chat connect</code> у остальных).
85
+ Агент работает на одной машине; свободного забирает первая, у которой есть его движок.</div>
86
+ <div id="runners"><div class="empty">Загрузка…</div></div>
87
+ </section>
88
+ </div>
89
+ <section class="panel">
90
+ <h2>Роли {{#owner}}<button type="button" class="primary" id="addrole">+ роль</button>{{/owner}}</h2>
91
+ <div class="note">Роль — инструкция агенту в markdown: за что он отвечает и чего не делает.
92
+ Уходит в каждое задание вместе с общими правилами чата (<code>agents/rules.md</code>).
93
+ В конкретном чате агенту можно дать другую роль — в настройках чата.</div>
94
+ <div id="roles"><div class="empty">Загрузка…</div></div>
95
+ </section>
96
+ </div>
97
+
98
+ {{#owner}}
99
+ <dialog id="agentdlg">
100
+ <form id="agentform">
101
+ <h3 id="agenttitle">Новый агент</h3>
102
+ <div class="two">
103
+ <label>Имя для упоминаний (@…)
104
+ <input name="handle" required pattern="[a-z0-9][a-z0-9_\-]{1,39}" placeholder="reviewer">
105
+ </label>
106
+ <label>Как показывать
107
+ <input name="name" required maxlength="60" placeholder="Ревьюер">
108
+ </label>
109
+ </div>
110
+ <div class="two">
111
+ <label>Роль
112
+ <select name="role" required></select>
113
+ </label>
114
+ <label>Движок — чем запускать на машине с раннером
115
+ <input name="runtime" required list="runtimelist" pattern="[a-z0-9_\-]{1,40}" placeholder="claude">
116
+ <datalist id="runtimelist"></datalist>
117
+ </label>
118
+ </div>
119
+ <div class="two">
120
+ <label>Цвет в ленте
121
+ <input name="accent" type="color" value="#6d28d9">
122
+ </label>
123
+ <label>Машина
124
+ <select name="host"></select>
125
+ </label>
126
+ </div>
127
+ <label class="check" id="disabledwrap"><input type="checkbox" name="disabled"> отключён — не отвечает, не упоминается</label>
128
+ <div class="err" id="agenterr"></div>
129
+ <div class="actions">
130
+ <button type="button" class="cancel">отмена</button>
131
+ <button type="submit" class="primary" id="agentsave">Создать</button>
132
+ </div>
133
+ </form>
134
+ </dialog>
135
+
136
+ <dialog id="runnerdlg">
137
+ <form id="runnerform">
138
+ <h3 id="runnertitle">Новая машина</h3>
139
+ <div id="runnerask">
140
+ <label>Чья это машина или как её звать
141
+ <input name="name" required maxlength="60" placeholder="Ноутбук Ивана">
142
+ </label>
143
+ </div>
144
+ <div class="tokenbox" id="runnerdone" hidden>
145
+ <p style="margin:0;color:var(--muted)">Токен показывается <b>один раз</b>. На той машине (нужны Node и python3,
146
+ и claude или codex) выполнить:</p>
147
+ <code id="runnercmd"></code>
148
+ <p style="margin:0;color:var(--muted);font-size:13px">Адрес в команде — тот, по которому открыта эта страница.
149
+ Если чат на localhost, с другой машины он не виден: нужен адрес, по которому до него можно достучаться.</p>
150
+ </div>
151
+ <div class="err" id="runnererr"></div>
152
+ <div class="actions">
153
+ <button type="button" class="cancel">закрыть</button>
154
+ <button type="submit" class="primary" id="runnersave">Выдать токен</button>
155
+ </div>
156
+ </form>
157
+ </dialog>
158
+
159
+ <dialog id="roledlg">
160
+ <form id="roleform">
161
+ <h3 id="roletitle">Новая роль</h3>
162
+ <div class="two">
163
+ <label>Название
164
+ <input name="name" required maxlength="60" placeholder="Ревьюер">
165
+ </label>
166
+ <label>Адрес (пусто — из названия)
167
+ <input name="slug" maxlength="40" pattern="[a-z0-9][a-z0-9\-]{0,39}" placeholder="reviewer">
168
+ </label>
169
+ </div>
170
+ <label>Инструкция (markdown) — за что отвечает, чего не делает, как проверяет
171
+ <textarea name="instructions" required maxlength="20000"
172
+ placeholder="Отвечаешь за **качество изменений**: ищешь ошибки и риски в коде, который пишут другие.&#10;&#10;- Код сам не пишешь — описываешь проблему и ставишь задачу разработчику.&#10;- …"></textarea>
173
+ </label>
174
+ <div class="err" id="roleerr"></div>
175
+ <div class="actions">
176
+ <button type="button" class="left" id="roledelete">удалить роль</button>
177
+ <button type="button" class="cancel">отмена</button>
178
+ <button type="submit" class="primary" id="rolesave">Создать</button>
179
+ </div>
180
+ </form>
181
+ </dialog>
182
+ {{/owner}}
183
+
184
+ <script>
185
+ const TOKEN = {{json token}};
186
+ const IS_OWNER = {{json owner}};
187
+ const H = {'Authorization':'Bearer '+TOKEN,'Content-Type':'application/json','Accept':'application/json'};
188
+ const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
189
+ const ago = iso => {
190
+ if (!iso) return 'не выходил на связь';
191
+ const s = Math.max(0, (Date.now() - new Date(iso)) / 1000);
192
+ return s < 60 ? 'на связи' : s < 3600 ? `был ${Math.round(s/60)} мин назад` : s < 86400 ? `был ${Math.round(s/3600)} ч назад` : `был ${Math.round(s/86400)} дн назад`;
193
+ };
194
+ let team = {agents:[], roles:[], runtimes:[]};
195
+
196
+ async function load(){
197
+ const r = await fetch('/api/team', {headers:H});
198
+ team = await r.json();
199
+ render();
200
+ }
201
+
202
+ function render(){
203
+ const note = document.getElementById('runtimesnote');
204
+ note.innerHTML = team.runtimes.length
205
+ ? `Движки на машине с раннером: ${team.runtimes.map(r => `<code>${esc(r)}</code>`).join(', ')}. Новый агент подхватывается раннером за ~15 с.`
206
+ : `Раннер ещё не выходил на связь — неизвестно, какие движки есть на машине. Агента можно завести, запустится он, когда раннер его увидит.`;
207
+
208
+ document.getElementById('agents').innerHTML = team.agents.length ? team.agents.map(a => {
209
+ const fresh = a.seen_at && (Date.now() - new Date(a.seen_at)) < 60000;
210
+ const overrides = Object.entries(a.chat_roles || {});
211
+ return `<div class="row ${a.disabled ? 'off' : ''}">
212
+ <div class="dot" style="background:${esc(a.accent)}">${esc(a.name.slice(0,1).toUpperCase())}</div>
213
+ <div class="main">
214
+ <div class="t">${esc(a.name)} <span class="m">@${esc(a.handle)}</span></div>
215
+ <div class="m"><b>${esc(a.role_name)}</b> · движок <b>${esc(a.runtime ?? '—')}</b> · ${a.host ? `машина <b>${esc(a.host.name)}</b>` : 'машина не выбрана'}
216
+ ${a.disabled ? ' · отключён' : ` · <span class="live" style="background:${fresh ? 'var(--ok)' : 'var(--warn)'}"></span>${ago(a.seen_at)}`}</div>
217
+ ${overrides.length ? `<div class="m">в чатах: ${overrides.map(([c, r]) => `${esc(c)} — ${esc((team.roles.find(x => x.slug === r) || {name:r}).name)}`).join(', ')}</div>` : ''}
218
+ </div>
219
+ ${IS_OWNER ? `<button type="button" data-agent="${esc(a.handle)}">изменить</button>` : ''}
220
+ </div>`;
221
+ }).join('') : '<div class="empty">Агентов нет.</div>';
222
+
223
+ document.getElementById('runners').innerHTML = team.runners.length ? team.runners.map(h => {
224
+ const fresh = h.seen_at && (Date.now() - new Date(h.seen_at)) < 60000;
225
+ return `<div class="row">
226
+ <div class="main">
227
+ <div class="t">${esc(h.name)} <span class="m">${esc(h.handle)}</span></div>
228
+ <div class="m"><span class="live" style="background:${fresh ? 'var(--ok)' : 'var(--warn)'}"></span>${ago(h.seen_at)}
229
+ · движки: ${h.runtimes.length ? h.runtimes.map(esc).join(', ') : 'не сообщал'}
230
+ · агенты: ${h.agents.length ? h.agents.map(x => '@' + esc(x)).join(', ') : 'нет'}</div>
231
+ </div>
232
+ ${IS_OWNER ? `<button type="button" data-rotate="${esc(h.handle)}" title="новый токен; старый перестанет работать">новый токен</button>` : ''}
233
+ </div>`;
234
+ }).join('') : '<div class="empty">Ни одна машина ещё не подключалась.</div>';
235
+
236
+ document.getElementById('roles').innerHTML = team.roles.length ? team.roles.map(r => `<div class="row">
237
+ <div class="main">
238
+ <div class="t">${esc(r.name)} <span class="m">${esc(r.slug)}</span></div>
239
+ <div class="m">${r.in_use ? 'назначена' : 'не назначена никому'}</div>
240
+ <pre>${esc(r.instructions)}</pre>
241
+ </div>
242
+ ${IS_OWNER ? `<button type="button" data-role="${esc(r.slug)}">изменить</button>` : ''}
243
+ </div>`).join('') : '<div class="empty">Ролей нет.</div>';
244
+ }
245
+
246
+ function errorText(d, status){
247
+ return d && d.errors ? Object.values(d.errors).flat().join(' ') : (d && d.message) || ('Ошибка ' + status);
248
+ }
249
+
250
+ if (IS_OWNER){
251
+ document.querySelectorAll('dialog .cancel').forEach(b => b.onclick = () => b.closest('dialog').close());
252
+
253
+ // --- агент
254
+ const adlg = document.getElementById('agentdlg'), af = document.getElementById('agentform');
255
+ let editingAgent = null;
256
+ function openAgent(handle){
257
+ editingAgent = handle ? team.agents.find(a => a.handle === handle) : null;
258
+ af.reset(); document.getElementById('agenterr').textContent = '';
259
+ af.role.innerHTML = team.roles.map(r => `<option value="${esc(r.slug)}">${esc(r.name)}</option>`).join('');
260
+ af.host.innerHTML = '<option value="">любая, у которой есть движок</option>'
261
+ + team.runners.map(h => `<option value="${esc(h.handle)}">${esc(h.name)}</option>`).join('');
262
+ document.getElementById('runtimelist').innerHTML = team.runtimes.map(r => `<option value="${esc(r)}">`).join('');
263
+ document.getElementById('agenttitle').textContent = editingAgent ? `@${editingAgent.handle}` : 'Новый агент';
264
+ document.getElementById('agentsave').textContent = editingAgent ? 'Сохранить' : 'Создать';
265
+ document.getElementById('disabledwrap').style.visibility = editingAgent ? 'visible' : 'hidden';
266
+ af.handle.disabled = !!editingAgent;
267
+ if (editingAgent){
268
+ af.handle.value = editingAgent.handle; af.name.value = editingAgent.name;
269
+ af.role.value = editingAgent.role; af.runtime.value = editingAgent.runtime || '';
270
+ af.accent.value = editingAgent.accent; af.disabled.checked = editingAgent.disabled;
271
+ af.host.value = editingAgent.host ? editingAgent.host.handle : '';
272
+ } else if (team.runtimes.length) {
273
+ af.runtime.value = team.runtimes[0];
274
+ }
275
+ adlg.showModal();
276
+ }
277
+ document.getElementById('addagent').onclick = () => openAgent(null);
278
+ af.onsubmit = async e => {
279
+ e.preventDefault();
280
+ const data = {name: af.name.value.trim(), role: af.role.value, runtime: af.runtime.value.trim(), accent: af.accent.value};
281
+ if (editingAgent){
282
+ data.disabled = af.disabled.checked;
283
+ const host = af.host.value || null;
284
+ if (host !== (editingAgent.host ? editingAgent.host.handle : null)) data.host = host;
285
+ } else data.handle = af.handle.value.trim();
286
+ const r = await fetch(editingAgent ? '/api/agents/' + encodeURIComponent(editingAgent.handle) : '/api/agents',
287
+ {method: editingAgent ? 'PATCH' : 'POST', headers:H, body:JSON.stringify(data)});
288
+ const d = await r.json().catch(() => null);
289
+ if (!r.ok){ document.getElementById('agenterr').textContent = errorText(d, r.status); return; }
290
+ adlg.close(); load();
291
+ };
292
+
293
+ // --- роль
294
+ const rdlg = document.getElementById('roledlg'), rf = document.getElementById('roleform');
295
+ let editingRole = null;
296
+ function openRole(slug){
297
+ editingRole = slug ? team.roles.find(r => r.slug === slug) : null;
298
+ rf.reset(); document.getElementById('roleerr').textContent = '';
299
+ document.getElementById('roletitle').textContent = editingRole ? `Роль «${editingRole.name}»` : 'Новая роль';
300
+ document.getElementById('rolesave').textContent = editingRole ? 'Сохранить' : 'Создать';
301
+ const del = document.getElementById('roledelete');
302
+ del.style.display = editingRole ? '' : 'none';
303
+ del.disabled = !!(editingRole && editingRole.in_use);
304
+ del.title = editingRole && editingRole.in_use ? 'роль назначена агентам — сначала смените им роль' : '';
305
+ rf.slug.disabled = !!editingRole;
306
+ if (editingRole){
307
+ rf.name.value = editingRole.name; rf.slug.value = editingRole.slug; rf.instructions.value = editingRole.instructions;
308
+ }
309
+ rdlg.showModal();
310
+ }
311
+ document.getElementById('addrole').onclick = () => openRole(null);
312
+
313
+ // --- машина: токен раннера показывается один раз, вместе с командой
314
+ const hdlg = document.getElementById('runnerdlg'), hf = document.getElementById('runnerform');
315
+ let rotating = null;
316
+ function openRunner(handle){
317
+ rotating = handle; hf.reset();
318
+ document.getElementById('runnererr').textContent = '';
319
+ document.getElementById('runnerask').hidden = !!handle;
320
+ document.getElementById('runnerdone').hidden = true;
321
+ document.getElementById('runnersave').hidden = false;
322
+ document.getElementById('runnertitle').textContent = handle ? `Новый токен для ${handle}` : 'Новая машина';
323
+ hdlg.showModal();
324
+ if (handle) hf.requestSubmit();
325
+ }
326
+ document.getElementById('addrunner').onclick = () => openRunner(null);
327
+ hf.onsubmit = async e => {
328
+ e.preventDefault();
329
+ const r = await fetch(rotating ? `/api/runners/${encodeURIComponent(rotating)}/token` : '/api/runners',
330
+ {method:'POST', headers:H, body:JSON.stringify(rotating ? {} : {name: hf.name.value.trim()})});
331
+ const d = await r.json().catch(() => null);
332
+ if (!r.ok){ document.getElementById('runnererr').textContent = errorText(d, r.status); return; }
333
+ document.getElementById('runnercmd').textContent = d.connect;
334
+ document.getElementById('runnerask').hidden = true;
335
+ document.getElementById('runnerdone').hidden = false;
336
+ document.getElementById('runnersave').hidden = true;
337
+ load();
338
+ };
339
+ rf.onsubmit = async e => {
340
+ e.preventDefault();
341
+ const data = {name: rf.name.value.trim(), instructions: rf.instructions.value};
342
+ if (!editingRole && rf.slug.value.trim()) data.slug = rf.slug.value.trim();
343
+ const r = await fetch(editingRole ? '/api/roles/' + encodeURIComponent(editingRole.slug) : '/api/roles',
344
+ {method: editingRole ? 'PATCH' : 'POST', headers:H, body:JSON.stringify(data)});
345
+ const d = await r.json().catch(() => null);
346
+ if (!r.ok){ document.getElementById('roleerr').textContent = errorText(d, r.status); return; }
347
+ rdlg.close(); load();
348
+ };
349
+ document.getElementById('roledelete').onclick = async () => {
350
+ if (!editingRole || !confirm(`Удалить роль «${editingRole.name}»?`)) return;
351
+ const r = await fetch('/api/roles/' + encodeURIComponent(editingRole.slug), {method:'DELETE', headers:H});
352
+ const d = await r.json().catch(() => null);
353
+ if (!r.ok){ document.getElementById('roleerr').textContent = errorText(d, r.status); return; }
354
+ rdlg.close(); load();
355
+ };
356
+
357
+ document.addEventListener('click', e => {
358
+ const a = e.target.closest('[data-agent]'); if (a) openAgent(a.dataset.agent);
359
+ const r = e.target.closest('[data-role]'); if (r) openRole(r.dataset.role);
360
+ const h = e.target.closest('[data-rotate]');
361
+ if (h && confirm(`Выдать новый токен для ${h.dataset.rotate}? Старый перестанет работать — раннер на той машине нужно будет перезапустить с новым.`)) openRunner(h.dataset.rotate);
362
+ });
363
+ }
364
+
365
+ load();
366
+ setInterval(load, 15000); // «на связи» — свежесть, без опроса устареет
367
+ </script>
368
+ </body>
369
+ </html>