SLOPSHOPPER

symbai-ui

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…

newpanebandguardcommandstatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · symbai-ui
│ ┃ Symbai · Asistenți ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ Symbai · Asistenți │ ┃ ⏺ Read(src/auth.ts) │ ┃ Nu găsesc nicio conexiune Symbai în această ⎿ Read 6 lines │ ┃ sesiune. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Conectează firma din portalul Symbai, apoi ⏺ Bash(bun test) │ ┃ apasă Reîmprospătează. ⎿ 3 pass, 1 fail │ ┃ │ ┃ [ Reîmprospătează ] ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /symbai │ ⎿ symbai-ui: Symbai: 0 asistenți activi │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Symbai · Asistenți
◆ Symbai · Asistenți Nu găsesc nicio conexiune Symbai în această sesiune. Conectează firma din portalul Symbai, apoi apasă Reîmprospătează. [ Reîmprospătează ]
README

Symbai pentru Claude Code

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:

  • skills/ — workflow-uri pas-cu-pas (configurarea completă a unui client nou — onboarding, importul datelor din Excel/CSV — pagina de import condusă prin extensia Chrome + verificare prin conexiune, configurare Portal Clienți, configurare Aplicație Staff/Symbai Staff, găsește o pagină, investighează o masă, adaugă un produs/rețetă, design pentru meniul fizic tipărit — ca un grafician, prin conexiune + vision, programează o postare, rapoarte & prețuri, trimite ticket de suport/sugestie către echipa Symbai).
  • knowledge/ — biblioteca completă de cunoștințe client-facing: un ghid pentru FIECARE modul Symbai (POS & sală, meniu & rețete, stocuri & furnizori, producție & trasabilitate, rezervări & evenimente, personal, rapoarte & P&L, finanțe & e-Factura, livrări & magazin online, marketing & website, echipamente & KDS, setări, AI în aplicație), plus referința pentru aplicațiile native Expo (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.

Instalare (clientul rulează o singură dată)

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": true este obligatoriu — fără el rămâi blocat pe versiunea de la instalare și nu mai primești ghidurile noi. Comanda /plugin marketplace add nu 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").

Actualizare

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.

Cum se livrează conținut nou

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ă version nu se schimbă, clienții rămân pe copia veche din cache chiar dacă au tras commit-uri noi.

Mod-uri Claude Code: pornire și retragere din marketplace.json

Câ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.

categoryCe 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":

  • Se atinge numai un 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.
  • Intrarea rămâne în manifest cu dosarul și versiunea ei: build-package.ps1 cere plugin.json pentru fiecare intrare. symbai-core nu se poate retrage.
  • Marcajul pus înapoi pe "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.
  • Connect 1.2.113 nu cunoaște marcajul: nu mai pornește un mod retras (în afară de 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.

Structură

.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

Compatibilitate

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.

Source 5 files
hooks/register.tsx 1338 lines
1import { 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 lines
1import { 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}
286
hooks/editor.tsx 1432 lines
1import { 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 lines
1import 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}
46
types/index.d.ts 240 lines
1export 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