eojeon-cli 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
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
+
5
13
  ## [0.2.0] - 2026-10-24
6
14
 
7
15
  Fixes from the first day of use.
@@ -35,5 +43,6 @@ First public version.
35
43
  - Hook payloads trimmed and secrets masked before leaving the hook process.
36
44
  - `scripts/check-publish.js` blocks publishing local paths, e-mail addresses or token-shaped strings.
37
45
 
46
+ [0.3.0]: https://github.com/hiio420pr/eojeon/releases/tag/v0.3.0
38
47
  [0.2.0]: https://github.com/hiio420pr/eojeon/releases/tag/v0.2.0
39
48
  [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 === 'init') {
61
+ if (cmd === 'claude') {
62
+ // everything after `claude` belongs to claude itself
63
+ const { run } = await import('../server/attach.js');
64
+ await run(process.argv.slice(process.argv.indexOf('claude') + 1));
65
+ }
66
+
67
+ if (cmd === 'claude') {
68
+ // handled above; keeps running until claude exits
69
+ } else if (cmd === 'init') {
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.2.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 && 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': return ['sys', '·', `claude 를 띄웠습니다 · ${ev.folder} (${{ new: '새 조회', continue: '이어서', resume: '세션 이어받기' }[ev.mode] || ev.mode})`];
123
+ case 'PtyStart':
124
+ if (ev.mode === 'mirror') return ['sys', '⇄', `다른 터미널의 claude 가 연결됐습니다 · ${ev.folder}`];
125
+ return ['sys', '·', `claude 를 띄웠습니다 · ${ev.folder} (${{ new: '새 조회', continue: '이어서', resume: '세션 이어받기' }[ev.mode] || ev.mode})`];
113
126
  case 'PtyExit': return ['sys', '·', `claude 가 끝났습니다 (code ${ev.code})`];
114
127
  case 'SessionStart':
115
128
  if (ev.source === 'clear') return ['sys', '·', '조정을 새로 엽니다 (/clear)'];
@@ -349,7 +362,8 @@ function connect() {
349
362
  switch (msg.t) {
350
363
  case 'hello':
351
364
  running = msg.running;
352
- setStatus(running ? 'running' : 'idle', running ? `진행 중 · ${msg.info?.folder || ''}` : '대기');
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
- setStatus('running', `진행 중 · ${msg.info.folder}`);
378
+ if (msg.size) {
379
+ term.reset();
380
+ court.reset();
381
+ }
382
+ setMirror(msg.size);
383
+ setStatus('running', `${msg.size ? '다른 터미널 연결' : '진행 중'} · ${msg.info.folder}`);
384
+ break;
385
+ case 'size':
386
+ setMirror({ cols: msg.cols, rows: msg.rows });
365
387
  break;
366
388
  case 'exit':
367
389
  running = false;
368
- setStatus('idle', `끝남 (code ${msg.code})`);
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">이미 다른 터미널에서 쓰는 Claude Code 도 보려면: <code>npx eojeon-cli watch</code></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>
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;
@@ -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/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 = { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor', FORCE_COLOR: '1' };
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: !!pty, info: ptyInfo,
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
- wss.on('connection', (ws) => {
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({ t: 'hello', demo: DEMO, running: !!pty, info: ptyInfo, history: events.slice(-200) }));
428
- if (pty && outBuf) ws.send(JSON.stringify({ t: 'out', d: outBuf, replay: true }));
405
+ ws.send(JSON.stringify({
406
+ t: 'hello', demo: DEMO, running: isRunning(), info: ptyInfo, history: events.slice(-200),
407
+ size: mirror ? { cols: mirror.cols, rows: mirror.rows } : null,
408
+ }));
409
+ if (isRunning() && outBuf) ws.send(JSON.stringify({ t: 'out', d: outBuf, replay: true }));
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 (pty && typeof m.d === 'string') pty.write(m.d);
442
+ if (typeof m.d !== 'string') break;
443
+ if (pty) pty.write(m.d);
444
+ else if (mirror && mirror.ws.readyState === 1) mirror.ws.send(JSON.stringify({ t: 'input', d: m.d }));
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
- stopClaude();
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
+ }