eojeon-cli 0.1.0 → 0.3.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,27 @@
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.3.0] - 2026-10-25
6
+
7
+ ### Added
8
+ - `eojeon claude [claude flags…]`: run claude in your own terminal and mirror it into the running court. The web terminal shows the same screen (following that terminal's size), and web input and 윤허/불허 stamps go into it. Hooks are attached the same way as for the court's own claude. Without a running court it runs plain claude.
9
+
10
+ ### Changed
11
+ - `server/pty.js` shares the claude lookup, node-pty loading and child environment between the server and `eojeon claude`.
12
+
13
+ ## [0.2.0] - 2026-10-24
14
+
15
+ Fixes from the first day of use.
16
+
17
+ ### Added
18
+ - `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`.
19
+ - `--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.
20
+ - 전령 (`jeollyeong`) office for an SNS-posting agent.
21
+
22
+ ### Changed
23
+ - One input on computers: the terminal. The 교지 쓰기 bar now shows only on touch screens.
24
+ - Petitions from outside sessions show "그 터미널에서 답하소서" instead of stamps; their log lines are tagged with the folder.
25
+
5
26
  ## [0.1.0] - 2026-10-24
6
27
 
7
28
  First public version.
@@ -22,4 +43,6 @@ First public version.
22
43
  - Hook payloads trimmed and secrets masked before leaving the hook process.
23
44
  - `scripts/check-publish.js` blocks publishing local paths, e-mail addresses or token-shaped strings.
24
45
 
46
+ [0.3.0]: https://github.com/hiio420pr/eojeon/releases/tag/v0.3.0
47
+ [0.2.0]: https://github.com/hiio420pr/eojeon/releases/tag/v0.2.0
25
48
  [0.1.0]: https://github.com/hiio420pr/eojeon/releases/tag/v0.1.0
package/README.md CHANGED
@@ -61,6 +61,10 @@ npm start # = node cli/eojeon.js
61
61
  | `--port <n>` | 포트 (기본 4747, 사용 중이면 다음 빈 포트) |
62
62
  | `--cwd <폴더>` | 시작 화면의 기본 작업 폴더 |
63
63
  | `--no-open` | 브라우저를 열지 않음 |
64
+ | `eojeon claude [claude 옵션…]` | **내 터미널에서** claude 를 켜고 그 화면을 어전에 실시간으로 그대로 띄움. 웹에서 친 글자·윤허 도장도 이 터미널의 claude 로 들어감. 예: `eojeon claude --continue` |
65
+ | `eojeon watch` / `eojeon watch --off` | 어전 밖(다른 터미널·IDE)에서 쓰는 Claude Code 도 화면에 보이게 / 끄기. 아래 '이미 쓰던 Claude Code 보기' |
66
+ | `--host <주소>` | 다른 기기(Tailscale·같은 와이파이)에서 접속 허용. 접속 키 필요. 아래 '다른 기기에서 보기' |
67
+ | `--new-key` | `--host` 와 함께: 접속 키 새로 만들기(예전 주소는 막힘) |
64
68
  | `--help`, `--version` | 도움말, 버전 |
65
69
 
66
70
  `COURT_CLAUDE_BIN=/path/to/claude` 로 다른 claude 실행 파일을 쓸 수 있습니다.
@@ -106,7 +110,46 @@ npm start # = node cli/eojeon.js
106
110
  - **시작 옵션**: 작업 폴더, 새 조회 / 이어서(`--continue`) / 지난 세션(`--resume <id>`), 권한 모드(`--permission-mode`: manual · acceptEdits · plan · auto · dontAsk — `bypassPermissions` 는 윤허 장면이 사라지고 위험해서 일부러 뺐습니다).
107
111
  - **장면**: 교지 반포 → 임금이 두루마리를 듦 · 도구 사용 → 담당 신하가 붓질하며 말풍선 · 서브에이전트 시작 → 그 관직의 신하가 입궐해 자기 책상으로, 끝나면 영의정께 보고하고 퇴궐 · 권한 요청 → 오른쪽 위에 "윤허를 청하옵니다" 패널과 **윤허 / 불허 / 보류** 도장(터미널 권한 창에 실제 키 전송: 윤허=`1`, 불허=`Esc`) · 실패 → 먹물 얼룩 말풍선.
108
112
  - 말풍선은 겹치지 않게 자리를 엇갈리고, 한 번에 3개까지만 보입니다. 나머지는 **사관 일지**(오른쪽)에 모두 남습니다.
109
- - **교지 쓰기 입력창**: 한글 입력이 편한 별도 입력창. Enter 로 터미널에 그대로 들어갑니다.
113
+ - **입력은 터미널 하나**: 컴퓨터에서는 아래 터미널에 바로 칩니다. 휴대폰·태블릿(터치 화면)에서만 터미널 아래에 **교지 쓰기** 칸이 보입니다(터치 키보드로 터미널에 한글을 치기 어려워서). Enter 하면 터미널에 그대로 들어갑니다.
114
+
115
+ ### 내 터미널을 어전에 그대로 (`eojeon claude`)
116
+
117
+ 어전 화면 안이 아니라 평소 쓰는 터미널에서 일하면서, 웹(다른 기기 포함)에도 **같은 CLI 화면과 궁궐 장면**이 실시간으로 보이게 합니다.
118
+
119
+ ```bash
120
+ npx eojeon-cli # 어전을 켜 두고
121
+ npx eojeon-cli claude --continue # 다른 터미널에서 claude 대신 이렇게 실행 (claude 옵션은 그대로 넘어감)
122
+ ```
123
+
124
+ - 웹 하단 터미널은 그 터미널의 화면을 그대로 보여 줍니다(크기도 그 터미널을 따름). 웹에서 입력하거나 윤허·불허 도장을 눌러도 그 claude 로 들어갑니다.
125
+ - 어전이 꺼져 있으면 그냥 평범한 claude 로 실행됩니다.
126
+ - 한 번에 하나만 연결됩니다. 어전 안에서 입궐한 claude 가 있으면 먼저 퇴청하세요.
127
+
128
+ ### 이미 쓰던 Claude Code 보기 (`eojeon watch`)
129
+
130
+ 어전에서 띄운 claude 말고, 원래 쓰던 터미널·IDE 의 Claude Code 도 궁궐에 보이게 합니다.
131
+
132
+ ```bash
133
+ npx eojeon-cli watch # 한 번만. ~/.claude/settings.json 에 hook 추가(처음 한 번 settings.json.eojeon-backup 백업)
134
+ npx eojeon-cli # 어전을 켜 두면, 이후 시작하는 모든 Claude Code 세션이 실시간으로 보임
135
+ npx eojeon-cli watch --off # 끄기(우리 hook 만 지우고 나머지 설정은 그대로)
136
+ ```
137
+
138
+ - **보기 전용**: 바깥 세션의 권한 요청은 패널에 "그 터미널에서 답하소서"로만 뜹니다. 도장이 그 터미널에 키를 보낼 수 없기 때문입니다. 사관 일지에는 `⇢ 폴더 이름` 이 붙습니다.
139
+ - 이미 열려 있는 세션은 `/hooks` 를 한 번 열거나 다시 시작해야 새 hook 을 읽습니다.
140
+ - 어전이 꺼져 있으면 hook 은 아무것도 하지 않고 바로 끝납니다. hook 파일은 `~/.eojeon/hook/` 에 복사해 두고 씁니다(npx 캐시 경로가 바뀌어도 동작).
141
+
142
+ ### 다른 기기에서 보기 (`--host`, Tailscale 등)
143
+
144
+ ```bash
145
+ npx eojeon-cli --host 0.0.0.0 # 또는 Tailscale IP 하나만: --host 100.x.y.z
146
+ ```
147
+
148
+ 시작할 때 터미널에 `http://<주소>:4747/?key=…` 가 찍힙니다(Tailscale 주소가 맨 위). **처음 한 번은 키가 붙은 그 주소로 여세요.** 그 뒤에는 기기에 쿠키가 남아 `http://macmini:4747` 처럼 키 없이 열립니다(30일).
149
+
150
+ - 키가 없으면 403. 키는 `~/.eojeon/remote-key` 에 있고 `--new-key` 로 바꿉니다.
151
+ - **키를 가진 사람은 이 컴퓨터의 claude 터미널을 쓸 수 있습니다.** 공용 와이파이에서는 `0.0.0.0` 대신 Tailscale IP 만 지정하세요.
152
+ - `--host` 없이 켜면 예전처럼 `127.0.0.1` 에만 열립니다.
110
153
 
111
154
  ## 작동 원리
112
155
 
@@ -117,7 +160,7 @@ npm start # = node cli/eojeon.js
117
160
 
118
161
  ## 개인정보
119
162
 
120
- - **로컬 전용**: 서버는 `127.0.0.1` 에만 열립니다. 외부로 보내는 요청이 없습니다. xterm 은 npm 패키지에서, 글꼴은 패키지 안에서 직접 서빙합니다(CDN 없음, 추적 없음).
163
+ - **로컬 전용**: 서버는 기본으로 `127.0.0.1` 에만 열립니다(`--host` 를 줄 때만 다른 기기에 열리고, 그때는 접속 키가 필요). 외부로 보내는 요청이 없습니다. xterm 은 npm 패키지에서, 글꼴은 패키지 안에서 직접 서빙합니다(CDN 없음, 추적 없음).
121
164
  - **비밀 가림**: 화면으로 가는 이벤트에는 파일 내용·도구 출력·전체 프롬프트가 없습니다. 프롬프트는 앞 60자, 명령은 첫 줄 56자까지, API 키·토큰·비밀번호·개인 키 모양 문자열은 `***` 로 가립니다.
122
165
  - **외부 연결 차단**: Host·Origin 을 검사해 다른 웹사이트가 내 터미널에 입력하거나 에이전트 파일을 쓰지 못하게 막고(DNS rebinding·CSWSH 방어), hook 수신은 실행마다 새로 만든 토큰으로만 받습니다.
123
166
  - 세션 목록은 `~/.claude/projects/` 기록 파일의 앞뒤만 읽기 전용으로 읽습니다.
@@ -185,7 +228,15 @@ claude plugin validate .
185
228
  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
229
  3. **git clone**: `git clone https://github.com/hiio420pr/eojeon.git && cd eojeon && npm install && npm start`
187
230
 
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`.
231
+ **Mirror your own terminal**: run `npx eojeon-cli claude [claude flags…]` (e.g. `--continue`) in any terminal while the court is running. The web shows that exact CLI screen live, and web input / permission stamps go into it. Without a running court it is plain claude.
232
+
233
+ **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.
234
+
235
+ **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.
236
+
237
+ On a computer you type into the terminal only; the separate 교지 input appears on touch screens only.
238
+
239
+ 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
240
 
190
241
  ### The Six Ministries (육조) — default subagents
191
242
 
@@ -206,7 +257,7 @@ Runs your **real** `claude` CLI in a PTY, shown in the browser with xterm.js. Ho
206
257
 
207
258
  ### Privacy
208
259
 
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.
260
+ 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
261
 
211
262
  ### Requirements
212
263
 
package/cli/eojeon.js CHANGED
@@ -3,6 +3,8 @@
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
7
+ // eojeon claude [claude args…] run claude in this terminal, mirrored live into the court
6
8
  // eojeon --help | --version
7
9
  import fs from 'node:fs';
8
10
  import path from 'node:path';
@@ -19,15 +21,23 @@ Usage
19
21
  eojeon demo [options] Replay a scripted session (no claude, no usage)
20
22
  eojeon init [dir] [--pack P] Add the Six Ministries (육조) subagents to dir/.claude/agents
21
23
  P = yukjo (default) | studio | all. Existing files are never overwritten.
24
+ eojeon claude [args…] Run claude in THIS terminal, mirrored live into the running court:
25
+ the web shows the same screen, and web input / 윤허 stamps reach it.
26
+ Any claude flags pass through (e.g. eojeon claude --continue).
27
+ eojeon watch [--off] Also show Claude Code sessions you run elsewhere (any terminal or IDE),
28
+ watch-only. Adds hooks to ~/.claude/settings.json (backup kept); --off removes them.
22
29
 
23
30
  Options
24
31
  --port <n> Port to listen on (default 4747; the next free port is used if taken)
25
32
  --cwd <dir> Default project folder shown in the launcher (default: current folder)
33
+ --host <addr> Also open to other devices (e.g. 0.0.0.0, or your Tailscale IP). Needs the access key
34
+ printed at start; whoever has it can use your claude terminal.
35
+ --new-key With --host: make a new access key (old addresses stop working)
26
36
  --no-open Do not open a browser
27
37
  -h, --help Show this help
28
38
  -v, --version Show the version
29
39
 
30
- Everything runs on 127.0.0.1 only. Docs: ${pkg.homepage}`;
40
+ By default everything runs on 127.0.0.1 only. Docs: ${pkg.homepage}`;
31
41
 
32
42
  function takeOpt(list, name) {
33
43
  const i = list.indexOf(name);
@@ -37,18 +47,26 @@ function takeOpt(list, name) {
37
47
  return v !== undefined && !v.startsWith('-') ? v : '';
38
48
  }
39
49
 
40
- if (argv.includes('-h') || argv.includes('--help') || argv[0] === 'help') {
50
+ if (argv[0] !== 'claude' && (argv.includes('-h') || argv.includes('--help') || argv[0] === 'help')) {
41
51
  console.log(HELP);
42
52
  process.exit(0);
43
53
  }
44
- if (argv.includes('-v') || argv.includes('--version')) {
54
+ if (argv[0] !== 'claude' && (argv.includes('-v') || argv.includes('--version'))) {
45
55
  console.log(pkg.version);
46
56
  process.exit(0);
47
57
  }
48
58
 
49
59
  const cmd = argv[0] && !argv[0].startsWith('-') ? argv.shift() : 'start';
50
60
 
51
- if (cmd === 'init') {
61
+ if (cmd === 'claude') {
62
+ // everything after `claude` belongs to claude itself
63
+ const { run } = await import('../server/attach.js');
64
+ await run(process.argv.slice(process.argv.indexOf('claude') + 1));
65
+ }
66
+
67
+ if (cmd === 'claude') {
68
+ // handled above; keeps running until claude exits
69
+ } else if (cmd === 'init') {
52
70
  const { install, PACKS } = await import('../server/agents.js');
53
71
  const pack = takeOpt(argv, '--pack') || 'yukjo';
54
72
  if (![...PACKS, 'all'].includes(pack)) {
@@ -66,6 +84,26 @@ if (cmd === 'init') {
66
84
  console.error(`init failed: ${e.message}`);
67
85
  process.exit(1);
68
86
  }
87
+ } else if (cmd === 'watch') {
88
+ const watch = await import('../server/watch.js');
89
+ try {
90
+ if (argv.includes('--off')) {
91
+ const r = watch.uninstall();
92
+ console.log(`어전 · eojeon watch off → removed our hooks from ${r.settingsFile}`);
93
+ } else if (argv.includes('--status')) {
94
+ console.log(watch.status() ? 'on' : 'off');
95
+ } else {
96
+ const r = watch.install();
97
+ console.log(`어전 · eojeon watch on → ${r.settingsFile}`);
98
+ if (r.backup) console.log(` backup: ${r.backup}`);
99
+ console.log(' Claude Code sessions started from now on (any terminal, IDE) appear in the court while it runs.');
100
+ console.log(' Watch-only: answer their permission prompts in their own terminal. Turn off: eojeon watch --off');
101
+ console.log(' Already-open sessions: run /hooks once (or restart) so they pick up the new hooks.');
102
+ }
103
+ } catch (e) {
104
+ console.error(`watch failed: ${e.message}`);
105
+ process.exit(1);
106
+ }
69
107
  } else if (cmd === 'start' || cmd === 'demo') {
70
108
  const pass = [...argv];
71
109
  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.3.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 && node --check server/pty.js && node --check server/attach.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
@@ -47,17 +47,28 @@ let running = false;
47
47
  const send = (m) => ws && ws.readyState === 1 && ws.send(JSON.stringify(m));
48
48
  const sendInput = (d) => (DEMO ? null : send({ t: 'input', d }));
49
49
 
50
+ // while another terminal is mirrored (eojeon claude), its size wins so the screen lines up
51
+ let mirrorSize = null;
50
52
  function doFit() {
51
53
  try {
54
+ if (mirrorSize) {
55
+ term.resize(mirrorSize.cols, mirrorSize.rows);
56
+ return;
57
+ }
52
58
  fit.fit();
53
59
  send({ t: 'resize', cols: term.cols, rows: term.rows });
54
60
  } catch {}
55
61
  }
62
+ function setMirror(size) {
63
+ mirrorSize = size || null;
64
+ $('term').classList.toggle('mirror', !!mirrorSize);
65
+ doFit();
66
+ }
56
67
  new ResizeObserver(() => doFit()).observe($('term'));
57
68
  term.onData((d) => {
58
69
  sendInput(d);
59
70
  // answered the permission dialog directly in the terminal: Esc = refused
60
- if (d === '\x1b' && court.petition && !court.petition.closed) {
71
+ if (d === '\x1b' && court.petition && !court.petition.closed && !court.petition.ext) {
61
72
  court.closePetition('no');
62
73
  addLog({ type: 'Verdict', time: Date.now(), verdict: 'no' });
63
74
  }
@@ -109,7 +120,9 @@ let lastAsk = 0;
109
120
  function logLine(ev) {
110
121
  const who = titleOf(ev.agentType);
111
122
  switch (ev.type) {
112
- case 'PtyStart': return ['sys', '·', `claude 를 띄웠습니다 · ${ev.folder} (${{ new: '새 조회', continue: '이어서', resume: '세션 이어받기' }[ev.mode] || ev.mode})`];
123
+ case 'PtyStart':
124
+ if (ev.mode === 'mirror') return ['sys', '⇄', `다른 터미널의 claude 가 연결됐습니다 · ${ev.folder}`];
125
+ return ['sys', '·', `claude 를 띄웠습니다 · ${ev.folder} (${{ new: '새 조회', continue: '이어서', resume: '세션 이어받기' }[ev.mode] || ev.mode})`];
113
126
  case 'PtyExit': return ['sys', '·', `claude 가 끝났습니다 (code ${ev.code})`];
114
127
  case 'SessionStart':
115
128
  if (ev.source === 'clear') return ['sys', '·', '조정을 새로 엽니다 (/clear)'];
@@ -162,7 +175,8 @@ function addLog(ev) {
162
175
  ic.textContent = icon;
163
176
  const tx = document.createElement('span');
164
177
  tx.className = 'tx';
165
- tx.textContent = text;
178
+ // sessions outside the court (eojeon watch) carry their folder name
179
+ tx.textContent = ev.ext ? `⇢ ${ev.folder || '바깥'} · ${text}` : text;
166
180
  li.append(t, ic, tx);
167
181
  if (ev.toolUseId) byTool.set(ev.toolUseId, li);
168
182
  const atBottom = logEl.scrollTop + logEl.clientHeight >= logEl.scrollHeight - 30;
@@ -174,6 +188,12 @@ function addLog(ev) {
174
188
  function onEvent(ev, opts = {}) {
175
189
  addLog(ev);
176
190
  court.handle(ev, opts);
191
+ if (ev.ext && !running && !DEMO) {
192
+ $('termHintMain').innerHTML = `바깥 터미널의 Claude Code 를 보는 중입니다 (<b>${escapeHtml(ev.folder || '')}</b>) — 보기 전용`;
193
+ }
194
+ }
195
+ function escapeHtml(s) {
196
+ return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
177
197
  }
178
198
 
179
199
  // ---------- launch controls ----------
@@ -342,7 +362,8 @@ function connect() {
342
362
  switch (msg.t) {
343
363
  case 'hello':
344
364
  running = msg.running;
345
- setStatus(running ? 'running' : 'idle', running ? `진행 중 · ${msg.info?.folder || ''}` : '대기');
365
+ setMirror(msg.size);
366
+ setStatus(running ? 'running' : 'idle', running ? `${msg.size ? '다른 터미널 연결' : '진행 중'} · ${msg.info?.folder || ''}` : '대기');
346
367
  hint.classList.toggle('hidden', running);
347
368
  logEl.innerHTML = '';
348
369
  for (const ev of msg.history || []) onEvent(ev, { replay: true });
@@ -354,11 +375,20 @@ function connect() {
354
375
  case 'started':
355
376
  running = true;
356
377
  hint.classList.add('hidden');
357
- setStatus('running', `진행 중 · ${msg.info.folder}`);
378
+ if (msg.size) {
379
+ term.reset();
380
+ court.reset();
381
+ }
382
+ setMirror(msg.size);
383
+ setStatus('running', `${msg.size ? '다른 터미널 연결' : '진행 중'} · ${msg.info.folder}`);
384
+ break;
385
+ case 'size':
386
+ setMirror({ cols: msg.cols, rows: msg.rows });
358
387
  break;
359
388
  case 'exit':
360
389
  running = false;
361
- setStatus('idle', `끝남 (code ${msg.code})`);
390
+ setMirror(null);
391
+ setStatus('idle', msg.mirror ? '다른 터미널 연결 끝남' : `끝남 (code ${msg.code})`);
362
392
  term.write('\r\n\x1b[2m[claude 가 끝났습니다. 위에서 다시 입궐할 수 있습니다]\x1b[0m\r\n');
363
393
  break;
364
394
  case 'event':
@@ -393,5 +423,6 @@ if (DEMO) {
393
423
  });
394
424
  } else {
395
425
  if (!info.claudeFound) setStatus('error', 'claude 를 찾지 못했습니다 — Claude Code 를 설치하세요');
426
+ if (info.remote) $('footLocal').textContent = '다른 기기 접속 켜짐 (--host) · 접속 키가 있는 기기만 들어올 수 있습니다.';
396
427
  connect();
397
428
  }
package/public/index.html CHANGED
@@ -53,12 +53,14 @@
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">내 터미널에서 쓰고 화면도 함께 보려면 그 터미널에서: <code>npx eojeon-cli claude</code> (예: <code>… claude --continue</code>)</p>
59
+ <p class="muted">이미 열린 Claude Code 의 움직임만 보려면: <code>npx eojeon-cli watch</code></p>
58
60
  </div>
59
61
  <form id="decreeForm" class="decree-bar" autocomplete="off">
60
62
  <label for="decreeInput" class="decree-label">교지 쓰기</label>
61
- <input id="decreeInput" type="text" placeholder="명령을 쓰고 Enter — 터미널에 그대로 들어갑니다" />
63
+ <input id="decreeInput" type="text" placeholder="휴대폰용 입력칸 — 쓰고 Enter 하면 위 터미널에 들어갑니다" />
62
64
  <button class="btn">반포</button>
63
65
  </form>
64
66
  </section>
@@ -86,7 +88,7 @@
86
88
  </dialog>
87
89
 
88
90
  <footer class="foot">
89
- <span>모든 것이 이 컴퓨터 안에서만 돕니다 (127.0.0.1). 외부로 보내는 것 없음.</span>
91
+ <span id="footLocal">모든 것이 이 컴퓨터 안에서만 돕니다 (127.0.0.1). 외부로 보내는 것 없음.</span>
90
92
  <span>Claude Code 비공식 도구 · MIT · 글꼴 Galmuri · D2Coding (SIL OFL 1.1)</span>
91
93
  </footer>
92
94
 
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; }
@@ -282,12 +285,16 @@ body { height: 100vh; }
282
285
  /* ---------- terminal ---------- */
283
286
  .term { flex: 1; min-height: 0; padding: 6px 0 0 8px; background: var(--paper); }
284
287
  .term .xterm { height: 100%; }
288
+ .term.mirror { overflow: auto; }
289
+ .term.mirror .xterm { height: auto; }
285
290
  .term-hint {
286
291
  position: absolute; inset: 0 0 46px 0; display: grid; place-content: center; text-align: center;
287
292
  color: var(--ink); pointer-events: none; font-size: 13px;
288
293
  }
289
294
  .term-hint p { margin: 4px 0; }
290
295
  .term-hint.hidden { display: none; }
296
+ /* one input on computers: the terminal. The decree bar is for touch screens, where typing into xterm is awkward */
297
+ @media (hover: hover) and (pointer: fine) { .decree-bar { display: none !important; } .term-hint { inset: 0; } }
291
298
  .decree-bar { display: flex; gap: 8px; align-items: center; padding: 8px; border-top: 1px solid var(--line); background: var(--card); }
292
299
  .decree-label { font-family: var(--pixel); font-size: 12px; color: var(--hwang); white-space: nowrap; }
293
300
  .decree-bar input {
@@ -0,0 +1,103 @@
1
+ // `eojeon claude [claude args…]`: run claude in *this* terminal and mirror it into the running court.
2
+ // The screen is copied to the browser as it is drawn, and whatever is typed in the browser
3
+ // (or a 윤허/불허 stamp) goes into this claude too. Without a running court it is plain claude.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import WebSocket from 'ws';
7
+ import { runtimeFile } from './watch.js';
8
+ import { findClaude, loadPty, childEnv } from './pty.js';
9
+
10
+ function readRuntime() {
11
+ try {
12
+ const rt = JSON.parse(fs.readFileSync(runtimeFile(), 'utf8'));
13
+ process.kill(rt.pid, 0); // still alive?
14
+ return rt;
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ function connect(rt, folder, cols, rows) {
21
+ return new Promise((resolve) => {
22
+ const ws = new WebSocket(`ws://127.0.0.1:${rt.port}/`, { headers: { origin: `http://127.0.0.1:${rt.port}` } });
23
+ const fail = (why) => {
24
+ try { ws.close(); } catch {}
25
+ resolve({ ws: null, why });
26
+ };
27
+ const timer = setTimeout(() => fail('timeout'), 3000);
28
+ ws.on('open', () => ws.send(JSON.stringify({ t: 'attach', token: rt.token, folder, cols, rows })));
29
+ ws.on('message', (raw) => {
30
+ let m;
31
+ try { m = JSON.parse(raw.toString()); } catch { return; }
32
+ if (m.t === 'attached') {
33
+ clearTimeout(timer);
34
+ resolve({ ws });
35
+ } else if (m.t === 'error') {
36
+ clearTimeout(timer);
37
+ fail(m.message);
38
+ }
39
+ });
40
+ ws.on('error', (e) => {
41
+ clearTimeout(timer);
42
+ fail(e.message);
43
+ });
44
+ });
45
+ }
46
+
47
+ export async function run(args) {
48
+ const claude = findClaude();
49
+ if (!claude) {
50
+ console.error('claude 를 찾지 못했습니다. Claude Code 를 먼저 설치하세요.');
51
+ process.exit(1);
52
+ }
53
+ const cwd = process.cwd();
54
+ let cols = process.stdout.columns || 100;
55
+ let rows = process.stdout.rows || 30;
56
+
57
+ const rt = readRuntime();
58
+ let ws = null;
59
+ if (rt) {
60
+ const r = await connect(rt, path.basename(cwd), cols, rows);
61
+ ws = r.ws;
62
+ if (!ws) console.error(`어전에 연결하지 못했습니다 (${r.why}). 그냥 claude 로 실행합니다.`);
63
+ } else {
64
+ console.error('어전이 꺼져 있습니다(npx eojeon-cli). 그냥 claude 로 실행합니다.');
65
+ }
66
+ const send = (m) => ws && ws.readyState === 1 && ws.send(JSON.stringify(m));
67
+
68
+ const claudeArgs = ws && rt.settings && fs.existsSync(rt.settings) ? ['--settings', rt.settings, ...args] : args;
69
+ const { spawn } = loadPty();
70
+ const pty = spawn(claude, claudeArgs, { name: 'xterm-256color', cols, rows, cwd, env: childEnv() });
71
+
72
+ pty.onData((d) => {
73
+ process.stdout.write(d);
74
+ send({ t: 'tout', d });
75
+ });
76
+ if (ws) {
77
+ ws.on('message', (raw) => {
78
+ let m;
79
+ try { m = JSON.parse(raw.toString()); } catch { return; }
80
+ if (m.t === 'input' && typeof m.d === 'string') pty.write(m.d);
81
+ });
82
+ ws.on('close', () => {
83
+ ws = null; // the court went away; keep working locally
84
+ });
85
+ }
86
+
87
+ if (process.stdin.isTTY) process.stdin.setRawMode(true);
88
+ process.stdin.setEncoding('utf8');
89
+ process.stdin.on('data', (d) => pty.write(d));
90
+ process.stdin.resume();
91
+ process.stdout.on('resize', () => {
92
+ cols = process.stdout.columns;
93
+ rows = process.stdout.rows;
94
+ try { pty.resize(cols, rows); } catch {}
95
+ send({ t: 'tsize', cols, rows });
96
+ });
97
+
98
+ pty.onExit(({ exitCode }) => {
99
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
100
+ try { ws?.close(); } catch {}
101
+ process.exit(exitCode ?? 0);
102
+ });
103
+ }
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,8 @@ 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';
19
+ import { findClaude, loadPty, childEnv } from './pty.js';
17
20
 
