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 +14 -0
- package/README.md +39 -4
- package/cli/eojeon.js +27 -1
- package/court.config.json +9 -0
- package/package.json +2 -2
- package/public/app.js +10 -2
- package/public/index.html +4 -3
- package/public/scene.js +8 -5
- package/public/style.css +5 -0
- package/server/hook.js +25 -4
- package/server/index.js +132 -21
- package/server/watch.js +96 -0
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
|
-
- **교지
|
|
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` 에만
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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> 가
|
|
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="
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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']) {
|
package/server/watch.js
ADDED
|
@@ -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
|
+
}
|