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 +23 -0
- package/README.md +55 -4
- package/cli/eojeon.js +42 -4
- package/court.config.json +9 -0
- package/package.json +2 -2
- package/public/app.js +37 -6
- package/public/index.html +5 -3
- package/public/scene.js +8 -5
- package/public/style.css +7 -0
- package/server/attach.js +103 -0
- package/server/hook.js +25 -4
- package/server/index.js +195 -72
- package/server/pty.js +59 -0
- package/server/watch.js +96 -0
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
|
-
- **교지
|
|
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` 에만
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 === '
|
|
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.
|
|
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':
|
|
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
|
-
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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> 가
|
|
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="
|
|
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 {
|
package/server/attach.js
ADDED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
356
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
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
|
+
}
|