18
21
  const require = createRequire(import.meta.url);
19
22
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -31,36 +34,36 @@ const DEMO = flag('--demo');
31
34
  const NO_OPEN = flag('--no-open') || process.env.COURT_NO_OPEN === '1';
32
35
  const WANT_PORT = Number(opt('--port', process.env.COURT_PORT || 4747));
33
36
  const DEFAULT_CWD = path.resolve(expandHome(opt('--cwd', process.env.INIT_CWD || process.cwd())));
37
+ const HOST = opt('--host', process.env.COURT_HOST || '127.0.0.1');
38
+ const LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1']);
39
+ const REMOTE = !LOOPBACK.has(HOST);
34
40
  const TOKEN = crypto.randomBytes(18).toString('hex');
41
+ const KEY = REMOTE ? remoteKey(flag('--new-key')) : '';
35
42
  const VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version;
36
43
 
44
+ // one key per machine, kept in ~/.eojeon/remote-key so a bookmarked address keeps working; --new-key rotates it
45
+ function remoteKey(rotate) {
46
+ const file = path.join(eojeonDir(), 'remote-key');
47
+ if (!rotate) {
48
+ try {
49
+ const k = fs.readFileSync(file, 'utf8').trim();
50
+ if (/^[0-9a-f]{32,}$/.test(k)) return k;
51
+ } catch {}
52
+ }
53
+ const k = crypto.randomBytes(24).toString('hex');
54
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
55
+ fs.writeFileSync(file, k + '\n', { mode: 0o600 });
56
+ return k;
57
+ }
58
+
37
59
  function expandHome(p) {
38
60
  return typeof p === 'string' && (p === '~' || p.startsWith('~/')) ? path.join(os.homedir(), p.slice(1)) : p;
39
61
  }
