eojeon-cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  All notable changes to this project are documented here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versioning: [SemVer](https://semver.org/).
4
4
 
5
+ ## [0.2.0] - 2026-10-24
6
+
7
+ Fixes from the first day of use.
8
+
9
+ ### Added
10
+ - `eojeon watch` / `eojeon watch --off`: show Claude Code sessions running outside the court (other terminals, IDEs), watch-only. Hooks go into `~/.claude/settings.json` (backup kept, only our entries are removed on `--off`); the hook copy lives in `~/.eojeon/hook/` and finds the court through `~/.eojeon/runtime.json`.
11
+ - `--host <addr>` (and `--new-key`): open the court to other devices, e.g. over Tailscale. Requires an access key printed at start (`?key=…` once, then an HttpOnly cookie for 30 days). The terminal still answers on 127.0.0.1 without a key.
12
+ - 전령 (`jeollyeong`) office for an SNS-posting agent.
13
+
14
+ ### Changed
15
+ - One input on computers: the terminal. The 교지 쓰기 bar now shows only on touch screens.
16
+ - Petitions from outside sessions show "그 터미널에서 답하소서" instead of stamps; their log lines are tagged with the folder.
17
+
5
18
  ## [0.1.0] - 2026-10-24
6
19
 
7
20
  First public version.
@@ -22,4 +35,5 @@ First public version.
22
35
  - Hook payloads trimmed and secrets masked before leaving the hook process.
23
36
  - `scripts/check-publish.js` blocks publishing local paths, e-mail addresses or token-shaped strings.
24
37
 
38
+ [0.2.0]: https://github.com/hiio420pr/eojeon/releases/tag/v0.2.0
25
39
  [0.1.0]: https://github.com/hiio420pr/eojeon/releases/tag/v0.1.0
package/README.md CHANGED
@@ -61,6 +61,9 @@ npm start # = node cli/eojeon.js
61
61
  | `--port <n>` | 포트 (기본 4747, 사용 중이면 다음 빈 포트) |
62
62
  | `--cwd <폴더>` | 시작 화면의 기본 작업 폴더 |
63
63
  | `--no-open` | 브라우저를 열지 않음 |
64
+ | `eojeon watch` / `eojeon watch --off` | 어전 밖(다른 터미널·IDE)에서 쓰는 Claude Code 도 화면에 보이게 / 끄기. 아래 '이미 쓰던 Claude Code 보기' |
65
+ | `--host <주소>` | 다른 기기(Tailscale·같은 와이파이)에서 접속 허용. 접속 키 필요. 아래 '다른 기기에서 보기' |
66
+ | `--new-key` | `--host` 와 함께: 접속 키 새로 만들기(예전 주소는 막힘) |
64
67
  | `--help`, `--version` | 도움말, 버전 |
65
68
 
66
69
  `COURT_CLAUDE_BIN=/path/to/claude` 로 다른 claude 실행 파일을 쓸 수 있습니다.
@@ -106,7 +109,33 @@ npm start # = node cli/eojeon.js
106
109
  - **시작 옵션**: 작업 폴더, 새 조회 / 이어서(`--continue`) / 지난 세션(`--resume <id>`), 권한 모드(`--permission-mode`: manual · acceptEdits · plan · auto · dontAsk — `bypassPermissions` 는 윤허 장면이 사라지고 위험해서 일부러 뺐습니다).
107
110
  - **장면**: 교지 반포 → 임금이 두루마리를 듦 · 도구 사용 → 담당 신하가 붓질하며 말풍선 · 서브에이전트 시작 → 그 관직의 신하가 입궐해 자기 책상으로, 끝나면 영의정께 보고하고 퇴궐 · 권한 요청 → 오른쪽 위에 "윤허를 청하옵니다" 패널과 **윤허 / 불허 / 보류** 도장(터미널 권한 창에 실제 키 전송: 윤허=`1`, 불허=`Esc`) · 실패 → 먹물 얼룩 말풍선.
108
111
  - 말풍선은 겹치지 않게 자리를 엇갈리고, 한 번에 3개까지만 보입니다. 나머지는 **사관 일지**(오른쪽)에 모두 남습니다.
109
- - **교지 쓰기 입력창**: 한글 입력이 편한 별도 입력창. Enter 로 터미널에 그대로 들어갑니다.
112
+ - **입력은 터미널 하나**: 컴퓨터에서는 아래 터미널에 바로 칩니다. 휴대폰·태블릿(터치 화면)에서만 터미널 아래에 **교지 쓰기** 칸이 보입니다(터치 키보드로 터미널에 한글을 치기 어려워서). Enter 하면 터미널에 그대로 들어갑니다.
113
+
114
+ ### 이미 쓰던 Claude Code 보기 (`eojeon watch`)
115
+
116
+ 어전에서 띄운 claude 말고, 원래 쓰던 터미널·IDE 의 Claude Code 도 궁궐에 보이게 합니다.
117
+
118
+ ```bash
119
+ npx eojeon-cli watch # 한 번만. ~/.claude/settings.json 에 hook 추가(처음 한 번 settings.json.eojeon-backup 백업)
120
+ npx eojeon-cli # 어전을 켜 두면, 이후 시작하는 모든 Claude Code 세션이 실시간으로 보임
121
+ npx eojeon-cli watch --off # 끄기(우리 hook 만 지우고 나머지 설정은 그대로)
122
+ ```
123
+
124
+ - **보기 전용**: 바깥 세션의 권한 요청은 패널에 "그 터미널에서 답하소서"로만 뜹니다. 도장이 그 터미널에 키를 보낼 수 없기 때문입니다. 사관 일지에는 `⇢ 폴더 이름` 이 붙습니다.
125
+ - 이미 열려 있는 세션은 `/hooks` 를 한 번 열거나 다시 시작해야 새 hook 을 읽습니다.
126
+ - 어전이 꺼져 있으면 hook 은 아무것도 하지 않고 바로 끝납니다. hook 파일은 `~/.eojeon/hook/` 에 복사해 두고 씁니다(npx 캐시 경로가 바뀌어도 동작).
127
+
128
+ ### 다른 기기에서 보기 (`--host`, Tailscale 등)
129
+
130
+ ```bash
131
+ npx eojeon-cli --host 0.0.0.0 # 또는 Tailscale IP 하나만: --host 100.x.y.z
132
+ ```
133
+
134
+ 시작할 때 터미널에 `http://<주소>:4747/?key=…` 가 찍힙니다(Tailscale 주소가 맨 위). **처음 한 번은 키가 붙은 그 주소로 여세요.** 그 뒤에는 기기에 쿠키가 남아 `http://macmini:4747` 처럼 키 없이 열립니다(30일).
135
+
136
+ - 키가 없으면 403. 키는 `~/.eojeon/remote-key` 에 있고 `--new-key` 로 바꿉니다.
137
+ - **키를 가진 사람은 이 컴퓨터의 claude 터미널을 쓸 수 있습니다.** 공용 와이파이에서는 `0.0.0.0` 대신 Tailscale IP 만 지정하세요.
138
+ - `--host` 없이 켜면 예전처럼 `127.0.0.1` 에만 열립니다.
110
139
 
111
140
  ## 작동 원리
112
141
 
@@ -117,7 +146,7 @@ npm start # = node cli/eojeon.js
117
146
 
118
147
  ## 개인정보
119
148
 
120
- - **로컬 전용**: 서버는 `127.0.0.1` 에만 열립니다. 외부로 보내는 요청이 없습니다. xterm 은 npm 패키지에서, 글꼴은 패키지 안에서 직접 서빙합니다(CDN 없음, 추적 없음).
149
+ - **로컬 전용**: 서버는 기본으로 `127.0.0.1` 에만 열립니다(`--host` 를 줄 때만 다른 기기에 열리고, 그때는 접속 키가 필요). 외부로 보내는 요청이 없습니다. xterm 은 npm 패키지에서, 글꼴은 패키지 안에서 직접 서빙합니다(CDN 없음, 추적 없음).
121
150
  - **비밀 가림**: 화면으로 가는 이벤트에는 파일 내용·도구 출력·전체 프롬프트가 없습니다. 프롬프트는 앞 60자, 명령은 첫 줄 56자까지, API 키·토큰·비밀번호·개인 키 모양 문자열은 `***` 로 가립니다.
122
151
  - **외부 연결 차단**: Host·Origin 을 검사해 다른 웹사이트가 내 터미널에 입력하거나 에이전트 파일을 쓰지 못하게 막고(DNS rebinding·CSWSH 방어), hook 수신은 실행마다 새로 만든 토큰으로만 받습니다.
123
152
  - 세션 목록은 `~/.claude/projects/` 기록 파일의 앞뒤만 읽기 전용으로 읽습니다.
@@ -185,7 +214,13 @@ claude plugin validate .
185
214
  2. **Claude Code plugin**: `/plugin marketplace add hiio420pr/eojeon` then `/plugin install eojeon@eojeon` (or on 2.1.275+: `/plugin install eojeon --marketplace hiio420pr/eojeon`). Then type `/eojeon` or "open the court". Third-party marketplaces don't auto-update by default: run `/plugin` → Marketplaces → eojeon → Enable auto-update (or `/plugin marketplace update eojeon`). You don't need both npx and the plugin. The plugin also provides the six agents as `eojeon:ijo` … `eojeon:gongjo`.
186
215
  3. **git clone**: `git clone https://github.com/hiio420pr/eojeon.git && cd eojeon && npm install && npm start`
187
216
 
188
- CLI: `eojeon [--port n] [--cwd dir] [--no-open]`, `eojeon demo`, `eojeon init [dir] [--pack yukjo|studio|all]` (never overwrites an existing file; prints what was added and skipped), `--help`, `--version`.
217
+ **Watch sessions you already run elsewhere**: `npx eojeon-cli watch` adds hooks to `~/.claude/settings.json` (backup kept) so any Claude Code session (other terminals, IDEs) shows up in the court while it runs — watch-only, answer its prompts in its own terminal. `eojeon watch --off` removes only our hooks.
218
+
219
+ **Other devices (Tailscale, LAN)**: `npx eojeon-cli --host 0.0.0.0` (or just your Tailscale IP). Open the printed `http://<addr>:4747/?key=…` once; the device keeps a cookie for 30 days. No key, no access. Whoever has the key can use your claude terminal; rotate with `--new-key`. Without `--host` it stays on 127.0.0.1.
220
+
221
+ On a computer you type into the terminal only; the separate 교지 input appears on touch screens only.
222
+
223
+ CLI: `eojeon [--port n] [--cwd dir] [--host addr] [--no-open]`, `eojeon watch [--off]`, `eojeon demo`, `eojeon init [dir] [--pack yukjo|studio|all]` (never overwrites an existing file; prints what was added and skipped), `--help`, `--version`.
189
224
 
190
225
  ### The Six Ministries (육조) — default subagents
191
226
 
@@ -206,7 +241,7 @@ Runs your **real** `claude` CLI in a PTY, shown in the browser with xterm.js. Ho
206
241
 
207
242
  ### Privacy
208
243
 
209
- Local only (binds to 127.0.0.1, no outbound requests, no CDN, no telemetry). Secrets masked before anything reaches the page. Host/Origin checks block other websites from typing into your terminal or writing agent files; hook posts need a per-run token.
244
+ Local only by default (binds to 127.0.0.1; `--host` opens it to other devices behind an access key; no outbound requests, no CDN, no telemetry). Secrets masked before anything reaches the page. Host/Origin checks block other websites from typing into your terminal or writing agent files; hook posts need a per-run token.
210
245
 
211
246
  ### Requirements
212
247
 
package/cli/eojeon.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // eojeon [--port N] [--cwd DIR] [--no-open] start the court (server + browser)
4
4
  // eojeon demo [--port N] replay a scripted session, no claude needed
5
5
  // eojeon init [DIR] [--pack yukjo|studio|all] copy subagent files into DIR/.claude/agents (never overwrites)
6
+ // eojeon watch [--off] also show Claude Code sessions running outside the court
6
7
  // eojeon --help | --version
7
8
  import fs from 'node:fs';
8
9
  import path from 'node:path';
@@ -19,15 +20,20 @@ Usage
19
20
  eojeon demo [options] Replay a scripted session (no claude, no usage)
20
21
  eojeon init [dir] [--pack P] Add the Six Ministries (육조) subagents to dir/.claude/agents
21
22
  P = yukjo (default) | studio | all. Existing files are never overwritten.
23
+ eojeon watch [--off] Also show Claude Code sessions you run elsewhere (any terminal or IDE),
24
+ watch-only. Adds hooks to ~/.claude/settings.json (backup kept); --off removes them.
22
25
 
23
26
  Options
24
27
  --port <n> Port to listen on (default 4747; the next free port is used if taken)
25
28
  --cwd <dir> Default project folder shown in the launcher (default: current folder)
29
+ --host <addr> Also open to other devices (e.g. 0.0.0.0, or your Tailscale IP). Needs the access key
30
+ printed at start; whoever has it can use your claude terminal.
31
+ --new-key With --host: make a new access key (old addresses stop working)
26
32
  --no-open Do not open a browser
27
33
  -h, --help Show this help
28
34
  -v, --version Show the version
29
35
 
30
- Everything runs on 127.0.0.1 only. Docs: ${pkg.homepage}`;
36
+ By default everything runs on 127.0.0.1 only. Docs: ${pkg.homepage}`;
31
37
 
32
38
  function takeOpt(list, name) {
33
39
  const i = list.indexOf(name);
@@ -66,6 +72,26 @@ if (cmd === 'init') {
66
72
  console.error(`init failed: ${e.message}`);
67
73
  process.exit(1);
68
74
  }
75
+ } else if (cmd === 'watch') {
76
+ const watch = await import('../server/watch.js');
77
+ try {
78
+ if (argv.includes('--off')) {
79
+ const r = watch.uninstall();
80
+ console.log(`어전 · eojeon watch off → removed our hooks from ${r.settingsFile}`);
81
+ } else if (argv.includes('--status')) {
82
+ console.log(watch.status() ? 'on' : 'off');
83
+ } else {
84
+ const r = watch.install();
85
+ console.log(`어전 · eojeon watch on → ${r.settingsFile}`);
86
+ if (r.backup) console.log(` backup: ${r.backup}`);
87
+ console.log(' Claude Code sessions started from now on (any terminal, IDE) appear in the court while it runs.');
88
+ console.log(' Watch-only: answer their permission prompts in their own terminal. Turn off: eojeon watch --off');
89
+ console.log(' Already-open sessions: run /hooks once (or restart) so they pick up the new hooks.');
90
+ }
91
+ } catch (e) {
92
+ console.error(`watch failed: ${e.message}`);
93
+ process.exit(1);
94
+ }
69
95
  } else if (cmd === 'start' || cmd === 'demo') {
70
96
  const pass = [...argv];
71
97
  if (cmd === 'demo' && !pass.includes('--demo')) pass.push('--demo');
package/court.config.json CHANGED
@@ -216,6 +216,15 @@
216
216
  "emblem": "album",
217
217
  "side": "west",
218
218
  "pack": "studio"
219
+ },
220
+ "jeollyeong": {
221
+ "title": "전령",
222
+ "short": "전",
223
+ "role": "완성된 글을 들고 SNS 고을마다 알린다",
224
+ "robe": "nok",
225
+ "emblem": "hoof",
226
+ "side": "west",
227
+ "pack": "studio"
219
228
  }
220
229
  },
