eojeon-cli 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/LICENSE +26 -0
- package/README.md +251 -2
- package/cli/eojeon.js +104 -0
- package/court.config.json +325 -0
- package/package.json +60 -4
- package/public/app.js +405 -0
- package/public/court-types.js +33 -0
- package/public/demo.js +100 -0
- package/public/fonts/D2Coding-Ver1.4.0-20261003.ttf +0 -0
- package/public/fonts/Galmuri11-Bold.woff2 +0 -0
- package/public/fonts/Galmuri11.woff2 +0 -0
- package/public/fonts/Galmuri7.woff2 +0 -0
- package/public/fonts/Galmuri9.woff2 +0 -0
- package/public/fonts/LICENSE-D2Coding.txt +97 -0
- package/public/fonts/LICENSE-Galmuri.txt +93 -0
- package/public/index.html +98 -0
- package/public/scene.js +1082 -0
- package/public/style.css +319 -0
- package/scripts/fix-pty.js +25 -0
- package/server/agents.js +78 -0
- package/server/hook.js +68 -0
- package/server/index.js +541 -0
- package/server/sanitize.js +155 -0
- package/server/sessions.js +115 -0
- package/server/watch.js +96 -0
- package/templates/agents/studio/animation-expert.md +29 -0
- package/templates/agents/studio/apple-designer.md +28 -0
- package/templates/agents/studio/branding-expert.md +29 -0
- package/templates/agents/studio/copywriter.md +28 -0
- package/templates/agents/studio/data-analyst.md +29 -0
- package/templates/agents/studio/marketer.md +28 -0
- package/templates/agents/studio/pr-lead.md +28 -0
- package/templates/agents/studio/product-designer.md +29 -0
- package/templates/agents/studio/sns-manager.md +28 -0
- package/templates/agents/studio/uiux-expert.md +31 -0
- package/templates/agents/studio/video-producer.md +29 -0
- package/templates/agents/yukjo/byeongjo.md +29 -0
- package/templates/agents/yukjo/gongjo.md +29 -0
- package/templates/agents/yukjo/hojo.md +29 -0
- package/templates/agents/yukjo/hyeongjo.md +29 -0
- package/templates/agents/yukjo/ijo.md +29 -0
- package/templates/agents/yukjo/yejo.md +29 -0
package/public/app.js
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { Court, W, H } from './scene.js';
|
|
2
|
+
import { runDemo } from './demo.js';
|
|
3
|
+
import { titleOf as typeTitle } from './court-types.js';
|
|
4
|
+
|
|
5
|
+
const $ = (id) => document.getElementById(id);
|
|
6
|
+
const params = new URLSearchParams(location.search);
|
|
7
|
+
|
|
8
|
+
const [config, info] = await Promise.all([
|
|
9
|
+
fetch('/api/config').then((r) => r.json()),
|
|
10
|
+
fetch('/api/info').then((r) => r.json()).catch(() => ({ demo: true })),
|
|
11
|
+
]);
|
|
12
|
+
const DEMO = info.demo || params.get('demo') === '1';
|
|
13
|
+
|
|
14
|
+
// ---------- branding from config ----------
|
|
15
|
+
document.title = config.name || document.title;
|
|
16
|
+
$('appName').textContent = config.name || '';
|
|
17
|
+
$('appSub').textContent = config.tagline || config.subtitle || '';
|
|
18
|
+
const root = document.documentElement.style;
|
|
19
|
+
for (const [k, v] of Object.entries(config.palette || {})) if (!k.startsWith('_')) root.setProperty(`--${k}`, v);
|
|
20
|
+
for (const k of ['hong', 'cheong', 'nok', 'inju', 'paper']) if (config.pixel?.[k]) root.setProperty(`--px-${k}`, config.pixel[k]);
|
|
21
|
+
|
|
22
|
+
// ---------- terminal ----------
|
|
23
|
+
// D2Coding first: its Hangul is exactly 2 cells wide, so Korean text doesn't drift.
|
|
24
|
+
// xterm measures the cell size once, so wait for the font before opening the terminal.
|
|
25
|
+
await Promise.race([document.fonts.load('13px "D2Coding"', '가A').catch(() => {}), new Promise((r) => setTimeout(r, 2500))]);
|
|
26
|
+
const term = new window.Terminal({
|
|
27
|
+
fontFamily: '"D2Coding", Menlo, Monaco, Consolas, "Noto Sans Mono CJK KR", monospace',
|
|
28
|
+
fontSize: 13,
|
|
29
|
+
lineHeight: 1.1,
|
|
30
|
+
cursorBlink: true,
|
|
31
|
+
scrollback: 5000,
|
|
32
|
+
allowProposedApi: true,
|
|
33
|
+
macOptionIsMeta: true,
|
|
34
|
+
theme: {
|
|
35
|
+
background: config.palette.paper,
|
|
36
|
+
foreground: config.palette.ink,
|
|
37
|
+
cursor: config.palette.hwang,
|
|
38
|
+
selectionBackground: '#3A3F4A',
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
const fit = new window.FitAddon.FitAddon();
|
|
42
|
+
term.loadAddon(fit);
|
|
43
|
+
term.open($('term'));
|
|
44
|
+
|
|
45
|
+
let ws = null;
|
|
46
|
+
let running = false;
|
|
47
|
+
const send = (m) => ws && ws.readyState === 1 && ws.send(JSON.stringify(m));
|
|
48
|
+
const sendInput = (d) => (DEMO ? null : send({ t: 'input', d }));
|
|
49
|
+
|
|
50
|
+
function doFit() {
|
|
51
|
+
try {
|
|
52
|
+
fit.fit();
|
|
53
|
+
send({ t: 'resize', cols: term.cols, rows: term.rows });
|
|
54
|
+
} catch {}
|
|
55
|
+
}
|
|
56
|
+
new ResizeObserver(() => doFit()).observe($('term'));
|
|
57
|
+
term.onData((d) => {
|
|
58
|
+
sendInput(d);
|
|
59
|
+
// answered the permission dialog directly in the terminal: Esc = refused
|
|
60
|
+
if (d === '\x1b' && court.petition && !court.petition.closed && !court.petition.ext) {
|
|
61
|
+
court.closePetition('no');
|
|
62
|
+
addLog({ type: 'Verdict', time: Date.now(), verdict: 'no' });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ---------- stage scaling (integer when big enough, so pixels stay square) ----------
|
|
67
|
+
const stageWrap = document.querySelector('.stage-wrap');
|
|
68
|
+
const stage = $('stage');
|
|
69
|
+
const canvas = $('scene');
|
|
70
|
+
const overlay = $('overlay');
|
|
71
|
+
function scaleStage() {
|
|
72
|
+
const r = stageWrap.getBoundingClientRect();
|
|
73
|
+
let s = Math.min(r.width / W, r.height / H);
|
|
74
|
+
if (s >= 2) s = Math.floor(s);
|
|
75
|
+
s = Math.max(0.5, s);
|
|
76
|
+
stage.style.width = `${W * s}px`;
|
|
77
|
+
stage.style.height = `${H * s}px`;
|
|
78
|
+
canvas.style.width = `${W * s}px`;
|
|
79
|
+
canvas.style.height = `${H * s}px`;
|
|
80
|
+
overlay.style.transform = `scale(${s})`;
|
|
81
|
+
overlay.style.transformOrigin = '0 0';
|
|
82
|
+
}
|
|
83
|
+
new ResizeObserver(scaleStage).observe(stageWrap);
|
|
84
|
+
scaleStage();
|
|
85
|
+
|
|
86
|
+
// ---------- court ----------
|
|
87
|
+
const court = new Court({
|
|
88
|
+
canvas,
|
|
89
|
+
overlay,
|
|
90
|
+
config,
|
|
91
|
+
onPetitionAction: (kind) => {
|
|
92
|
+
const keys = config.permissionKeys || {};
|
|
93
|
+
const k = keys[kind];
|
|
94
|
+
if (k) sendInput(k);
|
|
95
|
+
addLog({ type: 'Verdict', time: Date.now(), verdict: kind });
|
|
96
|
+
term.focus();
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ---------- 사관 일지 (event log) ----------
|
|
101
|
+
const logEl = $('log');
|
|
102
|
+
const ph = config.phrases || {};
|
|
103
|
+
const officials = config.officials || {};
|
|
104
|
+
const titleOf = (t) => typeTitle(t, config);
|
|
105
|
+
const fmt = (tpl, v) => String(tpl || '').replace(/\{(\w+)\}/g, (_, k) => (v[k] == null ? '' : v[k]));
|
|
106
|
+
const byTool = new Map();
|
|
107
|
+
let lastAsk = 0;
|
|
108
|
+
|
|
109
|
+
function logLine(ev) {
|
|
110
|
+
const who = titleOf(ev.agentType);
|
|
111
|
+
switch (ev.type) {
|
|
112
|
+
case 'PtyStart': return ['sys', '·', `claude 를 띄웠습니다 · ${ev.folder} (${{ new: '새 조회', continue: '이어서', resume: '세션 이어받기' }[ev.mode] || ev.mode})`];
|
|
113
|
+
case 'PtyExit': return ['sys', '·', `claude 가 끝났습니다 (code ${ev.code})`];
|
|
114
|
+
case 'SessionStart':
|
|
115
|
+
if (ev.source === 'clear') return ['sys', '·', '조정을 새로 엽니다 (/clear)'];
|
|
116
|
+
if (ev.source === 'compact') return ['sys', '·', '기록을 줄였사옵니다 (compact)'];
|
|
117
|
+
return ['sys', '開', fmt(ev.source === 'resume' || ev.source === 'fork' ? ph.sessionResume : ph.sessionStart, { folder: ev.folder || '' })];
|
|
118
|
+
case 'UserPromptSubmit': return ['decree', '敎', `교지: ${ev.prompt || ''}`];
|
|
119
|
+
case 'PreToolUse': return ['tool', '筆', `${who} · ${ev.tool}${ev.summary ? ': ' + ev.summary : ''}`];
|
|
120
|
+
case 'PostToolUseFailure': return ['fail', '×', `${who} · ${fmt(ph.failed, { tool: ev.tool })}`];
|
|
121
|
+
case 'PermissionRequest': lastAsk = ev.time; return ['ask', '?', `${who} · ${ph.permission} — ${ev.tool}${ev.summary ? ': ' + ev.summary : ''}`];
|
|
122
|
+
case 'PermissionDenied': return ['fail', '×', `${who} · ${fmt(ph.denied, { tool: ev.tool })}`];
|
|
123
|
+
case 'Notification':
|
|
124
|
+
if (ev.kind === 'permission_prompt') return ev.time - lastAsk < 15000 ? null : ['ask', '?', `${ph.permission} — ${ev.message || ''}`];
|
|
125
|
+
if (ev.kind === 'idle_prompt') return ['sys', '…', ph.idle];
|
|
126
|
+
return ['sys', '·', ev.message || ev.kind];
|
|
127
|
+
case 'SubagentStart': return ['sub', '入', `${titleOf(ev.agentType)} 입궐 (${ev.agentType})`];
|
|
128
|
+
case 'Agents': return ['sys', '六', ev.text];
|
|
129
|
+
case 'SubagentStop': return ['sub', '出', `${titleOf(ev.agentType)} 상소: ${ev.report || '맡은 일 마침'}`];
|
|
130
|
+
case 'Stop': return ['stop', '完', ph.stop];
|
|
131
|
+
case 'StopFailure': return ['fail', '×', '일을 마치지 못했사옵니다'];
|
|
132
|
+
case 'SessionEnd': return ['sys', '退', `${ph.sessionEnd}${ev.reason ? ` (${ev.reason})` : ''}`];
|
|
133
|
+
case 'Verdict': {
|
|
134
|
+
const s = config.stamps?.[ev.verdict] || {};
|
|
135
|
+
return ['ask', ev.verdict === 'ok' ? '○' : ev.verdict === 'no' ? '×' : '…', `판정: ${s.full || ev.verdict}`];
|
|
136
|
+
}
|
|
137
|
+
default: return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function addLog(ev) {
|
|
142
|
+
if (ev.type === 'PostToolUse') {
|
|
143
|
+
const li = ev.toolUseId && byTool.get(ev.toolUseId);
|
|
144
|
+
if (li && !li.querySelector('.ok')) {
|
|
145
|
+
const s = document.createElement('span');
|
|
146
|
+
s.className = 'ok';
|
|
147
|
+
s.textContent = ev.ms != null ? `✓ ${ev.ms < 1000 ? ev.ms + 'ms' : (ev.ms / 1000).toFixed(1) + 's'}` : '✓';
|
|
148
|
+
li.querySelector('.tx').appendChild(s);
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const line = logLine(ev);
|
|
153
|
+
if (!line) return;
|
|
154
|
+
const [kind, icon, text] = line;
|
|
155
|
+
const li = document.createElement('li');
|
|
156
|
+
li.className = `k-${kind}`;
|
|
157
|
+
const t = document.createElement('time');
|
|
158
|
+
const d = new Date(ev.time || Date.now());
|
|
159
|
+
t.textContent = [d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, '0')).join(':');
|
|
160
|
+
const ic = document.createElement('span');
|
|
161
|
+
ic.className = 'ic';
|
|
162
|
+
ic.textContent = icon;
|
|
163
|
+
const tx = document.createElement('span');
|
|
164
|
+
tx.className = 'tx';
|
|
165
|
+
// sessions outside the court (eojeon watch) carry their folder name
|
|
166
|
+
tx.textContent = ev.ext ? `⇢ ${ev.folder || '바깥'} · ${text}` : text;
|
|
167
|
+
li.append(t, ic, tx);
|
|
168
|
+
if (ev.toolUseId) byTool.set(ev.toolUseId, li);
|
|
169
|
+
const atBottom = logEl.scrollTop + logEl.clientHeight >= logEl.scrollHeight - 30;
|
|
170
|
+
logEl.appendChild(li);
|
|
171
|
+
while (logEl.children.length > 400) logEl.firstChild.remove();
|
|
172
|
+
if (atBottom) logEl.scrollTop = logEl.scrollHeight;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function onEvent(ev, opts = {}) {
|
|
176
|
+
addLog(ev);
|
|
177
|
+
court.handle(ev, opts);
|
|
178
|
+
if (ev.ext && !running && !DEMO) {
|
|
179
|
+
$('termHintMain').innerHTML = `바깥 터미널의 Claude Code 를 보는 중입니다 (<b>${escapeHtml(ev.folder || '')}</b>) — 보기 전용`;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function escapeHtml(s) {
|
|
183
|
+
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------- launch controls ----------
|
|
187
|
+
const cwdIn = $('cwd');
|
|
188
|
+
const sessSel = $('session');
|
|
189
|
+
const statusEl = $('status');
|
|
190
|
+
const hint = $('termHint');
|
|
191
|
+
function setStatus(state, text) {
|
|
192
|
+
statusEl.dataset.state = state;
|
|
193
|
+
statusEl.textContent = text;
|
|
194
|
+
}
|
|
195
|
+
let lastCwd = '';
|
|
196
|
+
try { lastCwd = localStorage.getItem('eojeon.cwd') || ''; } catch {}
|
|
197
|
+
cwdIn.value = DEMO ? '~/projects/my-app' : lastCwd || info.defaultCwd || '';
|
|
198
|
+
|
|
199
|
+
function fmtTime(ms) {
|
|
200
|
+
const d = new Date(ms);
|
|
201
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
202
|
+
return `${p(d.getMonth() + 1)}/${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function loadSessions() {
|
|
206
|
+
const keep = sessSel.value;
|
|
207
|
+
sessSel.innerHTML = '';
|
|
208
|
+
const add = (v, label, parent = sessSel) => {
|
|
209
|
+
const o = document.createElement('option');
|
|
210
|
+
o.value = v;
|
|
211
|
+
o.textContent = label;
|
|
212
|
+
parent.appendChild(o);
|
|
213
|
+
return o;
|
|
214
|
+
};
|
|
215
|
+
add('new', '새 조회 (새 세션)');
|
|
216
|
+
add('continue', '이어서 — 가장 최근 세션 (--continue)');
|
|
217
|
+
add('resume:', '세션 고르기 — Claude Code 목록 (/resume)');
|
|
218
|
+
if (DEMO) return;
|
|
219
|
+
try {
|
|
220
|
+
const r = await fetch(`/api/sessions?cwd=${encodeURIComponent(cwdIn.value.trim())}`).then((x) => x.json());
|
|
221
|
+
if (r.sessions?.length) {
|
|
222
|
+
const g = document.createElement('optgroup');
|
|
223
|
+
g.label = `지난 조회 ${r.sessions.length}개 (최신순)`;
|
|
224
|
+
for (const s of r.sessions) add(`resume:${s.id}`, `${fmtTime(s.mtime)} · ${s.title ? `[${s.title}] ` : ''}${s.first || s.id.slice(0, 8)}`, g);
|
|
225
|
+
sessSel.appendChild(g);
|
|
226
|
+
}
|
|
227
|
+
} catch {}
|
|
228
|
+
if ([...sessSel.options].some((o) => o.value === keep)) sessSel.value = keep;
|
|
229
|
+
}
|
|
230
|
+
let sessTimer;
|
|
231
|
+
cwdIn.addEventListener('input', () => {
|
|
232
|
+
clearTimeout(sessTimer);
|
|
233
|
+
sessTimer = setTimeout(loadSessions, 350);
|
|
234
|
+
});
|
|
235
|
+
loadSessions();
|
|
236
|
+
|
|
237
|
+
// ---------- permission mode ----------
|
|
238
|
+
const pmodeSel = $('pmode');
|
|
239
|
+
try { pmodeSel.value = localStorage.getItem('eojeon.pmode') || ''; } catch {}
|
|
240
|
+
if (![...pmodeSel.options].some((o) => o.value === pmodeSel.value)) pmodeSel.value = '';
|
|
241
|
+
pmodeSel.addEventListener('change', () => {
|
|
242
|
+
try { localStorage.setItem('eojeon.pmode', pmodeSel.value); } catch {}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// ---------- 육조 들이기 (offer the default subagents for this folder, once) ----------
|
|
246
|
+
const YUKJO = ['ijo', 'hojo', 'yejo', 'byeongjo', 'hyeongjo', 'gongjo'];
|
|
247
|
+
const askedKey = (cwd) => `eojeon.yukjo.never:${cwd}`;
|
|
248
|
+
async function offerYukjo(cwd) {
|
|
249
|
+
let st;
|
|
250
|
+
try {
|
|
251
|
+
st = await fetch(`/api/agents?cwd=${encodeURIComponent(cwd)}`).then((r) => r.json());
|
|
252
|
+
} catch {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (!st || st.error || st.folderMissing || !st.missing?.length) return;
|
|
256
|
+
try { if (localStorage.getItem(askedKey(st.cwd))) return; } catch {}
|
|
257
|
+
const dlg = $('yukjoDlg');
|
|
258
|
+
$('yukjoWhere').textContent = st.dir;
|
|
259
|
+
$('yukjoMissing').textContent = `${st.missing.length}명`;
|
|
260
|
+
const list = $('yukjoList');
|
|
261
|
+
list.innerHTML = '';
|
|
262
|
+
for (const k of YUKJO) {
|
|
263
|
+
const li = document.createElement('li');
|
|
264
|
+
const d = officials[k] || {};
|
|
265
|
+
li.textContent = `${d.title || k} (${k}) — ${d.role || ''}`;
|
|
266
|
+
if (!st.missing.includes(k)) {
|
|
267
|
+
li.className = 'have';
|
|
268
|
+
li.title = '이미 있음 · 그대로 둡니다';
|
|
269
|
+
}
|
|
270
|
+
list.appendChild(li);
|
|
271
|
+
}
|
|
272
|
+
$('studioChk').checked = false;
|
|
273
|
+
const answer = await new Promise((resolve) => {
|
|
274
|
+
dlg.addEventListener('close', () => resolve(dlg.returnValue), { once: true });
|
|
275
|
+
dlg.showModal();
|
|
276
|
+
});
|
|
277
|
+
if (answer === 'never') {
|
|
278
|
+
try { localStorage.setItem(askedKey(st.cwd), '1'); } catch {}
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (answer !== 'yes') return;
|
|
282
|
+
const pack = $('studioChk').checked ? 'all' : 'yukjo';
|
|
283
|
+
try {
|
|
284
|
+
const r = await fetch('/api/agents/install', {
|
|
285
|
+
method: 'POST',
|
|
286
|
+
headers: { 'content-type': 'application/json' },
|
|
287
|
+
body: JSON.stringify({ cwd: st.cwd, pack }),
|
|
288
|
+
}).then((x) => x.json());
|
|
289
|
+
if (r.error) throw new Error(r.error);
|
|
290
|
+
const text = `육조를 들였사옵니다 · 새로 ${r.copied.length}명${r.skipped.length ? ` · 이미 있어 건너뜀 ${r.skipped.length}명(${r.skipped.join(', ')})` : ''}`;
|
|
291
|
+
addLog({ type: 'Agents', time: Date.now(), text });
|
|
292
|
+
court.showToast(text, 4200);
|
|
293
|
+
} catch (err) {
|
|
294
|
+
setStatus('error', `육조 들이기 실패: ${err.message}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
$('launch').addEventListener('submit', async (e) => {
|
|
299
|
+
e.preventDefault();
|
|
300
|
+
if (DEMO) return;
|
|
301
|
+
const v = sessSel.value;
|
|
302
|
+
const cwd = cwdIn.value.trim();
|
|
303
|
+
if (running && !confirm('지금 조회를 끝내고 새로 시작할까요?')) return;
|
|
304
|
+
try { localStorage.setItem('eojeon.cwd', cwd); } catch {}
|
|
305
|
+
await offerYukjo(cwd);
|
|
306
|
+
let mode = 'new';
|
|
307
|
+
let sessionId = '';
|
|
308
|
+
if (v === 'continue') mode = 'continue';
|
|
309
|
+
else if (v.startsWith('resume:')) {
|
|
310
|
+
mode = 'resume';
|
|
311
|
+
sessionId = v.slice(7);
|
|
312
|
+
}
|
|
313
|
+
term.reset();
|
|
314
|
+
court.reset();
|
|
315
|
+
doFit();
|
|
316
|
+
send({ t: 'start', cwd, mode, sessionId, permissionMode: pmodeSel.value });
|
|
317
|
+
term.focus();
|
|
318
|
+
});
|
|
319
|
+
$('stopBtn').addEventListener('click', () => {
|
|
320
|
+
if (!DEMO && running && confirm('claude 를 끝낼까요? (진행 중인 일은 멈춥니다)')) send({ t: 'stop' });
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// ---------- decree input (sent straight into the PTY) ----------
|
|
324
|
+
const decreeIn = $('decreeInput');
|
|
325
|
+
$('decreeForm').addEventListener('submit', (e) => {
|
|
326
|
+
e.preventDefault();
|
|
327
|
+
const text = decreeIn.value;
|
|
328
|
+
if (!text.trim()) return;
|
|
329
|
+
if (!running && !DEMO) {
|
|
330
|
+
setStatus('error', '먼저 입궐(시작)하세요');
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
// text first, Enter a beat later so Claude Code doesn't treat it as a pasted newline
|
|
334
|
+
sendInput(text);
|
|
335
|
+
setTimeout(() => sendInput('\r'), 60);
|
|
336
|
+
decreeIn.value = '';
|
|
337
|
+
term.focus();
|
|
338
|
+
});
|
|
339
|
+
decreeIn.addEventListener('keydown', (e) => {
|
|
340
|
+
if (e.key === 'Enter' && e.isComposing) e.preventDefault(); // Korean IME: finish composition first
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// ---------- connection ----------
|
|
344
|
+
function connect() {
|
|
345
|
+
ws = new WebSocket(`ws://${location.host}/`);
|
|
346
|
+
ws.onopen = () => doFit();
|
|
347
|
+
ws.onmessage = (m) => {
|
|
348
|
+
const msg = JSON.parse(m.data);
|
|
349
|
+
switch (msg.t) {
|
|
350
|
+
case 'hello':
|
|
351
|
+
running = msg.running;
|
|
352
|
+
setStatus(running ? 'running' : 'idle', running ? `진행 중 · ${msg.info?.folder || ''}` : '대기');
|
|
353
|
+
hint.classList.toggle('hidden', running);
|
|
354
|
+
logEl.innerHTML = '';
|
|
355
|
+
for (const ev of msg.history || []) onEvent(ev, { replay: true });
|
|
356
|
+
break;
|
|
357
|
+
case 'out':
|
|
358
|
+
hint.classList.add('hidden');
|
|
359
|
+
term.write(msg.d);
|
|
360
|
+
break;
|
|
361
|
+
case 'started':
|
|
362
|
+
running = true;
|
|
363
|
+
hint.classList.add('hidden');
|
|
364
|
+
setStatus('running', `진행 중 · ${msg.info.folder}`);
|
|
365
|
+
break;
|
|
366
|
+
case 'exit':
|
|
367
|
+
running = false;
|
|
368
|
+
setStatus('idle', `끝남 (code ${msg.code})`);
|
|
369
|
+
term.write('\r\n\x1b[2m[claude 가 끝났습니다. 위에서 다시 입궐할 수 있습니다]\x1b[0m\r\n');
|
|
370
|
+
break;
|
|
371
|
+
case 'event':
|
|
372
|
+
onEvent(msg.ev);
|
|
373
|
+
break;
|
|
374
|
+
case 'error':
|
|
375
|
+
setStatus('error', msg.message);
|
|
376
|
+
term.write(`\r\n\x1b[31m${msg.message}\x1b[0m\r\n`);
|
|
377
|
+
break;
|
|
378
|
+
default:
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
ws.onclose = () => {
|
|
383
|
+
setStatus('error', '서버 연결 끊김 — 다시 연결 중');
|
|
384
|
+
setTimeout(connect, 1500);
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (DEMO) {
|
|
389
|
+
setStatus('demo', '데모 재생 중 (claude 없음)');
|
|
390
|
+
hint.classList.add('hidden');
|
|
391
|
+
$('startBtn').disabled = true;
|
|
392
|
+
$('stopBtn').disabled = true;
|
|
393
|
+
runDemo({
|
|
394
|
+
term,
|
|
395
|
+
onEvent,
|
|
396
|
+
court,
|
|
397
|
+
speed: Number(params.get('speed')) || 1,
|
|
398
|
+
stopAt: params.has('stop') ? Number(params.get('stop')) : null,
|
|
399
|
+
loop: !params.has('stop'),
|
|
400
|
+
});
|
|
401
|
+
} else {
|
|
402
|
+
if (!info.claudeFound) setStatus('error', 'claude 를 찾지 못했습니다 — Claude Code 를 설치하세요');
|
|
403
|
+
if (info.remote) $('footLocal').textContent = '다른 기기 접속 켜짐 (--host) · 접속 키가 있는 기기만 들어올 수 있습니다.';
|
|
404
|
+
connect();
|
|
405
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Pure helpers (no DOM) shared by the browser and the tests:
|
|
2
|
+
// map a Claude Code agent_type to an entry in court.config.json.
|
|
3
|
+
|
|
4
|
+
// Plugin agents arrive namespaced, e.g. "eojeon:ijo" or "my-plugin:review:security".
|
|
5
|
+
// Exact keys win; otherwise the part after the last colon is tried.
|
|
6
|
+
export function resolveAgentType(type, cfg) {
|
|
7
|
+
const t = typeof type === 'string' ? type : '';
|
|
8
|
+
const team = (cfg && cfg.officials) || {};
|
|
9
|
+
const guests = (cfg && cfg.guests) || {};
|
|
10
|
+
const has = (k) => k && !k.startsWith('_') && (Object.hasOwn(team, k) || Object.hasOwn(guests, k));
|
|
11
|
+
if (has(t)) return t;
|
|
12
|
+
if (t.includes(':')) {
|
|
13
|
+
const tail = t.slice(t.lastIndexOf(':') + 1);
|
|
14
|
+
if (has(tail)) return tail;
|
|
15
|
+
}
|
|
16
|
+
return t;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// -> { key, def, kind: 'main' | 'seat' | 'guest' | 'unknown' }
|
|
20
|
+
export function lookupOfficial(type, cfg) {
|
|
21
|
+
const team = (cfg && cfg.officials) || {};
|
|
22
|
+
const guests = (cfg && cfg.guests) || {};
|
|
23
|
+
if (!type) return { key: 'main', def: team.main, kind: 'main' };
|
|
24
|
+
const key = resolveAgentType(type, cfg);
|
|
25
|
+
if (key !== 'main' && Object.hasOwn(team, key) && !key.startsWith('_')) return { key, def: team[key], kind: 'seat' };
|
|
26
|
+
if (Object.hasOwn(guests, key) && key !== '*') return { key, def: guests[key], kind: 'guest' };
|
|
27
|
+
const any = guests['*'] || { title: '관리', role: '맡은 일을 한다', robe: 'nok' };
|
|
28
|
+
return { key, def: { ...any, title: `${any.title} · ${type}` }, kind: 'unknown' };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function titleOf(type, cfg) {
|
|
32
|
+
return lookupOfficial(type, cfg).def?.title || String(type || '');
|
|
33
|
+
}
|
package/public/demo.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Demo mode: replays a scripted session (fake hook events + a fake terminal
|
|
2
|
+
// transcript) without running claude. Used for screenshots and promo videos.
|
|
3
|
+
// URL params: ?demo=1 [&speed=2] [&stop=<seconds> → play up to that moment, then hold]
|
|
4
|
+
|
|
5
|
+
const ESC = '\x1b[';
|
|
6
|
+
const dim = (s) => `${ESC}2m${s}${ESC}0m`;
|
|
7
|
+
const bold = (s) => `${ESC}1m${s}${ESC}0m`;
|
|
8
|
+
const orange = (s) => `${ESC}38;5;209m${s}${ESC}0m`;
|
|
9
|
+
const green = (s) => `${ESC}32m${s}${ESC}0m`;
|
|
10
|
+
const red = (s) => `${ESC}31m${s}${ESC}0m`;
|
|
11
|
+
const nl = '\r\n';
|
|
12
|
+
|
|
13
|
+
const SID = 'demo-0000-4a1c-9d2e-000000000001';
|
|
14
|
+
const base = { session: SID, folder: 'my-app' };
|
|
15
|
+
|
|
16
|
+
const BANNER =
|
|
17
|
+
dim('[데모 모드 — 실제 claude 가 아니라 녹화용 재생 화면입니다]') + nl + nl +
|
|
18
|
+
orange(' ▐▛███▜▌') + ' ' + bold('Claude Code') + nl +
|
|
19
|
+
orange('▝▜█████▛▘') + ' ' + dim('~/projects/my-app') + nl +
|
|
20
|
+
orange(' ▘▘ ▝▝') + nl + nl;
|
|
21
|
+
|
|
22
|
+
const PROMPT_LINE = (s) => `${dim('>')} ${s}${nl}${nl}`;
|
|
23
|
+
|
|
24
|
+
// [time (s), action]
|
|
25
|
+
export const SCRIPT = [
|
|
26
|
+
[0.0, { out: BANNER }],
|
|
27
|
+
[0.2, { ev: { type: 'SessionStart', source: 'startup', model: 'claude-opus-5-5' } }],
|
|
28
|
+
[2.0, { out: PROMPT_LINE('로그인 화면에 비밀번호 보기 버튼 넣고, 문구 다듬고, 테스트 돌려 줘') }],
|
|
29
|
+
[2.1, { ev: { type: 'UserPromptSubmit', prompt: '로그인 화면에 비밀번호 보기 버튼 넣고, 문구 다듬고, 테스트 돌려 줘', slash: false } }],
|
|
30
|
+
[4.6, { out: `${orange('⏺')} ${bold('Read')}(src/screens/Login.jsx)${nl}` }],
|
|
31
|
+
[4.6, { ev: { type: 'PreToolUse', tool: 'Read', summary: 'src/screens/Login.jsx', toolUseId: 't1' } }],
|
|
32
|
+
[5.6, { ev: { type: 'PostToolUse', tool: 'Read', summary: 'src/screens/Login.jsx', toolUseId: 't1', ms: 14 } }],
|
|
33
|
+
[5.7, { out: ` ${dim('⎿ Read 142 lines')}${nl}${nl}` }],
|
|
34
|
+
[6.2, { out: `${orange('⏺')} ${bold('Task')}(gongjo · 비밀번호 보기 버튼)${nl}` }],
|
|
35
|
+
[6.2, { ev: { type: 'PreToolUse', tool: 'Task', summary: 'gongjo · 비밀번호 보기 버튼', spawnType: 'gongjo', toolUseId: 't2' } }],
|
|
36
|
+
[6.5, { ev: { type: 'SubagentStart', agentId: 'a1', agentType: 'gongjo' } }],
|
|
37
|
+
[7.0, { out: `${orange('⏺')} ${bold('Task')}(hyeongjo · 로그인 화면 리뷰)${nl}` }],
|
|
38
|
+
[7.0, { ev: { type: 'PreToolUse', tool: 'Task', summary: 'hyeongjo · 로그인 화면 리뷰', spawnType: 'hyeongjo', toolUseId: 't3' } }],
|
|
39
|
+
[7.3, { ev: { type: 'SubagentStart', agentId: 'a2', agentType: 'hyeongjo' } }],
|
|
40
|
+
[12.0, { ev: { type: 'PreToolUse', tool: 'Edit', summary: 'src/screens/Login.jsx', toolUseId: 't4', agentId: 'a1', agentType: 'gongjo' } }],
|
|
41
|
+
[12.0, { out: ` ${dim('⎿')} ${bold('Edit')}(src/screens/Login.jsx)${nl}` }],
|
|
42
|
+
[13.0, { ev: { type: 'PreToolUse', tool: 'Grep', summary: '"로그인" · src', toolUseId: 't5', agentId: 'a2', agentType: 'hyeongjo' } }],
|
|
43
|
+
[13.0, { out: ` ${dim('⎿')} ${bold('Grep')}("로그인", src)${nl}` }],
|
|
44
|
+
[8.0, { out: `${orange('⏺')} ${bold('Task')}(eojeon:yejo · 변경 기록)${nl}` }],
|
|
45
|
+
[8.0, { ev: { type: 'PreToolUse', tool: 'Task', summary: 'eojeon:yejo · 변경 기록', spawnType: 'eojeon:yejo', toolUseId: 't9' } }],
|
|
46
|
+
[8.3, { ev: { type: 'SubagentStart', agentId: 'a3', agentType: 'eojeon:yejo' } }],
|
|
47
|
+
[14.2, { ev: { type: 'PreToolUse', tool: 'Edit', summary: 'CHANGELOG.md', toolUseId: 't10', agentId: 'a3', agentType: 'eojeon:yejo' } }],
|
|
48
|
+
[13.6, { ev: { type: 'PreToolUse', tool: 'Bash', summary: 'npx eslint src/screens', toolUseId: 't6' } }],
|
|
49
|
+
[16.5, { ev: { type: 'PostToolUseFailure', tool: 'Bash', summary: 'npx eslint src/screens', toolUseId: 't6' } }],
|
|
50
|
+
[16.5, { out: `${orange('⏺')} ${bold('Bash')}(npx eslint src/screens)${nl} ${red('⎿ Error: 1 problem (1 error)')}${nl}` }],
|
|
51
|
+
[17.0, { ev: { type: 'PostToolUse', tool: 'Grep', summary: '"로그인" · src', toolUseId: 't5', agentId: 'a2', agentType: 'hyeongjo', ms: 40 } }],
|
|
52
|
+
[17.5, { ev: { type: 'PostToolUse', tool: 'Edit', summary: 'src/screens/Login.jsx', toolUseId: 't4', agentId: 'a1', agentType: 'gongjo', ms: 22 } }],
|
|
53
|
+
[18.5, { ev: { type: 'SubagentStop', agentId: 'a1', agentType: 'gongjo', report: '보기 버튼 넣고 접근성 라벨 달았습니다' } }],
|
|
54
|
+
[18.6, { out: ` ${green('⎿ Done')} ${dim('(gongjo · 3 tool uses)')}${nl}` }],
|
|
55
|
+
[18.0, { ev: { type: 'PostToolUse', tool: 'Edit', summary: 'CHANGELOG.md', toolUseId: 't10', agentId: 'a3', agentType: 'eojeon:yejo', ms: 18 } }],
|
|
56
|
+
[20.2, { ev: { type: 'SubagentStop', agentId: 'a3', agentType: 'eojeon:yejo', report: 'CHANGELOG 에 한 줄 적었습니다' } }],
|
|
57
|
+
[19.2, { ev: { type: 'SubagentStop', agentId: 'a2', agentType: 'hyeongjo', report: '막을 문제 없음 · 사소한 것 1건' } }],
|
|
58
|
+
[19.3, { out: ` ${green('⎿ Done')} ${dim('(hyeongjo · 2 tool uses)')}${nl}${nl}` }],
|
|
59
|
+
[26.0, { ev: { type: 'PreToolUse', tool: 'Bash', summary: 'npm test', toolUseId: 't7' } }],
|
|
60
|
+
[26.1, { ev: { type: 'PermissionRequest', tool: 'Bash', summary: 'npm test' } }],
|
|
61
|
+
[26.1, {
|
|
62
|
+
out:
|
|
63
|
+
`${orange('⏺')} ${bold('Bash')}(npm test)${nl}${nl}` +
|
|
64
|
+
` ${bold('Bash command')}${nl}${nl} npm test${nl} ${dim('Run the test suite')}${nl}${nl}` +
|
|
65
|
+
` Do you want to proceed?${nl} ${orange('❯')} 1. Yes${nl} 2. Yes, and don't ask again for ${bold('npm test')} commands${nl} 3. No, and tell Claude what to do differently ${dim('(esc)')}${nl}${nl}`,
|
|
66
|
+
}],
|
|
67
|
+
[26.8, { ev: { type: 'Notification', kind: 'permission_prompt', message: 'Claude needs your permission to use Bash' } }],
|
|
68
|
+
[32.0, { press: 'ok' }],
|
|
69
|
+
[32.4, { out: ` ${dim('⎿')} ${green('Tests: 24 passed, 24 total')}${nl}${nl}` }],
|
|
70
|
+
[33.0, { ev: { type: 'PostToolUse', tool: 'Bash', summary: 'npm test', toolUseId: 't7', ms: 2140 } }],
|
|
71
|
+
[33.6, { ev: { type: 'PreToolUse', tool: 'Bash', summary: 'git diff --stat', toolUseId: 't8' } }],
|
|
72
|
+
[34.6, { ev: { type: 'PostToolUse', tool: 'Bash', summary: 'git diff --stat', toolUseId: 't8', ms: 30 } }],
|
|
73
|
+
[34.6, { out: `${orange('⏺')} ${bold('Bash')}(git diff --stat)${nl} ${dim('⎿ 2 files changed, 38 insertions(+), 6 deletions(-)')}${nl}${nl}` }],
|
|
74
|
+
[35.5, { out: `${orange('⏺')} 비밀번호 보기 버튼을 넣고 문구를 다듬었습니다. 테스트 24개 모두 통과했습니다.${nl}${nl}` }],
|
|
75
|
+
[35.6, { ev: { type: 'Stop' } }],
|
|
76
|
+
[42.0, { ev: { type: 'Notification', kind: 'idle_prompt', message: 'Claude is waiting for your input' } }],
|
|
77
|
+
[48.0, { end: true }],
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
export function runDemo({ term, onEvent, court, speed = 1, stopAt = null, loop = true }) {
|
|
81
|
+
let timers = [];
|
|
82
|
+
const play = () => {
|
|
83
|
+
timers.forEach(clearTimeout);
|
|
84
|
+
timers = [];
|
|
85
|
+
term.reset();
|
|
86
|
+
court.reset();
|
|
87
|
+
for (const [t, a] of SCRIPT) {
|
|
88
|
+
if (stopAt != null && t > stopAt) continue;
|
|
89
|
+
timers.push(
|
|
90
|
+
setTimeout(() => {
|
|
91
|
+
if (a.out) term.write(a.out);
|
|
92
|
+
if (a.ev) onEvent({ ...base, ...a.ev, time: Date.now() });
|
|
93
|
+
if (a.press) court.petitionAction(a.press);
|
|
94
|
+
if (a.end && loop) play();
|
|
95
|
+
}, (t * 1000) / speed),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
play();
|
|
100
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Copyright (c) 2015, NAVER Corporation (https://www.navercorp.com),
|
|
2
|
+
with Reserved Font Name D2Coding.
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2015, NAVER Corporation (https://www.navercorp.com),
|
|
5
|
+
with Reserved Font Name D2Coding-Bold.
|
|
6
|
+
|
|
7
|
+
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
8
|
+
This license is copied below, and is also available with a FAQ at:
|
|
9
|
+
https://openfontlicense.org
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
-----------------------------------------------------------
|
|
13
|
+
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
14
|
+
-----------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
PREAMBLE
|
|
17
|
+
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
18
|
+
development of collaborative font projects, to support the font creation
|
|
19
|
+
efforts of academic and linguistic communities, and to provide a free and
|
|
20
|
+
open framework in which fonts may be shared and improved in partnership
|
|
21
|
+
with others.
|
|
22
|
+
|
|
23
|
+
The OFL allows the licensed fonts to be used, studied, modified and
|
|
24
|
+
redistributed freely as long as they are not sold by themselves. The
|
|
25
|
+
fonts, including any derivative works, can be bundled, embedded,
|
|
26
|
+
redistributed and/or sold with any software provided that any reserved
|
|
27
|
+
names are not used by derivative works. The fonts and derivatives,
|
|
28
|
+
however, cannot be released under any other type of license. The
|
|
29
|
+
requirement for fonts to remain under this license does not apply
|
|
30
|
+
to any document created using the fonts or their derivatives.
|
|
31
|
+
|
|
32
|
+
DEFINITIONS
|
|
33
|
+
"Font Software" refers to the set of files released by the Copyright
|
|
34
|
+
Holder(s) under this license and clearly marked as such. This may
|
|
35
|
+
include source files, build scripts and documentation.
|
|
36
|
+
|
|
37
|
+
"Reserved Font Name" refers to any names specified as such after the
|
|
38
|
+
copyright statement(s).
|
|
39
|
+
|
|
40
|
+
"Original Version" refers to the collection of Font Software components as
|
|
41
|
+
distributed by the Copyright Holder(s).
|
|
42
|
+
|
|
43
|
+
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
44
|
+
or substituting -- in part or in whole -- any of the components of the
|
|
45
|
+
Original Version, by changing formats or by porting the Font Software to a
|
|
46
|
+
new environment.
|
|
47
|
+
|
|
48
|
+
"Author" refers to any designer, engineer, programmer, technical
|
|
49
|
+
writer or other person who contributed to the Font Software.
|
|
50
|
+
|
|
51
|
+
PERMISSION & CONDITIONS
|
|
52
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
53
|
+
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
54
|
+
redistribute, and sell modified and unmodified copies of the Font
|
|
55
|
+
Software, subject to the following conditions:
|
|
56
|
+
|
|
57
|
+
1) Neither the Font Software nor any of its individual components,
|
|
58
|
+
in Original or Modified Versions, may be sold by itself.
|
|
59
|
+
|
|
60
|
+
2) Original or Modified Versions of the Font Software may be bundled,
|
|
61
|
+
redistributed and/or sold with any software, provided that each copy
|
|
62
|
+
contains the above copyright notice and this license. These can be
|
|
63
|
+
included either as stand-alone text files, human-readable headers or
|
|
64
|
+
in the appropriate machine-readable metadata fields within text or
|
|
65
|
+
binary files as long as those fields can be easily viewed by the user.
|
|
66
|
+
|
|
67
|
+
3) No Modified Version of the Font Software may use the Reserved Font
|
|
68
|
+
Name(s) unless explicit written permission is granted by the corresponding
|
|
69
|
+
Copyright Holder. This restriction only applies to the primary font name as
|
|
70
|
+
presented to the users.
|
|
71
|
+
|
|
72
|
+
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
73
|
+
Software shall not be used to promote, endorse or advertise any
|
|
74
|
+
Modified Version, except to acknowledge the contribution(s) of the
|
|
75
|
+
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
76
|
+
permission.
|
|
77
|
+
|
|
78
|
+
5) The Font Software, modified or unmodified, in part or in whole,
|
|
79
|
+
must be distributed entirely under this license, and must not be
|
|
80
|
+
distributed under any other license. The requirement for fonts to
|
|
81
|
+
remain under this license does not apply to any document created
|
|
82
|
+
using the Font Software.
|
|
83
|
+
|
|
84
|
+
TERMINATION
|
|
85
|
+
This license becomes null and void if any of the above conditions are
|
|
86
|
+
not met.
|
|
87
|
+
|
|
88
|
+
DISCLAIMER
|
|
89
|
+
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
90
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
91
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
92
|
+
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
93
|
+
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
94
|
+
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
95
|
+
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
96
|
+
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
97
|
+
OTHER DEALINGS IN THE FONT SOFTWARE.
|