40
62
 
41
63
  // ---------- claude binary ----------
42
- function findClaude() {
43
- if (process.env.COURT_CLAUDE_BIN) return process.env.COURT_CLAUDE_BIN;
44
- const names = process.platform === 'win32' ? ['claude.exe', 'claude.cmd', 'claude'] : ['claude'];
45
- for (const dir of (process.env.PATH || '').split(path.delimiter)) {
46
- for (const n of names) {
47
- const p = path.join(dir, n);
48
- try {
49
- if (fs.statSync(p).isFile()) return p;
50
- } catch {}
51
- }
52
- }
53
- const local = path.join(os.homedir(), '.local', 'bin', 'claude');
54
- return fs.existsSync(local) ? local : null;
55
- }
56
64
  const CLAUDE = findClaude();
57
65
 
58
66
  // ---------- 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
67
  let tmpDir = null;
65
68
  let settingsPath = null;
66
69
  let runtimePath = null;
@@ -76,16 +79,28 @@ function writeSettings(port) {
76
79
  // lets `npm run fake-hooks` find this server (token stays on this machine, owner-only file)
77
80
  runtimePath = path.join(os.tmpdir(), `eojeon-${port}.json`);
78
81
  fs.writeFileSync(runtimePath, JSON.stringify({ port, token: TOKEN, hook: HOOK }), { mode: 0o600 });
82
+ // lets `eojeon watch` hooks (sessions outside the court) find this server
83
+ try {
84
+ fs.mkdirSync(eojeonDir(), { recursive: true, mode: 0o700 });
85
+ fs.writeFileSync(runtimeFile(), JSON.stringify({ port, token: TOKEN, pid: process.pid, settings: settingsPath }), { mode: 0o600 });
86
+ } catch {}
79
87
  }