221
230
  "guests": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eojeon-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Claude Code as a Joseon royal court: issue a decree, AI officials (subagents) work, and ask for your royal assent. Pixel-art throne hall around your real Claude Code CLI. Unofficial. · Claude Code를 어전회의로 — 교지를 내리면 AI 신하들이 일하고 윤허를 청한다. 비공식 도구.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -41,7 +41,7 @@
41
41
  "start": "node cli/eojeon.js",
42
42
  "demo": "node cli/eojeon.js demo",
43
43
  "test": "node --test",
44
- "check": "node --check cli/eojeon.js && node --check server/index.js && node --check server/hook.js && node --check server/sessions.js && node --check server/sanitize.js && node --check server/agents.js",
44
+ "check": "node --check cli/eojeon.js && node --check server/index.js && node --check server/hook.js && node --check server/sessions.js && node --check server/sanitize.js && node --check server/agents.js && node --check server/watch.js",
45
45
  "check-publish": "node scripts/check-publish.js",
46
46
  "prepublishOnly": "npm test && node scripts/check-publish.js",
47
47
  "postinstall": "node scripts/fix-pty.js",
package/public/app.js CHANGED
@@ -57,7 +57,7 @@ new ResizeObserver(() => doFit()).observe($('term'));
57
57
  term.onData((d) => {
58
58
  sendInput(d);
59
59
  // answered the permission dialog directly in the terminal: Esc = refused
60
- if (d === '\x1b' && court.petition && !court.petition.closed) {
60
+ if (d === '\x1b' && court.petition && !court.petition.closed && !court.petition.ext) {
61
61
  court.closePetition('no');
62
62
  addLog({ type: 'Verdict', time: Date.now(), verdict: 'no' });
63
63
  }
@@ -162,7 +162,8 @@ function addLog(ev) {
162
162
  ic.textContent = icon;
163
163
  const tx = document.createElement('span');
164
164
  tx.className = 'tx';
165
- tx.textContent = text;
165
+ // sessions outside the court (eojeon watch) carry their folder name
166
+ tx.textContent = ev.ext ? `⇢ ${ev.folder || '바깥'} · ${text}` : text;
166
167
  li.append(t, ic, tx);
167
168
  if (ev.toolUseId) byTool.set(ev.toolUseId, li);
168
169
  const atBottom = logEl.scrollTop + logEl.clientHeight >= logEl.scrollHeight - 30;
@@ -174,6 +175,12 @@ function addLog(ev) {
174
175
  function onEvent(ev, opts = {}) {
175
176
  addLog(ev);
176
177
  court.handle(ev, opts);
178
+ if (ev.ext && !running && !DEMO) {
179
+ $('termHintMain').innerHTML = `바깥 터미널의 Claude Code 를 보는 중입니다 (<b>${escapeHtml(ev.folder || '')}</b>) — 보기 전용`;
180
+ }
181
+ }
182
+ function escapeHtml(s) {
183
+ return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
177
184
  }
178
185
 
179
186
  // ---------- launch controls ----------
@@ -393,5 +400,6 @@ if (DEMO) {
393
400
  });
394
401
  } else {
395
402
  if (!info.claudeFound) setStatus('error', 'claude 를 찾지 못했습니다 — Claude Code 를 설치하세요');
403
+ if (info.remote) $('footLocal').textContent = '다른 기기 접속 켜짐 (--host) · 접속 키가 있는 기기만 들어올 수 있습니다.';
396
404
  connect();
397
405
  }
package/public/index.html CHANGED
@@ -53,12 +53,13 @@
53
53
  <section class="term-wrap" aria-label="Claude Code 터미널">
54
54
  <div id="term" class="term"></div>
55
55
  <div id="termHint" class="term-hint">
56
- <p><b>입궐</b>을 누르면 이 폴더에서 <code>claude</code> 가 뜹니다.</p>
56
+ <p id="termHintMain"><b>입궐</b>을 누르면 이 폴더에서 <code>claude</code> 가 뜹니다. 명령은 이 터미널에 바로 치세요.</p>
57
57
  <p class="muted">슬래시 명령·권한 창·/resume 이 모두 터미널 그대로 동작합니다.</p>
58
+ <p class="muted">이미 다른 터미널에서 쓰는 Claude Code 도 보려면: <code>npx eojeon-cli watch</code></p>
58
59
  </div>
59
60
  <form id="decreeForm" class="decree-bar" autocomplete="off">
60
61
  <label for="decreeInput" class="decree-label">교지 쓰기</label>
61
- <input id="decreeInput" type="text" placeholder="명령을 쓰고 Enter — 터미널에 그대로 들어갑니다" />
62
+ <input id="decreeInput" type="text" placeholder="휴대폰용 입력칸 — 쓰고 Enter 하면 위 터미널에 들어갑니다" />
62
63
  <button class="btn">반포</button>
63
64
  </form>
64
65
  </section>
@@ -86,7 +87,7 @@
86
87
  </dialog>
87
88
 
88
89
  <footer class="foot">
89
- <span>모든 것이 이 컴퓨터 안에서만 돕니다 (127.0.0.1). 외부로 보내는 것 없음.</span>
90
+ <span id="footLocal">모든 것이 이 컴퓨터 안에서만 돕니다 (127.0.0.1). 외부로 보내는 것 없음.</span>
90
91
  <span>Claude Code 비공식 도구 · MIT · 글꼴 Galmuri · D2Coding (SIL OFL 1.1)</span>
91
92
  </footer>
92
93
 
package/public/scene.js CHANGED
@@ -544,6 +544,7 @@ export class Court {
544
544
  b.addEventListener('click', () => this.petitionAction(k));
545
545
  }
546
546
  el('div', 'p-hint', this.pet, this.ph.permissionHint || '');
547
+ el('div', 'p-ext', this.pet, '바깥 터미널의 조회입니다 — 그 터미널에서 답하소서');
547
548
  this.pet.title = this.ph.permissionHint || '';
548
549
  // stamp + plaque
549
550
  this.stamp = el('div', 'stamp', o);
@@ -730,7 +731,7 @@ export class Court {
730
731
  }
731
732
 
732
733
  // ---------- petition (permission request) ----------
733
- openPetition({ tool, summary, message, actor }) {
734
+ openPetition({ tool, summary, message, actor, ext = false }) {
734
735
  const who = actor || this.main;
735
736
  const head = tool ? fmt(this.ph.petition, { tool, summary: summary || '' }) : `아뢰옵니다 — ${message || '권한이 필요합니다'}`;
736
737
  if (this.petition && !this.petition.closed) {
@@ -739,8 +740,10 @@ export class Court {
739
740
  } else {
740
741
  this.stats.petitions++;
741
742
  }
742
- this.petition = { tool, summary, since: performance.now(), actor: who, closed: false };
743
+ this.petition = { tool, summary, since: performance.now(), actor: who, closed: false, ext };
743
744
  this.petHead.textContent = head;
745
+ // a session outside the court (eojeon watch): the stamps can't reach its terminal
746
+ this.pet.classList.toggle('ext', ext);
744
747
  this.pet.classList.add('on');
745
748
  this.pet.classList.remove('min');
746
749
  who.carry = true;
@@ -749,7 +752,7 @@ export class Court {
749
752
  }
750
753
 
751
754
  petitionAction(kind) {
752
- if (!this.petition || this.petition.closed) return;
755
+ if (!this.petition || this.petition.closed || this.petition.ext) return;
753
756
  if (kind === 'hold') {
754
757
  this.pet.classList.add('min');
755
758
  this.stampVerdict('hold');
@@ -829,7 +832,7 @@ export class Court {
829
832
  actor.doneWork(false, fmt(ph.failed, { tool: ev.tool }));
830
833
  break;
831
834
  case 'PermissionRequest':
832
- this.openPetition({ tool: ev.tool, summary: ev.summary, actor });
835
+ this.openPetition({ tool: ev.tool, summary: ev.summary, actor, ext: !!ev.ext });
833
836
  break;
834
837
  case 'PermissionDenied':
835
838
  if (this.petition && !this.petition.closed) this.closePetition('no');
@@ -837,7 +840,7 @@ export class Court {
837
840
  break;
838
841
  case 'Notification':
839
842
  if (ev.kind === 'permission_prompt') {
840
- if (!this.petition || this.petition.closed) this.openPetition({ message: ev.message, actor });
843
+ if (!this.petition || this.petition.closed) this.openPetition({ message: ev.message, actor, ext: !!ev.ext });
841
844
  } else if (ev.kind === 'idle_prompt') {
842
845
  this.main.say(ph.idle || '분부를 기다리옵니다', '', 120_000);
843
846
  } else if (ev.message) {
package/public/style.css CHANGED
@@ -223,6 +223,9 @@ body { height: 100vh; }
223
223
  .petition .p-ok { background: var(--px-hong); color: #fff; }
224
224
  .petition .p-no { background: #3A3F4A; }
225
225
  .petition .p-btn:focus-visible { outline: 2px solid var(--hwang); }
226
+ .petition .p-ext { display: none; font-family: 'Galmuri7', var(--pixel); font-size: 8px; color: var(--hwang); margin-top: 4px; }
227
+ .petition.ext .p-btns, .petition.ext .p-hint { display: none; }
228
+ .petition.ext .p-ext { display: block; }
226
229
  .petition .p-hint { display: none; } /* shown as a tooltip on the panel instead, to keep it short */
227
230
  .petition.min { width: 110px; left: 286px !important; }
228
231
  .petition.min .p-btns, .petition.min .p-hint, .petition.min .p-ask { display: none; }
@@ -288,6 +291,8 @@ body { height: 100vh; }
288
291
  }
289
292
  .term-hint p { margin: 4px 0; }
290
293
  .term-hint.hidden { display: none; }
294
+ /* one input on computers: the terminal. The decree bar is for touch screens, where typing into xterm is awkward */
295
+ @media (hover: hover) and (pointer: fine) { .decree-bar { display: none !important; } .term-hint { inset: 0; } }
291
296
  .decree-bar { display: flex; gap: 8px; align-items: center; padding: 8px; border-top: 1px solid var(--line); background: var(--card); }
292
297
  .decree-label { font-family: var(--pixel); font-size: 12px; color: var(--hwang); white-space: nowrap; }
293
298
  .decree-bar input {
package/server/hook.js CHANGED
@@ -1,17 +1,37 @@
1
1
  #!/usr/bin/env node
2
- // Claude Code hook handler (injected via `claude --settings <temp file>`).
2
+ // Claude Code hook handler.
3
3
  // Reads the hook JSON from stdin, trims it to a tiny display event, and POSTs it
4
4
  // to the local court server. It never blocks Claude Code: hard time limit,
5
5
  // prints nothing to stdout, and always exits 0.
6
6
  //
7
- // usage: node hook.js <port> <token>
7
+ // usage: node hook.js <port> <token> injected via `claude --settings <temp file>` for the court's own claude
8
+ // node hook.js --watch installed by `eojeon watch` into ~/.claude/settings.json; finds the
9
+ // running court through ~/.eojeon/runtime.json and does nothing if none
10
+ import fs from 'node:fs';
8
11
  import http from 'node:http';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
9
14
  import { sanitize } from './sanitize.js';
10
15
 
11
- const [port, token] = process.argv.slice(2);
12
16
  const done = () => process.exit(0);
13
17
  setTimeout(done, 1500).unref?.();
14
18
 
19
+ const WATCH = process.argv[2] === '--watch';
20
+ // the court's own claude already reports through its --settings hook; don't count it twice
21
+ if (WATCH && process.env.EOJEON_CHILD === '1') done();
22
+
23
+ let port;
24
+ let token;
25
+ if (WATCH) {
26
+ try {
27
+ ({ port, token } = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.eojeon', 'runtime.json'), 'utf8')));
28
+ } catch {
29
+ done(); // no court running
30
+ }
31
+ } else {
32
+ [port, token] = process.argv.slice(2);
33
+ }
34
+
15
35
  let buf = '';
16
36
  process.stdin.setEncoding('utf8');
17
37
  process.stdin.on('data', (c) => {
@@ -26,6 +46,7 @@ process.stdin.on('end', () => {
26
46
  return done();
27
47
  }
28
48
  if (!port || !token) return done();
49
+ if (WATCH) ev.ext = true; // a session outside the court: watch only, the stamps can't answer it
29
50
  const body = JSON.stringify(ev);
30
51
  const req = http.request(
31
52
  {
@@ -33,7 +54,7 @@ process.stdin.on('end', () => {
33
54
  port: Number(port),
34
55
  path: '/hook',
35
56
  method: 'POST',
36
- headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), 'x-court-token': token },
57
+ headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), 'x-court-token': String(token) },
37
58
  timeout: 800,
38
59
  },
39
60
  (res) => {
package/server/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  // Local server: serves the pixel court UI, runs your real `claude` in a PTY,
3
3
  // bridges it to the browser terminal over WebSocket, and receives Claude Code
4
- // hook events (injected with `claude --settings <temp file>`). Binds to 127.0.0.1 only.
4
+ // hook events (injected with `claude --settings <temp file>`). Binds to 127.0.0.1 only,
5
+ // unless started with --host (other devices, e.g. over Tailscale), which then requires an access key.
5
6
  import http from 'node:http';
6
7
  import fs from 'node:fs';
7
8
  import os from 'node:os';
@@ -14,6 +15,7 @@ import { WebSocketServer } from 'ws';
14
15
  import { listSessions } from './sessions.js';
15
16
  import { sanitize } from './sanitize.js';
16
17
  import { inspect as inspectAgents, install as installAgents, PACKS } from './agents.js';
18
+ import { HOOK_EVENTS, eojeonDir, runtimeFile } from './watch.js';
17
19
 
18
20
  const require = createRequire(import.meta.url);
19
21
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -31,9 +33,28 @@ const DEMO = flag('--demo');
31
33
  const NO_OPEN = flag('--no-open') || process.env.COURT_NO_OPEN === '1';
32
34
  const WANT_PORT = Number(opt('--port', process.env.COURT_PORT || 4747));
33
35
  const DEFAULT_CWD = path.resolve(expandHome(opt('--cwd', process.env.INIT_CWD || process.cwd())));
36
+ const HOST = opt('--host', process.env.COURT_HOST || '127.0.0.1');
37
+ const LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1']);
38
+ const REMOTE = !LOOPBACK.has(HOST);
34
39
  const TOKEN = crypto.randomBytes(18).toString('hex');
40
+ const KEY = REMOTE ? remoteKey(flag('--new-key')) : '';
35
41
  const VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version;
36
42
 
43
+ // one key per machine, kept in ~/.eojeon/remote-key so a bookmarked address keeps working; --new-key rotates it
44
+ function remoteKey(rotate) {
45
+ const file = path.join(eojeonDir(), 'remote-key');
46
+ if (!rotate) {
47
+ try {
48
+ const k = fs.readFileSync(file, 'utf8').trim();
49
+ if (/^[0-9a-f]{32,}$/.test(k)) return k;
50
+ } catch {}
51
+ }
52
+ const k = crypto.randomBytes(24).toString('hex');
53
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
54
+ fs.writeFileSync(file, k + '\n', { mode: 0o600 });
55
+ return k;
56
+ }
57
+
37
58
  function expandHome(p) {
38
59
  return typeof p === 'string' && (p === '~' || p.startsWith('~/')) ? path.join(os.homedir(), p.slice(1)) : p;
39
60
  }
@@ -56,11 +77,6 @@ function findClaude() {
56
77
  const CLAUDE = findClaude();
57
78
 
58
79
  // ---------- temp settings with hooks ----------
59
- const HOOK_EVENTS = [
60
- 'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure',
61
- 'PermissionRequest', 'PermissionDenied', 'SubagentStart', 'SubagentStop', 'Notification',
62
- 'Stop', 'StopFailure', 'SessionEnd',
63
- ];
64
80
  let tmpDir = null;
65
81
  let settingsPath = null;
66
82
  let runtimePath = null;
@@ -76,11 +92,20 @@ function writeSettings(port) {
76
92
  // lets `npm run fake-hooks` find this server (token stays on this machine, owner-only file)
77
93
  runtimePath = path.join(os.tmpdir(), `eojeon-${port}.json`);
78
94
  fs.writeFileSync(runtimePath, JSON.stringify({ port, token: TOKEN, hook: HOOK }), { mode: 0o600 });
95
+ // lets `eojeon watch` hooks (sessions outside the court) find this server
96
+ try {
97
+ fs.mkdirSync(eojeonDir(), { recursive: true, mode: 0o700 });
98
+ fs.writeFileSync(runtimeFile(), JSON.stringify({ port, token: TOKEN, pid: process.pid }), { mode: 0o600 });
99
+ } catch {}
79
100
  }
80
101
 
81
102
  function cleanup() {
82
103
  try { if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
83
104
  try { if (runtimePath) fs.rmSync(runtimePath, { force: true }); } catch {}
105
+ try {
106
+ // only our own: a second court may have taken over the file
107
+ if (JSON.parse(fs.readFileSync(runtimeFile(), 'utf8')).pid === process.pid) fs.rmSync(runtimeFile(), { force: true });
108
+ } catch {}
84
109
  }
85
110
 
86
111
  // ---------- state ----------
@@ -162,6 +187,7 @@ function startClaude({ cwd, mode, sessionId, permissionMode }) {
162
187
  'CLAUDE_CODE_MESSAGING_TOKEN', 'CLAUDE_CODE_BRIDGE_SESSION_ID', 'CLAUDE_CODE_EXECPATH', 'CLAUDE_PID', 'CLAUDE_EFFORT',
163
188
  ]) delete env[k];
164
189
  for (const k of Object.keys(env)) if (k.startsWith('npm_')) delete env[k];
190
+ env.EOJEON_CHILD = '1'; // `eojeon watch` hooks skip this session (it already reports through --settings)
165
191
 
166
192
  const { spawn } = loadPty();
167
193
  pty = spawn(CLAUDE, args, { name: 'xterm-256color', cols: size.cols, rows: size.rows, cwd: dir, env });
@@ -211,6 +237,27 @@ function allowedHost(h) {
211
237
  function allowedOrigin(o) {
212
238
  return o === `http://localhost:${PORT}` || o === `http://127.0.0.1:${PORT}` || o === `http://[::1]:${PORT}`;
213
239
  }
240
+ const fromLoopback = (req) => /^(127\.|::1$|::ffff:127\.)/.test(req.socket.remoteAddress || '');
241
+ function cookieKey(req) {
242
+ const m = /(?:^|;\s*)eojeon_key=([0-9a-f]+)/.exec(req.headers.cookie || '');
243
+ return m ? m[1] : '';
244
+ }
245
+ function keyOk(k) {
246
+ return !!KEY && typeof k === 'string' && k.length === KEY.length && crypto.timingSafeEqual(Buffer.from(k), Buffer.from(KEY));
247
+ }
248
+ // Who may use the page and the terminal:
249
+ // - this computer, under a localhost name (DNS-rebinding guard) — as before
250
+ // - with --host: anyone holding the access key (cookie set from ?key=… on the first visit)
251
+ function access(req) {
252
+ if (fromLoopback(req) && allowedHost(req.headers.host || '')) return 'local';
253
+ if (REMOTE && keyOk(cookieKey(req))) return 'key';
254
+ return '';
255
+ }
256
+ // same-origin check for anything that writes (terminal socket, agent install)
257
+ function sameOrigin(req, who) {
258
+ const o = req.headers.origin || '';
259
+ return who === 'local' ? allowedOrigin(o) : o === `http://${req.headers.host}`;
260
+ }
214
261
 
215
262
  function sendFile(res, file) {
216
263
  fs.readFile(file, (err, data) => {
@@ -247,17 +294,13 @@ function loadConfig() {
247
294
  return JSON.parse(fs.readFileSync(path.join(ROOT, 'court.config.json'), 'utf8'));
248
295
  }
249
296
 
250
- const server = http.createServer(async (req, res) => {
251
- // DNS-rebinding guard: only answer to localhost names
252
- if (!allowedHost(req.headers.host || '')) {
253
- res.writeHead(403).end('forbidden host');
254
- return;
255
- }
297
+ async function handle(req, res) {
256
298
  const url = new URL(req.url, `http://localhost:${PORT}`);
257
299
  const p = url.pathname;
258
300
 
259
301
  if (p === '/hook' && req.method === 'POST') {
260
- if (req.headers['x-court-token'] !== TOKEN) {
302
+ // hooks always run on this computer
303
+ if (!fromLoopback(req) || req.headers['x-court-token'] !== TOKEN) {
261
304
  res.writeHead(403).end();
262
305
  return;
263
306
  }
@@ -273,9 +316,29 @@ const server = http.createServer(async (req, res) => {
273
316
  return;
274
317
  }
275
318
 
319
+ let who = access(req);
320
+ if (!who && REMOTE && req.method === 'GET' && keyOk(url.searchParams.get('key'))) {
321
+ // first visit from another device: trade the key in the address for a cookie, then drop it from the address bar
322
+ url.searchParams.delete('key');
323
+ res.writeHead(302, {
324
+ 'set-cookie': `eojeon_key=${KEY}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${60 * 60 * 24 * 30}`,
325
+ location: url.pathname + url.search,
326
+ 'cache-control': 'no-store',
327
+ });
328
+ res.end();
329
+ return;
330
+ }
331
+ if (!who) {
332
+ // DNS-rebinding guard: only localhost names, or the access key with --host
333
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' }).end(
334
+ REMOTE ? 'forbidden — 어전을 켠 터미널에 찍힌 주소(?key=…)로 한 번 열어 주세요' : 'forbidden host',
335
+ );
336
+ return;
337
+ }
338
+
276
339
  if (p === '/api/agents/install' && req.method === 'POST') {
277
340
  // writes files: only from our own page (same-origin fetch always sends Origin) and only JSON
278
- if (!allowedOrigin(req.headers.origin || '') || !/^application\/json/.test(req.headers['content-type'] || '')) {
341
+ if (!sameOrigin(req, who) || !/^application\/json/.test(req.headers['content-type'] || '')) {
279
342
  res.writeHead(403).end();
280
343
  return;
281
344
  }
@@ -298,7 +361,7 @@ const server = http.createServer(async (req, res) => {
298
361
  return json(res, 200, {
299
362
  app: 'eojeon', version: VERSION,
300
363
  demo: DEMO, defaultCwd: DEFAULT_CWD, claudeFound: !!CLAUDE, running: !!pty, info: ptyInfo,
301
- home: os.homedir(), platform: process.platform, permissionModes: PERMISSION_MODES,
364
+ home: os.homedir(), platform: process.platform, permissionModes: PERMISSION_MODES, remote: REMOTE,
302
365
  });
303
366
  }
304
367
  if (p === '/api/config') {
@@ -336,19 +399,28 @@ const server = http.createServer(async (req, res) => {
336
399
  return;
337
400
  }
338
401
  sendFile(res, file);
339
- });
402
+ }
340
403
 
341
404
  // ---------- websocket ----------
342
405
  const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
343
- server.on('upgrade', (req, socket, head) => {
406
+ function onUpgrade(req, socket, head) {
344
407
  // Cross-site WebSocket hijacking guard: a random web page must never be able to type into your terminal.
345
- if (!allowedHost(req.headers.host || '') || !allowedOrigin(req.headers.origin || '')) {
408
+ const who = access(req);
409
+ if (!who || !sameOrigin(req, who)) {
346
410
  socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
347
411
  socket.destroy();
348
412
  return;
349
413
  }
350
414
  wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
351
- });
415
+ }
416
+ function makeServer() {
417
+ const s = http.createServer(handle);
418
+ s.on('upgrade', onUpgrade);
419
+ return s;
420
+ }
421
+ // always on 127.0.0.1 (hooks post there); with --host also on that address unless it already covers loopback
422
+ const server = makeServer();
423
+ const WILDCARD = HOST === '0.0.0.0' || HOST === '::';
352
424
 
353
425
  wss.on('connection', (ws) => {
354
426
  clients.add(ws);
@@ -399,10 +471,34 @@ function listen(port, tries = 10) {
399
471
  };
400
472
  server.once('error', onErr);
401
473
  server.once('listening', onOk);
402
- server.listen(port, '127.0.0.1');
474
+ server.listen(port, WILDCARD ? HOST : '127.0.0.1');
475
+ });
476
+ }
477
+
478
+ function listenAlso(port) {
479
+ return new Promise((resolve, reject) => {
480
+ const s = makeServer();
481
+ s.once('error', reject);
482
+ s.listen(port, HOST, () => resolve(s));
403
483
  });
404
484
  }
405
485
 
486
+ // addresses another device can use: Tailscale (100.64.0.0/10), LAN, and this computer's name
487
+ function remoteUrls(port) {
488
+ const out = [];
489
+ if (!WILDCARD) out.push(HOST);
490
+ else {
491
+ for (const list of Object.values(os.networkInterfaces())) {
492
+ for (const a of list || []) if (a.family === 'IPv4' && !a.internal) out.push(a.address);
493
+ }
494
+ out.push(os.hostname().replace(/\.local$/, ''));
495
+ }
496
+ const ts = (a) => /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(a);
497
+ return [...new Set(out)]
498
+ .sort((a, b) => ts(b) - ts(a))
499
+ .map((h) => `http://${h.includes(':') ? `[${h}]` : h}:${port}/?key=${KEY}${ts(h) ? ' (Tailscale)' : ''}`);
500
+ }
501
+
406
502
  function openBrowser(url) {
407
503
  const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
408
504
  const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
@@ -412,12 +508,27 @@ function openBrowser(url) {
412
508
  }
413
509
 
414
510
  PORT = await listen(WANT_PORT);
511
+ if (REMOTE && !WILDCARD) {
512
+ try {
513
+ await listenAlso(PORT);
514
+ } catch (e) {
515
+ console.error(` --host ${HOST}: ${e.message}`);
516
+ process.exit(1);
517
+ }
518
+ }
415
519
  writeSettings(PORT);
416
520
  const url = `http://localhost:${PORT}/${DEMO ? '?demo=1' : ''}`;
417
521
  console.log(`\n 어전 · eojeon ${VERSION} ${DEMO ? '(demo)' : ''}`);
418
522
  console.log(` ${url}`);
419
523
  console.log(` claude: ${CLAUDE || '(not found)'} folder: ${DEFAULT_CWD}`);
420
- console.log(' Everything stays on this machine (127.0.0.1). Ctrl+C to quit.\n');
524
+ if (REMOTE) {
525
+ console.log(`\n 다른 기기에서 (--host ${HOST}) — 처음 한 번은 키가 붙은 주소로 여세요:`);
526
+ for (const u of remoteUrls(PORT)) console.log(` ${u}`);
527
+ console.log(' ! 이 키를 가진 사람은 이 컴퓨터의 claude 터미널을 쓸 수 있습니다. 믿는 기기에서만 여세요. 키 바꾸기: --new-key');
528
+ console.log(' Ctrl+C to quit.\n');
529
+ } else {
530
+ console.log(' Everything stays on this machine (127.0.0.1). Ctrl+C to quit.\n');
531
+ }
421
532
  if (!NO_OPEN) openBrowser(url);
422
533
 
423
534
  for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
@@ -0,0 +1,96 @@
1
+ // `eojeon watch`: show Claude Code sessions that run outside the court (any terminal, IDE, ...).
2
+ // Adds one hook command per event to ~/.claude/settings.json. The command runs a copy of hook.js
3
+ // kept in ~/.eojeon/hook/ (a stable path, unlike the npx cache), which looks for a running court
4
+ // in ~/.eojeon/runtime.json and exits at once when there is none.
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
11
+
12
+ export const HOOK_EVENTS = [
13
+ 'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure',
14
+ 'PermissionRequest', 'PermissionDenied', 'SubagentStart', 'SubagentStop', 'Notification',
15
+ 'Stop', 'StopFailure', 'SessionEnd',
16
+ ];
17
+
18
+ const MARK = path.join('.eojeon', 'hook', 'hook.js');
19
+ const isOurs = (h) => typeof h?.command === 'string' && h.command.replace(/\\/g, '/').includes(MARK.replace(/\\/g, '/'));
20
+
21
+ export function eojeonDir(home = os.homedir()) {
22
+ return path.join(home, '.eojeon');
23
+ }
24
+
25
+ export function runtimeFile(home = os.homedir()) {
26
+ return path.join(eojeonDir(home), 'runtime.json');
27
+ }
28
+
29
+ function readSettings(file) {
30
+ if (!fs.existsSync(file)) return {};
31
+ const text = fs.readFileSync(file, 'utf8');
32
+ if (!text.trim()) return {};
33
+ try {
34
+ return JSON.parse(text);
35
+ } catch (e) {
36
+ throw new Error(`${file} 을 읽지 못했습니다(JSON 오류). 손대지 않았습니다: ${e.message}`);
37
+ }
38
+ }
39
+
40
+ function writeSettings(file, obj) {
41
+ fs.mkdirSync(path.dirname(file), { recursive: true });
42
+ const tmp = `${file}.eojeon-tmp`;
43
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n');
44
+ fs.renameSync(tmp, file);
45
+ }
46
+
47
+ // drop our entries, keep everything else exactly as the user had it
48
+ function strip(hooks) {
49
+ const out = {};
50
+ for (const [ev, groups] of Object.entries(hooks || {})) {
51
+ if (!Array.isArray(groups)) {
52
+ out[ev] = groups;
53
+ continue;
54
+ }
55
+ const kept = groups
56
+ .map((g) => (Array.isArray(g?.hooks) ? { ...g, hooks: g.hooks.filter((h) => !isOurs(h)) } : g))
57
+ .filter((g) => !Array.isArray(g?.hooks) || g.hooks.length > 0);
58
+ if (kept.length) out[ev] = kept;
59
+ }
60
+ return out;
61
+ }
62
+
63
+ export function install({ home = os.homedir(), node = process.execPath } = {}) {
64
+ const settingsFile = path.join(home, '.claude', 'settings.json');
65
+ const settings = readSettings(settingsFile); // throws before anything is written
66
+ const dir = path.join(eojeonDir(home), 'hook');
67
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
68
+ for (const f of ['hook.js', 'sanitize.js']) fs.copyFileSync(path.join(HERE, f), path.join(dir, f));
69
+ fs.writeFileSync(path.join(dir, 'package.json'), '{ "type": "module" }\n');
70
+ const backup = `${settingsFile}.eojeon-backup`;
71
+ if (fs.existsSync(settingsFile) && !fs.existsSync(backup)) fs.copyFileSync(settingsFile, backup);
72
+
73
+ const q = (s) => `"${s.replace(/"/g, '\\"')}"`;
74
+ const command = `${q(node)} ${q(path.join(dir, 'hook.js'))} --watch`;
75
+ const hooks = strip(settings.hooks);
76
+ for (const ev of HOOK_EVENTS) hooks[ev] = [...(hooks[ev] || []), { hooks: [{ type: 'command', command, timeout: 5 }] }];
77
+ writeSettings(settingsFile, { ...settings, hooks });
78
+ return { settingsFile, backup: fs.existsSync(backup) ? backup : null, command };
79
+ }
80
+
81
+ export function uninstall({ home = os.homedir() } = {}) {
82
+ const settingsFile = path.join(home, '.claude', 'settings.json');
83
+ const settings = readSettings(settingsFile);
84
+ const hooks = strip(settings.hooks);
85
+ const next = { ...settings };
86
+ if (Object.keys(hooks).length) next.hooks = hooks;
87
+ else delete next.hooks;
88
+ if (fs.existsSync(settingsFile)) writeSettings(settingsFile, next);
89
+ fs.rmSync(path.join(eojeonDir(home), 'hook'), { recursive: true, force: true });
90
+ return { settingsFile };
91
+ }
92
+
93
+ export function status({ home = os.homedir() } = {}) {
94
+ const settings = readSettings(path.join(home, '.claude', 'settings.json'));
95
+ return HOOK_EVENTS.every((ev) => (settings.hooks?.[ev] || []).some((g) => (g?.hooks || []).some(isOurs)));
96
+ }