eojeon-cli 0.2.0 → 0.3.1
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 +15 -0
- package/README.md +16 -0
- package/cli/eojeon.js +15 -3
- package/package.json +2 -2
- package/public/app.js +27 -4
- package/public/index.html +2 -1
- package/public/style.css +2 -0
- package/server/attach.js +127 -0
- package/server/index.js +65 -53
- package/server/pty.js +59 -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.3.1] - 2026-10-25
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- `eojeon claude` no longer gives up when the court is busy (another claude already running in it) or not started yet: it keeps trying every 3 s and attaches as soon as the court is free, then makes claude redraw so the web shows the full screen.
|
|
9
|
+
|
|
10
|
+
## [0.3.0] - 2026-10-25
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `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.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- `server/pty.js` shares the claude lookup, node-pty loading and child environment between the server and `eojeon claude`.
|
|
17
|
+
|
|
5
18
|
## [0.2.0] - 2026-10-24
|
|
6
19
|
|
|
7
20
|
Fixes from the first day of use.
|
|
@@ -35,5 +48,7 @@ First public version.
|
|
|
35
48
|
- Hook payloads trimmed and secrets masked before leaving the hook process.
|
|
36
49
|
- `scripts/check-publish.js` blocks publishing local paths, e-mail addresses or token-shaped strings.
|
|
37
50
|
|
|
51
|
+
[0.3.1]: https://github.com/hiio420pr/eojeon/releases/tag/v0.3.1
|
|
52
|
+
[0.3.0]: https://github.com/hiio420pr/eojeon/releases/tag/v0.3.0
|
|
38
53
|
[0.2.0]: https://github.com/hiio420pr/eojeon/releases/tag/v0.2.0
|
|
39
54
|
[0.1.0]: https://github.com/hiio420pr/eojeon/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -61,6 +61,7 @@ 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` |
|
|
64
65
|
| `eojeon watch` / `eojeon watch --off` | 어전 밖(다른 터미널·IDE)에서 쓰는 Claude Code 도 화면에 보이게 / 끄기. 아래 '이미 쓰던 Claude Code 보기' |
|
|
65
66
|
| `--host <주소>` | 다른 기기(Tailscale·같은 와이파이)에서 접속 허용. 접속 키 필요. 아래 '다른 기기에서 보기' |
|
|
66
67
|
| `--new-key` | `--host` 와 함께: 접속 키 새로 만들기(예전 주소는 막힘) |
|
|
@@ -111,6 +112,19 @@ npm start # = node cli/eojeon.js
|
|
|
111
112
|
- 말풍선은 겹치지 않게 자리를 엇갈리고, 한 번에 3개까지만 보입니다. 나머지는 **사관 일지**(오른쪽)에 모두 남습니다.
|
|
112
113
|
- **입력은 터미널 하나**: 컴퓨터에서는 아래 터미널에 바로 칩니다. 휴대폰·태블릿(터치 화면)에서만 터미널 아래에 **교지 쓰기** 칸이 보입니다(터치 키보드로 터미널에 한글을 치기 어려워서). Enter 하면 터미널에 그대로 들어갑니다.
|
|
113
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
|
+
|
|
114
128
|
### 이미 쓰던 Claude Code 보기 (`eojeon watch`)
|
|
115
129
|
|
|
116
130
|
어전에서 띄운 claude 말고, 원래 쓰던 터미널·IDE 의 Claude Code 도 궁궐에 보이게 합니다.
|
|
@@ -214,6 +228,8 @@ claude plugin validate .
|
|
|
214
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`.
|
|
215
229
|
3. **git clone**: `git clone https://github.com/hiio420pr/eojeon.git && cd eojeon && npm install && npm start`
|
|
216
230
|
|
|
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
|
+
|
|
217
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.
|
|
218
234
|
|
|
219
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.
|
package/cli/eojeon.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
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
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
|
|
7
8
|
// eojeon --help | --version
|
|
8
9
|
import fs from 'node:fs';
|
|
9
10
|
import path from 'node:path';
|
|
@@ -20,6 +21,9 @@ Usage
|
|
|
20
21
|
eojeon demo [options] Replay a scripted session (no claude, no usage)
|
|
21
22
|
eojeon init [dir] [--pack P] Add the Six Ministries (육조) subagents to dir/.claude/agents
|
|
22
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).
|
|
23
27
|
eojeon watch [--off] Also show Claude Code sessions you run elsewhere (any terminal or IDE),
|
|
24
28
|
watch-only. Adds hooks to ~/.claude/settings.json (backup kept); --off removes them.
|
|
25
29
|
|
|
@@ -43,18 +47,26 @@ function takeOpt(list, name) {
|
|
|
43
47
|
return v !== undefined && !v.startsWith('-') ? v : '';
|
|
44
48
|
}
|
|
45
49
|
|
|
46
|
-
if (argv.includes('-h') || argv.includes('--help') || argv[0] === 'help') {
|
|
50
|
+
if (argv[0] !== 'claude' && (argv.includes('-h') || argv.includes('--help') || argv[0] === 'help')) {
|
|
47
51
|
console.log(HELP);
|
|
48
52
|
process.exit(0);
|
|
49
53
|
}
|
|
50
|
-
if (argv.includes('-v') || argv.includes('--version')) {
|
|
54
|
+
if (argv[0] !== 'claude' && (argv.includes('-v') || argv.includes('--version'))) {
|
|
51
55
|
console.log(pkg.version);
|
|
52
56
|
process.exit(0);
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
const cmd = argv[0] && !argv[0].startsWith('-') ? argv.shift() : 'start';
|
|
56
60
|
|
|
57
|
-
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') {
|
|
58
70
|
const { install, PACKS } = await import('../server/agents.js');
|
|
59
71
|
const pack = takeOpt(argv, '--pack') || 'yukjo';
|
|
60
72
|
if (![...PACKS, 'all'].includes(pack)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eojeon-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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 && node --check server/watch.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,12 +47,23 @@ 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);
|
|
@@ -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)'];
|
|
@@ -349,7 +362,8 @@ function connect() {
|
|
|
349
362
|
switch (msg.t) {
|
|
350
363
|
case 'hello':
|
|
351
364
|
running = msg.running;
|
|
352
|
-
|
|
365
|
+
setMirror(msg.size);
|
|
366
|
+
setStatus(running ? 'running' : 'idle', running ? `${msg.size ? '다른 터미널 연결' : '진행 중'} · ${msg.info?.folder || ''}` : '대기');
|
|
353
367
|
hint.classList.toggle('hidden', running);
|
|
354
368
|
logEl.innerHTML = '';
|
|
355
369
|
for (const ev of msg.history || []) onEvent(ev, { replay: true });
|
|
@@ -361,11 +375,20 @@ function connect() {
|
|
|
361
375
|
case 'started':
|
|
362
376
|
running = true;
|
|
363
377
|
hint.classList.add('hidden');
|
|
364
|
-
|
|
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 });
|
|
365
387
|
break;
|
|
366
388
|
case 'exit':
|
|
367
389
|
running = false;
|
|
368
|
-
|
|
390
|
+
setMirror(null);
|
|
391
|
+
setStatus('idle', msg.mirror ? '다른 터미널 연결 끝남' : `끝남 (code ${msg.code})`);
|
|
369
392
|
term.write('\r\n\x1b[2m[claude 가 끝났습니다. 위에서 다시 입궐할 수 있습니다]\x1b[0m\r\n');
|
|
370
393
|
break;
|
|
371
394
|
case 'event':
|
package/public/index.html
CHANGED
|
@@ -55,7 +55,8 @@
|
|
|
55
55
|
<div id="termHint" class="term-hint">
|
|
56
56
|
<p id="termHintMain"><b>입궐</b>을 누르면 이 폴더에서 <code>claude</code> 가 뜹니다. 명령은 이 터미널에 바로 치세요.</p>
|
|
57
57
|
<p class="muted">슬래시 명령·권한 창·/resume 이 모두 터미널 그대로 동작합니다.</p>
|
|
58
|
-
<p class="muted"
|
|
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>
|
|
59
60
|
</div>
|
|
60
61
|
<form id="decreeForm" class="decree-bar" autocomplete="off">
|
|
61
62
|
<label for="decreeInput" class="decree-label">교지 쓰기</label>
|
package/public/style.css
CHANGED
|
@@ -285,6 +285,8 @@ body { height: 100vh; }
|
|
|
285
285
|
/* ---------- terminal ---------- */
|
|
286
286
|
.term { flex: 1; min-height: 0; padding: 6px 0 0 8px; background: var(--paper); }
|
|
287
287
|
.term .xterm { height: 100%; }
|
|
288
|
+
.term.mirror { overflow: auto; }
|
|
289
|
+
.term.mirror .xterm { height: auto; }
|
|
288
290
|
.term-hint {
|
|
289
291
|
position: absolute; inset: 0 0 46px 0; display: grid; place-content: center; text-align: center;
|
|
290
292
|
color: var(--ink); pointer-events: none; font-size: 13px;
|
package/server/attach.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
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
|
+
let pty = null;
|
|
60
|
+
const send = (m) => ws && ws.readyState === 1 && ws.send(JSON.stringify(m));
|
|
61
|
+
|
|
62
|
+
// (re)connect to the court; when it is busy or off, keep trying quietly so the mirror starts as soon as it can
|
|
63
|
+
let connecting = false;
|
|
64
|
+
async function tryAttach() {
|
|
65
|
+
if (ws || connecting) return;
|
|
66
|
+
const now = readRuntime();
|
|
67
|
+
if (!now) return;
|
|
68
|
+
connecting = true;
|
|
69
|
+
const r = await connect(now, path.basename(cwd), cols, rows);
|
|
70
|
+
connecting = false;
|
|
71
|
+
if (!r.ws) return;
|
|
72
|
+
ws = r.ws;
|
|
73
|
+
ws.on('message', (raw) => {
|
|
74
|
+
let m;
|
|
75
|
+
try { m = JSON.parse(raw.toString()); } catch { return; }
|
|
76
|
+
if (m.t === 'input' && typeof m.d === 'string' && pty) pty.write(m.d);
|
|
77
|
+
});
|
|
78
|
+
ws.on('close', () => {
|
|
79
|
+
ws = null;
|
|
80
|
+
});
|
|
81
|
+
// make claude redraw its whole screen so the court sees it from the top
|
|
82
|
+
if (pty) {
|
|
83
|
+
try {
|
|
84
|
+
pty.resize(cols, Math.max(5, rows - 1));
|
|
85
|
+
setTimeout(() => { try { pty.resize(cols, rows); } catch {} }, 80);
|
|
86
|
+
} catch {}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (rt) {
|
|
91
|
+
await tryAttach();
|
|
92
|
+
if (!ws) console.error('어전이 바쁘거나 응답이 없습니다. claude 를 먼저 띄우고, 어전이 비면 자동으로 연결합니다.');
|
|
93
|
+
} else {
|
|
94
|
+
console.error('어전이 꺼져 있습니다(npx eojeon-cli). 어전을 켜면 자동으로 연결합니다.');
|
|
95
|
+
}
|
|
96
|
+
const retry = setInterval(tryAttach, 3000);
|
|
97
|
+
retry.unref?.();
|
|
98
|
+
|
|
99
|
+
// hooks: the court's settings when known (they find the court through the token in that file)
|
|
100
|
+
const settings = (readRuntime() || {}).settings;
|
|
101
|
+
const claudeArgs = settings && fs.existsSync(settings) ? ['--settings', settings, ...args] : args;
|
|
102
|
+
const { spawn } = loadPty();
|
|
103
|
+
pty = spawn(claude, claudeArgs, { name: 'xterm-256color', cols, rows, cwd, env: childEnv() });
|
|
104
|
+
|
|
105
|
+
pty.onData((d) => {
|
|
106
|
+
process.stdout.write(d);
|
|
107
|
+
send({ t: 'tout', d });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
111
|
+
process.stdin.setEncoding('utf8');
|
|
112
|
+
process.stdin.on('data', (d) => pty.write(d));
|
|
113
|
+
process.stdin.resume();
|
|
114
|
+
process.stdout.on('resize', () => {
|
|
115
|
+
cols = process.stdout.columns;
|
|
116
|
+
rows = process.stdout.rows;
|
|
117
|
+
try { pty.resize(cols, rows); } catch {}
|
|
118
|
+
send({ t: 'tsize', cols, rows });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
pty.onExit(({ exitCode }) => {
|
|
122
|
+
clearInterval(retry);
|
|
123
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
124
|
+
try { ws?.close(); } catch {}
|
|
125
|
+
process.exit(exitCode ?? 0);
|
|
126
|
+
});
|
|
127
|
+
}
|
package/server/index.js
CHANGED
|
@@ -16,6 +16,7 @@ import { listSessions } from './sessions.js';
|
|
|
16
16
|
import { sanitize } from './sanitize.js';
|
|
17
17
|
import { inspect as inspectAgents, install as installAgents, PACKS } from './agents.js';
|
|
18
18
|
import { HOOK_EVENTS, eojeonDir, runtimeFile } from './watch.js';
|
|
19
|
+
import { findClaude, loadPty, childEnv } from './pty.js';
|
|
19
20
|
|
|
20
21
|
const require = createRequire(import.meta.url);
|
|
21
22
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -60,20 +61,6 @@ function expandHome(p) {
|
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
// ---------- claude binary ----------
|
|
63
|
-
function findClaude() {
|
|
64
|
-
if (process.env.COURT_CLAUDE_BIN) return process.env.COURT_CLAUDE_BIN;
|
|
65
|
-
const names = process.platform === 'win32' ? ['claude.exe', 'claude.cmd', 'claude'] : ['claude'];
|
|
66
|
-
for (const dir of (process.env.PATH || '').split(path.delimiter)) {
|
|
67
|
-
for (const n of names) {
|
|
68
|
-
const p = path.join(dir, n);
|
|
69
|
-
try {
|
|
70
|
-
if (fs.statSync(p).isFile()) return p;
|
|
71
|
-
} catch {}
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
const local = path.join(os.homedir(), '.local', 'bin', 'claude');
|
|
75
|
-
return fs.existsSync(local) ? local : null;
|
|
76
|
-
}
|
|
77
64
|
const CLAUDE = findClaude();
|
|
78
65
|
|
|
79
66
|
// ---------- temp settings with hooks ----------
|
|
@@ -95,7 +82,7 @@ function writeSettings(port) {
|
|
|
95
82
|
// lets `eojeon watch` hooks (sessions outside the court) find this server
|
|
96
83
|
try {
|
|
97
84
|
fs.mkdirSync(eojeonDir(), { recursive: true, mode: 0o700 });
|
|
98
|
-
fs.writeFileSync(runtimeFile(), JSON.stringify({ port, token: TOKEN, pid: process.pid }), { mode: 0o600 });
|
|
85
|
+
fs.writeFileSync(runtimeFile(), JSON.stringify({ port, token: TOKEN, pid: process.pid, settings: settingsPath }), { mode: 0o600 });
|
|
99
86
|
} catch {}
|
|
100
87
|
}
|
|
101
88
|
|
|
@@ -111,6 +98,9 @@ function cleanup() {
|
|
|
111
98
|
// ---------- state ----------
|
|
112
99
|
let pty = null;
|
|
113
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;
|
|
114
104
|
let outBuf = '';
|
|
115
105
|
const OUT_MAX = 256 * 1024;
|
|
116
106
|
const events = [];
|
|
@@ -133,28 +123,6 @@ function sysEvent(type, extra = {}) {
|
|
|
133
123
|
pushEvent({ type, time: Date.now(), ...extra });
|
|
134
124
|
}
|
|
135
125
|
|
|
136
|
-
// node-pty ships prebuilt binaries for macOS (arm64/x64) and Windows (x64/arm64).
|
|
137
|
-
// On Linux the prebuilt fork @homebridge/node-pty-prebuilt-multiarch is used instead.
|
|
138
|
-
// Both are optionalDependencies so `npx eojeon-cli` never fails to install (demo still works).
|
|
139
|
-
const PTY_MODULES = ['node-pty', '@homebridge/node-pty-prebuilt-multiarch'];
|
|
140
|
-
let ptyMod = null;
|
|
141
|
-
function loadPty() {
|
|
142
|
-
if (ptyMod) return ptyMod;
|
|
143
|
-
const errors = [];
|
|
144
|
-
for (const name of PTY_MODULES) {
|
|
145
|
-
try {
|
|
146
|
-
ptyMod = require(name);
|
|
147
|
-
return ptyMod;
|
|
148
|
-
} catch (e) {
|
|
149
|
-
errors.push(`${name}: ${String(e.message).split('\n')[0]}`);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
throw new Error(
|
|
153
|
-
'터미널 모듈(node-pty)을 불러오지 못했습니다. `npm i -g eojeon` 으로 다시 설치하거나 빌드 도구(python3, make, C++ 컴파일러)를 설치해 주세요. ' +
|
|
154
|
-
'Could not load node-pty. ' + errors.join(' | '),
|
|
155
|
-
);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
126
|
// `claude --help` → --permission-mode choices. bypassPermissions is left out on purpose:
|
|
159
127
|
// with it no petition ever reaches the court, and it skips every safety prompt.
|
|
160
128
|
export const PERMISSION_MODES = ['manual', 'acceptEdits', 'plan', 'auto', 'dontAsk'];
|
|
@@ -164,6 +132,7 @@ function startClaude({ cwd, mode, sessionId, permissionMode }) {
|
|
|
164
132
|
if (!CLAUDE) throw new Error('PATH 에서 claude 를 찾지 못했습니다. Claude Code 를 먼저 설치하세요.');
|
|
165
133
|
const dir = path.resolve(expandHome(cwd || DEFAULT_CWD));
|
|
166
134
|
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) throw new Error(`폴더가 없습니다: ${dir}`);
|
|
135
|
+
if (mirror) throw new Error('다른 터미널의 claude(eojeon claude)가 연결돼 있습니다. 그 터미널에서 끝낸 뒤 입궐하세요.');
|
|
167
136
|
stopClaude();
|
|
168
137
|
const args = ['--settings', settingsPath];
|
|
169
138
|
if (mode === 'continue') args.push('--continue');
|
|
@@ -178,16 +147,7 @@ function startClaude({ cwd, mode, sessionId, permissionMode }) {
|
|
|
178
147
|
args.push('--permission-mode', permissionMode);
|
|
179
148
|
}
|
|
180
149
|
|
|
181
|
-
const env =
|
|
182
|
-
// if this server itself was started from inside Claude Code, don't let the child think it's nested
|
|
183
|
-
// (session-scoped markers only; user config such as CLAUDE_CODE_USE_BEDROCK is kept)
|
|
184
|
-
for (const k of [
|
|
185
|
-
'CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SESSION_ID',
|
|
186
|
-
'CLAUDE_CODE_SESSION_ATTENDED', 'CLAUDE_CODE_SSE_PORT', 'CLAUDE_CODE_MESSAGING_SOCKET',
|
|
187
|
-
'CLAUDE_CODE_MESSAGING_TOKEN', 'CLAUDE_CODE_BRIDGE_SESSION_ID', 'CLAUDE_CODE_EXECPATH', 'CLAUDE_PID', 'CLAUDE_EFFORT',
|
|
188
|
-
]) delete env[k];
|
|
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)
|
|
150
|
+
const env = childEnv();
|
|
191
151
|
|
|
192
152
|
const { spawn } = loadPty();
|
|
193
153
|
pty = spawn(CLAUDE, args, { name: 'xterm-256color', cols: size.cols, rows: size.rows, cwd: dir, env });
|
|
@@ -360,7 +320,7 @@ async function handle(req, res) {
|
|
|
360
320
|
if (p === '/api/info') {
|
|
361
321
|
return json(res, 200, {
|
|
362
322
|
app: 'eojeon', version: VERSION,
|
|
363
|
-
demo: DEMO, defaultCwd: DEFAULT_CWD, claudeFound: !!CLAUDE, running:
|
|
323
|
+
demo: DEMO, defaultCwd: DEFAULT_CWD, claudeFound: !!CLAUDE, running: isRunning(), info: ptyInfo,
|
|
364
324
|
home: os.homedir(), platform: process.platform, permissionModes: PERMISSION_MODES, remote: REMOTE,
|
|
365
325
|
});
|
|
366
326
|
}
|
|
@@ -422,18 +382,69 @@ function makeServer() {
|
|
|
422
382
|
const server = makeServer();
|
|
423
383
|
const WILDCARD = HOST === '0.0.0.0' || HOST === '::';
|
|
424
384
|
|
|
425
|
-
|
|
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
|
+
}
|
|
402
|
+
|
|
403
|
+
wss.on('connection', (ws, req) => {
|
|
426
404
|
clients.add(ws);
|
|
427
|
-
ws.send(JSON.stringify({
|
|
428
|
-
|
|
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 }));
|
|
429
410
|
ws.on('message', (raw) => {
|
|
430
411
|
let m;
|
|
431
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
|
+
}
|
|
432
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;
|
|
433
441
|
case 'input':
|
|
434
|
-
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 }));
|
|
435
445
|
break;
|
|
436
446
|
case 'resize': {
|
|
447
|
+
if (mirror) break; // the other terminal decides the size
|
|
437
448
|
const cols = Math.max(20, Math.min(400, m.cols | 0));
|
|
438
449
|
const rows = Math.max(5, Math.min(200, m.rows | 0));
|
|
439
450
|
size = { cols, rows };
|
|
@@ -448,7 +459,8 @@ wss.on('connection', (ws) => {
|
|
|
448
459
|
}
|
|
449
460
|
break;
|
|
450
461
|
case 'stop':
|
|
451
|
-
|
|
462
|
+
if (mirror) ws.send(JSON.stringify({ t: 'error', message: '연결된 터미널의 claude 는 그 터미널에서 끝내세요 (/exit).' }));
|
|
463
|
+
else stopClaude();
|
|
452
464
|
break;
|
|
453
465
|
default:
|
|
454
466
|
break;
|
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
|
+
}
|