Symbai în Claude Code: panoul cu asistenții WhatsApp (oprește, pornește, repară și șterge; rolul pe scurt, promptul complet editabil, setări), banda de stare a…

Acest repo e un plugin Claude Code care învață asistentul AI cum să lucreze cu platforma Symbai (sistem de management pentru restaurante și hoteluri). Conține:
expo-aplicatii-mobile.md: Symbai POS, Symbai Portal, Symbai Staff, branding, emulator Android, build, GP/Viva app2app, push). Plus referințele transversale de navigare și operare MCP — agent-operare-avansata.md (standardul „consultant + inginer + QA" pentru task-uri complexe), erp-manufacturing-benchmark.md (pattern-uri ERP/MES pentru producție: BoM, MPS, readiness, QC, capacitate), navigare-rapida.md (cheat-sheet compact intenție→URL), navigare.md (cum DUCI userul pe pagină), claude-code-mcp-operare.md (doctrina skill→knowledge→MCP live→dry-run→confirmare→verificare), harta-aplicatiei.md (indexul exhaustiv al paginilor) și tools-mcp.md (catalog generat din registry-ul live al tool-urilor MCP, cu modelul de permisiuni) — și folderul knowledge/onboarding/ (planul general + 12 faze pentru configurarea de la zero a unui client nou, folosit de skill-ul onboarding-symbai).Pluginul se folosește împreună cu conexiunea MCP la instanța ta Symbai (date live + acțiuni reale). Conexiunea o adaugi separat din portalul Hub. Pentru email marketing, skill-urile sunt optimizate pentru flow-uri complexe, deliverability/warm-up, trimitere predictivă pe ore individuale și atribuire conversii din POS/rezervări.
Metoda recomandată — prin fișier, cu AUTO-UPDATE pornit din start. Funcționează în orice mediu (inclusiv aplicația desktop, unde comanda /plugin poate lipsi) și e singura care pornește actualizarea automată. Editează settings.json din folderul Claude (Windows: C:\Users\<nume>\.claude\settings.json; macOS/Linux: ~/.claude/settings.json; creează-l dacă lipsește) și îmbină următoarele chei, păstrând restul fișierului:
{
"extraKnownMarketplaces": {
"symbai": {
"source": { "source": "git", "url": "https://github.com/sinesteziadrimo/symbaimemory.git" },
"autoUpdate": true
}
},
"enabledPlugins": { "symbai-core@symbai": true }
}
⚠️
"autoUpdate": trueeste obligatoriu — fără el rămâi blocat pe versiunea de la instalare și nu mai primești ghidurile noi. Comanda/plugin marketplace addnu pornește auto-update — de aceea folosim fișierul.
Începând cu symbai-core v0.27, pluginul pornește la fiecare sesiune un hook fail-safe de self-update: aduce clona locală de marketplace symbai la zi (fast-forward; re-sincronizare cu upstream dacă a divergat dar nu are modificări locale) și, dacă a apărut o versiune nouă, face singur upgrade-ul instalării — versiunea nouă se încarcă la următoarea pornire. E necesar pentru că auto-update-ul nativ al pluginurilor nu rulează în aplicația desktop (procesul e pornit cu DISABLE_AUTOUPDATER=1); hook-ul nu depinde de el și nu cere pași manuali clientului.
Apoi instalează pluginul o singură dată (activarea efectivă), pe metoda disponibilă la tine:
# caseta de chat Claude Code:
/plugin install symbai-core@symbai
# aplicația desktop fără /plugin: Customize / Settings → Plugins/Marketplaces → instalează „symbai-core" din marketplace-ul „symbai"
# terminal, doar dacă `claude --version` merge:
claude plugin install symbai-core@symbai
Repornește Claude Code. De aici încolo pluginul se actualizează singur la fiecare pornire — nu mai faci nimic.
Separat, din portalul tău Symbai Hub → Acces AI, ia instrucțiunile de conectare la datele instanței tale (server MCP symbai). Dacă ceva nu merge la conectare, spune-i asistentului „conectează-mă la Symbai" — skill-ul conecteaza-symbai îl ghidează (inclusiv la eroarea „Some MCP servers could not be loaded").
De la symbai-core v0.27 nu trebuie să faci nimic — hook-ul de self-update din plugin aduce marketplace-ul la zi și upgradează pluginul la fiecare pornire de sesiune (cu un throttle de 6h), independent de auto-update-ul nativ al Claude Code (care în aplicația desktop nici nu rulează). "autoUpdate": true din settings rămâne recomandat ca plasă suplimentară pe CLI.
Dacă ești blocat pe o versiune veche (≤0.26.x, dinainte de self-update) sau vrei ultima versiune imediat: scrie în chat „actualizează skill-urile Symbai" (skill-ul symbai-update conține pașii de recovery — un git merge --ff-only pe clona de marketplace + rularea scriptului self-heal-marketplace.mjs --force), sau, dacă ai comanda disponibilă, /plugin marketplace update symbai. După o singură astfel de trecere pe v0.27+, actualizarea rămâne automată pe veci.
Echipa Symbai urcă fișiere noi de skill/knowledge în acest repo (pe main) și bumpează versiunea în plugins/symbai-core/.claude-plugin/plugin.json, plugins/symbai-core/.codex-plugin/plugin.json și intrarea din .claude-plugin/marketplace.json. Clienții cu autoUpdate: true le primesc automat la următoarea pornire; ceilalți, manual cu /plugin marketplace update symbai.
Bumpul de versiune e obligatoriu la fiecare livrare. Claude Code servește pluginul dintr-un folder fixat pe versiune (
cache/symbai/symbai-core/<versiune>/); dacăversionnu se schimbă, clienții rămân pe copia veche din cache chiar dacă au tras commit-uri noi.
marketplace.jsonCâmpul category al unei intrări din .claude-plugin/marketplace.json îi spune lui Symbai Connect ce să facă cu pluginul pe PC-urile clienților. Ajunge la ei doar cu un pachet nou: numărul pachetului e versiunea symbai-core, deci se bumpează și ea, apoi build-package.ps1 -Publish.
category | Ce face Connect în Claude Code (Codex nu primește mod-uri) |
|---|---|
"mod" | Pornește modul (<nume>@symbai: true în ~/.claude/settings.json + claude plugin install), fără versiune nouă de Connect. Un false pus de om rămâne. |
"mod-retras" | Oprirea centrală: scrie false și rulează claude plugin uninstall <nume>@symbai, care scoate cheia și copia din cache. |
Scoaterea unui mod din pachet nu îl oprește: intrarea true și copia din cache rămân. Un mod care se poartă rău se retrage cu "mod-retras":
true de sub @symbai. Un false pus de om rămâne, iar un plugin cu același nume din alt marketplace nu se atinge.build-package.ps1 cere plugin.json pentru fiecare intrare. symbai-core nu se poate retrage."mod" repornește modul acolo unde dezinstalarea a reușit. Unde a eșuat, a rămas false, care nu se mai deosebește de alegerea omului, deci modul rămâne oprit.symbai-ui, pe care îl are într-o listă fixă), dar nici nu-l oprește. Oprirea propriu-zisă vine cu versiunea de Connect de după 1.2.113..claude-plugin/marketplace.json # marketplace (listează pluginul symbai-core)
plugins/symbai-core/
.claude-plugin/plugin.json # manifestul pluginului
skills/<nume>/SKILL.md # workflow-uri
knowledge/*.md # cunoștințe pe module
Skill-urile urmează standardul deschis Agent Skills (SKILL.md), deci funcționează în Claude Code. Pentru alte unelte (ex. Codex) suportul poate diferi — conexiunea MCP funcționează oricum cross-tool.
hooks/register.tsx 1338 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Api, McpToolResult, Register } from 'claude-code'
3
4import { EDIT_MARK, causeOf, countOf, findServers, loadDetail, loadPos, loadWhatsApp, plural, problemOf, refresh, splitMcpName, startTextEdit, statusText, toAssistant, toDetail, toMachine } from './core'
5import type { Problem, Servers } from './core'
6import { closeEditor, drawEditor, openEditor, saveText } from './editor'
7import { errorOf, jsonOf, shortTime, splitWords, str, strings, textOf } from './util'
8import type { Assistant, AssistantDetail, Bound, Confirm, Editing, Machine, PosLine, Selected, Snapshot, WhatsAppLine } from '../types'
9
10const PANE = 'symbai-asistenti'
11const PANE_TITLE = 'Symbai · Asistenți'
12const BRAND = '#7C3AED'
13const OBJECTIVE_PREVIEW = 700
14
15// Watch changes made through these tools refresh the list right after.
16const WATCH_TOOLS = new Set([
17 'watch_chat',
18 'update_watch',
19 'stop_watch',
20 'owner_watch_control',
21 'connect_repair',
22])
23
24const MODES = [
25 { value: 'always', label: 'Mereu', explain: 'Răspunde la orice mesaj nou din conversație.' },
26 {
27 value: 'mention',
28 label: 'Doar când e chemat',
29 explain: 'Răspunde numai când e chemat pe nume, cu un cuvânt de mai jos sau la un răspuns dat lui.',
30 },
31 {
32 value: 'silent',
33 label: 'Silențios',
34 explain: 'Citește tot și lucrează în Symbai, dar nu scrie în conversație; te anunță pe tine când e nevoie.',
35 },
36]
37
38const RUNNERS = [
39 { value: 'codex', label: 'Codex', explain: 'Rulează singur pe acest calculator, cu Codex.' },
40 { value: 'claude', label: 'Claude Code', explain: 'Rulează singur pe acest calculator, cu Claude Code.' },
41 {
42 value: 'stream',
43 label: 'Sesiunea Claude',
44 explain: 'Mesajele ajung într-o sesiune Claude Code deschisă; lucrează doar cât e deschisă.',
45 },
46]
47
48
49const snapshot = atom({ plugin: 'symbai-ui', key: 'snapshot' } as const, null)
50const isLoading = atom({ plugin: 'symbai-ui', key: 'isLoading' } as const, false)
51const isBandHidden = atom({ plugin: 'symbai-ui', key: 'isBandHidden' } as const, false)
52const showInactive = atom({ plugin: 'symbai-ui', key: 'showInactive' } as const, false)
53const selected = atom({ plugin: 'symbai-ui', key: 'selected' } as const, null)
54const detail = atom({ plugin: 'symbai-ui', key: 'detail' } as const, null)
55const isBusy = atom({ plugin: 'symbai-ui', key: 'isBusy' } as const, false)
56const notice = atom({ plugin: 'symbai-ui', key: 'notice' } as const, null)
57const showFullObjective = atom({ plugin: 'symbai-ui', key: 'showFullObjective' } as const, false)
58const editing = atom({ plugin: 'symbai-ui', key: 'editing' } as const, null)
59const undo = atom({ plugin: 'symbai-ui', key: 'undo' } as const, null)
60const role = atom({ plugin: 'symbai-ui', key: 'role' } as const, null)
61const isRoleLoading = atom({ plugin: 'symbai-ui', key: 'isRoleLoading' } as const, false)
62const busyId = atom({ plugin: 'symbai-ui', key: 'busyId' } as const, null)
63const confirming = atom({ plugin: 'symbai-ui', key: 'confirming' } as const, null)
64const boundChat = atom({ plugin: 'symbai-ui', key: 'boundChat' } as const, null)
65const isChatBandHidden = atom({ plugin: 'symbai-ui', key: 'isChatBandHidden' } as const, false)
66const editorOpen = atom({ plugin: 'symbai-ui', key: 'editor' } as const, null)
67
68async function openDetail($: Api, target: Selected): Promise<void> {
69 await update($, confirming, () => null)
70 await update($, selected, () => target)
71 await update($, detail, () => null)
72 await update($, notice, () => null)
73 await update($, showFullObjective, () => false)
74 await update($, role, () => null)
75 try {
76 const found = await loadDetail($, target)
77 if (!found) await update($, notice, () => ({ text: 'Nu mai găsesc acest asistent.', isError: true }))
78 else void loadRole($, found, false).catch(() => undefined)
79 } catch (cause) {
80 await update($, notice, () => ({ text: `Nu pot citi asistentul: ${errorOf(cause)}`, isError: true }))
81 }
82}
83
84async function closeDetail($: Api): Promise<void> {
85 await closeEditor($)
86 await update($, confirming, () => null)
87 await update($, selected, () => null)
88 await update($, detail, () => null)
89 await update($, notice, () => null)
90}
91
92function refusalOf(result: McpToolResult): string | null {
93 const body = jsonOf(result)
94 const refused = result.isError || body?.success === false || typeof body?.error === 'string'
95 if (!refused) return null
96 return str(body?.error) ?? str(body?.message) ?? (textOf(result).slice(0, 300) || 'Connect a refuzat.')
97}
98
99// One change on one assistant, from the list or from its page; then the real state is read back.
100async function act($: Api, target: Selected, tool: 'update_watch' | 'stop_watch', args: Record<string, unknown>, done: string): Promise<boolean> {
101 if (await read($, isBusy)) return false
102 await update($, isBusy, () => true)
103 await update($, busyId, () => `${target.server}:${target.id}`)
104 await update($, notice, () => null)
105 let isDone = false
106 try {
107 const failure = refusalOf(await $.mcp.call(target.server, tool, { watch: target.id, ...args }))
108 if (failure) await update($, notice, () => ({ text: `Nu s-a schimbat: ${failure}`, isError: true }))
109 else {
110 isDone = true
111 await update($, notice, () => ({ text: done, isError: false }))
112 }
113 } catch (cause) {
114 await update($, notice, () => ({ text: `Nu s-a schimbat: ${errorOf(cause)}`, isError: true }))
115 } finally {
116 await update($, isBusy, () => false)
117 await update($, busyId, () => null)
118 }
119 const shown = await read($, selected)
120 if (shown && shown.id === target.id && shown.server === target.server) await loadDetail($, target).catch(() => null)
121 await refresh($).catch(() => undefined)
122 return isDone
123}
124
125// The page's own changes: only while the assistant read is the one shown.
126async function change($: Api, args: Record<string, unknown>, done: string): Promise<void> {
127 const target = await read($, selected)
128 const current = await read($, detail)
129 if (!target || !current || current.id !== target.id || current.server !== target.server) return
130 // Only the fields given: Connect keeps a pause, and its reason, when `active` is left out.
131 // Sending active:false again would clear that reason (e.g. the resume after login).
132 await act($, target, 'update_watch', args, done)
133}
134
135function pauseOf(one: Assistant): { args: Record<string, unknown>; done: string } {
136 return one.isActive
137 ? { args: { active: false }, done: `„${one.name}” e oprit. Nu mai răspunde până îl pornești.` }
138 : { args: { active: true }, done: `„${one.name}” e pornit și preia mesajele venite cât a stat oprit.` }
139}
140
141// Stopping is immediate; starting asks first, since it answers what came in meanwhile.
142async function toggle($: Api, one: Assistant): Promise<void> {
143 if (!one.isActive) {
144 await update($, confirming, () => ({ server: one.server, id: one.id, action: 'start' }))
145 return
146 }
147 const { args, done } = pauseOf(one)
148 await act($, { server: one.server, id: one.id }, 'update_watch', args, done)
149}
150
151async function start($: Api, one: Assistant): Promise<void> {
152 await update($, confirming, () => null)
153 const { args, done } = pauseOf(one)
154 await act($, { server: one.server, id: one.id }, 'update_watch', args, done)
155}
156
157function isConfirming(asked: Confirm | null, one: Assistant, action: Confirm['action']): boolean {
158 return asked !== null && asked.action === action && asked.id === one.id && asked.server === one.server
159}
160
161async function fix($: Api, one: Assistant, problem: Problem): Promise<void> {
162 const target = { server: one.server, id: one.id }
163 if (problem.fix === 'reset') {
164 const args: Record<string, unknown> = { clear_session: true }
165 if (!one.isActive) args.active = true
166 await act($, target, 'update_watch', args, `„${one.name}” o ia de la zero${one.isActive ? '' : ' și e pornit'}. Promptul și notițele au rămas.`)
167 } else if (problem.fix === 'resume') {
168 await act($, target, 'update_watch', { active: true }, `„${one.name}” e pornit din nou.`)
169 } else if (problem.fix === 'rerun') {
170 await act($, target, 'update_watch', { check_now: true }, `Am pornit o rulare nouă pentru „${one.name}”.`)
171 }
172}
173
174async function remove($: Api, target: Selected, name: string): Promise<void> {
175 await update($, confirming, () => null)
176 const isDone = await act($, target, 'stop_watch', { delete: true }, `„${name}” a fost șters.`)
177 if (!isDone) return
178 const edit = await read($, editing)
179 if (edit && edit.id === target.id && edit.server === target.server) await cancelEdit($)
180 const shown = await read($, selected)
181 if (shown && shown.id === target.id && shown.server === target.server) {
182 const kept = await read($, notice)
183 await closeDetail($)
184 await update($, notice, () => kept)
185 }
186}
187
188async function askClaude($: Api, request: string): Promise<void> {
189 const current = await read($, detail)
190 const text = request.trim()
191 if (!current || text.length === 0) return
192 await $.prompt.submit({
193 text:
194 `Modifică asistentul WhatsApp „${current.name}” (monitorizarea ${current.id}, serverul ${current.server}): ${text}\n` +
195 'Citește întâi obiectivul și notițele complete cu list_watches, schimbă prin update_watch doar ce cer, ' +
196 'păstrează restul obiectivului și al regulilor, apoi recitește monitorizarea și spune-mi pe scurt ce s-a schimbat.',
197 })
198 await update($, notice, () => ({ text: 'Am trimis cererea către Claude, în conversație.', isError: false }))
199}
200
201function hashOf(text: string): string {
202 let hash = 5381
203 for (let i = 0; i < text.length; i++) hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0
204 return (hash >>> 0).toString(36)
205}
206
207function modeExplain(mode: string): string {
208 return MODES.find(one => one.value === mode)?.explain ?? ''
209}
210
211function runnerExplain(runner: string): string {
212 return RUNNERS.find(one => one.value === runner)?.explain ?? ''
213}
214
215// A plain summary of what the assistant is for, written once per prompt version and kept.
216async function loadRole($: Api, one: AssistantDetail, force: boolean): Promise<void> {
217 const key = `rol:${one.server}:${one.id}:${hashOf(one.objective)}`
218 const isStillShown = async () => (await read($, selected))?.id === one.id
219 if (!force) {
220 const cached = await $.store.get(key)
221 if (typeof cached === 'string' && cached.length > 0) {
222 if (await isStillShown()) await update($, role, () => ({ id: one.id, text: cached, error: null }))
223 return
224 }
225 }
226 if (one.objective.trim().length === 0) {
227 await update($, role, () => ({ id: one.id, text: null, error: 'Asistentul nu are încă un prompt.' }))
228 return
229 }
230 await update($, isRoleLoading, () => true)
231 try {
232 const answer = await $.model.complete({
233 model: 'haiku',
234 maxTokens: 400,
235 system:
236 'Explici unui proprietar de firmă, fără termeni tehnici, ce rol are un asistent automat de WhatsApp. ' +
237 'Scrii în română, direct, fără introducere.',
238 prompt:
239 'Din instrucțiunile de mai jos, scrie exact patru rânduri, fiecare de cel mult 25 de cuvinte:\n' +
240 'Ce face: …\nUnde și pentru cine: …\nCe nu are voie: …\nCine îi dă instrucțiuni: …\n' +
241 'Dacă instrucțiunile nu spun ceva, scrie „nu e precizat”.\n\n' +
242 `Conversația: ${one.kind} „${one.name}”. ${modeExplain(one.mode)}\n\n` +
243 `Instrucțiunile asistentului:\n${one.objective}`,
244 })
245 if (!(await isStillShown())) return
246 if (answer.isAnswered && answer.text.trim().length > 0) {
247 const text = answer.text.trim()
248 await $.store.set(key, text)
249 await update($, role, () => ({ id: one.id, text, error: null }))
250 } else {
251 await update($, role, () => ({ id: one.id, text: null, error: 'Nu am putut face rezumatul acum.' }))
252 }
253 } finally {
254 await update($, isRoleLoading, () => false)
255 }
256}
257
258async function startEdit($: Api): Promise<void> {
259 const one = await read($, detail)
260 if (!one) return
261 await startTextEdit($, { server: one.server, id: one.id, name: one.name, previous: one.objective, field: 'objective' }, one.objective, 'promptul')
262}
263
264async function cancelEdit($: Api): Promise<void> {
265 await update($, editing, () => null)
266 const box = await $.prompt.read()
267 if (box.text.startsWith(EDIT_MARK)) await $.prompt.fill({ text: '' })
268}
269
270async function writeObjective($: Api, target: Editing, objective: string): Promise<string | null> {
271 const result = await $.mcp.call(target.server, 'update_watch', { watch: target.id, objective })
272 if (result.isError) return textOf(result).slice(0, 300) || 'Connect a refuzat schimbarea.'
273 // Read back: the saved text is what counts, not the answer to the write.
274 const saved = await loadDetail($, { server: target.server, id: target.id })
275 if (!saved) return 'Nu mai găsesc asistentul după salvare.'
276 if (saved.objective.trim() !== objective.trim()) return 'Textul salvat diferă de cel trimis; verifică promptul.'
277 return null
278}
279
280// Enter in the prompt box while a prompt is being edited: save it, never send it to the model.
281async function saveEdit($: Api, text: string): Promise<string> {
282 const target = await read($, editing)
283 if (!target) {
284 await $.prompt.fill({ text })
285 return 'Nu mai știu ce asistent editezi. Textul e înapoi în casetă: deschide asistentul și apasă din nou „Editează promptul”.'
286 }
287 const cut = text.indexOf('\n')
288 if (target.field && target.field !== 'objective') {
289 const body = cut < 0 ? '' : text.slice(cut + 1).trim()
290 if (body === target.previous.trim()) {
291 await update($, editing, () => null)
292 return `Textul lui „${target.name}” e neschimbat.`
293 }
294 if (target.field !== 'notes' && body.length < 10) {
295 await $.prompt.fill({ text })
296 return 'Textul e prea scurt (cel puțin 10 caractere). Nu am salvat nimic.'
297 }
298 const failure = await saveText($, target, body).catch(cause => errorOf(cause))
299 if (failure) {
300 await $.prompt.fill({ text })
301 await update($, notice, () => ({ text: `Nu s-a salvat: ${failure}`, isError: true }))
302 return `Nu s-a salvat: ${failure}`
303 }
304 await update($, editing, () => null)
305 await update($, notice, () => ({ text: 'Textul e salvat și recitit.', isError: false }))
306 return `Textul lui „${target.name}” e salvat. Nu s-a trimis lui Claude.`
307 }
308 const objective = cut < 0 ? '' : text.slice(cut + 1).trim()
309 if (objective.length === 0) {
310 await $.prompt.fill({ text })
311 return 'Promptul nu poate fi gol. Nu am salvat nimic.'
312 }
313 if (objective === target.previous.trim()) {
314 await update($, editing, () => null)
315 return `Promptul lui „${target.name}” e neschimbat.`
316 }
317 const failure = await writeObjective($, target, objective).catch(cause => errorOf(cause))
318 if (failure) {
319 await $.prompt.fill({ text })
320 await update($, notice, () => ({ text: `Promptul nu s-a salvat: ${failure}`, isError: true }))
321 return `Promptul nu s-a salvat: ${failure}`
322 }
323 await update($, editing, () => null)
324 await update($, undo, () => ({ server: target.server, id: target.id, objective: target.previous, saved: objective }))
325 await update($, notice, () => ({ text: 'Promptul e salvat.', isError: false }))
326 const saved = await read($, detail)
327 if (saved && saved.id === target.id) void loadRole($, saved, false).catch(() => undefined)
328 void refresh($).catch(() => undefined)
329 return `Promptul lui „${target.name}” e salvat. Nu s-a trimis lui Claude.`
330}
331
332async function undoEdit($: Api): Promise<void> {
333 const last = await read($, undo)
334 const one = await read($, detail)
335 if (!last || !one || one.id !== last.id || one.server !== last.server || (await read($, isBusy))) return
336 if (one.objective.trim() !== last.saved.trim()) {
337 await update($, undo, () => null)
338 await update($, notice, () => ({
339 text: 'Promptul s-a schimbat între timp, așa că nu mai anulez automat. Editează-l direct dacă vrei altă variantă.',
340 isError: true,
341 }))
342 return
343 }
344 await update($, isBusy, () => true)
345 try {
346 const failure = await writeObjective(
347 $,
348 { server: last.server, id: last.id, name: one.name, previous: one.objective },
349 last.objective,
350 )
351 if (failure) {
352 await update($, notice, () => ({ text: `Nu am putut reveni: ${failure}`, isError: true }))
353 return
354 }
355 await update($, undo, () => null)
356 await update($, notice, () => ({ text: 'Am pus la loc promptul de dinainte.', isError: false }))
357 const restored = await read($, detail)
358 if (restored) void loadRole($, restored, false).catch(() => undefined)
359 } catch (cause) {
360 await update($, notice, () => ({ text: `Nu am putut reveni: ${errorOf(cause)}`, isError: true }))
361 } finally {
362 await update($, isBusy, () => false)
363 }
364}
365
366function runnerLabel(runner: string): string {
367 return RUNNERS.find(one => one.value === runner)?.label ?? (runner || '—')
368}
369
370function modeLabel(mode: string): string {
371 return MODES.find(one => one.value === mode)?.label ?? (mode || '—')
372}
373
374// Connect's advice, without the tool call meant for agents.
375function tipText(tip: string): string {
376 return tip
377 .replace(/\s*\([^)]*update_watch[^)]*\)/g, '')
378 .replace(/modul mention/g, 'modul „Doar când e chemat”')
379}
380
381// The band's hidden/shown choice is kept across chats: hidden in one, it stays small in every other.
382const BAND_HIDDEN_KEY = 'isBandHidden'
383
384async function setBandHidden($: Api, value: boolean): Promise<void> {
385 await $.store.set(BAND_HIDDEN_KEY, value).catch(() => undefined)
386 await update($, isBandHidden, () => value)
387}
388
389// A command chat in the sidebar starts with this mark: ⟦asistent-symbai <server> <id> <name>⟧.
390// The chat then shows that assistant's own band; the mark is kept per session id.
391const CHAT_MARK = /^⟦asistent-symbai ([\w-]+) ([0-9a-f-]{8,64}) ([^⟧]{1,120})⟧/
392// Text that reaches a chat from elsewhere (a report, another session, a channel) never binds it,
393// even when it quotes a mark.
394const NOT_BINDING = new Set([
395 'task-notification',
396 'peer',
397 'peer-send-message',
398 'channel',
399 'scheduled-trigger',
400 'auto-continuation',
401 'coordinator',
402 'observer',
403 'observer-activity',
404 'slack-ping',
405 'projects-relay',
406])
407
408async function mayBind($: Api, text: string, kind: string | undefined): Promise<boolean> {
409 if (kind && NOT_BINDING.has(kind)) return false
410 if (text.startsWith(EDIT_MARK) || !CHAT_MARK.test(text.trimStart())) return false
411 return (await read($, boundChat)) === null
412}
413
414async function saveBound($: Api, bound: Bound): Promise<void> {
415 await update($, boundChat, () => bound)
416 await $.store.set(`chat:${await $.session.id()}`, bound).catch(() => undefined)
417}
418
419async function bindChat($: Api, text: string): Promise<void> {
420 const found = CHAT_MARK.exec(text.trimStart())
421 if (!found) return
422 await saveBound($, { server: found[1], id: found[2], name: found[3].trim() })
423}
424
425// The desktop app hands a new chat its first message without prompt.submit: the chat's own
426// first message is read instead, once per process. Only that first message binds.
427let isChatLookedUp = false
428
429async function discoverChat($: Api): Promise<void> {
430 if (isChatLookedUp || (await read($, boundChat))) return
431 const messages = await $.session.messages()
432 if (!Array.isArray(messages)) return
433 const first = messages.find(one => one.role === 'user' && typeof one.text === 'string' && one.text.trim().length > 0)
434 // Not there yet (a new chat at its start): the end of its first turn looks again.
435 if (!first || typeof first.text !== 'string') return
436 isChatLookedUp = true
437 await bindChat($, first.text)
438}
439
440// A chat's Claude can also bind it by calling this tool once.
441const BIND_TOOL = 'leaga_chat_asistent'
442const BIND_TOOL_FULL = `mcp__symbai-ui__${BIND_TOOL}`
443
444async function serveBind($: Api, input: Record<string, unknown>): Promise<{ result: unknown } | { deny: string }> {
445 const server = typeof input.server === 'string' ? input.server.trim() : ''
446 const id = typeof input.watch === 'string' ? input.watch.trim() : ''
447 const name = typeof input.name === 'string' ? input.name.trim().slice(0, 120) : ''
448 if (!/^[\w-]{1,64}$/.test(server) || !/^[0-9a-f-]{8,64}$/.test(id) || name.length === 0) {
449 return { deny: 'Trimite server, watch (id-ul monitorizării) și name, exact ca în primul mesaj al chatului.' }
450 }
451 const current = await read($, boundChat)
452 if (current && (current.id !== id || current.server !== server)) {
453 return { deny: `Chatul e deja legat de „${current.name}”. Un chat de comandă rămâne la asistentul lui.` }
454 }
455 await saveBound($, { server, id, name })
456 void refresh($).catch(() => undefined)
457 return { result: { legat: true, asistent: name, watch: id, server } }
458}
459
460async function restoreChat($: Api): Promise<void> {
461 const saved = (await $.store.get(`chat:${await $.session.id()}`).catch(() => undefined)) as Bound | undefined
462 if (saved && typeof saved.server === 'string' && typeof saved.id === 'string') {
463 await update($, boundChat, () => ({ server: saved.server, id: saved.id, name: String(saved.name ?? '') }))
464 }
465 const hidden = (await $.store.get(`chatHidden:${await $.session.id()}`).catch(() => undefined)) === true
466 await update($, isChatBandHidden, () => hidden)
467}
468
469async function setChatBandHidden($: Api, value: boolean): Promise<void> {
470 await $.store.set(`chatHidden:${await $.session.id()}`, value).catch(() => undefined)
471 await update($, isChatBandHidden, () => value)
472}
473
474// The assistant's page in the pane, from its chat's band.
475async function showAssistant($: Api, target: Selected): Promise<void> {
476 await $.ui.open({ id: PANE, title: PANE_TITLE, focus: true })
477 await openDetail($, target)
478}
479
480// A chat already open picks up the choice made in another one.
481async function syncBandHidden($: Api): Promise<void> {
482 const hidden = (await $.store.get(BAND_HIDDEN_KEY).catch(() => undefined)) === true
483 if ((await read($, isBandHidden)) !== hidden) await update($, isBandHidden, () => hidden)
484}
485
486
487export const register: Register = on => {
488 on('session.start', async ($, e, next) => {
489 await $.command.register({
490 name: 'symbai',
491 description: 'Deschide panoul Symbai cu asistenții și conexiunile',
492 })
493 await syncBandHidden($)
494 await restoreChat($).catch(() => undefined)
495 isChatLookedUp = false
496 await $.tool
497 .register({
498 name: BIND_TOOL,
499 description:
500 'Leagă acest chat de comandă de asistentul WhatsApp al lui, ca deasupra casetei să apară banda asistentului. ' +
501 'Se apelează o singură dată, la începutul unui chat de comandă, cu datele din marcajul ⟦asistent-symbai <server> <watch> <name>⟧ din primul mesaj.',
502 inputSchema: {
503 type: 'object',
504 properties: {
505 server: { type: 'string', description: 'Serverul MCP al numărului WhatsApp, ex. symbai-whatsapp-numarul-meu.' },
506 watch: { type: 'string', description: 'Id-ul monitorizării (asistentului).' },
507 name: { type: 'string', description: 'Numele asistentului.' },
508 },
509 required: ['server', 'watch', 'name'],
510 },
511 })
512 .catch(() => undefined)
513 await discoverChat($).catch(() => undefined)
514 void refresh($).catch(() => undefined)
515 // The pane opens only when asked (/symbai or „Asistenți”), never by itself in a new chat.
516
517 return next(e)
518 })
519
520 on('command.run', { command: 'symbai' }, async $ => {
521 await $.ui.open({ id: PANE, title: PANE_TITLE, focus: true })
522 await refresh($)
523 const snap = await read($, snapshot)
524
525 return { text: snap ? statusText(snap) : 'Symbai: nicio conexiune găsită în această sesiune.' }
526 })
527
528 // MCP servers may still be connecting at session start: try again on the next prompt.
529 on('prompt.submit', async ($, e, next) => {
530 const fromPerson = !e.origin || e.origin.kind === 'composer' || e.origin.kind === 'bridge'
531 if (await mayBind($, e.text, e.origin?.kind).catch(() => false)) await bindChat($, e.text).catch(() => undefined)
532 if (fromPerson && e.text.startsWith(EDIT_MARK) && !e.command) {
533 return { drop: await saveEdit($, e.text) }
534 }
535 // A notification arriving mid-edit is not the person giving up on the edit.
536 if (fromPerson && (await read($, editing))) await update($, editing, () => null)
537 void syncBandHidden($).catch(() => undefined)
538 const snap = await read($, snapshot)
539 const bound = await read($, boundChat)
540 const boundLine = bound ? snap?.whatsapp.find(line => line.server === bound.server) : undefined
541 if (!snap || (snap.whatsapp.length === 0 && snap.pos.length === 0) || (bound && (!boundLine || boundLine.error))) {
542 void refresh($).catch(() => undefined)
543 }
544
545 return next(e)
546 }).catch(async ($, e, next) => {
547 // An edited prompt is never handed to the model, even when saving it failed; the text goes back.
548 if (!e.text.startsWith(EDIT_MARK)) return next(e)
549 await $.prompt.fill({ text: e.text }).catch(() => undefined)
550 return { drop: 'Promptul nu s-a salvat. Textul e înapoi în casetă.' }
551 })
552
553 // A brand-new chat: its first message is in place once its first turn ends.
554 on('turn.complete', async ($, e, next) => {
555 const done = await next(e)
556 if (!isChatLookedUp) await discoverChat($).catch(() => undefined)
557 return done
558 }).catch(($, e, next) => next(e))
559
560 on('tool.call', async ($, e, next) => {
561 if (e.tool === BIND_TOOL_FULL) return serveBind($, (e.input ?? {}) as Record<string, unknown>)
562 const ran = await next(e)
563 const parts = splitMcpName(e.tool)
564 if (parts && parts.server.startsWith('symbai') && WATCH_TOOLS.has(parts.tool)) {
565 void refresh($).catch(() => undefined)
566 const target = await read($, selected)
567 if (target && target.server === parts.server) void loadDetail($, target).catch(() => null)
568 }
569
570 return ran
571 }).catch(($, e, next) => next(e))
572
573 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
574 const snap = await read($, snapshot)
575 if (snap === null || (snap.whatsapp.length === 0 && snap.pos.length === 0)) return next(e)
576 if (e.props.hasSurvey) return next(e)
577
578 const { Box, Button, Text } = $.ui.resolve(e)
579 const openPane = () => $.ui.open({ id: PANE, title: PANE_TITLE, focus: true })
580 const counts = countOf(snap)
581
582 // ── A command chat: this assistant's own band ───────────────────────
583 const bound = await read($, boundChat)
584 if (bound) {
585 const phone = snap.whatsapp.find(line => line.server === bound.server)
586 const one = phone?.assistants.find(item => item.id === bound.id) ?? null
587 const target = { server: bound.server, id: bound.id }
588 if (await read($, isChatBandHidden)) {
589 return (
590 <Box flexDirection="row">
591 <Button key="chat-show" plain dimColor onPress={() => setChatBandHidden($, false)}>
592 <Text color={BRAND} bold>
593 🤖 {one?.name ?? bound.name} ▸
594 </Text>
595 </Button>
596 </Box>
597 )
598 }
599 if (!one) {
600 // Not read yet, Connect not answering, or really gone: three different things to say.
601 const why = !phone
602 ? 'Se conectează la WhatsApp…'
603 : phone.error
604 ? `Connect nu răspunde: ${phone.error}`
605 : 'nu mai apare în Connect (șters sau pe alt număr)'
606 return (
607 <Box flexDirection="row" gap={1} flexWrap="wrap">
608 <Text color={BRAND} bold>
609 🤖 {bound.name}
610 </Text>
611 <Text color={phone && !phone.error ? 'warning' : undefined} dimColor={!phone}>
612 {why}
613 </Text>
614 <Button key="chat-refresh" label="Reîmprospătează" dimColor onPress={() => refresh($)} />
615 </Box>
616 )
617 }
618 const problem = problemOf(one, phone?.machine ?? null)
619 const busy = await read($, isBusy)
620 const info = await read($, notice)
621 const state = problem
622 ? { mark: '▲', color: 'warning', label: problem.title }
623 : one.isActive
624 ? { mark: '●', color: 'success', label: 'activ' }
625 : { mark: '○', color: 'inactive', label: 'oprit' }
626 const repair =
627 problem?.fix === 'reset'
628 ? 'Repară'
629 : problem?.fix === 'resume'
630 ? 'Pornește din nou'
631 : problem?.fix === 'rerun'
632 ? 'Încearcă din nou'
633 : null
634 return (
635 <Box flexDirection="column">
636 <Box flexDirection="row" gap={1} flexWrap="wrap">
637 <Text color={BRAND} bold>
638 🤖 {one.name}
639 </Text>
640 <Text color={state.color} bold>
641 {state.mark} {state.label}
642 </Text>
643 <Text dimColor>
644 · {one.kind === 'grup' ? 'grup' : 'direct'} · {modeLabel(one.mode)} · {runnerLabel(one.runner)} · ultimul
645 mesaj {shortTime(one.lastMessageAt)}
646 </Text>
647 </Box>
648 <Box flexDirection="row" gap={1} flexWrap="wrap">
649 {problem && repair && (
650 <Button
651 key="chat-fix"
652 label={busy ? 'Se repară…' : repair}
653 variant="primary"
654 onPress={() => (busy ? undefined : fix($, one, problem))}
655 />
656 )}
657 {one.isActive ? (
658 <Button key="chat-pause" label="Oprește" dimColor onPress={() => (busy ? undefined : toggle($, one))} />
659 ) : (
660 <Button
661 key="chat-start"
662 label="Pornește…"
663 dimColor
664 onPress={async () => {
665 await showAssistant($, target)
666 await toggle($, one)
667 }}
668 />
669 )}
670 {one.isActive && one.runner !== 'stream' && (
671 <Button
672 key="chat-run"
673 label="Rulează acum"
674 dimColor
675 onPress={() =>
676 busy
677 ? undefined
678 : act($, target, 'update_watch', { check_now: true }, `Am pornit o rulare pentru „${one.name}”.`)
679 }
680 />
681 )}
682 <Button
683 key="chat-today"
684 label="Ce a făcut azi?"
685 dimColor
686 onPress={() =>
687 $.prompt.submit({
688 text: `Ce a făcut azi asistentul „${one.name}” (monitorizarea ${one.id}, ${one.server})? Citește watch_activity și mesajele noi din conversația lui, apoi spune-mi pe scurt: ce a primit, ce a răspuns, ce a rămas deschis.`,
689 })
690 }
691 />
692 <Button
693 key="chat-edit"
694 label="✎ Modifică"
695 variant="primary"
696 onPress={async () => {
697 await showAssistant($, target)
698 await openEditor($, target)
699 }}
700 />
701 <Button key="chat-card" label="Fișa" dimColor onPress={() => showAssistant($, target)} />
702 <Button key="chat-all" label="Toți asistenții" dimColor onPress={openPane} />
703 <Button key="chat-hide" label="Ascunde" dimColor onPress={() => setChatBandHidden($, true)} />
704 </Box>
705 {info && (
706 <Text color={info.isError ? 'error' : 'success'}>
707 {info.isError ? '✗ ' : '✓ '}
708 {info.text}
709 </Text>
710 )}
711 </Box>
712 )
713 }
714 if (await read($, isBandHidden)) {
715 return (
716 <Box flexDirection="row">
717 <Button key="show" plain dimColor onPress={() => setBandHidden($, false)}>
718 <Text color={BRAND} bold>
719 Symbai ▸
720 </Text>
721 {counts.failed > 0 ? <Text color="warning"> ▲{counts.failed}</Text> : ''}
722 </Button>
723 </Box>
724 )
725 }
726 const phone = snap.whatsapp[0]
727 const posDown = snap.pos.filter(line => !line.isOk)
728
729 return (
730 <Box flexDirection="row" gap={1} flexWrap="wrap">
731 <Text color={BRAND} bold>
732 ◆ Symbai
733 </Text>
734 {phone && (
735 <Text color={phone.isConnected ? 'success' : 'error'}>
736 {phone.isConnected ? '●' : '○'} WhatsApp {phone.number ?? phone.server}
737 </Text>
738 )}
739 {phone && <Text dimColor>· {plural(counts.active, 'asistent activ', 'asistenți activi')}</Text>}
740 {counts.failed > 0 && (
741 <Button key="problems" plain onPress={openPane}>
742 <Text color="warning" bold>
743 ▲ {counts.failed} de reparat
744 </Text>
745 </Button>
746 )}
747 {snap.pos.length > 0 && (
748 <Text color={posDown.length > 0 ? 'error' : undefined} dimColor={posDown.length === 0}>
749 ·{' '}
750 {posDown.length > 0
751 ? `${posDown.map(line => line.server).join(', ')} nu răspunde`
752 : `${plural(snap.pos.length, 'firmă conectată', 'firme conectate')} ✓`}
753 </Text>
754 )}
755 <Button key="open" label="Asistenți" dimColor onPress={openPane} />
756 <Button key="hide" label="Ascunde" dimColor onPress={() => setBandHidden($, true)} />
757 </Box>
758 )
759 })
760
761 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
762 const { Box, Button, Input, Link, Select, Text } = $.ui.resolve(e)
763 const snap = await read($, snapshot)
764 const loading = await read($, isLoading)
765 const withInactive = await read($, showInactive)
766 const target = await read($, selected)
767 const info = await read($, notice)
768 const busy = await read($, isBusy)
769 const working = await read($, busyId)
770
771 const noticeLine = info && (
772 <Box key="notice" borderStyle="round" borderColor={info.isError ? 'error' : 'success'} paddingX={1}>
773 <Text color={info.isError ? 'error' : 'success'}>
774 {info.isError ? '✗ ' : '✓ '}
775 {info.text}
776 </Text>
777 </Box>
778 )
779 const machineOf = (server: string) => snap?.whatsapp.find(line => line.server === server)?.machine ?? null
780 const statusOf = (one: Assistant, problem: Problem | null) =>
781 problem
782 ? { mark: '▲', color: 'warning', label: 'de reparat' }
783 : one.isActive
784 ? { mark: '●', color: 'success', label: 'activ' }
785 : { mark: '○', color: 'inactive', label: 'oprit' }
786 const fixLabel = (problem: Problem): string | null =>
787 problem.fix === 'reset'
788 ? 'Repară'
789 : problem.fix === 'resume'
790 ? 'Pornește din nou'
791 : problem.fix === 'rerun'
792 ? 'Încearcă din nou'
793 : null
794 const kindOf = (one: Assistant) => (one.kind === 'grup' ? 'grup' : 'direct')
795 const asked = await read($, confirming)
796 const isWorkingOn = (one: Assistant) => working === `${one.server}:${one.id}`
797 const startAsk = (one: Assistant) =>
798 isConfirming(asked, one, 'start') && (
799 <Box key={`start-ask-${one.id}`} flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1}>
800 <Text color="warning" bold>
801 Pornești „{one.name}”?
802 </Text>
803 <Text>Răspunde și la mesajele venite cât a stat oprit (ultimul: {shortTime(one.lastMessageAt)}).</Text>
804 <Box flexDirection="row" gap={1}>
805 <Button
806 key={`start-confirm-${one.id}`}
807 label="Da, pornește"
808 variant="primary"
809 onPress={() => (busy ? undefined : start($, one))}
810 />
811 <Button key={`start-cancel-${one.id}`} label="Renunță" dimColor onPress={() => update($, confirming, () => null)} />
812 </Box>
813 </Box>
814 )
815
816 // ── One assistant: see and change it ────────────────────────────────
817 if (target) {
818 const one = await read($, detail)
819 const full = await read($, showFullObjective)
820 const edit = await read($, editing)
821 const last = await read($, undo)
822 const summary = await read($, role)
823 const summaryLoading = await read($, isRoleLoading)
824
825 if (!one) {
826 return (
827 <Box flexDirection="column" gap={1}>
828 <Button key="back" label="← Toți asistenții" dimColor onPress={() => closeDetail($)} />
829 {noticeLine ?? <Text dimColor>Citesc asistentul…</Text>}
830 </Box>
831 )
832 }
833
834 // The full editor of this assistant, opened with „Modifică”.
835 const ed = await read($, editorOpen)
836 if (ed && ed.id === one.id && ed.server === one.server) {
837 const isEditingHere = edit !== null && edit.id === one.id && edit.server === one.server
838 return (
839 <Box flexDirection="column" gap={1}>
840 {isEditingHere && (
841 <Box flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1}>
842 <Text color="warning" bold>
843 Editezi un text în caseta de scris de jos
844 </Text>
845 <Text>Schimbă-l acolo și apasă Enter. Se salvează la asistent și nu se trimite lui Claude.</Text>
846 <Button key="edit-cancel" label="Renunță la editare" dimColor onPress={() => cancelEdit($)} />
847 </Box>
848 )}
849 {(await drawEditor($, { Box, Button, Input, Text, Select, Link }, one)) as any}
850 </Box>
851 )
852 }
853
854 const problem = problemOf(one, machineOf(one.server))
855 const status = statusOf(one, problem)
856 const pause = pauseOf(one)
857 const repair = problem ? fixLabel(problem) : null
858 const isAskingDelete = isConfirming(asked, one, 'delete')
859 const isEditingThis = edit !== null && edit.id === one.id && edit.server === one.server
860 const canUndo = last !== null && last.id === one.id && last.server === one.server
861 const roleText = summary && summary.id === one.id ? summary : null
862 const objective =
863 full || one.objective.length <= OBJECTIVE_PREVIEW ? one.objective : `${one.objective.slice(0, OBJECTIVE_PREVIEW)}…`
864 const listensTo =
865 one.senders.length > 0
866 ? `Doar ${one.senders.length === 1 ? 'o persoană anume' : `${one.senders.length} identități anume`} îl pot porni.`
867 : 'Oricine scrie în conversație îl poate porni.'
868 const isOldFailure = one.lastRunState === 'failed' && one.runner === 'stream'
869
870 return (
871 <Box flexDirection="column" gap={1}>
872 <Button key="back" label="← Toți asistenții" dimColor onPress={() => closeDetail($)} />
873
874 <Box flexDirection="column">
875 <Text size="title" color={BRAND} bold>
876 {one.name}
877 </Text>
878 <Box flexDirection="row" gap={1} flexWrap="wrap">
879 <Text color={status.color} bold>
880 {status.mark} {status.label}
881 </Text>
882 <Text dimColor>
883 · {one.kind === 'grup' ? 'grup WhatsApp' : 'conversație directă'} · {modeLabel(one.mode)} ·{' '}
884 {runnerLabel(one.runner)}
885 </Text>
886 </Box>
887 {one.callNames.length > 0 && <Text dimColor>Îl chemi cu: {one.callNames.join(', ')}</Text>}
888 </Box>
889
890 <Box flexDirection="row" gap={1} flexWrap="wrap">
891 <Button
892 key="edit-all"
893 label="✎ Modifică tot"
894 variant="primary"
895 onPress={() => openEditor($, { server: one.server, id: one.id })}
896 />
897 <Button
898 key="pause"
899 label={busy ? 'Se salvează…' : one.isActive ? 'Oprește' : 'Pornește'}
900 variant={one.isActive ? undefined : 'primary'}
901 onPress={() => (busy ? undefined : one.isActive ? change($, pause.args, pause.done) : toggle($, one))}
902 />
903 {one.isActive && (
904 <Button
905 key="run"
906 label="Rulează acum"
907 dimColor
908 onPress={() => (busy ? undefined : change($, { check_now: true }, 'Am pornit o rulare acum.'))}
909 />
910 )}
911 <Button
912 key="delete-ask"
913 label="Șterge"
914 dimColor
915 onPress={() => update($, confirming, () => ({ server: one.server, id: one.id, action: 'delete' }))}
916 />
917 </Box>
918
919 {startAsk(one)}
920
921 {isAskingDelete && (
922 <Box flexDirection="column" borderStyle="round" borderColor="error" paddingX={1}>
923 <Text color="error" bold>
924 Ștergi definitiv „{one.name}”?
925 </Text>
926 <Text>Se pierd promptul, notițele și istoricul lui. Dacă vrei doar să nu mai răspundă, apasă „Oprește”.</Text>
927 <Box flexDirection="row" gap={1}>
928 <Button
929 key="delete-confirm"
930 label="Da, șterge"
931 variant="primary"
932 onPress={() => (busy ? undefined : remove($, { server: one.server, id: one.id }, one.name))}
933 />
934 <Button key="delete-cancel" label="Renunță" dimColor onPress={() => update($, confirming, () => null)} />
935 </Box>
936 </Box>
937 )}
938
939 {noticeLine}
940
941 {problem && (
942 <Box flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1}>
943 <Text color="warning" bold>
944 ▲ {problem.title}
945 </Text>
946 <Text>{problem.explain}</Text>
947 {repair && (
948 <Box flexDirection="row">
949 <Button
950 key="fix"
951 label={busy ? 'Se repară…' : repair}
952 variant="primary"
953 onPress={() => (busy ? undefined : fix($, one, problem))}
954 />
955 </Box>
956 )}
957 </Box>
958 )}
959
960 {!problem && one.tip && (
961 <Box flexDirection="column" borderStyle="round" borderColor="suggestion" paddingX={1}>
962 <Text color="suggestion" bold>
963 Sfat
964 </Text>
965 <Text>{tipText(one.tip)}</Text>
966 {one.mode !== 'mention' && /mention/.test(one.tip) && (
967 <Box flexDirection="row">
968 <Button
969 key="tip-mention"
970 label="Treci pe „Doar când e chemat”"
971 dimColor
972 onPress={() => change($, { mode: 'mention' }, 'Modul e acum: Doar când e chemat.')}
973 />
974 </Box>
975 )}
976 </Box>
977 )}
978
979 {isEditingThis && (
980 <Box flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1}>
981 <Text color="warning" bold>
982 Editezi promptul în caseta de scris de jos
983 </Text>
984 <Text>Schimbă textul acolo și apasă Enter. Se salvează la asistent și nu se trimite lui Claude.</Text>
985 <Button key="edit-cancel" label="Renunță la editare" dimColor onPress={() => cancelEdit($)} />
986 </Box>
987 )}
988
989 <Box flexDirection="column" borderStyle="round" borderColor={BRAND} paddingX={1}>
990 <Text color={BRAND} bold>
991 Rolul pe scurt
992 </Text>
993 {roleText?.text ? (
994 <Text>{roleText.text}</Text>
995 ) : (
996 <Text dimColor>
997 {summaryLoading ? 'Scriu rezumatul din prompt…' : (roleText?.error ?? 'Încă nu am rezumatul.')}
998 </Text>
999 )}
1000 <Box flexDirection="row" gap={1} flexWrap="wrap">
1001 <Text dimColor>Scris automat din prompt, poate scăpa nuanțe.</Text>
1002 <Button
1003 key="role-refresh"
1004 label={summaryLoading ? 'Se scrie…' : 'Refă rezumatul'}
1005 dimColor
1006 onPress={() => (summaryLoading ? undefined : loadRole($, one, true))}
1007 />
1008 </Box>
1009 </Box>
1010
1011 <Box flexDirection="column">
1012 <Text bold>Cum lucrează</Text>
1013 <Text>
1014 <Text dimColor>Unde: </Text>
1015 {one.kind === 'grup' ? `în grupul „${one.name}”` : `în conversația cu ${one.name}`}
1016 </Text>
1017 <Text>
1018 <Text dimColor>Când răspunde: </Text>
1019 {modeExplain(one.mode) || one.mode}
1020 </Text>
1021 <Text>
1022 <Text dimColor>Cine lucrează: </Text>
1023 {runnerExplain(one.runner) || runnerLabel(one.runner)}
1024 </Text>
1025 <Text>
1026 <Text dimColor>Pe cine ascultă: </Text>
1027 {listensTo}
1028 {one.trustedCount > 0 ? ` ${one.trustedCount} persoane au încredere ca tine.` : ''}
1029 </Text>
1030 {one.ownerStart && (
1031 <Text>
1032 <Text dimColor>Mesajele tale: </Text>
1033 {one.ownerStart}
1034 </Text>
1035 )}
1036 </Box>
1037
1038 <Box flexDirection="column">
1039 <Text bold>Promptul (instrucțiunile lui)</Text>
1040 <Text>{objective || '—'}</Text>
1041 <Box flexDirection="row" gap={1} flexWrap="wrap">
1042 <Button
1043 key="edit-start"
1044 label="Editează promptul"
1045 variant="primary"
1046 onPress={() => (isEditingThis ? undefined : startEdit($))}
1047 />
1048 {one.objective.length > OBJECTIVE_PREVIEW && (
1049 <Button
1050 key="objective-toggle"
1051 label={full ? 'Restrânge' : 'Arată tot promptul'}
1052 dimColor
1053 onPress={() => update($, showFullObjective, value => !value)}
1054 />
1055 )}
1056 {canUndo && (
1057 <Button key="edit-undo" label="Anulează ultima modificare" dimColor onPress={() => undoEdit($)} />
1058 )}
1059 </Box>
1060 <Text dimColor>
1061 {one.notesLength > 0 ? `Mai are notițe de lucru: ${one.notesLength} caractere` : 'Fără notițe de lucru'}
1062 {one.maxRunsPerHour !== null ? ` · maxim ${one.maxRunsPerHour} rulări pe oră` : ''}
1063 </Text>
1064 </Box>
1065
1066 <Box flexDirection="column">
1067 <Text bold>Sau cere lui Claude o schimbare</Text>
1068 <Input
1069 key="ask"
1070 placeholder="ex.: să răspundă doar între 8 și 17"
1071 submitLabel="trimite lui Claude"
1072 onSubmit={value => askClaude($, value)}
1073 />
1074 <Text dimColor>Claude schimbă doar ce ceri și păstrează restul promptului.</Text>
1075 </Box>
1076
1077 <Box flexDirection="column">
1078 <Text bold>Când răspunde</Text>
1079 <Box flexDirection="row" gap={1} flexWrap="wrap">
1080 {MODES.map(mode => (
1081 <Button
1082 key={`mode-${mode.value}`}
1083 label={mode.label}
1084 variant={one.mode === mode.value ? 'primary' : undefined}
1085 dimColor={one.mode !== mode.value}
1086 onPress={() =>
1087 one.mode === mode.value ? undefined : change($, { mode: mode.value }, `Modul e acum: ${mode.label}.`)
1088 }
1089 />
1090 ))}
1091 </Box>
1092 </Box>
1093
1094 <Box flexDirection="column">
1095 <Text bold>Cine lucrează</Text>
1096 <Box flexDirection="row" gap={1} flexWrap="wrap">
1097 {RUNNERS.map(runner => (
1098 <Button
1099 key={`runner-${runner.value}`}
1100 label={runner.label}
1101 variant={one.runner === runner.value ? 'primary' : undefined}
1102 dimColor={one.runner !== runner.value}
1103 onPress={() =>
1104 one.runner === runner.value
1105 ? undefined
1106 : change($, { dispatch: runner.value }, `Lucrează acum: ${runner.label}.`)
1107 }
1108 />
1109 ))}
1110 </Box>
1111 <Text dimColor>Schimbarea o ia de la zero cu conversația asistentului.</Text>
1112 </Box>
1113
1114 <Box flexDirection="column">
1115 <Text bold>Cuvinte care îl cheamă</Text>
1116 <Input
1117 key="words"
1118 placeholder="ex.: Sym, Asistent (separate prin virgulă)"
1119 value={one.triggerWords.join(', ')}
1120 submitLabel="salvează"
1121 onSubmit={value => change($, { trigger_words: splitWords(value) }, 'Cuvintele sunt salvate.')}
1122 />
1123 </Box>
1124
1125 <Box flexDirection="column">
1126 <Text bold>Ultima rulare</Text>
1127 <Text color={problem && !isOldFailure ? 'warning' : undefined} dimColor={!problem || isOldFailure}>
1128 {one.lastRunAt
1129 ? `${shortTime(one.lastRunAt)} · ${one.lastRunState === 'failed' ? 'eșuată' : 'reușită'}`
1130 : 'fără rulări'}
1131 {' · ultimul mesaj '}
1132 {shortTime(one.lastMessageAt)}
1133 </Text>
1134 {isOldFailure && (
1135 <Text dimColor>E veche: în „Sesiunea Claude” nu are rulări automate, deci nu e o problemă acum.</Text>
1136 )}
1137 {one.lastRunSummary && <Text dimColor>{one.lastRunSummary}</Text>}
1138 </Box>
1139 </Box>
1140 )
1141 }
1142
1143 // ── The list ────────────────────────────────────────────────────────
1144 const toolbar = (
1145 <Box flexDirection="row" gap={1}>
1146 <Button key="refresh" label={loading ? 'Se încarcă…' : 'Reîmprospătează'} dimColor onPress={() => refresh($)} />
1147 </Box>
1148 )
1149 const title = (
1150 <Text size="title" color={BRAND} bold>
1151 ◆ Symbai · Asistenți
1152 </Text>
1153 )
1154
1155 if (!snap) {
1156 return (
1157 <Box flexDirection="column" gap={1}>
1158 {title}
1159 <Text dimColor>{loading ? 'Citesc conexiunile Symbai…' : 'Încă nu am citit conexiunile.'}</Text>
1160 {toolbar}
1161 </Box>
1162 )
1163 }
1164
1165 if (snap.whatsapp.length === 0 && snap.pos.length === 0) {
1166 return (
1167 <Box flexDirection="column" gap={1}>
1168 {title}
1169 <Text dimColor>Nu găsesc nicio conexiune Symbai în această sesiune.</Text>
1170 <Text dimColor>Conectează firma din portalul Symbai, apoi apasă Reîmprospătează.</Text>
1171 {toolbar}
1172 </Box>
1173 )
1174 }
1175
1176 type Row = { one: Assistant; problem: Problem | null }
1177 const rows: Row[] = snap.whatsapp.flatMap(phone =>
1178 phone.assistants.map(one => ({ one, problem: problemOf(one, phone.machine) })),
1179 )
1180 const byLastMessage = (a: Row, b: Row) => (b.one.lastMessageAt ?? '').localeCompare(a.one.lastMessageAt ?? '')
1181 const needs = rows.filter(row => row.problem).sort(byLastMessage)
1182 const active = rows.filter(row => !row.problem && row.one.isActive).sort(byLastMessage)
1183 const stopped = rows.filter(row => !row.problem && !row.one.isActive).sort(byLastMessage)
1184 const counts = countOf(snap)
1185 const machineProblems = [...new Set(snap.whatsapp.flatMap(phone => phone.machine.problems))]
1186 const show = (one: Assistant) => openDetail($, { server: one.server, id: one.id })
1187
1188 const rowOf = ({ one, problem }: Row) => {
1189 const status = statusOf(one, problem)
1190 return (
1191 <Box key={`item-${one.id}`} flexDirection="column">
1192 <Box key={`row-${one.id}`} flexDirection="row" justifyContent="space-between" gap={1}>
1193 <Box flexDirection="column" flexShrink={1}>
1194 <Button key={`open-${one.id}`} plain dimColor={!one.isActive} onPress={() => show(one)}>
1195 <Text color={status.color}>{status.mark}</Text> <Text bold={one.isActive}>{one.name}</Text>
1196 </Button>
1197 <Text dimColor wrap="truncate-end">
1198 {' '}
1199 {kindOf(one)} · {modeLabel(one.mode)} · {runnerLabel(one.runner)} · ultimul mesaj{' '}
1200 {shortTime(one.lastMessageAt)}hooks/core.ts 286 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Api, McpToolResult } from 'claude-code'
3
4import { errorOf, jsonOf, str, strings, textOf } from './util'
5import type { Assistant, AssistantDetail, Editing, Machine, PosLine, Selected, Snapshot, WhatsAppLine } from '../types'
6
7// Reading the conversations and the long-text editing both the pane and the editor use.
8// The values are the pane's own: a ref names the value, so every module shares it.
9const snapshot = atom({ plugin: 'symbai-ui', key: 'snapshot' } as const, null)
10const isLoading = atom({ plugin: 'symbai-ui', key: 'isLoading' } as const, false)
11const selected = atom({ plugin: 'symbai-ui', key: 'selected' } as const, null)
12const detail = atom({ plugin: 'symbai-ui', key: 'detail' } as const, null)
13const notice = atom({ plugin: 'symbai-ui', key: 'notice' } as const, null)
14const editing = atom({ plugin: 'symbai-ui', key: 'editing' } as const, null)
15
16// The first line of the prompt box while a prompt is being edited there. Enter saves the rest;
17// a submission without it is a normal prompt again.
18export const EDIT_MARK = '✎ PROMPT '
19
20export type Servers = { whatsapp: string[]; pos: string[] }
21
22export function splitMcpName(name: string): { server: string; tool: string } | null {
23 if (!name.startsWith('mcp__')) return null
24 const cut = name.lastIndexOf('__')
25 if (cut <= 5) return null
26 return { server: name.slice(5, cut), tool: name.slice(cut + 2) }
27}
28
29export async function findServers($: Api): Promise<Servers> {
30 const servers: Servers = { whatsapp: [], pos: [] }
31 for (const tool of await $.tool.list()) {
32 if (!tool.mcp) continue
33 const parts = splitMcpName(tool.name)
34 if (!parts || !parts.server.startsWith('symbai')) continue
35 if (parts.tool === 'list_watches') servers.whatsapp.push(parts.server)
36 if (parts.tool === 'verifica_conexiune') servers.pos.push(parts.server)
37 }
38 return servers
39}
40
41export function toAssistant(server: string, raw: Record<string, any>): Assistant {
42 const chat = raw.conversatie ?? {}
43 const run = raw.ultimaRulare ?? null
44 return {
45 id: String(raw.id ?? ''),
46 server,
47 name: str(chat.nume) ?? str(chat.jid) ?? 'fără nume',
48 kind: chat.tip === 'grup' ? 'grup' : 'contact',
49 isActive: raw.activa === true,
50 mode: str(raw.mod) ?? '',
51 runner: str(raw.dispatch) ?? '',
52 lastRunState: run ? str(run.stare) : null,
53 lastRunAt: run ? str(run.la) : null,
54 lastRunSummary: run ? str(run.rezumat) : null,
55 lastMessageAt: str(raw.ultimulMesaj),
56 stopReason: str(raw.motivOprire),
57 tip: str(raw.sfat),
58 runs: typeof raw.rulari === 'number' ? raw.rulari : 0,
59 }
60}
61
62export function toMachine(body: Record<string, any> | null): Machine {
63 const claude = body?.pregatire ?? null
64 const codex = body?.pregatireCodex ?? null
65 // Not ready: not installed (cliFound false) or not logged in.
66 const ready = (one: any): boolean | null =>
67 one && typeof one.loggedIn === 'boolean' ? one.loggedIn && one.cliFound !== false : null
68 const problems = [...strings(claude?.problems)]
69 // A company Codex cannot reach is said once, by name; the raw line repeats it.
70 const blocked = Array.isArray(codex?.connections)
71 ? codex.connections.filter((one: any) => one?.state === 'blocked').map((one: any) => String(one.name))
72 : []
73 for (const name of blocked) problems.push(`Codex nu poate intra în firma „${name}”. Asistenții Codex care lucrează acolo vor da eroare.`)
74 if (codex && codex.loggedIn === false) problems.push('Codex nu este autentificat pe acest calculator.')
75 return { claudeReady: ready(claude), codexReady: ready(codex), problems }
76}
77
78export type Problem = {
79 title: string
80 explain: string
81 // reset: forget the lost conversation; rerun: try again now; resume: start it again.
82 fix: 'reset' | 'rerun' | 'resume' | null
83}
84
85export function causeOf(text: string): Problem {
86 const first = text.split('\n')[0].trim()
87 if (/no rollout found/i.test(text)) {
88 return {
89 title: 'Și-a pierdut conversația salvată',
90 explain: 'Codex nu mai găsește discuția lui de dinainte, așa că nu poate continua. „Repară” o ia de la zero; promptul și notițele rămân.',
91 fix: 'reset',
92 }
93 }
94 if (/limit/i.test(text)) {
95 return {
96 title: 'Contul a atins limita de folosire',
97 explain: 'Încearcă din nou după ce se resetează limita sau schimbă cine lucrează (Codex / Claude Code).',
98 fix: 'rerun',
99 }
100 }
101 if (/not logged|login|autentific/i.test(text)) {
102 return {
103 title: 'Programul care îl rulează nu e autentificat',
104 explain: 'Autentifică-l din Symbai Connect (secțiunea Monitorizări), apoi încearcă din nou.',
105 fix: 'rerun',
106 }
107 }
108 return { title: 'Ultima rulare a eșuat', explain: first || 'Fără detalii de la Connect.', fix: 'rerun' }
109}
110
111// What needs the person now, in plain words; null when nothing does.
112export function problemOf(one: Assistant, machine: Machine | null): Problem | null {
113 // Active, or stopped by its failures: it cannot work until the computer is ready, so no start is offered.
114 const isWaiting = one.isActive || one.stopReason !== null
115 if (isWaiting && one.runner === 'claude' && machine?.claudeReady === false) {
116 return {
117 title: 'Nu poate lucra: Claude Code nu e pregătit pe calculator',
118 explain: 'Apasă „Autentifică Claude Code” în Symbai Connect (Monitorizări) sau mută-l pe Codex.',
119 fix: null,
120 }
121 }
122 if (isWaiting && one.runner === 'codex' && machine?.codexReady === false) {
123 return {
124 title: 'Nu poate lucra: Codex nu e pregătit pe calculator',
125 explain: 'Autentifică Codex pe acest calculator sau mută-l pe Claude Code.',
126 fix: null,
127 }
128 }
129 if (!one.isActive) {
130 if (!one.stopReason) return null
131 const cause = causeOf(one.stopReason)
132 return {
133 title: 'S-a oprit singur după mai multe erori',
134 explain: `${cause.title}. ${cause.fix === 'reset' ? '„Repară” îl ia de la zero și îl pornește; ' : '„Pornește” îl reia; '}preia și mesajele venite cât a stat oprit.`,
135 fix: cause.fix === 'reset' ? 'reset' : 'resume',
136 }
137 }
138 // „Sesiunea Claude” has no automatic runs: a failed one is from before it moved there.
139 if (one.lastRunState === 'failed' && one.runner !== 'stream') return causeOf(one.lastRunSummary ?? '')
140 return null
141}
142
143export function toDetail(server: string, raw: Record<string, any>): AssistantDetail {
144 return {
145 ...toAssistant(server, raw),
146 objective: str(raw.obiectiv) ?? '',
147 triggerWords: strings(raw.cuvinteDeclansare),
148 callNames: strings(raw.numeDeApel),
149 ownerStart: str(raw.pornireProprietar),
150 includeOwnerMessages: raw.includeOwnerMessages === true,
151 maxRunsPerHour: typeof raw.plafonOrar === 'number' ? raw.plafonOrar : null,
152 notesLength:
153 typeof raw.notite === 'string'
154 ? raw.notite.length
155 : typeof raw.notiteCaractere === 'number'
156 ? raw.notiteCaractere
157 : 0,
158 trustedCount: strings(raw.persoaneDeIncredere).length,
159 senders: strings(raw.doarDeLa),
160 }
161}
162
163export async function loadWhatsApp($: Api, server: string): Promise<WhatsAppLine> {
164 const line: WhatsAppLine = {
165 server,
166 number: null,
167 name: null,
168 isConnected: false,
169 organization: null,
170 licence: null,
171 assistants: [],
172 machine: { claudeReady: null, codexReady: null, problems: [] },
173 error: null,
174 }
175 try {
176 const status = jsonOf(await $.mcp.call(server, 'connection_status'))
177 line.number = str(status?.whatsapp?.numar)
178 line.name = str(status?.numarul?.nume) ?? str(status?.whatsapp?.nume)
179 line.isConnected = status?.whatsapp?.conectat === true
180 line.organization = str(status?.licenta?.organizatie)
181 line.licence = str(status?.licenta?.stare)
182 const watches = jsonOf(await $.mcp.call(server, 'list_watches'))
183 const list = Array.isArray(watches?.monitorizari) ? watches.monitorizari : []
184 line.assistants = list.map((raw: Record<string, any>) => toAssistant(server, raw))
185 line.machine = toMachine(watches)
186 } catch (cause) {
187 line.error = errorOf(cause)
188 }
189 return line
190}
191
192export async function loadPos($: Api, server: string): Promise<PosLine> {
193 try {
194 const result = await $.mcp.call(server, 'verifica_conexiune')
195 const body = jsonOf(result)
196 return {
197 server,
198 employee: str(body?.data?.angajat?.nume),
199 version: str(body?.data?.versiuneInstanta),
200 isOk: !result.isError && body?.success === true,
201 error: result.isError ? textOf(result).slice(0, 200) : null,
202 }
203 } catch (cause) {
204 return { server, employee: null, version: null, isOk: false, error: errorOf(cause) }
205 }
206}
207
208export function countOf(snap: Snapshot) {
209 const all = snap.whatsapp.flatMap(line => line.assistants.map(one => ({ one, machine: line.machine })))
210 const active = all.filter(({ one }) => one.isActive)
211 const failed = all.filter(({ one, machine }) => problemOf(one, machine) !== null)
212 return { all: all.length, active: active.length, stopped: all.length - active.length, failed: failed.length }
213}
214
215export function plural(count: number, one: string, many: string): string {
216 return count === 1 ? `1 ${one}` : `${count} ${many}`
217}
218
219export function statusText(snap: Snapshot): string {
220 const counts = countOf(snap)
221 const failed = counts.failed > 0 ? `, ${counts.failed} de reparat` : ''
222 return `Symbai: ${plural(counts.active, 'asistent activ', 'asistenți activi')}${failed}`
223}
224
225// A refresh asked while one runs runs once more after it, so a change is never shown stale.
226let isRefreshAgain = false
227
228export async function refresh($: Api): Promise<void> {
229 if (await read($, isLoading)) {
230 isRefreshAgain = true
231 return
232 }
233 await update($, isLoading, () => true)
234 try {
235 const servers = await findServers($)
236 const [whatsapp, pos] = await Promise.all([
237 Promise.all(servers.whatsapp.map(server => loadWhatsApp($, server))),
238 Promise.all(servers.pos.map(server => loadPos($, server))),
239 ])
240 const snap: Snapshot = { refreshedAt: await $.clock.now(), whatsapp, pos }
241 await update($, snapshot, () => snap)
242 if (whatsapp.length > 0 || pos.length > 0) $.ui.status(statusText(snap))
243 } finally {
244 await update($, isLoading, () => false)
245 }
246 if (isRefreshAgain) {
247 isRefreshAgain = false
248 await refresh($)
249 }
250}
251
252export async function loadDetail($: Api, target: Selected): Promise<AssistantDetail | null> {
253 const body = jsonOf(await $.mcp.call(target.server, 'list_watches', { watch: target.id }))
254 const raw = Array.isArray(body?.monitorizari) ? body.monitorizari.find((one: any) => one?.id === target.id) : null
255 const found = raw ? toDetail(target.server, raw) : null
256 // A late answer for an assistant the person already left must not replace the one shown.
257 const now = await read($, selected)
258 if (now?.id === target.id && now.server === target.server) await update($, detail, () => found)
259 return found
260}
261
262// Any long text of an assistant goes into the prompt box under the edit mark; Enter saves it there.
263export async function startTextEdit($: Api, edit: Editing, text: string, title: string): Promise<void> {
264 const draft = await $.prompt.read()
265 if (draft.text.trim().length > 0 && !draft.text.startsWith(EDIT_MARK)) {
266 await update($, notice, () => ({
267 text: 'Ai un text început în caseta de scris. Trimite-l sau golește caseta, apoi apasă din nou butonul de editare.',
268 isError: true,
269 }))
270 return
271 }
272 const name = edit.name || (await read($, detail))?.name || ''
273 const filled = await $.prompt.fill({
274 text: `${EDIT_MARK}„${name}” · ${title} — schimbă textul de mai jos și apasă Enter ca să-l salvezi. Ca să renunți, apasă „Renunță la editare” în panou.\n${text}`,
275 })
276 if (!filled.isFilled) {
277 await update($, notice, () => ({
278 text: 'Caseta de scris e ocupată acum. Închide fereastra deschisă și încearcă din nou.',
279 isError: true,
280 }))
281 return
282 }
283 await update($, editing, () => ({ ...edit, name }))
284 await update($, notice, () => null)
285}
286hooks/editor.tsx 1432 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Api, McpToolResult } from 'claude-code'
3
4import { findServers, loadDetail, refresh, startTextEdit } from './core'
5import { errorOf, jsonOf, num, shortTime, splitWords, str, strings, textOf } from './util'
6import type {
7 AssistantDetail,
8 CapabilityInfo,
9 Danger,
10 Editing,
11 Editor,
12 EditorTab,
13 PosCompany,
14 PosIdentity,
15 PosTask,
16 Reminder,
17 Routine,
18 RunItem,
19 Selected,
20 TextField,
21 WatchFull,
22} from '../types'
23
24// The same values the pane reads: a ref names the value, so both modules share it.
25const editor = atom({ plugin: 'symbai-ui', key: 'editor' } as const, null)
26const danger = atom({ plugin: 'symbai-ui', key: 'danger' } as const, null)
27const openArea = atom({ plugin: 'symbai-ui', key: 'openArea' } as const, null)
28const itemConfirm = atom({ plugin: 'symbai-ui', key: 'itemConfirm' } as const, null)
29const notice = atom({ plugin: 'symbai-ui', key: 'notice' } as const, null)
30const isBusy = atom({ plugin: 'symbai-ui', key: 'isBusy' } as const, false)
31const selected = atom({ plugin: 'symbai-ui', key: 'selected' } as const, null)
32const detail = atom({ plugin: 'symbai-ui', key: 'detail' } as const, null)
33
34const BRAND = '#7C3AED'
35const PREVIEW = 600
36
37export const TABS: { value: EditorTab; label: string }[] = [
38 { value: 'general', label: 'General' },
39 { value: 'reguli', label: 'Când și cui răspunde' },
40 { value: 'rulare', label: 'Cum rulează' },
41 { value: 'pos', label: 'În POS' },
42 { value: 'sarcini', label: 'Sarcini și monitorizări' },
43 { value: 'istoric', label: 'Istoric' },
44 { value: 'pericol', label: 'Oprește sau șterge' },
45]
46
47const EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra']
48
49// The assistant's page closes with the editor when the assistant is deleted.
50async function closeDetail($: Api): Promise<void> {
51 await update($, selected, () => null)
52 await update($, detail, () => null)
53}
54
55// ── Reading ─────────────────────────────────────────────────────────
56
57function toWatchFull(raw: Record<string, any>): WatchFull {
58 const wait = /(\d+)s liniște, maxim (\d+)s/.exec(str(raw.asteptare) ?? '')
59 return {
60 jid: str(raw.conversatie?.jid) ?? '',
61 notify: str(raw.anuntaPe),
62 quietSeconds: wait ? Number(wait[1]) : null,
63 maxWaitSeconds: wait ? Number(wait[2]) : null,
64 model: str(raw.model),
65 senders: strings(raw.doarDeLa),
66 trusted: strings(raw.persoaneDeIncredere),
67 createdAt: str(raw.creata),
68 events: num(raw.evenimente) ?? 0,
69 runs: num(raw.rulari) ?? 0,
70 listeners: num(raw.ascultatori) ?? 0,
71 sessionMemory: raw.memorieSesiune === true,
72 notes: typeof raw.notite === 'string' ? raw.notite : '',
73 objective: str(raw.obiectiv) ?? '',
74 }
75}
76
77function toRun(raw: Record<string, any>): RunItem {
78 return {
79 at: str(raw.la),
80 status: str(raw.stare) ?? '',
81 trigger: str(raw.declansata),
82 messages: num(raw.mesaje),
83 steps: num(raw.pasi),
84 seconds: num(raw.durataSecunde),
85 summary: str(raw.rezumat) ?? str(raw.eroare),
86 }
87}
88
89// A routine works on this chat when its own text names the chat.
90function toRoutines(body: unknown, jid: string): Routine[] {
91 if (!Array.isArray(body) || jid.length === 0) return []
92 return body
93 .filter((one: any) => typeof one?.spec?.objective === 'string' && one.spec.objective.includes(jid))
94 .map((one: any) => ({
95 id: String(one.id),
96 name: str(one.spec?.name) ?? 'fără nume',
97 active: one.active === true,
98 nextAt: str(one.nextAt),
99 lastSummary: str(one.lastSummary),
100 failures: num(one.failures) ?? 0,
101 }))
102}
103
104function toReminders(body: Record<string, any> | null, jid: string): Reminder[] {
105 const list = Array.isArray(body?.reamintiri) ? body.reamintiri : []
106 return list
107 .filter((one: any) => JSON.stringify(one).includes(jid))
108 .map((one: any) => ({
109 id: Number(one.id),
110 kind: str(one.fel) ?? str(one.kind) ?? '',
111 due: str(one.termen) ?? str(one.due),
112 status: str(one.stare) ?? str(one.status) ?? '',
113 }))
114}
115
116// A POS tool's answer: { success, data } or a refusal.
117function posData(result: McpToolResult): Record<string, any> {
118 const body = jsonOf(result)
119 if (result.isError || body?.success === false) {
120 throw new Error(str(body?.error) ?? str(body?.message) ?? (textOf(result).slice(0, 300) || 'POS a refuzat.'))
121 }
122 return (body?.data ?? body ?? {}) as Record<string, any>
123}
124
125async function posRead($: Api, server: string, tool: string, args: Record<string, unknown>): Promise<Record<string, any>> {
126 return posData(await $.mcp.call(server, 'citeste_tool', { nume: tool, argumente: args }))
127}
128
129// A long text read in parts says where the next part starts; a definition is always read whole.
130function hasMoreText(value: unknown): boolean {
131 return /"nextTextOffset":\d/.test(JSON.stringify(value ?? null))
132}
133
134// A random identity for a request the POS keeps once (run-task).
135function requestId(): string {
136 const hex = Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16))
137 hex[12] = '4'
138 hex[16] = ((Number.parseInt(hex[16], 16) & 0x3) | 0x8).toString(16)
139 const all = hex.join('')
140 return `${all.slice(0, 8)}-${all.slice(8, 12)}-${all.slice(12, 16)}-${all.slice(16, 20)}-${all.slice(20)}`
141}
142
143function toTask(raw: Record<string, any>, jid: string): PosTask {
144 const chatJid =
145 str(raw.deliver?.whatsapp?.chatJid) ??
146 (Array.isArray(raw.monitor?.sources) ? str(raw.monitor.sources.find((one: any) => one?.chatJid)?.chatJid) : null)
147 return {
148 id: String(raw.id),
149 revision: num(raw.revision) ?? 1,
150 name: str(raw.name) ?? 'fără nume',
151 kind: str(raw.kind) ?? '',
152 cadence: str(raw.cadence) ?? '',
153 active: raw.active === true,
154 lastRunAt: str(raw.lastRunAt),
155 lastError: str(raw.lastError),
156 runStatus: str(raw.runStatus),
157 chatJid,
158 isThisChat: chatJid !== null && chatJid === jid,
159 isParticipation: raw.whatsapp != null && typeof raw.whatsapp === 'object',
160 }
161}
162
163function toIdentity(profile: Record<string, any>, taskItems: Record<string, any>[], jid: string): PosIdentity | null {
164 const one = profile.assistant
165 if (!one || typeof one !== 'object') return null
166 const def = (one.definition ?? {}) as Record<string, any>
167 const envelope = def.envelope ?? {}
168 const tasks = taskItems.map(task => toTask(task, jid))
169 return {
170 id: String(one.id),
171 revision: num(one.revision) ?? 1,
172 name: str(def.name) ?? 'fără nume',
173 handle: str(def.handle) ?? '',
174 role: typeof def.role === 'string' ? def.role : '',
175 personality: typeof def.personality === 'string' ? def.personality : '',
176 instructions: typeof def.instructions === 'string' ? def.instructions : '',
177 active: def.active === true,
178 model: str(def.model),
179 effort: str(def.effort),
180 executor: str(def.executor) ?? 'codex',
181 stockMode: str(def.stockMode),
182 learningMode: str(def.learningMode),
183 capabilities: strings(envelope.capabilities),
184 brandIds: Array.isArray(envelope.brandIds) ? envelope.brandIds.filter((id: unknown) => typeof id === 'number') : [],
185 locationIds: Array.isArray(envelope.locationIds) ? envelope.locationIds.filter((id: unknown) => typeof id === 'number') : [],
186 definition: def,
187 tasks,
188 }
189}
190
191async function loadCompany($: Api, server: string, jid: string): Promise<PosCompany> {
192 const company: PosCompany = {
193 server,
194 company: null,
195 error: null,
196 identities: [],
197 catalog: [],
198 ceiling: [],
199 brands: [],
200 locations: [],
201 models: [],
202 isLegacyHeld: false,
203 }
204 try {
205 const list = await posRead($, server, 'asistenti_lista', {})
206 company.company = str(list.connection?.company)
207 company.catalog = Array.isArray(list.capabilities)
208 ? list.capabilities.map(
209 (one: any): CapabilityInfo => ({
210 id: String(one.id),
211 area: str(one.area) ?? 'Altele',
212 label: str(one.label) ?? String(one.id),
213 effect: str(one.effect) ?? 'read',
214 }),
215 )
216 : []
217 company.ceiling = strings(list.ceiling?.capabilities)
218 company.brands = Array.isArray(list.brands) ? list.brands.map((one: any) => ({ id: Number(one.id), name: String(one.name) })) : []
219 company.locations = Array.isArray(list.locations)
220 ? list.locations.map((one: any) => ({ id: Number(one.id), name: String(one.name) }))
221 : []
222 const device = Array.isArray(list.connection?.devices) ? list.connection.devices.find((one: any) => one?.online) : null
223 for (const executor of ['codex', 'claude']) {
224 for (const model of Array.isArray(device?.models?.[executor]) ? device.models[executor] : []) {
225 company.models.push({ executor, id: String(model.id), label: str(model.label) ?? String(model.id) })
226 }
227 }
228 const channels = Array.isArray(list.taskTargets?.whatsappChannels) ? list.taskTargets.whatsappChannels : []
229 company.isLegacyHeld = channels.some((one: any) => strings(one?.legacyBusyChats).includes(jid))
230 const assistants = Array.isArray(list.assistants) ? list.assistants : []
231 for (const one of assistants) {
232 if (!one?.id || (num(one.tasks?.total) ?? 0) === 0) continue
233 const profile = await posRead($, server, 'asistent_citeste', { id: one.id, sectiune: 'profil', limit: 20 })
234 // Every task, past the first page: the participation on this chat may be any of them.
235 const items: Record<string, any>[] = Array.isArray(profile.tasks?.items) ? [...profile.tasks.items] : []
236 let offset = num(profile.tasks?.nextOffset)
237 for (let page = 0; offset !== null && page < 10; page++) {
238 const more = await posRead($, server, 'asistent_citeste', { id: one.id, sectiune: 'sarcini', offset, limit: 20 }).catch(() => null)
239 const list = more?.tasks ?? more
240 if (!Array.isArray(list?.items)) break
241 items.push(...list.items)
242 offset = num(list.nextOffset)
243 }
244 const identity = toIdentity(profile, items, jid)
245 if (identity && identity.tasks.some(task => task.isThisChat)) company.identities.push(identity)
246 }
247 } catch (cause) {
248 company.error = errorOf(cause)
249 }
250 return company
251}
252
253async function patchEditor($: Api, id: string, change: (now: Editor) => Editor): Promise<void> {
254 await update($, editor, now => (now && now.id === id ? change(now) : now))
255}
256
257async function loadConnect($: Api, target: Selected): Promise<void> {
258 const [watches, activity, routines, reminders] = await Promise.all([
259 $.mcp.call(target.server, 'list_watches', { watch: target.id }).catch(() => null),
260 $.mcp.call(target.server, 'watch_activity', { watch: target.id, limit: 15 }).catch(() => null),
261 $.mcp.call(target.server, 'list_routines', {}).catch(() => null),
262 $.mcp.call(target.server, 'list_reminders', {}).catch(() => null),
263 ])
264 const raw = watches ? jsonOf(watches)?.monitorizari?.find((one: any) => one?.id === target.id) : null
265 const watch = raw ? toWatchFull(raw) : null
266 const jid = watch?.jid ?? ''
267 const runs = activity ? jsonOf(activity)?.rulari : null
268 await patchEditor($, target.id, now => ({
269 ...now,
270 watch,
271 runs: Array.isArray(runs) ? runs.map(toRun) : [],
272 routines: routines ? toRoutines(jsonOf(routines), jid) : [],
273 reminders: reminders ? toReminders(jsonOf(reminders), jid) : [],
274 error: watch ? null : 'Nu găsesc monitorizarea în Connect.',
275 }))
276}
277
278export async function loadPos($: Api, target: Selected): Promise<void> {
279 const now = await read($, editor)
280 const jid = now?.watch?.jid ?? ''
281 if (jid.length === 0) return
282 await patchEditor($, target.id, one => ({ ...one, isPosLoading: true }))
283 try {
284 const servers = (await findServers($)).pos
285 const pos = await Promise.all(servers.map(server => loadCompany($, server, jid)))
286 await patchEditor($, target.id, one => ({ ...one, pos }))
287 } finally {
288 await patchEditor($, target.id, one => ({ ...one, isPosLoading: false }))
289 }
290}
291
292export async function openEditor($: Api, target: Selected, tab: EditorTab = 'general'): Promise<void> {
293 await update($, danger, () => null)
294 await update($, itemConfirm, () => null)
295 await update($, editor, () => ({
296 server: target.server,
297 id: target.id,
298 tab,
299 watch: null,
300 runs: null,
301 routines: null,
302 reminders: null,
303 pos: null,
304 isPosLoading: false,
305 error: null,
306 }))
307 await loadConnect($, target)
308 void loadPos($, target).catch(() => undefined)
309}
310
311export async function closeEditor($: Api): Promise<void> {
312 await update($, editor, () => null)
313 await update($, danger, () => null)
314 await update($, itemConfirm, () => null)
315}
316
317// ── Writing ─────────────────────────────────────────────────────────
318
319async function say($: Api, text: string, isError: boolean): Promise<void> {
320 await update($, notice, () => ({ text, isError }))
321}
322
323function refusalOf(result: McpToolResult): string | null {
324 const body = jsonOf(result)
325 if (!result.isError && body?.success !== false && typeof body?.error !== 'string') return null
326 return str(body?.error) ?? str(body?.message) ?? (textOf(result).slice(0, 400) || 'Refuzat.')
327}
328
329async function busy<T>($: Api, work: () => Promise<T>): Promise<T | null> {
330 if (await read($, isBusy)) return null
331 await update($, isBusy, () => true)
332 try {
333 return await work()
334 } catch (cause) {
335 await say($, `Nu s-a schimbat: ${errorOf(cause)}`, true)
336 return null
337 } finally {
338 await update($, isBusy, () => false)
339 }
340}
341
342async function afterConnectChange($: Api, target: Selected): Promise<void> {
343 await loadConnect($, target).catch(() => undefined)
344 await loadDetail($, target).catch(() => null)
345 void refresh($).catch(() => undefined)
346}
347
348// One Connect change on this watch; only the fields given change.
349export async function changeWatch($: Api, args: Record<string, unknown>, done: string): Promise<void> {
350 const now = await read($, editor)
351 if (!now) return
352 const target = { server: now.server, id: now.id }
353 await busy($, async () => {
354 const failure = refusalOf(await $.mcp.call(target.server, 'update_watch', { watch: target.id, ...args }))
355 await say($, failure ? `Nu s-a schimbat: ${failure}` : done, failure !== null)
356 })
357 await afterConnectChange($, target)
358}
359
360async function connectCall($: Api, server: string, tool: string, args: Record<string, unknown>): Promise<string | null> {
361 return refusalOf(await $.mcp.call(server, tool, args))
362}
363
364// A POS write, read back by the caller. The person's press on the panel is the owner's request.
365async function posWrite($: Api, server: string, tool: string, args: Record<string, unknown>, done: string): Promise<boolean> {
366 const result = await $.mcp.call(server, 'ruleaza_tool', { nume: tool, argumente: args })
367 const failure = refusalOf(result)
368 if (failure) {
369 await say($, `POS nu a schimbat: ${failure}`, true)
370 return false
371 }
372 await say($, done, false)
373 return true
374}
375
376async function reloadPosAfter($: Api): Promise<void> {
377 const now = await read($, editor)
378 if (now) await loadPos($, { server: now.server, id: now.id }).catch(() => undefined)
379}
380
381// The definition keys asistent_actualizeaza takes; any other key read back is left out.
382const DEFINITION_KEYS = [
383 'session',
384 'reporting',
385 'local',
386 'memory',
387 'stockMode',
388 'name',
389 'handle',
390 'role',
391 'personality',
392 'instructions',
393 'avatar',
394 'color',
395 'executor',
396 'deviceId',
397 'model',
398 'modelAutoUpgrade',
399 'effort',
400 'envelope',
401 'learningMode',
402 'active',
403]
404
405function definitionOf(identity: PosIdentity, change: Record<string, unknown>): Record<string, unknown> {
406 const out: Record<string, unknown> = {}
407 for (const key of DEFINITION_KEYS) {
408 if (identity.definition[key] !== undefined) out[key] = identity.definition[key]
409 }
410 return { ...out, ...change }
411}
412
413export async function changeIdentity(
414 $: Api,
415 server: string,
416 identity: PosIdentity,
417 change: Record<string, unknown>,
418 done: string,
419): Promise<boolean> {
420 const ok = await busy($, () =>
421 posWrite(
422 $,
423 server,
424 'asistent_actualizeaza',
425 {
426 id: identity.id,
427 expectedRevision: identity.revision,
428 definition: definitionOf(identity, change),
429 reason: 'Schimbat de proprietar din panoul Symbai din Claude Code.',
430 },
431 done,
432 ),
433 )
434 await reloadPosAfter($)
435 return ok === true
436}
437
438type TaskAction = 'pause-task' | 'resume-task' | 'run-task' | 'delete-task'
439
440export async function taskAction($: Api, server: string, identity: PosIdentity, task: PosTask, action: TaskAction): Promise<boolean> {
441 const done = {
442 'pause-task': `Sarcina „${task.name}” e oprită în POS.`,
443 'resume-task': `Sarcina „${task.name}” e pornită în POS.`,
444 'run-task': `Am pornit acum sarcina „${task.name}”.`,
445 'delete-task': `Sarcina „${task.name}” a fost ștearsă din POS.`,
446 }[action]
447 const args =
448 action === 'run-task'
449 ? { action, id: identity.id, taskId: task.id, requestId: requestId() }
450 : { action, id: identity.id, taskId: task.id, expectedRevision: task.revision }
451 const ok = await busy($, () => posWrite($, server, 'asistent_comanda', args, done))
452 await reloadPosAfter($)
453 return ok === true
454}
455
456// The participation fields asistent_whatsapp takes back; a task with anything else set is changed in the POS app.
457const SARCINA_KEYS = [
458 'stockMode',
459 'name',
460 'kind',
461 'instructions',
462 'timezone',
463 'schedule',
464 'interval',
465 'monitor',
466 'deliver',
467 'envelope',
468 'maxRunsPerHour',
469 'maxRunsPerDay',
470 'active',
471 'recoverMissed',
472 'templateId',
473 'document',
474]
475const WHATSAPP_KEYS = [
476 'privacy',
477 'mode',
478 'ownMessages',
479 'ownerEmail',
480 'triggerWords',
481 'debounceSeconds',
482 'maxWaitSeconds',
483 'intervalMinutes',
484 'stopWhenResolved',
485]
486
487function sarcinaOf(config: Record<string, any>, change: Record<string, unknown>): Record<string, unknown> | string {
488 const wa = config.whatsapp
489 if (!wa || typeof wa !== 'object') return 'Sarcina nu e o participare WhatsApp; schimb-o din aplicația POS.'
490 const extra =
491 (Array.isArray(wa.clientRules) && wa.clientRules.length > 0) ||
492 (typeof wa.limits === 'string' && wa.limits.trim().length > 0) ||
493 (typeof wa.publicInformation === 'string' && wa.publicInformation.trim().length > 0) ||
494 wa.route != null ||
495 wa.ownReservations === true ||
496 wa.ownEvents === true ||
497 (typeof wa.humanPauseMinutes === 'number' && wa.humanPauseMinutes !== 10)
498 // The tool saves the participation whole: what it cannot carry would go back to its default.
499 if (extra) return 'Participarea are setări pentru clienți pe care nu le pot trimite de aici fără să le pierd; schimb-o din aplicația POS.'
500 const out: Record<string, unknown> = {}
501 for (const key of SARCINA_KEYS) if (config[key] !== undefined) out[key] = config[key]
502 const whatsapp: Record<string, unknown> = {}
503 for (const key of WHATSAPP_KEYS) if (wa[key] !== undefined) whatsapp[key] = wa[key]
504 const { whatsapp: waChange, ...rest } = change as { whatsapp?: Record<string, unknown> }
505 return { ...out, ...rest, whatsapp: { ...whatsapp, ...(waChange ?? {}) } }
506}
507
508export async function changeParticipation(
509 $: Api,
510 server: string,
511 identity: PosIdentity,
512 task: PosTask,
513 change: Record<string, unknown>,
514 done: string,
515): Promise<boolean> {
516 const ok = await busy($, async () => {
517 const read = await posRead($, server, 'asistent_citeste', { id: identity.id, taskId: task.id })
518 const config = read.task?.config
519 if (!config || hasMoreText(read)) throw new Error('Nu am putut citi sarcina întreagă; schimb-o din aplicația POS.')
520 const sarcina = sarcinaOf(config, change)
521 if (typeof sarcina === 'string') throw new Error(sarcina)
522 return posWrite(
523 $,
524 server,
525 'asistent_whatsapp',
526 { id: identity.id, taskId: task.id, expectedRevision: num(read.task?.revision) ?? task.revision, sarcina },
527 done,
528 )
529 })
530 await reloadPosAfter($)
531 return ok === true
532}
533
534// ── Long texts: the prompt box is the editor ────────────────────────
535
536export async function editText($: Api, field: TextField, extra?: { server: string; identity: PosIdentity; task?: PosTask }): Promise<void> {
537 const now = await read($, editor)
538 if (!now?.watch) return
539 const base = { server: now.server, id: now.id, name: '' }
540 if (field === 'notes') {
541 const body = jsonOf(await $.mcp.call(now.server, 'read_watch_notes', { watch: now.id }))
542 const text = typeof body?.notite === 'string' ? body.notite : typeof body?.notes === 'string' ? body.notes : now.watch.notes
543 await startTextEdit($, { ...base, previous: text, field }, text, 'notițele')
544 return
545 }
546 if (!extra) return
547 const pos = { server: extra.server, assistantId: extra.identity.id, taskId: extra.task?.id }
548 if (field === 'pos-task-instructions' && extra.task) {
549 const got = await posRead($, extra.server, 'asistent_citeste', { id: extra.identity.id, taskId: extra.task.id })
550 const text = typeof got.task?.config?.instructions === 'string' ? got.task.config.instructions : ''
551 await startTextEdit($, { ...base, previous: text, field, pos }, text, `instrucțiunile sarcinii „${extra.task.name}” din POS`)
552 return
553 }
554 const text = field === 'pos-personality' ? extra.identity.personality : extra.identity.instructions
555 const what = field === 'pos-personality' ? 'personalitatea' : 'instrucțiunile'
556 await startTextEdit($, { ...base, previous: text, field, pos }, text, `${what} lui „${extra.identity.name}” din POS`)
557}
558
559// Enter in the prompt box on one of the editor's texts. Returns the failure, or null once saved and read back.
560export async function saveText($: Api, edit: Editing, text: string): Promise<string | null> {
561 if (edit.field === 'notes') {
562 const failure = await connectCall($, edit.server, 'update_watch', { watch: edit.id, notes: text })
563 if (failure) return failure
564 await afterConnectChange($, { server: edit.server, id: edit.id })
565 return null
566 }
567 const pos = edit.pos
568 const now = await read($, editor)
569 const company = now?.pos?.find(one => one.server === pos?.server)
570 const identity = company?.identities.find(one => one.id === pos?.assistantId)
571 if (!pos || !identity) return 'Nu mai găsesc asistentul din POS; redeschide editorul.'
572 let ok = false
573 if (edit.field === 'pos-task-instructions') {
574 const task = identity.tasks.find(one => one.id === pos.taskId)
575 if (!task) return 'Nu mai găsesc sarcina din POS.'
576 ok = await changeParticipation($, pos.server, identity, task, { instructions: text }, 'Instrucțiunile sarcinii sunt salvate în POS.')
577 } else {
578 const key = edit.field === 'pos-personality' ? 'personality' : 'instructions'
579 ok = await changeIdentity($, pos.server, identity, { [key]: text }, 'Textul e salvat în POS.')
580 }
581 if (ok) return null
582 const info = await read($, notice)
583 return info?.isError ? info.text : 'Altă schimbare era în lucru; încearcă din nou.'
584}
585
586// ── Stop or delete, everywhere ──────────────────────────────────────
587
588function linkedTasks(now: Editor): { server: string; identity: PosIdentity; task: PosTask }[] {
589 const out: { server: string; identity: PosIdentity; task: PosTask }[] = []
590 for (const company of now.pos ?? []) {
591 for (const identity of company.identities) {
592 for (const task of identity.tasks) if (task.isThisChat && task.isParticipation) out.push({ server: company.server, identity, task })
593 }
594 }
595 return out
596}
597
598export async function runDanger($: Api): Promise<void> {
599 const ask = await read($, danger)
600 const now = await read($, editor)
601 if (!ask || !now || ask.id !== now.id) return
602 await update($, danger, () => null)
603 const target = { server: now.server, id: now.id }
604 const done: string[] = []
605 const failed: string[] = []
606 const posFailed: string[] = []
607 await busy($, async () => {
608 if (ask.withPos) {
609 for (const { server, identity, task } of linkedTasks(now)) {
610 const action = ask.action === 'delete-all' ? 'delete-task' : task.active ? 'pause-task' : null
611 if (!action) continue
612 const ok = await posWrite(
613 $,
614 server,
615 'asistent_comanda',
616 { action, id: identity.id, taskId: task.id, expectedRevision: task.revision },
617 '',
618 )
619 ;(ok ? done : posFailed).push(`POS: ${task.name}`)
620 }
621 }
622 failed.push(...posFailed)
623 // Deleting the watch with the POS participation still on would leave it answering, unreachable from here.
624 if (ask.action === 'delete-all' && failed.length > 0) return
625 if (ask.withRoutines) {
626 for (const routine of now.routines ?? []) {
627 const failure =
628 ask.action === 'delete-all'
629 ? await connectCall($, target.server, 'delete_routine', { id: routine.id })
630 : routine.active
631 ? await connectCall($, target.server, 'set_routine_active', { id: routine.id, active: false })
632 : null
633 ;(failure ? failed : done).push(`sarcina programată „${routine.name}”`)
634 }
635 }
636 if (ask.action === 'delete-all' && failed.length > 0) return
637 if (ask.action === 'delete-all') {
638 // The per-watch reporting route is not removed with the watch: drop it first.
639 await connectCall($, target.server, 'assistant_reporting', { action: 'inherit', watch: target.id }).catch(() => null)
640 const failure = await connectCall($, target.server, 'stop_watch', { watch: target.id, delete: true })
641 ;(failure ? failed : done).push(failure ? `monitorizarea din Connect (${failure})` : 'monitorizarea din Connect, cu notițele și istoricul')
642 } else {
643 const failure = await connectCall($, target.server, 'update_watch', { watch: target.id, active: false })
644 ;(failure ? failed : done).push(failure ? `monitorizarea din Connect (${failure})` : 'monitorizarea din Connect')
645 }
646 })
647 const verb = ask.action === 'delete-all' ? 'Șters' : 'Oprit'
648 const isHalted = ask.action === 'delete-all' && failed.length > 0
649 const text = isHalted
650 ? `Nu am șters monitorizarea din Connect: nu s-a putut ${failed.join(', ')}. Rezolvă întâi asta, apoi încearcă din nou.` +
651 (done.length > 0 ? ` Deja făcut: ${done.join(', ')}.` : '')
652 : `${verb}: ${done.length > 0 ? done.join(', ') : 'nimic'}.` + (failed.length > 0 ? ` Nu s-a putut: ${failed.join(', ')}.` : '')
653 if (ask.action === 'delete-all' && failed.length === 0) {
654 await closeEditor($)
655 await closeDetail($)
656 await say($, text, false)
657 void refresh($).catch(() => undefined)
658 return
659 }
660 await say($, text, failed.length > 0)
661 await afterConnectChange($, target)
662 await reloadPosAfter($)
663}
664
665// ── Drawing ─────────────────────────────────────────────────────────
666
667type Els = { Box: any; Button: any; Input: any; Text: any; Select: any; Link: any }
668
669// The POS page where the owner deletes an assistant, from the connection's name (symbai-<firmă>).
670function myAssistantsUrl(server: string): string | null {
671 const found = /^symbai-([a-z0-9]+(?:-[a-z][a-z0-9]*)*?)(?:-\d+)?$/.exec(server)
672 return found ? `https://${found[1]}.symbai.app/my-assistants` : null
673}
674
675function grouped(catalog: CapabilityInfo[], ceiling: string[]): [string, CapabilityInfo[]][] {
676 const areas = new Map<string, CapabilityInfo[]>()
677 for (const one of catalog) {
678 if (!ceiling.includes(one.id)) continue
679 const list = areas.get(one.area) ?? []
680 list.push(one)
681 areas.set(one.area, list)
682 }
683 return [...areas.entries()]
684}
685
686function toggled(list: string[], id: string): string[] {
687 return list.includes(id) ? list.filter(one => one !== id) : [...list, id]
688}
689
690function toggledNum(list: number[], id: number): number[] {
691 return list.includes(id) ? list.filter(one => one !== id) : [...list, id]
692}
693
694function intOf(value: string): number | null {
695 const n = Number.parseInt(value.trim(), 10)
696 return Number.isFinite(n) ? n : null
697}
698
699export async function drawEditor($: Api, els: Els, one: AssistantDetail): Promise<unknown> {
700 const { Box, Button, Input, Text, Select, Link } = els
701 const now = await read($, editor)
702 if (!now) return null
703 const isWorking = await read($, isBusy)
704 const info = await read($, notice)
705 const ask = await read($, danger)
706 const itemAsk = await read($, itemConfirm)
707 const area = await read($, openArea)
708 const watch = now.watch
709
710 const section = (title: string, children: unknown, hint?: string) => (
711 <Box flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1}>
712 <Text bold color={BRAND}>
713 {title}
714 </Text>
715 {hint && <Text dimColor>{hint}</Text>}
716 {children}
717 </Box>
718 )
719 const row = (label: string, value: unknown) => (
720 <Text>
721 <Text dimColor>{label}: </Text>
722 {value}
723 </Text>
724 )
725 const preview = (text: string) => (text.length > PREVIEW ? `${text.slice(0, PREVIEW)}…` : text || '—')
726
727 const header = (
728 <Box flexDirection="column">
729 <Button key="editor-back" label="← Înapoi la fișă" dimColor onPress={() => closeEditor($)} />
730 <Text size="title" color={BRAND} bold>
731 ✎ {one.name}
732 </Text>
733 <Text dimColor>
734 {one.kind === 'grup' ? 'grup WhatsApp' : 'conversație directă'} · {one.isActive ? 'activ' : 'oprit'} ·{' '}
735 {watch?.jid ?? ''}
736 </Text>
737 <Box flexDirection="row" gap={1} flexWrap="wrap" marginTop={1}>
738 {TABS.map(tab => (
739 <Button
740 key={`tab-${tab.value}`}
741 label={tab.label}
742 variant={now.tab === tab.value ? 'primary' : undefined}
743 dimColor={now.tab !== tab.value}
744 onPress={() => update($, editor, ed => (ed ? { ...ed, tab: tab.value } : ed))}
745 />
746 ))}
747 </Box>
748 </Box>
749 )
750
751 const noticeLine = info && (
752 <Box borderStyle="round" borderColor={info.isError ? 'error' : 'success'} paddingX={1}>
753 <Text color={info.isError ? 'error' : 'success'}>
754 {info.isError ? '✗ ' : '✓ '}
755 {info.text}
756 </Text>
757 </Box>
758 )
759
760 if (!watch) {
761 return (
762 <Box flexDirection="column" gap={1}>
763 {header}
764 {noticeLine}
765 <Text dimColor>{now.error ?? 'Citesc tot ce știe Connect despre asistent…'}</Text>
766 </Box>
767 )
768 }
769
770 let body: unknown = null
771
772 if (now.tab === 'general') {
773 body = (
774 <Box flexDirection="column" gap={1}>
775 {section(
776 'Promptul (instrucțiunile lui din Connect)',
777 <Box flexDirection="column">
778 <Text>{preview(watch.objective)}</Text>
779 <Text dimColor>{watch.objective.length} caractere</Text>
780 <Box flexDirection="row" gap={1}>
781 <Button key="ed-objective" label="Editează promptul" variant="primary" onPress={() => startTextEdit($, { server: now.server, id: now.id, name: one.name, previous: watch.objective, field: 'objective' }, watch.objective, 'promptul')} />
782 </Box>
783 </Box>,
784 'Se editează în caseta de scris de jos; Enter salvează, nu se trimite lui Claude.',
785 )}
786 {section(
787 'Notițele de lucru',
788 <Box flexDirection="column">
789 <Text>{preview(watch.notes)}</Text>
790 <Text dimColor>{watch.notes.length} caractere (peste 12.000 primește avertisment, peste 20.000 e refuzat)</Text>
791 <Box flexDirection="row" gap={1}>
792 <Button key="ed-notes" label="Editează notițele" dimColor onPress={() => editText($, 'notes')} />
793 </Box>
794 </Box>,
795 'Memoria asistentului: decizii, convenții, ce a rămas deschis.',
796 )}
797 {section(
798 'Pe scurt',
799 <Box flexDirection="column">
800 {row('Creat', watch.createdAt ?? '—')}
801 {row('Mesaje primite', String(watch.events))}
802 {row('Rulări', String(watch.runs))}
803 {row('Sesiuni care ascultă', String(watch.listeners))}
804 {row('Ține minte conversația între rulări', watch.sessionMemory ? 'da' : 'nu')}
805 </Box>,
806 )}
807 </Box>
808 )
809 }
810
811 if (now.tab === 'reguli') {
812 body = (
813 <Box flexDirection="column" gap={1}>
814 {section(
815 'Când răspunde',
816 <Box flexDirection="row" gap={1} flexWrap="wrap">
817 {[
818 { value: 'always', label: 'Mereu' },
819 { value: 'mention', label: 'Doar când e chemat' },
820 { value: 'silent', label: 'Silențios' },
821 ].map(mode => (
822 <Button
823 key={`ed-mode-${mode.value}`}
824 label={mode.label}
825 variant={one.mode === mode.value ? 'primary' : undefined}
826 dimColor={one.mode !== mode.value}
827 onPress={() => (one.mode === mode.value ? undefined : changeWatch($, { mode: mode.value }, `Modul e acum: ${mode.label}.`))}
828 />
829 ))}
830 </Box>,
831 )}
832 {section(
833 'Cuvinte care îl cheamă',
834 <Input
835 key="ed-words"
836 placeholder="ex.: Sym, Asistent (separate prin virgulă)"
837 value={one.triggerWords.join(', ')}
838 submitLabel="salvează"
839 onSubmit={(value: string) => changeWatch($, { trigger_words: splitWords(value) }, 'Cuvintele sunt salvate.')}
840 />,
841 one.callNames.length > 0 ? `Din prompt îl mai cheamă: ${one.callNames.join(', ')}.` : undefined,
842 )}
843 {section(
844 'Cine îl poate porni',
845 <Input
846 key="ed-senders"
847 placeholder="gol = oricine; altfel numere sau JID-uri separate prin virgulă"
848 value={watch.senders.join(', ')}
849 submitLabel="salvează"
850 onSubmit={(value: string) => changeWatch($, { senders: splitWords(value) }, 'Lista celor care îl pot porni e salvată.')}
851 />,
852 watch.senders.length === 0 ? 'Acum: oricine scrie în conversație.' : `Acum: ${watch.senders.length} identități.`,
853 )}
854 {section(
855 'Pe cine ascultă ca pe tine',
856 <Input
857 key="ed-trusted"
858 placeholder="numere sau JID-uri separate prin virgulă"
859 value={watch.trusted.join(', ')}
860 submitLabel="salvează"
861 onSubmit={(value: string) => changeWatch($, { trusted: splitWords(value) }, 'Persoanele de încredere sunt salvate.')}
862 />,
863 'Instrucțiunile lor contează ca ale proprietarului.',
864 )}
865 {section(
866 'Mesajele tale',
867 <Box flexDirection="column">
868 <Text>{one.ownerStart ?? '—'}</Text>
869 <Box flexDirection="row" gap={1}>
870 <Button
871 key="ed-owner"
872 label={one.includeOwnerMessages ? 'Nu-l mai porni la mesajele mele' : 'Pornește-l și la mesajele mele'}
873 dimColor
874 onPress={() =>
875 changeWatch(
876 $,
877 { include_owner_messages: !one.includeOwnerMessages },
878 one.includeOwnerMessages ? 'Mesajele tale nu-l mai pornesc.' : 'Mesajele tale îl pornesc acum.',
879 )
880 }
881 />
882 </Box>
883 </Box>,
884 )}
885 {section(
886 'Cui îi cere decizii',
887 <Input
888 key="ed-notify"
889 placeholder="număr sau JID; gol = proprietarul"
890 value={watch.notify ?? ''}
891 submitLabel="salvează"
892 onSubmit={(value: string) => changeWatch($, { notify: value.trim() }, 'Destinația deciziilor e salvată.')}
893 />,
894 )}
895 </Box>
896 )
897 }
898
899 if (now.tab === 'rulare') {
900 const number = (key: string, label: string, value: number | null, field: string, hint: string) => (
901 <Box flexDirection="column">
902 <Text>
903 {label} <Text dimColor>· acum {value ?? 'implicit'}</Text>
904 </Text>
905 <Input
906 key={key}
907 placeholder={hint}
908 submitLabel="salvează"
909 onSubmit={async (value: string) => {
910 const n = intOf(value)
911 if (n === null) return say($, 'Scrie un număr întreg.', true)
912 return changeWatch($, { [field]: n }, `${label}: ${n}.`)
913 }}
914 />
915 </Box>
916 )
917 body = (
918 <Box flexDirection="column" gap={1}>
919 {section(
920 'Cine lucrează',
921 <Box flexDirection="column">
922 <Box flexDirection="row" gap={1} flexWrap="wrap">
923 {[
924 { value: 'codex', label: 'Codex' },
925 { value: 'claude', label: 'Claude Code' },
926 { value: 'stream', label: 'Sesiunea Claude' },
927 ].map(runner => (
928 <Button
929 key={`ed-runner-${runner.value}`}
930 label={runner.label}
931 variant={one.runner === runner.value ? 'primary' : undefined}
932 dimColor={one.runner !== runner.value}
933 onPress={() =>
934 one.runner === runner.value ? undefined : changeWatch($, { dispatch: runner.value }, `Lucrează acum: ${runner.label}.`)
935 }
936 />
937 ))}
938 </Box>
939 <Text dimColor>Schimbarea o ia de la zero cu conversația și modelul; nu merge în timpul unei rulări.</Text>
940 </Box>,
941 )}
942 {section(
943 'Modelul',
944 <Input
945 key="ed-model"
946 placeholder="gol = modelul implicit al executorului"
947 value={watch.model ?? ''}
948 submitLabel="salvează"
949 onSubmit={(value: string) => changeWatch($, { model: value.trim() }, value.trim() ? `Modelul e acum ${value.trim()}.` : 'Folosește modelul implicit.')}
950 />,
951 )}
952 {section(
953 'Ritm și limite',
954 <Box flexDirection="column" gap={1}>
955 {number('ed-quiet', 'Așteaptă liniște (secunde)', watch.quietSeconds, 'quiet_seconds', '0–120')}
956 {number('ed-wait', 'Așteaptă cel mult (secunde)', watch.maxWaitSeconds, 'max_wait_seconds', 'până la 600')}
957 {number('ed-hour', 'Rulări pe oră, cel mult', one.maxRunsPerHour, 'max_runs_per_hour', '1–120; peste, se oprește singur')}
958 {number('ed-turns', 'Pași într-o rulare, cel mult', null, 'max_turns', '5–200')}
959 </Box>,
960 )}
961 {section(
962 'Acțiuni',
963 <Box flexDirection="row" gap={1} flexWrap="wrap">
964 {one.isActive && one.runner !== 'stream' && (
965 <Button key="ed-run" label="Rulează acum" dimColor onPress={() => changeWatch($, { check_now: true }, 'Am pornit o rulare.')} />
966 )}
967 <Button
968 key="ed-clear"
969 label="O ia de la zero (uită conversația)"
970 dimColor
971 onPress={() => changeWatch($, { clear_session: true }, 'Conversația lui e uitată; promptul și notițele au rămas.')}
972 />
973 </Box>,
974 )}
975 </Box>
976 )
977 }
978
979 if (now.tab === 'pos') {
980 const companies = now.pos ?? []
981 const linked = companies.filter(company => company.identities.length > 0 || company.error)
982 body = (
983 <Box flexDirection="column" gap={1}>
984 <Text dimColor>
985 Asistenții numiți din POS care lucrează în această conversație. Schimbările se scriu direct în POS și se recitesc.
986 </Text>
987 {now.isPosLoading && <Text dimColor>Citesc firmele conectate…</Text>}
988 {!now.isPosLoading && now.pos !== null && linked.length === 0 && (
989 <Text>Niciun asistent din POS nu are această conversație. Asistentul lucrează doar prin Connect.</Text>
990 )}
991 {linked.map(company => (
992 <Box key={`co-${company.server}`} flexDirection="column" gap={1}>
993 <Text bold>
994 {company.company ?? company.server} <Text dimColor>· {company.server}</Text>
995 </Text>
996 {company.error && <Text color="error">Nu pot citi: {company.error}</Text>}
997 {company.isLegacyHeld && (
998 <Text color="warning">Conversația e ținută acum de monitorizarea din Connect; participarea din POS așteaptă.</Text>
999 )}
1000 {company.identities.map(identity => {
1001 const models = company.models.filter(model => model.executor === identity.executor)
1002 const areas = grouped(company.catalog, company.ceiling)
1003 return (
1004 <Box key={`id-${identity.id}`} flexDirection="column" gap={1} borderStyle="round" borderColor={BRAND} paddingX={1}>
1005 <Text bold color={BRAND}>
1006 🤖 {identity.name} <Text dimColor>@{identity.handle}</Text>
1007 </Text>
1008 <Text color={identity.active ? 'success' : 'inactive'}>{identity.active ? '● activ în POS' : '○ oprit în POS'}</Text>
1009 <Box flexDirection="row" gap={1} flexWrap="wrap">
1010 <Button
1011 key={`pi-active-${identity.id}`}
1012 label={identity.active ? 'Oprește identitatea în POS' : 'Pornește identitatea în POS'}
1013 dimColor
1014 onPress={() =>
1015 changeIdentity($, company.server, identity, { active: !identity.active }, identity.active ? `„${identity.name}” e oprit în POS.` : `„${identity.name}” e pornit în POS.`)
1016 }
1017 />
1018 <Button key={`pi-instr-${identity.id}`} label="Editează instrucțiunile" variant="primary" onPress={() => editText($, 'pos-instructions', { server: company.server, identity })} />
1019 <Button key={`pi-pers-${identity.id}`} label="Editează personalitatea" dimColor onPress={() => editText($, 'pos-personality', { server: company.server, identity })} />
1020 </Box>
1021 {row('Rolul', identity.role || '—')}
1022 <Input
1023 key={`pi-role-${identity.id}`}
1024 placeholder="rolul pe scurt (cel mult 240 de caractere)"
1025 value={identity.role}
1026 submitLabel="salvează rolul"
1027 onSubmit={(value: string) => changeIdentity($, company.server, identity, { role: value.trim().slice(0, 240) }, 'Rolul e salvat în POS.')}
1028 />
1029 {row('Instrucțiuni', preview(identity.instructions))}
1030 <Box flexDirection="row" gap={1} flexWrap="wrap">
1031 <Select
1032 key={`pi-exec-${identity.id}`}
1033 label="Executor"
1034 value={identity.executor}
1035 options={[
1036 { value: 'codex', label: 'Codex' },
1037 { value: 'claude', label: 'Claude Code' },
1038 ]}
1039 onSelect={(value: string) => (value === identity.executor ? undefined : changeIdentity($, company.server, identity, { executor: value, model: null }, `Executorul e acum ${value}.`))}
1040 />
1041 {models.length > 0 && (
1042 <Select
1043 key={`pi-model-${identity.id}`}
1044 label="Model"
1045 value={identity.model ?? ''}
1046 options={[{ value: '', label: 'implicit' }, ...models.map(model => ({ value: model.id, label: model.label }))]}
1047 onSelect={(value: string) => changeIdentity($, company.server, identity, { model: value || null }, `Modelul e acum ${value || 'implicit'}.`)}
1048 />
1049 )}
1050 <Select
1051 key={`pi-effort-${identity.id}`}
1052 label="Efort"
1053 value={identity.effort ?? ''}
1054 options={[{ value: '', label: 'implicit' }, ...EFFORTS.map(effort => ({ value: effort, label: effort }))]}
1055 onSelect={(value: string) => changeIdentity($, company.server, identity, { effort: value || null }, `Efortul e acum ${value || 'implicit'}.`)}
1056 />
1057 <Select
1058 key={`pi-stock-${identity.id}`}
1059 label="Stocuri"
1060 value={identity.stockMode ?? ''}
1061 options={[
1062 { value: '', label: 'nesetat' },
1063 { value: 'report', label: 'doar raportează' },
1064 { value: 'operate', label: 'operează' },
1065 ]}
1066 onSelect={(value: string) => changeIdentity($, company.server, identity, { stockMode: value || null }, 'Modul stocurilor e salvat.')}
1067 />
1068 <Select
1069 key={`pi-learn-${identity.id}`}
1070 label="Învățare"
1071 value={identity.learningMode ?? 'review'}
1072 options={[
1073 { value: 'review', label: 'cere aprobarea lecțiilor' },
1074 { value: 'group-notes', label: 'învață singur în grupul sursă' },
1075 ]}
1076 onSelect={(value: string) => changeIdentity($, company.server, identity, { learningMode: value }, 'Modul de învățare e salvat.')}
1077 />
1078 </Box>
1079 <Text bold>Unde are voie</Text>
1080 <Box flexDirection="row" gap={1} flexWrap="wrap">
1081 {company.brands.map(brand => (
1082 <Button
1083 key={`pi-brand-${identity.id}-${brand.id}`}
1084 label={`${identity.brandIds.includes(brand.id) ? '☑' : '☐'} ${brand.name}`}
1085 dimColor={!identity.brandIds.includes(brand.id)}
1086 onPress={() =>
1087 changeIdentity($, company.server, identity, { envelope: { ...(identity.definition.envelope as object), brandIds: toggledNum(identity.brandIds, brand.id) } }, 'Brandurile sunt salvate.')
1088 }
1089 />
1090 ))}
1091 {company.locations.map(location => (
1092 <Button
1093 key={`pi-loc-${identity.id}-${location.id}`}
1094 label={`${identity.locationIds.includes(location.id) ? '☑' : '☐'} ${location.name}`}
1095 dimColor={!identity.locationIds.includes(location.id)}
1096 onPress={() =>
1097 changeIdentity($, company.server, identity, { envelope: { ...(identity.definition.envelope as object), locationIds: toggledNum(identity.locationIds, location.id) } }, 'Unitățile sunt salvate.')
1098 }
1099 />
1100 ))}
1101 </Box>
1102 <Text bold>
1103 Drepturi <Text dimColor>· {identity.capabilities.length} din {company.ceiling.length} permise firmei</Text>
1104 </Text>
1105 {areas.map(([name, items]) => {
1106 const key = `${identity.id}:${name}`
1107 const isOpen = area === key
1108 const on = items.filter(item => identity.capabilities.includes(item.id)).length
1109 return (
1110 <Box key={`area-${key}`} flexDirection="column">
1111 <Button key={`area-btn-${key}`} plain dimColor={on === 0} onPress={() => update($, openArea, value => (value === key ? null : key))}>
1112 <Text>
1113 {isOpen ? '▾' : '▸'} {name} <Text dimColor>· {on}/{items.length}</Text>
1114 </Text>
1115 </Button>
1116 {isOpen &&
1117 items.map(item => {
1118 const has = identity.capabilities.includes(item.id)
1119 return (
1120 <Button
1121 key={`cap-${identity.id}-${item.id}`}
1122 plain
1123 dimColor={!has}
1124 onPress={() =>
1125 changeIdentity(
1126 $,
1127 company.server,
1128 identity,
1129 { envelope: { ...(identity.definition.envelope as object), capabilities: toggled(identity.capabilities, item.id) } },
1130 has ? `Am scos dreptul: ${item.label}.` : `Am dat dreptul: ${item.label}.`,
1131 )
1132 }
1133 >
1134 <Text>
1135 {' '}
1136 {has ? '☑' : '☐'} {item.label} <Text dimColor>{item.effect === 'write' ? '· modifică' : '· citește'}</Text>
1137 </Text>
1138 </Button>
1139 )
1140 })}
1141 </Box>
1142 )
1143 })}
1144 <Box flexDirection="column">
1145 <Text bold>Șterge identitatea de tot</Text>
1146 <Text dimColor>
1147 POS-ul o șterge doar din pagina „Asistenții mei”, unde scrii numele „{identity.name}” ca să confirmi; se pierd toate sarcinile și istoricul ei.
1148 </Text>
1149 {myAssistantsUrl(company.server) ? (
1150 <Link href={myAssistantsUrl(company.server)} label="Deschide „Asistenții mei” în POS" />
1151 ) : (
1152 <Text dimColor>În POS: meniul Personal → Asistenții mei.</Text>
1153 )}
1154 </Box>
1155 <Text bold>Participarea în această conversație</Text>
1156 {identity.tasks
1157 .filter(task => task.isThisChat && task.isParticipation)
1158 .map(task => (
1159 <Box key={`pt-${task.id}`} flexDirection="column">
1160 <Text>
1161 <Text color={task.active ? 'success' : 'inactive'}>{task.active ? '●' : '○'}</Text> {task.name}{' '}
1162 <Text dimColor>· {task.cadence}</Text>
1163 </Text>
1164 <Box flexDirection="row" gap={1} flexWrap="wrap">
1165 <Button key={`pt-instr-${task.id}`} label="Editează instrucțiunile participării" dimColor onPress={() => editText($, 'pos-task-instructions', { server: company.server, identity, task })} />
1166 {['mention', 'always', 'silent'].map(mode => (
1167 <Button
1168 key={`pt-mode-${task.id}-${mode}`}
1169 label={mode === 'mention' ? 'Doar când e chemat' : mode === 'always' ? 'Mereu' : 'Silențios'}
1170 dimColor
1171 onPress={() => changeParticipation($, company.server, identity, task, { whatsapp: { mode } }, 'Modul participării e salvat în POS.')}
1172 />
1173 ))}
1174 </Box>
1175 </Box>
1176 ))}
1177 </Box>
1178 )
1179 })}
1180 </Box>
1181 ))}
1182 <Box flexDirection="row" gap={1}>
1183 <Button key="pos-reload" label={now.isPosLoading ? 'Se citește…' : 'Recitește din POS'} dimColor onPress={() => loadPos($, { server: now.server, id: now.id })} />
1184 </Box>
1185 </Box>
1186 )
1187 }
1188
1189 if (now.tab === 'sarcini') {
1190 const companies = (now.pos ?? []).filter(company => company.identities.length > 0)
1191 const taskRow = (server: string, identity: PosIdentity, task: PosTask) => {
1192 const key = `${identity.id}:${task.id}`
1193 const isAsking = itemAsk?.kind === 'pos-task-delete' && itemAsk.key === key
1194 return (
1195 <Box key={`st-${key}`} flexDirection="column">
1196 <Text>
1197 <Text color={task.active ? 'success' : 'inactive'}>{task.active ? '●' : '○'}</Text> {task.name}{' '}
1198 <Text dimColor>
1199 · {task.cadence || task.kind} · ultima {shortTime(task.lastRunAt)}
1200 {task.isThisChat ? ' · această conversație' : ''}hooks/util.ts 46 lines1import type { McpToolResult } from 'claude-code'
2
3export function textOf(result: McpToolResult): string {
4 return result.content
5 .map(block => (block.type === 'text' && typeof block.text === 'string' ? block.text : ''))
6 .join('')
7}
8
9export function jsonOf(result: McpToolResult): Record<string, any> | null {
10 if (result.structuredContent && typeof result.structuredContent === 'object') {
11 return result.structuredContent as Record<string, any>
12 }
13 try {
14 return JSON.parse(textOf(result))
15 } catch {
16 return null
17 }
18}
19
20export function errorOf(cause: unknown): string {
21 return cause instanceof Error ? cause.message : String(cause)
22}
23
24export const str = (value: unknown): string | null =>
25 typeof value === 'string' && value.length > 0 ? value : null
26
27export const strings = (value: unknown): string[] =>
28 Array.isArray(value) ? value.filter((one): one is string => typeof one === 'string') : []
29
30export const num = (value: unknown): number | null =>
31 typeof value === 'number' && Number.isFinite(value) ? value : null
32
33export function shortTime(value: string | null): string {
34 if (!value) return '—'
35 // "2026-10-20 13:40:39" → "20.10 13:40"
36 const match = /^\d{4}-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/.exec(value)
37 return match ? `${match[2]}.${match[1]} ${match[3]}:${match[4]}` : value
38}
39
40export function splitWords(value: string): string[] {
41 return value
42 .split(',')
43 .map(one => one.trim())
44 .filter(one => one.length > 0)
45}
46types/index.d.ts 240 lines1export type Assistant = {
2 id: string
3 server: string
4 name: string
5 kind: string
6 isActive: boolean
7 mode: string
8 runner: string
9 lastRunState: string | null
10 lastRunAt: string | null
11 lastRunSummary: string | null
12 lastMessageAt: string | null
13 stopReason: string | null
14 tip: string | null
15 runs: number
16}
17
18export type AssistantDetail = Assistant & {
19 objective: string
20 triggerWords: string[]
21 callNames: string[]
22 ownerStart: string | null
23 includeOwnerMessages: boolean
24 maxRunsPerHour: number | null
25 notesLength: number
26 trustedCount: number
27 senders: string[]
28}
29
30// What this computer can run: null when Connect did not say.
31export type Machine = {
32 claudeReady: boolean | null
33 codexReady: boolean | null
34 problems: string[]
35}
36
37export type WhatsAppLine = {
38 server: string
39 number: string | null
40 name: string | null
41 isConnected: boolean
42 organization: string | null
43 licence: string | null
44 assistants: Assistant[]
45 machine: Machine
46 error: string | null
47}
48
49export type PosLine = {
50 server: string
51 employee: string | null
52 version: string | null
53 isOk: boolean
54 error: string | null
55}
56
57export type Snapshot = {
58 refreshedAt: number
59 whatsapp: WhatsAppLine[]
60 pos: PosLine[]
61}
62
63export type Selected = { server: string; id: string }
64
65export type Notice = { text: string; isError: boolean }
66
67// A long text edited in the prompt box: a watch's prompt or notes, or a text of its assistant in the POS.
68export type TextField = 'objective' | 'notes' | 'pos-instructions' | 'pos-personality' | 'pos-task-instructions'
69
70export type Editing = {
71 server: string
72 id: string
73 name: string
74 previous: string
75 field?: TextField
76 pos?: { server: string; assistantId: string; taskId?: string }
77}
78
79export type Undo = { server: string; id: string; objective: string; saved: string }
80
81// A step the person confirms first: deleting, or starting one that will answer what piled up.
82export type Confirm = Selected & { action: 'delete' | 'start' }
83
84// ── The full editor of one assistant ───────────────────────────────
85
86// Everything Connect keeps for a watch, read whole.
87export type WatchFull = {
88 jid: string
89 notify: string | null
90 quietSeconds: number | null
91 maxWaitSeconds: number | null
92 model: string | null
93 senders: string[]
94 trusted: string[]
95 createdAt: string | null
96 events: number
97 runs: number
98 listeners: number
99 sessionMemory: boolean
100 notes: string
101 objective: string
102}
103
104export type RunItem = {
105 at: string | null
106 status: string
107 trigger: string | null
108 messages: number | null
109 steps: number | null
110 seconds: number | null
111 summary: string | null
112}
113
114// A scheduled task of this computer that works on this conversation.
115export type Routine = {
116 id: string
117 name: string
118 active: boolean
119 nextAt: string | null
120 lastSummary: string | null
121 failures: number
122}
123
124export type Reminder = { id: number; kind: string; due: string | null; status: string }
125
126export type PosTask = {
127 id: string
128 revision: number
129 name: string
130 kind: string
131 cadence: string
132 active: boolean
133 lastRunAt: string | null
134 lastError: string | null
135 runStatus: string | null
136 chatJid: string | null
137 isThisChat: boolean
138 // A WhatsApp participation (the assistant answers in the chat), not a task that only delivers there.
139 isParticipation: boolean
140}
141
142// A named assistant of the company whose tasks include this conversation.
143export type PosIdentity = {
144 id: string
145 revision: number
146 name: string
147 handle: string
148 role: string
149 personality: string
150 instructions: string
151 active: boolean
152 model: string | null
153 effort: string | null
154 executor: string
155 stockMode: string | null
156 learningMode: string | null
157 capabilities: string[]
158 brandIds: number[]
159 locationIds: number[]
160 // The whole saved definition, sent back on every change with only the edited field replaced.
161 definition: Record<string, unknown>
162 tasks: PosTask[]
163}
164
165export type CapabilityInfo = { id: string; area: string; label: string; effect: string }
166
167export type PosCompany = {
168 server: string
169 company: string | null
170 error: string | null
171 identities: PosIdentity[]
172 catalog: CapabilityInfo[]
173 ceiling: string[]
174 brands: { id: number; name: string }[]
175 locations: { id: number; name: string }[]
176 models: { executor: string; id: string; label: string }[]
177 // Connect still holds this chat with the old watch: the POS participation waits.
178 isLegacyHeld: boolean
179}
180
181export type EditorTab = 'general' | 'reguli' | 'rulare' | 'pos' | 'sarcini' | 'istoric' | 'pericol'
182
183export type Editor = {
184 server: string
185 id: string
186 tab: EditorTab
187 watch: WatchFull | null
188 runs: RunItem[] | null
189 routines: Routine[] | null
190 reminders: Reminder[] | null
191 pos: PosCompany[] | null
192 isPosLoading: boolean
193 error: string | null
194}
195
196// What the danger zone will do, chosen before the last press.
197export type Danger = {
198 server: string
199 id: string
200 action: 'delete-all' | 'stop-all'
201 withPos: boolean
202 withRoutines: boolean
203}
204
205// A step on a single POS task or Connect routine that is confirmed first.
206export type ItemConfirm = { kind: 'pos-task-delete' | 'routine-delete'; key: string }
207
208// The assistant a command chat in the sidebar belongs to.
209export type Bound = { server: string; id: string; name: string }
210
211export type Role = { id: string; text: string | null; error: string | null }
212
213declare module 'claude-code' {
214 interface PluginState {
215 'symbai-ui': {
216 snapshot: Snapshot | null
217 isLoading: boolean
218 isBandHidden: boolean
219 showInactive: boolean
220 selected: Selected | null
221 detail: AssistantDetail | null
222 isBusy: boolean
223 notice: Notice | null
224 showFullObjective: boolean
225 editing: Editing | null
226 undo: Undo | null
227 role: Role | null
228 isRoleLoading: boolean
229 busyId: string | null
230 confirming: Confirm | null
231 boundChat: Bound | null
232 isChatBandHidden: boolean
233 editor: Editor | null
234 danger: Danger | null
235 openArea: string | null
236 itemConfirm: ItemConfirm | null
237 }
238 }
239}
240