Observe-only: writes the agent's state (working / idle / awaiting_approval / ended), its transitions and its rate-limit readings to a JSON file. Never blocks…


AI csapatod, ami fut amíg te alszol.
Marveen egy AI asszisztens keretrendszer, ami Claude Code-ra épül. Saját AI csapatot építhetsz, akik Telegramon, Slacken vagy Discordon kommunikálnak veled, önállóan dolgoznak, és egymással is együttműködnek.
Marveen is a self-hostable agent harness for Claude Code: it runs a team of AI agents, each with its own chat channel (Telegram, Slack or Discord), persistent memory, scheduled tasks, and MCP tools, lets them delegate work to one another, and gives you a web dashboard to watch and steer them.
Részletes, funkciónkénti leírások a docs/ mappában — mindegyik lap két szemszögből: 🎯 mit tud / miért érdekes + 🛠 hogyan működik.
| Funkció | Lap |
|---|---|
| Heartbeat + fokozatos autonómia | docs/heartbeat-autonomy.md |
| Memória-rendszer (FTS5 + vektor + RRF) | docs/memory-system.md |
| Kanban (auto-breakdown, swimlane, WIP-limit, card-aging) | docs/kanban.md |
| Ügynök-flotta + inter-agent | docs/agent-fleet.md |
| Föderáció (több példány összekötése, dashboard-menüvel) | docs/federation.md |
| Skill-factory (öntanulás) | docs/skill-factory.md |
| Channels (Telegram / Slack / Discord) | docs/channels.md |
| Printing-press CLI-k | docs/printing-press-cli.md |
| Skool CLI | docs/skool-cli.md |
| connectors.hu | docs/connectors-hu.md |
| Vault & titkosítás | docs/vault.md |
| Dream-engine | docs/dream-engine.md |
| Háttér-feladatok | docs/background-tasks.md |
| Ütemezett feladatok | docs/scheduled-tasks.md |
| Költöztetés (másik gépre) | docs/MIGRATION.md |
| Beszélgetés-folytonosság | docs/conversation-continuity.md |
| Channel reply-guard | docs/channel-reply-guard.md |
| Telegram haladásjelző | docs/telegram-progress-indicator.md |
| Slack haladásjelző | docs/slack-progress-indicator.md |
| Új asszisztens onboarding | docs/onboarding-uj-asszisztens.md |
Az ágensek automatikusan tanulnak a munkájukból: komplex feladat vagy hiba-recovery után újrahasznosítható skill-t (recept) írnak maguknak, a meglévőket pedig célzottan patch-elik. A skill-ek token-hatékonyan, 3 szinten töltődnek (progressive disclosure). A flotta-szintű skill-ek és ütemezett feladatok a seed-skills/ és seed-scheduled-tasks/ mappából terjednek minden telepítésre (idempotens: a meglévő testreszabást nem írja felül).
→ Részletek: docs/skill-factory.md
Minden ágens saját, réteges memóriával rendelkezik (hot / warm / cold / shared), SQLite-ban tárolva. A keresés hibrid: FTS5 full-text + szemantikus vektor (Ollama nomic-embed-text), RRF-fel fúzionálva. A memóriák salience decay-en mennek át (a régi, nem használt tételek halványulnak, de sosem törlődnek), és minden este napi napló készül. A PreCompact hook a kontextus-tömörítés előtt automatikusan elmenti a fontos döntéseket. A dashboardon gráf-nézet is van.
→ Részletek: docs/memory-system.md
cd ~
git clone --branch main https://github.com/Szotasz/marveen.git
cd marveen
./install.sh
Alapértelmezés szerint a dashboard a 3420-as porton indul (http://localhost:3420). Egyedi port beállításához:
./install-linux.sh --port 3421 # vagy: WEB_PORT=3421 ./install-linux.sh
irm https://raw.githubusercontent.com/Szotasz/marveen/main/install-windows.ps1 | iex
Vagy manuálisan:
git clone --branch main https://github.com/Szotasz/marveen.git
cd marveen
.\install-windows.ps1
A Windows telepítő automatikusan beállítja a WSL-t (Windows Subsystem for Linux) és azon belül telepíti a Marveen-t.
Ha a PowerShell ablak bezárul / a telepítő nem jut túl a WSL+Ubuntu lépésen: nyisd meg az Ubuntu-t (Start menü → Ubuntu), majd a WSL Ubuntu shellben futtasd közvetlenül a Linux-telepítőt (a PowerShell wrapper megkerülése):
cd ~ && curl -fsSL https://raw.githubusercontent.com/Szotasz/marveen/main/install-linux.sh -o install.sh && bash install.shEz a megbízható út, ha a
wsl.exe/Windows-claude környezet összeakad.
A telepítő végigvezet a beállításokon:
A platform szabadon márkázható telepítéskor. Két, egymástól független beállítás:
| Beállítás | Mi ez | Default |
|---|---|---|
BOT_NAME | A fő ágens megjelenített neve (pl. MyAssistant) | Marveen |
BRAND_NAME | A termék / rendszer neve a dashboard fejlécében (böngésző-cím, oldalsáv, mobil topbar) | BOT_NAME |
A telepítő mindkettőt megkérdezi. Ha csak Entert nyomsz, minden marad Marveen (a viselkedés változatlan a meglévő telepítésekhez képest). Ha külön márkanevet adsz meg, a teljes felület és az OS szolgáltatás-azonosítók is azzal jönnek létre:
# Példa: az ágens neve "MyAssistant", a terméké "AcmeAI"
# Mi legyen a botod neve? [Marveen]: MyAssistant
# Mi a termék/márka neve? [MyAssistant]: AcmeAI
A .env-ben ezek a kulcsok jelennek meg (lásd .env.example):
BOT_NAME=MyAssistant
BRAND_NAME=AcmeAI
MAIN_AGENT_ID=myassistant # belső ágens-azonosító (a BOT_NAME ASCII slug-ja)
SERVICE_ID=acmeai # OS szolgáltatás-azonosító (a BRAND_NAME ASCII slug-ja)
A MAIN_AGENT_ID és SERVICE_ID értékeket a telepítő automatikusan származtatja; ritkán kell kézzel szerkeszteni. Ha a BRAND_NAME megegyezik a BOT_NAME-mel (a default), a SERVICE_ID megegyezik a MAIN_AGENT_ID-vel, így a launchd/systemd unit-nevek byte-azonosak a márkázatlan telepítéssel: a helyben történő frissítés nem törik el.
Nyisd meg: http://localhost:3420
A telepítés során választhatsz csatorna providert (Linuxon és macOS-en is). Az alapértelmezett a Telegram.
Írj a botodnak Telegramon -- Marveen válaszol.
Slack használatához a telepítő automatikusan végigvezet, de manuálisan is beállíthatod:
xapp-...) a connections:write scope-palchat:write, channels:read, files:write, files:readxoxb-...)/invite @BotNev).env fájlban állítsd be: `` CHANNEL_PROVIDER=slack SLACK_BOT_TOKEN=xoxb-... SLACK_APP_TOKEN=xapp-... SLACK_CHANNEL_ID=C01234ABCDE ``slack@jeremylongshore/claude-code-slack-channelA telepítő végigvezet (Linuxon és macOS-en is a 3. opció), de manuálisan is beállíthatod:
.env fájlban állítsd be: `` CHANNEL_PROVIDER=discord DISCORD_BOT_TOKEN=... DISCORD_CHANNEL_ID=... OPERATOR_DISCORD_USER_ID=... ``discord@claude-plugins-officialRészletek (managed-settings allowlist, párosítás): docs/channels.md.
A csatorna váltáshoz futtasd újra a ./install.sh-t vagy szerkeszd a .env fájlt manuálisan.
A Csapat oldalon hozz létre új ágenseket. Mindegyik:
A telepítő automatikusan generál egy pixel-art avatart és Telegramon elküldi neked a beállítási utasításokkal. Ha egyedi képet szeretnél:
agents/<AGENT_NEVE>/avatar.png alá (png/jpg/jpeg/webp)./scripts/stop.sh && ./scripts/start.sh)Beállítás a Telegram botodra:
/setuserpic parancsotA dashboardon (Csapat oldal) is cserélhetsz avatart: kattints a bot kártyájára, válassz a galériából vagy tölts fel sajátot -- a rendszer automatikusan elküldi a Telegram chatbe.
Időzített feladatok és heartbeat monitorok beállítása:
Az MCP szerverek API kulcsait, tokenjeit és jelszavait egy titkosított Vault kezeli (AES-256-GCM), a master key macOS-en a Keychain-ben (Linuxon fájl-alapú fallback). A .mcp.json-ben csak vault:SECRET_ID referenciák állnak — a plaintext kulcsok nem hevernek olvashatóan. A dashboard Vault-oldalán kezelheted a titkokat, a Scan & Import megtalálja a meglévő plaintext kulcsokat.
→ Részletek: docs/vault.md
A monitor_agents.sh script összefogja az összes futó ágens tmux session-jét egyetlen monitor session-be, iTerm2 Control Mode-dal (-CC) minden ágens külön iTerm tab-ként jelenik meg.
# Lokálisan (a gépen ahol az ágensek futnak):
./scripts/monitor_agents.sh
# Távolról (laptopról SSH-n, iTerm2-vel):
ssh macmini -t "~/marveen/scripts/monitor_agents.sh"
# Ha új ágens indult és nem látod a monitorban -- kill + újraindítás:
ssh macmini "/opt/homebrew/bin/tmux kill-session -t monitor" && \
ssh macmini -t "~/marveen/scripts/monitor_agents.sh"
A script automatikusan felderíti a futó agent-* és marveen-channels session-öket. A monitor session törlése nem érinti az ágens session-öket -- csak a linked-window referenciákat szünteti meg.
A helper that lets an operator enroll a single device's SSH public key with a tightly restricted authorized_keys entry, then hands back a copyable connection bundle. Each device carries its own revocation id (marveen-remote:<uuid>) so access can be replaced or removed per device.
Run it with the public key line as a single quoted argument:
npm run remote-enroll -- "ssh-ed25519 <base64 key> marveen-remote:<uuid>"
# optional flags:
npm run remote-enroll -- --host 203.0.113.10 --port 2222 "ssh-ed25519 <base64 key> marveen-remote:<uuid>"
The public key line must be exactly three fields (type, key, comment) with no authorized_keys options and no extra fields. Only ssh-ed25519 keys are accepted, and the comment must be marveen-remote:<uuid> (uuid v4).
It appends (or replaces, when the same id is re-enrolled) this restricted line to the invoking user's ~/.ssh/authorized_keys:
restrict,port-forwarding,permitopen="127.0.0.1:3420",command="/bin/false" ssh-ed25519 <base64 key> marveen-remote:<uuid>
restrict disables pty, agent, and X11 forwarding; the forced command is /bin/false; and the only endpoint the key may open is 127.0.0.1:3420. The write is atomic (temp file plus rename) and guarded by an authorized_keys.lock file so concurrent runs cannot corrupt the list. ~/.ssh is created 0700 and authorized_keys 0600 when missing; if either already exists with looser permissions the tool warns instead of changing them silently.
After enrolling, it prints a base64 connection bundle between clearly marked delimiters. The bundle carries the host, SSH port and user, the fixed remote port (3420), the device id, the machine's ssh-ed25519 host key, and -- by default -- the dashboard bearer token (DASHBOARD_TOKEN env or store/.dashboard-token), so the connecting app can authenticate against the dashboard without a separate step. A token-bearing bundle is a SECRET: hand it over on a private channel only, never by email or shared chat. Pass --no-dashboard-token to emit a token-free bundle (the device user must then obtain the dashboard access URL out of band). If no token can be found the tool warns and emits a token-free bundle. The host key is looked up in the known public-key locations (/etc/ssh, /private/etc/ssh, Homebrew and /usr/local prefixes) and, when none of those files exist -- as on stock macOS -- read from the running SSH server itself via ssh-keyscan on loopback. The connecting side requires the host key, so if it cannot be obtained from any source the tool exits with an error instead of printing an unusable bundle; start the SSH server (macOS: System Settings > General > Sharing > Remote Login) and re-run. When --host is not given, the tool prints a hint to verify the resolved address is the one the device will reach.
To revoke a device, delete the line whose comment matches its id (marveen-remote:<uuid>) from ~/.ssh/authorized_keys.
./update.sh
./scripts/stop.sh
./scripts/start.sh
Linux VPS-en (Ubuntu 22+, Debian 12+) az ./install.sh automatikusan az install-linux.sh-t futtatja. Headless szerveren a bejelentkezéshez OAuth token kell, mert nincs böngésző.
# 1. A SAJÁT gépeden (ahol van böngésző):
claude setup-token
# Másold ki a generált tokent (sk-ant-oat01-...)
# 2. A VPS-en:
export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
cd ~
git clone --branch main https://github.com/Szotasz/marveen.git
cd marveen
./install.sh # automatikusan install-linux.sh-t futtat
A token 1 évig érvényes. Ne állíts be ANTHROPIC_API_KEY-t mellé.
Fontos VPS-specifikus tudnivalók:
./install-linux.sh (Linux) vagy ./install-macos.sh (macOS) ha az OS-detekciót ki akarod hagyni.Kérdésed van? Csatlakozz az AI a mindennapokban közösséghez:
Ha hasznos számodra a Marveen, támogasd a fejlesztést:
A Marveen több külső projektre és koncepcióra épít. A teljes felsorolás (forrás, szerző, licensz, hogyan használjuk) az ATTRIBUTIONS.md fájlban található. Köszönet a Perplexity AI-nek (Bumblebee), Artem Zhutovnak (handoff / retrospective / skill-management skill suite), Mike Van Hornnak (printing-press), Andrej Karpathynak (CLAUDE.md pattern), és Matt Pococknak (handoff design tippek) a munkájukért.
Szota Szabolcs -- AI konzultáns, az "AI a mindennapokban" csatorna készítője
hooks/register.ts 227 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// agent-state-observer -- an OBSERVE-ONLY Claude Code mod.
4//
5// What it does: on session, turn and tool events it writes this agent's state
6// (starting / idle / working / awaiting_approval / ended), its last 200
7// transitions and its rate-limit readings ($.session.usage()) to one JSON file.
8// What it never does: every hook passes the event on with next(e) and returns
9// next's result unchanged -- it never denies, rewrites or delays a call, and
10// draws nothing. Writes are fire-and-forget; a failed write is swallowed, so
11// the mod can never stall the agent.
12//
13// Identity and output come from the launcher, not from a path convention:
14// MARVEEN_AGENT_ID the agent's name (letters, digits, "_" and "-")
15// MARVEEN_STATE_OBSERVER_DIR the folder it writes <agent>.json into
16// Without both (or with a name that does not fit) it writes nothing at all.
17// Kill switches, no restart needed: <dir>/DISABLE stops every agent,
18// <dir>/DISABLE-<agent> stops that one; the hooks then only pass through.
19
20const AGENT_NAME = /^[a-z0-9_-]+$/i
21const HISTORY_MAX = 200
22
23/** The agent name the launcher gave, or null when it is missing or malformed. */
24export function validAgent(value: string | undefined): string | null {
25 return value && AGENT_NAME.test(value) ? value : null
26}
27const TICK_MS = 60_000
28
29type AgentState = 'starting' | 'idle' | 'working' | 'awaiting_approval' | 'ended'
30
31type RateLimitReading = { kind: string; percentUsed: number; resetsAt?: string }
32
33type Usage = {
34 read_at: number
35 rateLimits: RateLimitReading[]
36 rateLimits_empty: boolean
37 context_percent: number | null
38 cost_usd: number | null
39}
40
41type Snapshot = {
42 v: 1
43 agent: string
44 state: AgentState
45 since: number
46 updated_at: number
47 alive_at: number
48 session_id: string | null
49 turn_id: string | null
50 tool: string | null
51 reason: string | null
52 seq: number
53 history: { ts: number; from: AgentState; to: AgentState; tool?: string; reason?: string }[]
54 usage: Usage | null
55 usage_history: Usage[]
56 first_usage_probe: { at: number; rateLimits_empty: boolean } | null
57}
58
59const now = () => Date.now()
60function freshSnapshot(): Snapshot {
61 return {
62 v: 1,
63 agent: '',
64 state: 'starting',
65 since: now(),
66 updated_at: now(),
67 alive_at: now(),
68 session_id: null,
69 turn_id: null,
70 tool: null,
71 reason: null,
72 seq: 0,
73 history: [],
74 usage: null,
75 usage_history: [],
76 first_usage_probe: null,
77 }
78}
79
80// Module state starts over on every load; register() resets it explicitly too.
81let snap: Snapshot = freshSnapshot()
82
83// Writes are chained so two events never interleave their file writes.
84let chain: Promise<void> = Promise.resolve()
85let disabled = false
86// Set by session.start from the launcher's environment; until then, and when
87// either value is missing, nothing is written.
88let agent: string | null = null
89let stateDir: string | null = null
90
91function flush($: EngineInterface): void {
92 const name = agent
93 const dir = stateDir
94 if (!name || !dir) return
95 const text = JSON.stringify(snap)
96 chain = chain
97 .then(async () => {
98 disabled = (await $.fs.exists(`${dir}/DISABLE`)) || (await $.fs.exists(`${dir}/DISABLE-${name}`))
99 if (!disabled) await $.fs.write(`${dir}/${name}.json`, text)
100 })
101 .catch(() => {})
102}
103
104function move($: EngineInterface, to: AgentState, extra: { tool?: string; reason?: string; turnId?: string } = {}): void {
105 if (disabled) return
106 const t = now()
107 if (extra.turnId !== undefined) snap.turn_id = extra.turnId
108 snap.tool = extra.tool ?? null
109 snap.reason = extra.reason ?? null
110 snap.updated_at = t
111 snap.alive_at = t
112 if (to === snap.state) {
113 flush($)
114 return
115 }
116 snap.history.push({ ts: t, from: snap.state, to, ...(extra.tool ? { tool: extra.tool } : {}), ...(extra.reason ? { reason: extra.reason } : {}) })
117 if (snap.history.length > HISTORY_MAX) snap.history.splice(0, snap.history.length - HISTORY_MAX)
118 snap.state = to
119 snap.since = t
120 snap.seq += 1
121 flush($)
122}
123
124async function readUsage($: EngineInterface, probe: boolean): Promise<void> {
125 if (disabled) return
126 try {
127 const u = await $.session.usage()
128 const reading: Usage = {
129 read_at: now(),
130 rateLimits: (u.rateLimits ?? []).map((r: RateLimitReading) => ({
131 kind: r.kind,
132 percentUsed: r.percentUsed,
133 ...(r.resetsAt ? { resetsAt: r.resetsAt } : {}),
134 })),
135 rateLimits_empty: !(u.rateLimits && u.rateLimits.length > 0),
136 context_percent: u.context?.percent ?? null,
137 cost_usd: u.cost?.usd ?? null,
138 }
139 const prev = snap.usage
140 const changed =
141 !prev ||
142 JSON.stringify(prev.rateLimits) !== JSON.stringify(reading.rateLimits) ||
143 prev.context_percent !== reading.context_percent
144 snap.usage = reading
145 if (changed) {
146 snap.usage_history.push(reading)
147 if (snap.usage_history.length > HISTORY_MAX) snap.usage_history.splice(0, snap.usage_history.length - HISTORY_MAX)
148 }
149 if (probe && !snap.first_usage_probe) {
150 snap.first_usage_probe = { at: reading.read_at, rateLimits_empty: reading.rateLimits_empty }
151 }
152 flush($)
153 } catch {
154 // A usage read failing must not touch the agent; the next tick retries.
155 }
156}
157
158export const register: Register = on => {
159 snap = freshSnapshot()
160 chain = Promise.resolve()
161 disabled = false
162 agent = null
163 stateDir = null
164
165 on('session.start', async ($, e, next) => {
166 const r = await next(e)
167 try {
168 agent = validAgent(await $.env.get('MARVEEN_AGENT_ID'))
169 stateDir = (await $.env.get('MARVEEN_STATE_OBSERVER_DIR'))?.replace(/\/+$/, '') || null
170 } catch {
171 agent = null
172 stateDir = null
173 }
174 snap.agent = agent ?? ''
175 try {
176 snap.session_id = await $.session.id()
177 } catch {}
178 move($, 'idle', { reason: 'session.start' })
179 $.clock.every(TICK_MS, () => {
180 if (disabled) return
181 snap.alive_at = now()
182 flush($)
183 void readUsage($, false)
184 })
185 return r
186 })
187
188 on('turn.start', async ($, e, next) => {
189 move($, 'working', { turnId: e.turnId, reason: 'turn.start' })
190 return next(e)
191 })
192
193 on('tool.check', async ($, e, next) => {
194 const r = await next(e)
195 // Only a real call carries tool_use_id; a $.tool.check query does not.
196 if (e.tool_use_id && r.decision === 'ask') {
197 move($, 'awaiting_approval', { tool: e.tool, reason: r.hook ?? r.rule ?? 'ask' })
198 }
199 return r
200 })
201
202 on('tool.call', async ($, e, next) => {
203 move($, 'working', { tool: String(e.tool), reason: 'tool.call' })
204 return next(e)
205 })
206
207 on('turn.complete', async ($, e, next) => {
208 const r = await next(e)
209 // A subagent's turn ending (agentId set) does not end the main loop's turn.
210 if (e.agentId) return r
211 move($, 'idle', { reason: `turn.complete:${e.reason}` })
212 void readUsage($, true)
213 return r
214 })
215
216 on('session.measure', async ($, e, next) => {
217 const r = await next(e)
218 void readUsage($, false)
219 return r
220 })
221
222 on('session.end', async ($, e, next) => {
223 move($, 'ended', { reason: `session.end:${String(e.reason)}` })
224 return next(e)
225 })
226}
227