지니(페르소나) 메시지 → 세션 → 도구 → 응답 흐름
TL;DR
- 페르소나(jini·wansu·raphael·nano·jarvis·asurada)는 같은 Pantheon Python 프로세스 안에서 띄운 6개의 독립 Slack 봇이다. 발화 주체는 봇 토큰 분리로 강제된다.
- 사용자 메시지가 도착하면 Bolt 이벤트 핸들러 → 라우팅 게이트 → 세션·컨텍스트 조립 → LLM 서브프로세스 spawn → 응답 5단계로 흐른다. LLM 프로세스는 매 메시지마다 새로 뜨고, 끝난다.
- 세션은 LLM 안이 아니라 Pantheon의 파일 시스템(
sessions.json+persona_sessions/+persona_memory/)에 산다. 페르소나가 "기억한다"는 건 다음 호출 때 과거 컨텍스트를 다시 주입한다는 의미. - 도구는 spawn된 Claude CLI 서브프로세스의 환경이 결정한다. 따라서 사용자 본인 Claude Code 세션과 페르소나 세션은 도구 셋이 별개다 — 한쪽에 OAuth가 끝났다고 다른 쪽이 자동으로 받지 않는다.
- 따라서 도구 관리는 "본 세션에서 한 번 켜면 페르소나도 자동" 모델이 아님. 페르소나 측에서 명시적으로 토큰을 환경에 주입해야 한다. Notion만 깨진 게 아니라 OAuth 기반 MCP 전반의 일반 패턴이다.
본 보고서는 사고 해명이 아니라 도구 관리 전략 의사결정용 인프라 보고서다. 콜로세움 Notion 케이스는 예시로만 인용한다.
표기 규약
- 확정: 소스 파일·라인 인용으로 검증됨 (
file:line형식) - 추정: 정황 기반 가설 (소스로 직접 확인 못한 부분)
- 모름: 명시적으로 미확인 — 추가 검증 항목
전체 흐름 한 장
flowchart TB
U["사용자 (Slack)"] -->|"@jini ..."| SL[Slack Cloud]
subgraph Pantheon["Pantheon 단일 Python 프로세스 (bridge.py)"]
direction TB
SH1["Bolt App: jini bot<br/>JINI_BOT_TOKEN"]
SH2["Bolt App: wansu bot<br/>WANSU_BOT_TOKEN"]
SH3["Bolt App: raphael / nano / jarvis / asurada"]
ROUTER["라우팅 게이트<br/>force prefix → auto_channels<br/>→ northstar 자체 판단"]
DEDUP["중복 가드<br/>_dup_guard + _in_flight"]
SESS["세션 매니저<br/>sessions.json + persona_sessions<br/>+ persona_memory"]
PROMPT["프롬프트 조립<br/>persona.md + memory_context<br/>+ thread_context + ctx prefix"]
DISPATCH["ask_llm dispatcher<br/>(provider 선택)"]
end
subgraph PROV["LLM 호출 (subprocess.Popen)"]
direction TB
PROV_C["providers/claude.py<br/>claude -p ... --resume"]
PROV_X["providers/codex.py<br/>codex CLI"]
PROV_G["providers/gemini.py<br/>HTTPS API"]
end
subgraph LLM_PROC["LLM 프로세스 (매 메시지마다 새로 spawn, 응답 후 종료)"]
direction TB
LLM_RT["Claude CLI / Codex / Gemini 런타임"]
MCP_SET["MCP 도구 셋<br/>이 프로세스의 환경에서 보이는 것만"]
LLM_RT <--> MCP_SET
end
subgraph USER_CC["사용자 Claude Code 본 세션 (별개 프로세스, 별개 환경)"]
direction TB
USER_LLM["claude CLI (대화형)"]
USER_MCP["MCP 도구 셋<br/>사용자 OAuth/토큰"]
USER_LLM <--> USER_MCP
end
SL --> SH1 & SH2 & SH3
SH1 & SH2 & SH3 --> ROUTER --> DEDUP --> SESS --> PROMPT --> DISPATCH
DISPATCH --> PROV_C & PROV_X & PROV_G
PROV_C & PROV_X --> LLM_PROC
PROV_G -.->|"HTTPS"| LLM_PROC
LLM_PROC -->|"응답 텍스트 + session_id"| DISPATCH
DISPATCH -->|"say(..) with persona bot_token"| SL
SL -->|"jini 명의로 게시"| U
style USER_CC stroke:#f85149,stroke-dasharray: 5 5
style LLM_PROC stroke:#3fb950
style Pantheon stroke:#58a6ff
style PROV stroke:#d29922
핵심 관전 포인트는 오른쪽 빨간 점선 박스다. 같은 머신·같은 사용자 계정 위에서 돌아도, 사용자 Claude Code 세션과 페르소나 LLM 세션은 프로세스·환경·MCP config가 별개다. 도구 노출 차이는 거의 전부 이 분리에서 나온다.
1. 메시지 수신: 페르소나마다 독립 Slack 봇
확정 (bridge.py:23, 654-678, 3409-3420)
Pantheon은 하나의 Python 프로세스에서 페르소나마다 별도 slack_bolt.App 인스턴스를 만든다. 각 App은 자기 봇 토큰·앱 토큰·서명 시크릿을 환경변수에서 읽는다.
| 페르소나 | 봇 토큰 env | 앱 토큰 env |
|---|---|---|
| jini | JINI_BOT_TOKEN |
JINI_APP_TOKEN |
| wansu | WANSU_BOT_TOKEN |
WANSU_APP_TOKEN |
| raphael | RAPHAEL_BOT_TOKEN |
RAPHAEL_APP_TOKEN |
| nano | NANO_BOT_TOKEN |
NANO_APP_TOKEN |
| jarvis · asurada | (각 페르소나 env) | (각 페르소나 env) |
각 App은 Slack Socket Mode로 자기 이벤트 스트림을 구독한다. 이벤트 타입은 두 개:
app_mention: 메시지 본문에 해당 페르소나 봇이 명시적으로 멘션됨 (bridge.py:3422)message: 채널/스레드/DM의 일반 본문 (bridge.py:3452)
같은 Slack 이벤트가 여러 봇의 구독 조건에 동시에 잡힐 수 있다. 예: @jini @wansu 동시 멘션. 그래서 두 가지 가드를 둔다.
_dup_guard(파일 영속, 30분 TTL):(persona, event_ts)한 번 처리하면 두 번째는 스킵 (bridge.py:92-111, 2083-2088)._in_flight(in-memory dict): 같은 스레드에서 새 메시지가 오면 진행 중인 LLM 프로세스를 kill하고 새 요청으로 교체 (bridge.py:117, 1965-1990). "이전 요청을 취소하고 새 요청을 처리합니다." 메시지가 여기서 발사된다.
핵심 함의: 페르소나는 각자 자기 Slack 앱이다. 봇 토큰 분리가 발화 주체 보존의 근본 메커니즘이다. 같은 사용자 토큰으로 post하면 발화 주체가 사용자로 보임 → 페르소나 정체성이 흔들린다 ([feedback_no_persona_via_user_token]).
2. 라우팅: 이 페르소나가 응답해야 하는가
확정 (bridge.py:382-433, 3422-3450) + 추정 (router.py 사용 경로)
라우팅은 3중 게이트로 결정된다.
flowchart TD
EV["이벤트 도착<br/>(이 페르소나 봇의 핸들러)"]
EV --> Q1{"직접 멘션<br/>(app_mention)?"}
Q1 -->|"yes"| R1["응답 *필수*<br/>(_response_required=true)"]
Q1 -->|"no"| Q2{"force prefix<br/>!jini / !wansu / ...?"}
Q2 -->|"yes"| R2["해당 페르소나가 응답"]
Q2 -->|"no"| Q3{"자동응답 채널?<br/>(JINI_AUTO_CHANNELS 등)"}
Q3 -->|"no"| SKIP["무시"]
Q3 -->|"yes"| GATE["Northstar gate<br/>LLM이 응답 가치를 자체 판단"]
GATE -->|"NO_RESPONSE"| SILENT["조용히 :speech_balloon: 리액션만<br/>(silent_decision)"]
GATE -->|"본문 응답"| R3["응답 *선택*"]
R1 & R2 & R3 --> RUN["handle_user_message"]
여기서 흥미로운 게 2개 있어요.
첫째, Northstar gate는 LLM이 자기 자신에게 묻는 구조다. 시스템 프롬프트에 "응답 가치가 없으면 NO_RESPONSE만 출력하라" 룰을 박아 두고, LLM이 그렇게 답하면 본문 게시를 막고 :speech_balloon: 리액션만 단다 (bridge.py:2344-2354, 2384-2390). 즉 침묵도 의도된 응답이다 — 무응답이 아니라 "이번엔 안 끼겠습니다" 결정이 기록된다.
둘째, 별도의 Haiku 라우터가 있다 (router.py). 이건 어떤 페르소나가 응답할지 결정하는 fan-out용으로, dispatcher 경로에서 호출된다 ([project_dispatcher_agent]). 위 3중 게이트는 페르소나가 자기 결정하는 1차 게이트고, Haiku 라우터는 시스템이 페르소나를 골라주는 2차 게이트다. 2026-06 현재 두 경로가 공존하고 있고, 어느 쪽이 final이 될지는 dispatcher 2세대 작업 결과에 달려 있다.
3. 세션·메모리: "기억한다"가 무슨 뜻인가
확정 (bridge.py:689-696, 1992-2061, persona_sessions.py, persona_memory.py)
세션 영속화는 3겹이다.
flowchart LR
M["메시지"] --> CHECK{"thread_ts<br/>존재?"}
CHECK -->|"yes (이어가기)"| S1
CHECK -->|"no (새 스레드)"| AUTO{"채널 자동응답<br/>대상?"}
AUTO -->|"yes"| S2["채널 단위 세션<br/>channel_session:{persona}:{channel}"]
AUTO -->|"no"| NEW["session_id = None<br/>(새로 시작)"]
S1["스레드 세션<br/>{persona}:{thread_ts}"]
S1 & S2 --> SID["sessions.json에서 claude session_id 조회"]
SID --> RESUME["claude -p ... --resume <session_id>"]
M --> MEM["persona_memory<br/>JSONL 검색<br/>(visibility/channel/thread/keyword)"]
MEM --> INJECT["memory_block 프롬프트에 주입"]
M --> XSESS["persona_sessions<br/>채널별 last_topic/last_step<br/>(7d TTL)"]
XSESS --> HINT["session_context 프롬프트에 주입"]
3겹의 역할 분리:
- Layer A —
sessions.json: 스레드별 Claude CLIsession_id매핑.claude -p --resume <id>로 LLM 본인의 대화 컨텍스트를 재개. 즉 LLM 측 "기억". (bridge.py:689-696, 1996-2002) - Layer B —
persona_sessions/: 페르소나×채널의 상태 스냅샷(last_topic,last_step등). 7일 TTL. 채널을 옮겨도 같은 페르소나가 컨텍스트를 이어받게 하는 다리. (persona_sessions.py) - Layer C —
persona_memory/: 페르소나별 발화 episodic JSONL. 매 회차 발화가 누적되고, 새 메시지가 오면 키워드·스레드·채널·최신성 기반으로 점수 매겨 상위 N개를 프롬프트에 주입. (persona_memory.py)
이걸 다 모은 게 프롬프트 조립 단계(bridge.py:2043-2070). 최종 프롬프트는 대략 이 모양:
[Jini recent episodic memory]
- ... ← persona_memory 검색 결과 (Layer C)
[이전 세션 상태: last_topic=..., last_step=...] ← persona_sessions (Layer B)
[ctx: channel=Cxxx, thread=1780...] ← 채널·스레드 식별자
{thread_context}{user_text} ← 본문
이 위에 시스템 프롬프트(personas/jini.md 내용)가 --append-system-prompt로 붙는다 (providers/claude.py:59-60).
핵심 함의: "지니가 기억한다"는 세 군데의 파일을 다음 호출 때 다시 읽어서 주입한다는 뜻이다. LLM 프로세스는 살아남지 않는다 — 그래서 도구 OAuth 핸드셰이크 같은 프로세스-수명 상태는 매 호출마다 처음부터다.
4. LLM 호출: 매번 새 서브프로세스
확정 (bridge.py:1694-1774, providers/claude.py:38-187)
sequenceDiagram
participant B as bridge.py:handle_user_message
participant A as ask_llm dispatcher
participant CP as providers/claude.py
participant CLI as claude CLI subprocess
participant MCP as MCP 서버들
B->>A: ask_llm(persona, prompt, session_id, force_provider=?)
A->>A: default_provider_for_persona(persona)<br/>jini→claude, wansu→codex
alt provider == claude
A->>CP: chat(prompt, system_prompt, session_id, model)
CP->>CLI: Popen(["claude", "-p", prompt,<br/>"--output-format","stream-json","--verbose",<br/>"--model", model, "--permission-mode","bypassPermissions",<br/>"--append-system-prompt", persona.md,<br/>"--resume", session_id])
Note over CLI: 새 프로세스. Pantheon 프로세스의 환경변수 상속<br/>(JINI_BOT_TOKEN, OAuth 토큰 파일 경로 등)
CLI->>MCP: 환경에 보이는 MCP 도구 호출
MCP-->>CLI: 결과
CLI-->>CP: stream-json line: {"type":"result",...}
CP-->>A: LLMResponse(text, session_id, usage)
else provider == codex
A->>A: providers/codex.py 경로 (codex CLI subprocess)
else provider == gemini
A->>A: providers/gemini.py 경로 (HTTPS API)
end
A-->>B: (text, session_id, usage, model_name)
페르소나별 기본 provider는 코드에 박혀 있다 (bridge.py:733-738).
- jini → claude (Opus).
personas/jini.md첫 줄model: opus로 기본 모델 지정. - wansu → codex (OpenAI Codex CLI).
- 나머지 → 시스템 기본값 (현재 claude).
Claude provider의 fallback 체인 (bridge.py:1776-1779): 페르소나 기본 모델 → opus → haiku. Anthropic Max plan quota가 모델별이라 한 티어가 막혀도 같은 페르소나가 다른 모델로 살아남게 만든다.
여기서 도구 노출이 결정된다. Claude CLI 서브프로세스가 보는 MCP config가 곧 도구 셋이다. 그 config 위치는 추정:
| 후보 | 위치 | 정황 |
|---|---|---|
~/.claude/settings.json |
사용자 글로벌 | 사용자 본 세션과 같은 파일을 보면 도구가 공유됨 |
${CLAUDE_CONFIG_DIR}/... |
환경변수 override | Pantheon 측에서 별도 디렉토리 지정 시 분리됨 |
| 봇 토큰 환경변수 | JINI_BOT_TOKEN 등 |
Slack MCP는 이 토큰을 환경에서 직접 읽는 듯 |
모름: Pantheon 프로세스 부팅 시 어떤 env가 주입되고, Claude CLI가 어느 MCP config를 어떤 우선순위로 읽는지의 정확한 매핑. 콜로세움 케이스가 노출한 갭이 정확히 여기.
5. 도구 접근: 페르소나가 보는 MCP
관측 사실 (2026-06-08 콜로세움 케이스)
jini 페르소나 세션에서 본 deferred 도구를 정리하면:
| MCP 서버 | jini 측 노출 | 사용자 본 세션 | 인증 방식 | 차이 |
|---|---|---|---|---|
| slack-hangman | ✅ 전체 | ✅ 전체 | Bot/User 토큰 (env) | 동일 |
| Google Drive | ✅ 전체 | ✅ 전체 | OAuth | 동일 — Pantheon 환경에 토큰 주입됨 (추정) |
| Linear (hangman/davinci) | ✅ 전체 | ✅ 전체 | API key (env) | 동일 |
| GCal | ✅ 전체 | ✅ 전체 | OAuth (파일 영속) | 동일 |
| Notion | ⚠️ authenticate만 |
✅ 전체 | OAuth | jini 측 토큰 미주입 — notion-search / notion-fetch 표면화 실패 |
| Chrome DevTools | (미확인) | ✅ | 로컬 Chrome | 모름 |
패턴: API 키 기반은 환경변수 한 줄로 페르소나에 자동 전파. OAuth 기반은 토큰이 어디에 저장되어 Claude CLI에 어떻게 전달되는지가 분기점. Google Drive는 정상, Notion만 실패한 게 본질이다.
추정 가설 3개:
- 토큰 저장 위치 차이: GDrive는 Pantheon 프로세스가 상속하는 디렉토리(예: 사용자 home의 OAuth cache)에 토큰이 있고, Notion은 별도 위치 (예: 본 세션 전용 캐시)에 있어 페르소나 환경에 안 잡힘.
- MCP 서버 부팅 시 인증 방식 차이: Notion MCP는 부팅 시점에 매번 OAuth handshake를 새로 시도하고, Pantheon 측에 대화형 입력 경로가 없어
authenticate도구만 표면화한 채로 중단. - MCP config 분기: 사용자 본 세션은
notionMCP를 enable한 config, 페르소나 spawn 환경은 그 entry가 빠진 config를 읽음.
이 셋은 동시에 사실일 수도, 하나만 사실일 수도 있다. 검증 순서는 §8.
6. 응답 게시: 페르소나 명의 보존
확정 (bridge.py:2195~ say(reply, ...), tools/slack_send.py)
응답 경로는 2가지.
- 메인 경로: 각 Bolt App이
say()콜로 자기 봇 토큰으로 자기 명의 게시.thread_ts를reply_thread_ts(채널 세션이면 root, 보통은 thread_ts)로 넘긴다 (bridge.py:2070-2073, 2195). LLM이 새session_id를 돌려주면sessions.json에 갱신 저장 (bridge.py:save_sessions). - 서브 경로: 페르소나 명의로 Pantheon 외부에서 post해야 할 때 (서브에이전트·CLI·스킬).
tools/slack_send.py --persona jini호출 → 해당 페르소나 봇 토큰으로 직접chat.postMessage([reference_pantheon_slack_send]).
룰 ([feedback_no_persona_via_user_token]): 페르소나 모드에서 slack-hangman MCP의 slack_send_message(사용자 토큰)로 게시 금지. 발화 주체가 사용자 본인으로 보임 → 페르소나 정체성 무너짐. 페르소나 명의가 필요한 외부 호출은 반드시 slack_send.py --persona 경로.
7. 사용자 Claude Code 세션과의 분리 지점
여기서 콜로세움 케이스의 본질이 드러난다. 같은 머신에서 돌지만 두 세션은 격리되어 있다.
| 항목 | 사용자 본 세션 | 페르소나 세션 |
|---|---|---|
| 프로세스 | claude CLI (대화형, 장수명) |
claude -p CLI (1-shot, 매 메시지) |
| 부모 | 터미널 | Pantheon bridge.py |
| 환경변수 | 사용자 shell rc 그대로 | Pantheon 부팅 env (별도 launchd 정의) |
| MCP config | ~/.claude/settings.json (사용자 OAuth) |
추정: 같거나, env override로 분기 |
| 토큰 캐시 | 사용자 OAuth 캐시 디렉토리 | 추정: 상속되는 것만 보임 |
| 세션 영속 | Claude CLI 본인 (사용자 대화 컨텍스트) | sessions.json + persona_memory + persona_sessions |
| 발화 주체 | 항승 본인 | 페르소나 (봇 토큰) |
핵심 함의: 본 세션에서 notion authenticate를 끝내도, Pantheon 프로세스가 그 토큰을 자기 환경에서 볼 수 있어야 페르소나에 전파된다. Pantheon은 launchd daemon으로 부팅 시점의 env를 고정 상속하므로 (reference_pantheon_redeploy_check), 부팅 후에 만들어진 토큰은 재기동 전까지 페르소나에서 안 보이는 게 자연스럽다.
8. 검증 순서: 다음에 도구가 깨질 때 따라갈 플레이북
순서대로 한 번 돌리면 어느 레이어의 문제인지 항상 좁힐 수 있다.
- Layer A — 봇 자체:
pantheon/status.py또는!health보고로 페르소나 봇 살아있는지 확인. 죽었으면!redeploy. - Layer B — env 주입:
launchctl print system/com.hangman.pantheon(또는 동등 plist)에서 해당 MCP의 토큰/디렉토리 env가 들어있는지 확인. 없으면 부팅 env 누락이 본질. - Layer C — MCP config: Pantheon 측
claude --print-config류로 그 프로세스의 환경에서 보이는 MCP 목록을 확인. 도구가 안 잡히면 MCP entry 자체 누락. - Layer D — 인증 상태: 도구가 표면화는 됐는데
authenticate만 보이면 OAuth 토큰이 비어 있거나 만료. 페르소나 측에서 OAuth 대화형 입력 경로는 없으므로 Internal Integration Token 같은 영속 토큰을 env에 박는 우회가 현실적. - Layer E — 호출 권한: 도구는 보이는데 권한 에러면 토큰 스코프. 페르소나 명의 인증을 별도로 만들지(옵션 B), 사용자 토큰을 빌릴지(옵션 A) 결정 필요.
9. 도구 관리 전략 옵션 3가지 — 다시 (2026-06-08 보고서 요약)
2026-06-08 보고서에서 펼친 옵션 A/B/C 그대로다. 이번 보고서가 더하는 건 5단 검증 플레이북과 분리 지점 표다.
| 옵션 | 한 줄 | 강점 | 약점 |
|---|---|---|---|
| A. 본 세션 MCP 완전 공유 | env 상속만 깔끔하게 | 추가 비용 거의 0 | 페르소나가 사용자 본인 명의로 외부 액션 → 주체 혼동 |
| B. 페르소나별 전용 도구 셋 | OAuth도 페르소나 명의로 별도 발급 | Slack 봇 토큰 분리와 같은 철학, 권한 최소화 | 신규 MCP마다 N개 셋업 |
| C. 읽기 공유 + 쓰기 분리 | 검색은 빠르게, 쓰기만 페르소나 인증 | 비용·안전 균형 | 도구별 read/write 라우팅 룰 필요 |
본 보고서는 옵션 선택을 강요하지 않는다 — 다만 옵션 A를 고르더라도 launchd env에 토큰을 박아 부팅 상속을 보장해야 한다는 점, 옵션 B를 고르면 페르소나 명의 Notion Integration이 필요하다는 점은 위 §7·§8의 자연스러운 따름.
10. 모름·확인 필요
검증해서 보고서를 채워야 하는 빈칸:
- 사용자 본 세션 Claude CLI가 읽는 정확한 MCP config 파일 경로 우선순위 (env var, settings.json, project-local 등)
- Pantheon launchd plist에 실제로 주입되는 env 전체 목록 — Notion 토큰 자리가 비었는지 1차 확인
- Google Drive MCP는 OAuth인데 왜 페르소나에서도 보이는지의 정확한 메커니즘 (토큰 파일 위치? Pantheon 부팅 후 갱신된 토큰을 자동 reload?)
- Chrome DevTools MCP가 페르소나에 노출되는지 — 노출된다면 로컬 Chrome 접근이라 데스크탑 세션 점유 충돌 가능성
이 4개는 옵션 A/B/C 선택과 무관하게 채워야 한다. 어느 옵션이든 env 주입 표준이 없으면 계속 같은 사고가 난다.
11. 결정 위임 — 사용자에게 묻고 싶은 것
다음 결정 3개로 좁히면 전략이 닫힌다.
- 옵션 A/B/C 중 어느 철학을 디폴트로 잡을까요? (도구별로 다르게 갈 수도 있어요 — 그럼 default + exception 목록 구조.)
- 페르소나가 "사용자 본인 명의로" 외부 시스템 액션을 하는 게 어디까지 허용되는가요? Slack post는 절대 금지인데 Notion/Linear/Drive의 write는 룰이 명시 안 돼 있어요.
- Notion 케이스 quick fix(Integration Token 환경변수 박기) 우선 진행할까요, 아니면 옵션 결정 후 일반 패턴으로 풀까요? Quick fix는 빠르지만 같은 패턴이 다음 OAuth MCP에서 또 터집니다.
링크·참조
- 소스 인용:
pantheon/bridge.py,pantheon/providers/claude.py,pantheon/persona_sessions.py,pantheon/persona_memory.py,pantheon/router.py,pantheon/tools/slack_send.py - 관련 보고서: 2026-06-08 페르소나 도구·세션 아키텍처 — 옵션 A/B/C 원문
- 관련 메모리:
reference_pantheon_slack_send,feedback_no_persona_via_user_token,feedback_pantheon_persona_skill_execution,feedback_pantheon_redeploy_check,project_dispatcher_agent,reference_pantheon_wedged_patterns - 관측 케이스: 콜로세움 트래커 row fetch (2026-06-08 #northstar)