report jini draft 2026-06-09

지니(페르소나) 메시지 → 세션 → 도구 → 응답 흐름

TL;DR

본 보고서는 사고 해명이 아니라 도구 관리 전략 의사결정용 인프라 보고서다. 콜로세움 Notion 케이스는 예시로만 인용한다.

표기 규약

전체 흐름 한 장

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로 자기 이벤트 스트림을 구독한다. 이벤트 타입은 두 개:

같은 Slack 이벤트가 여러 봇의 구독 조건에 동시에 잡힐 수 있다. 예: @jini @wansu 동시 멘션. 그래서 두 가지 가드를 둔다.

핵심 함의: 페르소나는 각자 자기 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겹의 역할 분리:

이걸 다 모은 게 프롬프트 조립 단계(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).

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개:

  1. 토큰 저장 위치 차이: GDrive는 Pantheon 프로세스가 상속하는 디렉토리(예: 사용자 home의 OAuth cache)에 토큰이 있고, Notion은 별도 위치 (예: 본 세션 전용 캐시)에 있어 페르소나 환경에 안 잡힘.
  2. MCP 서버 부팅 시 인증 방식 차이: Notion MCP는 부팅 시점에 매번 OAuth handshake를 새로 시도하고, Pantheon 측에 대화형 입력 경로가 없어 authenticate 도구만 표면화한 채로 중단.
  3. MCP config 분기: 사용자 본 세션은 notion MCP를 enable한 config, 페르소나 spawn 환경은 그 entry가 빠진 config를 읽음.

이 셋은 동시에 사실일 수도, 하나만 사실일 수도 있다. 검증 순서는 §8.

6. 응답 게시: 페르소나 명의 보존

확정 (bridge.py:2195~ say(reply, ...), tools/slack_send.py)

응답 경로는 2가지.

룰 ([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. 검증 순서: 다음에 도구가 깨질 때 따라갈 플레이북

순서대로 한 번 돌리면 어느 레이어의 문제인지 항상 좁힐 수 있다.

  1. Layer A — 봇 자체: pantheon/status.py 또는 !health 보고로 페르소나 봇 살아있는지 확인. 죽었으면 !redeploy.
  2. Layer B — env 주입: launchctl print system/com.hangman.pantheon (또는 동등 plist)에서 해당 MCP의 토큰/디렉토리 env가 들어있는지 확인. 없으면 부팅 env 누락이 본질.
  3. Layer C — MCP config: Pantheon 측 claude --print-config 류로 그 프로세스의 환경에서 보이는 MCP 목록을 확인. 도구가 안 잡히면 MCP entry 자체 누락.
  4. Layer D — 인증 상태: 도구가 표면화는 됐는데 authenticate만 보이면 OAuth 토큰이 비어 있거나 만료. 페르소나 측에서 OAuth 대화형 입력 경로는 없으므로 Internal Integration Token 같은 영속 토큰을 env에 박는 우회가 현실적.
  5. 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. 모름·확인 필요

검증해서 보고서를 채워야 하는 빈칸:

이 4개는 옵션 A/B/C 선택과 무관하게 채워야 한다. 어느 옵션이든 env 주입 표준이 없으면 계속 같은 사고가 난다.

11. 결정 위임 — 사용자에게 묻고 싶은 것

다음 결정 3개로 좁히면 전략이 닫힌다.

  1. 옵션 A/B/C 중 어느 철학을 디폴트로 잡을까요? (도구별로 다르게 갈 수도 있어요 — 그럼 default + exception 목록 구조.)
  2. 페르소나가 "사용자 본인 명의로" 외부 시스템 액션을 하는 게 어디까지 허용되는가요? Slack post는 절대 금지인데 Notion/Linear/Drive의 write는 룰이 명시 안 돼 있어요.
  3. Notion 케이스 quick fix(Integration Token 환경변수 박기) 우선 진행할까요, 아니면 옵션 결정 후 일반 패턴으로 풀까요? Quick fix는 빠르지만 같은 패턴이 다음 OAuth MCP에서 또 터집니다.

링크·참조