80
88
 
81
89
  function cleanup() {
82
90
  try { if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
83
91
  try { if (runtimePath) fs.rmSync(runtimePath, { force: true }); } catch {}
92
+ try {
93
+ // only our own: a second court may have taken over the file
94
+ if (JSON.parse(fs.readFileSync(runtimeFile(), 'utf8')).pid === process.pid) fs.rmSync(runtimeFile(), { force: true });
95
+ } catch {}
84
96
  }
85
97
 
86
98
  // ---------- state ----------
87
99
  let pty = null;
88
100
  let ptyInfo = null; // { cwd, mode, sessionId, startedAt }
101
+ // `eojeon claude` in another terminal: its screen is mirrored here and web input goes back to it
102
+ let mirror = null; // { ws, cols, rows }
103
+ const isRunning = () => !!pty || !!mirror;
89
104
  let outBuf = '';
90
105
  const OUT_MAX = 256 * 1024;
91
106
  const events = [];
@@ -108,28 +123,6 @@ function sysEvent(type, extra = {}) {
108
123
  pushEvent({ type, time: Date.now(), ...extra });
109
124
  }
110
125
 
111
- // node-pty ships prebuilt binaries for macOS (arm64/x64) and Windows (x64/arm64).
112
- // On Linux the prebuilt fork @homebridge/node-pty-prebuilt-multiarch is used instead.
113
- // Both are optionalDependencies so `npx eojeon-cli` never fails to install (demo still works).
114
- const PTY_MODULES = ['node-pty', '@homebridge/node-pty-prebuilt-multiarch'];
115
- let ptyMod = null;
116
- function loadPty() {
117
- if (ptyMod) return ptyMod;
118
- const errors = [];
119
- for (const name of PTY_MODULES) {
120
- try {
121
- ptyMod = require(name);
122
- return ptyMod;
123
- } catch (e) {
124
- errors.push(`${name}: ${String(e.message).split('\n')[0]}`);
125
- }
126
- }
127
- throw new Error(
128
- '터미널 모듈(node-pty)을 불러오지 못했습니다. `npm i -g eojeon` 으로 다시 설치하거나 빌드 도구(python3, make, C++ 컴파일러)를 설치해 주세요. ' +
129
- 'Could not load node-pty. ' + errors.join(' | '),
130
- );
131
- }
132
-
133
126
  // `claude --help` → --permission-mode choices. bypassPermissions is left out on purpose:
134
127
  // with it no petition ever reaches the court, and it skips every safety prompt.
135
128
  export const PERMISSION_MODES = ['manual', 'acceptEdits', 'plan', 'auto', 'dontAsk'];
@@ -139,6 +132,7 @@ function startClaude({ cwd, mode, sessionId, permissionMode }) {
139
132
  if (!CLAUDE) throw new Error('PATH 에서 claude 를 찾지 못했습니다. Claude Code 를 먼저 설치하세요.');
140
133
  const dir = path.resolve(expandHome(cwd || DEFAULT_CWD));
141
134
  if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) throw new Error(`폴더가 없습니다: ${dir}`);
135
+ if (mirror) throw new Error('다른 터미널의 claude(eojeon claude)가 연결돼 있습니다. 그 터미널에서 끝낸 뒤 입궐하세요.');
142
136
  stopClaude();
143
137
  const args = ['--settings', settingsPath];
144
138
  if (mode === 'continue') args.push('--continue');
@@ -153,15 +147,7 @@ function startClaude({ cwd, mode, sessionId, permissionMode }) {
153
147
  args.push('--permission-mode', permissionMode);
154
148
  }
155
149
 
156
- const env = { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor', FORCE_COLOR: '1' };
157
- // if this server itself was started from inside Claude Code, don't let the child think it's nested
158
- // (session-scoped markers only; user config such as CLAUDE_CODE_USE_BEDROCK is kept)
159
- for (const k of [
160
- 'CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SESSION_ID',
161
- 'CLAUDE_CODE_SESSION_ATTENDED', 'CLAUDE_CODE_SSE_PORT', 'CLAUDE_CODE_MESSAGING_SOCKET',
162
- 'CLAUDE_CODE_MESSAGING_TOKEN', 'CLAUDE_CODE_BRIDGE_SESSION_ID', 'CLAUDE_CODE_EXECPATH', 'CLAUDE_PID', 'CLAUDE_EFFORT',
163
- ]) delete env[k];
164
- for (const k of Object.keys(env)) if (k.startsWith('npm_')) delete env[k];
150
+ const env = childEnv();
165
151
 
166
152
  const { spawn } = loadPty();
167
153
  pty = spawn(CLAUDE, args, { name: 'xterm-256color', cols: size.cols, rows: size.rows, cwd: dir, env });
@@ -211,6 +197,27 @@ function allowedHost(h) {
211
197
  function allowedOrigin(o) {
212
198
  return o === `http://localhost:${PORT}` || o === `http://127.0.0.1:${PORT}` || o === `http://[::1]:${PORT}`;
213
199
  }
200
+ const fromLoopback = (req) => /^(127\.|::1$|::ffff:127\.)/.test(req.socket.remoteAddress || '');
201
+ function cookieKey(req) {
202
+ const m = /(?:^|;\s*)eojeon_key=([0-9a-f]+)/.exec(req.headers.cookie || '');
203
+ return m ? m[1] : '';
204
+ }
205
+ function keyOk(k) {
206
+ return !!KEY && typeof k === 'string' && k.length === KEY.length && crypto.timingSafeEqual(Buffer.from(k), Buffer.from(KEY));
207
+ }
208
+ // Who may use the page and the terminal:
209
+ // - this computer, under a localhost name (DNS-rebinding guard) — as before
210
+ // - with --host: anyone holding the access key (cookie set from ?key=… on the first visit)
211
+ function access(req) {
212
+ if (fromLoopback(req) && allowedHost(req.headers.host || '')) return 'local';
213
+ if (REMOTE && keyOk(cookieKey(req))) return 'key';
214
+ return '';
215
+ }
216
+ // same-origin check for anything that writes (terminal socket, agent install)
217
+ function sameOrigin(req, who) {
218
+ const o = req.headers.origin || '';
219
+ return who === 'local' ? allowedOrigin(o) : o === `http://${req.headers.host}`;
220
+ }
214
221
 
215
222
  function sendFile(res, file) {
216
223
  fs.readFile(file, (err, data) => {
@@ -247,17 +254,13 @@ function loadConfig() {
247
254
  return JSON.parse(fs.readFileSync(path.join(ROOT, 'court.config.json'), 'utf8'));
248
255
  }
249
256
 
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
- }
257
+ async function handle(req, res) {
256
258
  const url = new URL(req.url, `http://localhost:${PORT}`);
257
259
  const p = url.pathname;
258
260
 
259
261
  if (p === '/hook' && req.method === 'POST') {
260
- if (req.headers['x-court-token'] !== TOKEN) {
262
+ // hooks always run on this computer
263
+ if (!fromLoopback(req) || req.headers['x-court-token'] !== TOKEN) {
261
264
  res.writeHead(403).end();
262
265
  return;
263
266
  }
@@ -273,9 +276,29 @@ const server = http.createServer(async (req, res) => {
273
276
  return;
274
277
  }
275
278
 
279
+ let who = access(req);
280
+ if (!who && REMOTE && req.method === 'GET' && keyOk(url.searchParams.get('key'))) {
281
+ // first visit from another device: trade the key in the address for a cookie, then drop it from the address bar
282
+ url.searchParams.delete('key');
283
+ res.writeHead(302, {
284
+ 'set-cookie': `eojeon_key=${KEY}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${60 * 60 * 24 * 30}`,
285
+ location: url.pathname + url.search,
286
+ 'cache-control': 'no-store',
287
+ });
288
+ res.end();
289
+ return;
290
+ }
291
+ if (!who) {
292
+ // DNS-rebinding guard: only localhost names, or the access key with --host
293
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' }).end(
294
+ REMOTE ? 'forbidden — 어전을 켠 터미널에 찍힌 주소(?key=…)로 한 번 열어 주세요' : 'forbidden host',
295
+ );
296
+ return;
297
+ }
298
+
276
299
  if (p === '/api/agents/install' && req.method === 'POST') {
277
300
  // 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'] || '')) {
301
+ if (!sameOrigin(req, who) || !/^application\/json/.test(req.headers['content-type'] || '')) {
279
302
  res.writeHead(403).end();
280
303
  return;
281
304
  }
@@ -297,8 +320,8 @@ const server = http.createServer(async (req, res) => {
297
320
  if (p === '/api/info') {
298
321
  return json(res, 200, {
299
322
  app: 'eojeon', version: VERSION,
300
- demo: DEMO, defaultCwd: DEFAULT_CWD, claudeFound: !!CLAUDE, running: !!pty, info: ptyInfo,
301
- home: os.homedir(), platform: process.platform, permissionModes: PERMISSION_MODES,
323
+ demo: DEMO, defaultCwd: DEFAULT_CWD, claudeFound: !!CLAUDE, running: isRunning(), info: ptyInfo,
324
+ home: os.homedir(), platform: process.platform, permissionModes: PERMISSION_MODES, remote: REMOTE,
302
325
  });
303
326
  }
304
327
  if (p === '/api/config') {
@@ -336,32 +359,92 @@ const server = http.createServer(async (req, res) => {
336
359
  return;
337
360
  }
338
361
  sendFile(res, file);
339
- });
362
+ }
340
363
 
341
364
  // ---------- websocket ----------
342
365
  const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
343
- server.on('upgrade', (req, socket, head) => {
366
+ function onUpgrade(req, socket, head) {
344
367
  // 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 || '')) {
368
+ const who = access(req);
369
+ if (!who || !sameOrigin(req, who)) {
346
370
  socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
347
371
  socket.destroy();
348
372
  return;
349
373
  }
350
374
  wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
351
- });
375
+ }
376
+ function makeServer() {
377
+ const s = http.createServer(handle);
378
+ s.on('upgrade', onUpgrade);
379
+ return s;
380
+ }
381
+ // always on 127.0.0.1 (hooks post there); with --host also on that address unless it already covers loopback
382
+ const server = makeServer();
383
+ const WILDCARD = HOST === '0.0.0.0' || HOST === '::';
384
+
385
+ function attachMirror(ws, m) {
386
+ if (pty) throw new Error('어전 안에서 claude 가 이미 돌고 있습니다. 화면에서 퇴청한 뒤 다시 시도하세요.');
387
+ if (mirror) throw new Error('다른 터미널이 이미 연결돼 있습니다.');
388
+ clients.delete(ws); // the mirror is a source, not a viewer
389
+ mirror = { ws, cols: Math.max(20, Math.min(400, m.cols | 0)), rows: Math.max(5, Math.min(200, m.rows | 0)) };
390
+ ptyInfo = { folder: String(m.folder || '').slice(0, 64), mode: 'mirror', startedAt: Date.now() };
391
+ outBuf = '';
392
+ ws.send(JSON.stringify({ t: 'attached' }));
393
+ broadcast({ t: 'started', info: ptyInfo, size: { cols: mirror.cols, rows: mirror.rows } });
394
+ sysEvent('PtyStart', { folder: ptyInfo.folder, mode: 'mirror' });
395
+ ws.on('close', () => {
396
+ if (mirror?.ws !== ws) return;
397
+ mirror = null;
398
+ broadcast({ t: 'exit', code: 0, mirror: true });
399
+ sysEvent('PtyExit', { code: 0 });
400
+ });
401
+ }
352
402
 
353
- wss.on('connection', (ws) => {
403
+ wss.on('connection', (ws, req) => {
354
404
  clients.add(ws);
355
- ws.send(JSON.stringify({ t: 'hello', demo: DEMO, running: !!pty, info: ptyInfo, history: events.slice(-200) }));
356
- if (pty && outBuf) ws.send(JSON.stringify({ t: 'out', d: outBuf, replay: true }));
405
+ ws.send(JSON.stringify({
406
+ t: 'hello', demo: DEMO, running: isRunning(), info: ptyInfo, history: events.slice(-200),
407
+ size: mirror ? { cols: mirror.cols, rows: mirror.rows } : null,
408
+ }));
409
+ if (isRunning() && outBuf) ws.send(JSON.stringify({ t: 'out', d: outBuf, replay: true }));
357
410
  ws.on('message', (raw) => {
358
411
  let m;
359
412
  try { m = JSON.parse(raw.toString()); } catch { return; }
413
+ // messages from the attached terminal itself
414
+ if (mirror && ws === mirror.ws) {
415
+ if (m.t === 'tout' && typeof m.d === 'string') {
416
+ outBuf += m.d;
417
+ if (outBuf.length > OUT_MAX) outBuf = outBuf.slice(outBuf.length - OUT_MAX);
418
+ broadcast({ t: 'out', d: m.d });
419
+ } else if (m.t === 'tsize') {
420
+ mirror.cols = Math.max(20, Math.min(400, m.cols | 0));
421
+ mirror.rows = Math.max(5, Math.min(200, m.rows | 0));
422
+ broadcast({ t: 'size', cols: mirror.cols, rows: mirror.rows });
423
+ }
424
+ return;
425
+ }
360
426
  switch (m.t) {
427
+ case 'attach':
428
+ // only a process on this computer that can read ~/.eojeon/runtime.json
429
+ if (!fromLoopback(req) || m.token !== TOKEN) {
430
+ ws.send(JSON.stringify({ t: 'error', message: 'attach refused' }));
431
+ ws.close();
432
+ break;
433
+ }
434
+ try {
435
+ attachMirror(ws, m);
436
+ } catch (e) {
437
+ ws.send(JSON.stringify({ t: 'error', message: e.message }));
438
+ ws.close();
439
+ }
440
+ break;
361
441
  case 'input':
362
- if (pty && typeof m.d === 'string') pty.write(m.d);
442
+ if (typeof m.d !== 'string') break;
443
+ if (pty) pty.write(m.d);
444
+ else if (mirror && mirror.ws.readyState === 1) mirror.ws.send(JSON.stringify({ t: 'input', d: m.d }));
363
445
  break;
364
446
  case 'resize': {
447
+ if (mirror) break; // the other terminal decides the size
365
448
  const cols = Math.max(20, Math.min(400, m.cols | 0));
366
449
  const rows = Math.max(5, Math.min(200, m.rows | 0));
367
450
  size = { cols, rows };
@@ -376,7 +459,8 @@ wss.on('connection', (ws) => {
376
459
  }
377
460
  break;
378
461
  case 'stop':
379
- stopClaude();
462
+ if (mirror) ws.send(JSON.stringify({ t: 'error', message: '연결된 터미널의 claude 는 그 터미널에서 끝내세요 (/exit).' }));
463
+ else stopClaude();
380
464
  break;
381
465
  default:
382
466
  break;
@@ -399,10 +483,34 @@ function listen(port, tries = 10) {
399
483
  };
400
484
  server.once('error', onErr);
401
485
  server.once('listening', onOk);
402
- server.listen(port, '127.0.0.1');
486
+ server.listen(port, WILDCARD ? HOST : '127.0.0.1');
403
487
  });
404
488
  }
405
489
 
490
+ function listenAlso(port) {
491
+ return new Promise((resolve, reject) => {
492
+ const s = makeServer();
493
+ s.once('error', reject);
494
+ s.listen(port, HOST, () => resolve(s));
495
+ });
496
+ }
497
+
498
+ // addresses another device can use: Tailscale (100.64.0.0/10), LAN, and this computer's name
499
+ function remoteUrls(port) {
500
+ const out = [];
501
+ if (!WILDCARD) out.push(HOST);
502
+ else {
503
+ for (const list of Object.values(os.networkInterfaces())) {
504
+ for (const a of list || []) if (a.family === 'IPv4' && !a.internal) out.push(a.address);
505
+ }
506
+ out.push(os.hostname().replace(/\.local$/, ''));
507
+ }
508
+ const ts = (a) => /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(a);
509
+ return [...new Set(out)]
510
+ .sort((a, b) => ts(b) - ts(a))
511
+ .map((h) => `http://${h.includes(':') ? `[${h}]` : h}:${port}/?key=${KEY}${ts(h) ? ' (Tailscale)' : ''}`);
512
+ }
513
+
406
514
  function openBrowser(url) {
407
515
  const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
408
516
  const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
@@ -412,12 +520,27 @@ function openBrowser(url) {
412
520
  }
413
521
 
414
522
  PORT = await listen(WANT_PORT);
523
+ if (REMOTE && !WILDCARD) {
524
+ try {
525
+ await listenAlso(PORT);
526
+ } catch (e) {
527
+ console.error(` --host ${HOST}: ${e.message}`);
528
+ process.exit(1);
529
+ }
530
+ }
415
531
  writeSettings(PORT);
416
532
  const url = `http://localhost:${PORT}/${DEMO ? '?demo=1' : ''}`;
417
533
  console.log(`\n 어전 · eojeon ${VERSION} ${DEMO ? '(demo)' : ''}`);
418
534
  console.log(` ${url}`);
419
535
  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');
536
+ if (REMOTE) {
537
+ console.log(`\n 다른 기기에서 (--host ${HOST}) — 처음 한 번은 키가 붙은 주소로 여세요:`);
538
+ for (const u of remoteUrls(PORT)) console.log(` ${u}`);
539
+ console.log(' ! 이 키를 가진 사람은 이 컴퓨터의 claude 터미널을 쓸 수 있습니다. 믿는 기기에서만 여세요. 키 바꾸기: --new-key');
540
+ console.log(' Ctrl+C to quit.\n');
541
+ } else {
542
+ console.log(' Everything stays on this machine (127.0.0.1). Ctrl+C to quit.\n');
543
+ }
421
544
  if (!NO_OPEN) openBrowser(url);
422
545
 
423
546
  for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
package/server/pty.js ADDED
@@ -0,0 +1,59 @@
1
+ // Shared by the court server and `eojeon claude`: find the claude binary, load node-pty,
2
+ // and build a clean environment for a claude child process.
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { createRequire } from 'node:module';
7
+
8
+ const require = createRequire(import.meta.url);
9
+
10
+ export function findClaude() {
11
+ if (process.env.COURT_CLAUDE_BIN) return process.env.COURT_CLAUDE_BIN;
12
+ const names = process.platform === 'win32' ? ['claude.exe', 'claude.cmd', 'claude'] : ['claude'];
13
+ for (const dir of (process.env.PATH || '').split(path.delimiter)) {
14
+ for (const n of names) {
15
+ const p = path.join(dir, n);
16
+ try {
17
+ if (fs.statSync(p).isFile()) return p;
18
+ } catch {}
19
+ }
20
+ }
21
+ const local = path.join(os.homedir(), '.local', 'bin', 'claude');
22
+ return fs.existsSync(local) ? local : null;
23
+ }
24
+
25
+ // node-pty ships prebuilt binaries for macOS (arm64/x64) and Windows (x64/arm64).
26
+ // On Linux the prebuilt fork @homebridge/node-pty-prebuilt-multiarch is used instead.
27
+ // Both are optionalDependencies so `npx eojeon-cli` never fails to install (demo still works).
28
+ const PTY_MODULES = ['node-pty', '@homebridge/node-pty-prebuilt-multiarch'];
29
+ let ptyMod = null;
30
+ export function loadPty() {
31
+ if (ptyMod) return ptyMod;
32
+ const errors = [];
33
+ for (const name of PTY_MODULES) {
34
+ try {
35
+ ptyMod = require(name);
36
+ return ptyMod;
37
+ } catch (e) {
38
+ errors.push(`${name}: ${String(e.message).split('\n')[0]}`);
39
+ }
40
+ }
41
+ throw new Error(
42
+ '터미널 모듈(node-pty)을 불러오지 못했습니다. `npm i -g eojeon` 으로 다시 설치하거나 빌드 도구(python3, make, C++ 컴파일러)를 설치해 주세요. ' +
43
+ 'Could not load node-pty. ' + errors.join(' | '),
44
+ );
45
+ }
46
+
47
+ export function childEnv() {
48
+ const env = { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor', FORCE_COLOR: '1' };
49
+ // if the court itself was started from inside Claude Code, don't let the child think it's nested
50
+ // (session-scoped markers only; user config such as CLAUDE_CODE_USE_BEDROCK is kept)
51
+ for (const k of [
52
+ 'CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SESSION_ID',
53
+ 'CLAUDE_CODE_SESSION_ATTENDED', 'CLAUDE_CODE_SSE_PORT', 'CLAUDE_CODE_MESSAGING_SOCKET',
54
+ 'CLAUDE_CODE_MESSAGING_TOKEN', 'CLAUDE_CODE_BRIDGE_SESSION_ID', 'CLAUDE_CODE_EXECPATH', 'CLAUDE_PID', 'CLAUDE_EFFORT',
55
+ ]) delete env[k];
56
+ for (const k of Object.keys(env)) if (k.startsWith('npm_')) delete env[k];
57
+ env.EOJEON_CHILD = '1'; // `eojeon watch` hooks skip this session (it already reports through --settings)
58
+ return env;
59
+ }
@@ -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
+ }