claude-ai-office 0.25.1 → 0.25.6

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/bin/ai-office.js CHANGED
@@ -17,6 +17,7 @@ const os = require('os');
17
17
  const net = require('net');
18
18
  const { spawn, execFileSync } = require('child_process');
19
19
  const { readHookPort } = require('../lib/hookport');
20
+ const { isVersionedNodePath } = require('../lib/nodepath');
20
21
  const crypto = require('crypto');
21
22
 
22
23
  const SRC = path.join(__dirname, '..');
@@ -65,6 +66,11 @@ function fingerprint(dir) {
65
66
  const runningFromSource = () => path.resolve(SRC) !== path.resolve(APP) && fs.existsSync(path.join(SRC, '.git'));
66
67
  function install({ force = false } = {}) {
67
68
  if (path.resolve(SRC) === path.resolve(APP)) return APP; // 이미 설치 경로에서 실행 중
69
+ // 데스크톱 앱이 관리하는 설치본(.app-version 스탬프)은 덮어쓰지 않는다. 덮으면 다음 앱 실행 때 앱 버전으로 되돌아가 서로 덮어쓴다
70
+ if (!force && fs.existsSync(path.join(APP, '.app-version'))) {
71
+ log(`AI Office 앱이 ${APP} 를 관리하고 있어 그대로 둡니다 (앱 ${fs.readFileSync(path.join(APP, '.app-version'), 'utf8').trim()}, 덮어쓰려면 --force)`);
72
+ return APP;
73
+ }
68
74
  const have = installedVersion();
69
75
  const fp = fingerprint(SRC);
70
76
  let installedFp = null;
@@ -125,7 +131,7 @@ async function doctor() {
125
131
  log(`node ${process.version} ${major >= 18 ? '✓' : '✗ 18 이상 필요'} (${process.execPath})`);
126
132
  log(`platform ${process.platform} ${os.release()}`);
127
133
  const settings = process.env.CLAUDE_SETTINGS || path.join(os.homedir(), '.claude', 'settings.json');
128
- let hooks = 0, stale = 0;
134
+ let hooks = 0, stale = 0, versioned = 0;
129
135
  try {
130
136
  const h = JSON.parse(fs.readFileSync(settings, 'utf8')).hooks || {};
131
137
  for (const ev of Object.values(h)) for (const g of ev) for (const x of g.hooks || []) {
@@ -135,9 +141,11 @@ async function doctor() {
135
141
  if (m && !fs.existsSync(m[1])) stale++;
136
142
  const n = /^"([^"]+)"/.exec(x.command); // 앞의 node 경로(앱이 등록한 내장 node 등)도 있어야 한다
137
143
  if (n && n[1] !== 'node' && !fs.existsSync(n[1])) stale++;
144
+ if (n && isVersionedNodePath(n[1])) versioned++;
138
145
  }
139
146
  }
140
147
  log(`settings ${settings} ✓ 훅 ${hooks}개 등록${stale ? `, ${stale}개는 node 또는 emit.js 파일 없음 → ai-office install-hooks` : ''}`);
148
+ if (versioned) log(` ⚠ 훅 ${versioned}개가 Node 버전 폴더 경로라 brew upgrade 뒤 깨질 수 있음 → ai-office install-hooks`);
141
149
  } catch { log(`settings ${settings} ✗ 읽을 수 없음`); }
142
150
  log(`app ${installedVersion() ? `${installedVersion()} @ ${APP} ✓` : '미설치 (ai-office install)'}`);
143
151
  log(`port ${PORT} ${(await portInUse(PORT)) ? '사용 중 (서버 떠 있음)' : '비어 있음'}`);
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+ // 훅 명령에 넣을 Node 경로 (BE-18). Node 를 업그레이드해도 깨지지 않는 경로를 고른다.
3
+ // 1) ~/.ai-office/node(.exe): 데스크톱 앱이 복사해 둔 Node. 앱과 CLI 가 같은 훅 명령을 쓰게 된다
4
+ // 2) Homebrew 의 버전 폴더(/opt/homebrew/Cellar/node/26.12.0/bin/node) → 버전 없는 opt 링크(/opt/homebrew/opt/node/bin/node)
5
+ // 3) 그 밖에는 실행 중인 Node 그대로
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const os = require('os');
9
+
10
+ const CELLAR = /^(.*)[\\/]Cellar[\\/](node(?:@\d+)?)[\\/][^\\/]+[\\/]bin[\\/]node$/;
11
+
12
+ function isFile(p, fsImpl) { try { return fsImpl.statSync(p).isFile(); } catch { return false; } }
13
+
14
+ /** Homebrew Cellar 의 버전 폴더 경로인가 (업그레이드·cleanup 때 사라진다) */
15
+ function isVersionedNodePath(p) { return CELLAR.test(String(p || '')); }
16
+
17
+ function stableNodePath({
18
+ execPath = process.execPath,
19
+ home = process.env.AI_OFFICE_HOME || path.join(os.homedir(), '.ai-office'),
20
+ platform = process.platform,
21
+ fsImpl = fs,
22
+ } = {}) {
23
+ const appNode = path.join(home, platform === 'win32' ? 'node.exe' : 'node');
24
+ if (isFile(appNode, fsImpl)) return appNode;
25
+ const m = CELLAR.exec(execPath);
26
+ if (m) {
27
+ const sep = execPath.includes('\\') && !execPath.includes('/') ? '\\' : '/'; // Homebrew 경로는 POSIX, 그 구분자를 그대로 쓴다 (Windows 의 path.join 은 \\ 를 쓴다)
28
+ const opt = [m[1], 'opt', m[2], 'bin', 'node'].join(sep);
29
+ if (isFile(opt, fsImpl)) return opt;
30
+ }
31
+ return execPath;
32
+ }
33
+
34
+ module.exports = { stableNodePath, isVersionedNodePath };
package/lib/state.js CHANGED
@@ -40,7 +40,50 @@ function defaultStat(file) {
40
40
  try { return { mtimeMs: fs.statSync(file).mtimeMs }; } catch { return null; }
41
41
  }
42
42
 
43
- function createState({ me = 'me', statFile = defaultStat, styleFile = null } = {}) {
43
+ const FIRST_PROMPT_HEAD_BYTES = 1024 * 1024;
44
+ /** transcript 앞부분에서 첫 사용자 메시지(실제 요청)를 읽는다. 이미지가 붙은 첫 줄은 수백 KB 라 잘려도 텍스트만 뽑는다. 없으면 null */
45
+ function defaultFirstPrompt(file) {
46
+ let fd;
47
+ try {
48
+ fd = fs.openSync(file, 'r');
49
+ const buf = Buffer.alloc(FIRST_PROMPT_HEAD_BYTES);
50
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
51
+ for (const line of buf.subarray(0, n).toString('utf8').split('\n')) {
52
+ if (!line.includes('"type":"user"') || line.includes('"isMeta":true')) continue;
53
+ let text = null;
54
+ try {
55
+ const c = JSON.parse(line).message?.content;
56
+ text = typeof c === 'string' ? c : Array.isArray(c) ? (c.find((x) => x && x.type === 'text') || {}).text : null;
57
+ } catch { // 잘린 줄: 첫 "text" 값만 꺼낸다
58
+ const m = /"content":"((?:[^"\\]|\\.)*)"|"type":"text","text":"((?:[^"\\]|\\.)*)"/.exec(line);
59
+ if (m) try { text = JSON.parse(`"${m[1] ?? m[2]}"`); } catch {}
60
+ }
61
+ if (text && text.trim() && !/^\s*</.test(text)) return text.trim();
62
+ }
63
+ return null;
64
+ } catch { return null; } finally { if (fd !== undefined) try { fs.closeSync(fd); } catch {} }
65
+ }
66
+
67
+ const TITLE_TAIL_BYTES = 64 * 1024;
68
+ /** transcript 끝부분에서 마지막 세션 제목(custom-title 기록)을 읽는다. 없거나 못 읽으면 null */
69
+ function defaultTitle(file) {
70
+ let fd;
71
+ try {
72
+ fd = fs.openSync(file, 'r');
73
+ const size = fs.fstatSync(fd).size;
74
+ const len = Math.min(size, TITLE_TAIL_BYTES);
75
+ const buf = Buffer.alloc(len);
76
+ fs.readSync(fd, buf, 0, len, size - len);
77
+ const lines = buf.toString('utf8').split('\n');
78
+ for (let i = lines.length - 1; i >= 0; i--) {
79
+ if (!lines[i].includes('"custom-title"')) continue;
80
+ try { const j = JSON.parse(lines[i]); if (j.type === 'custom-title' && typeof j.customTitle === 'string' && j.customTitle.trim()) return j.customTitle.trim(); } catch {}
81
+ }
82
+ return null;
83
+ } catch { return null; } finally { if (fd !== undefined) try { fs.closeSync(fd); } catch {} }
84
+ }
85
+
86
+ function createState({ me = 'me', statFile = defaultStat, titleOf = defaultTitle, firstPromptOf = defaultFirstPrompt, styleFile = null } = {}) {
44
87
  const emitter = new EventEmitter();
45
88
  const floors = new Map(); // cwd → Floor
46
89
  const sessions = new Map(); // session_id → Session
@@ -115,13 +158,14 @@ function createState({ me = 'me', statFile = defaultStat, styleFile = null } = {
115
158
  s = {
116
159
  id: ev.sessionId, floorId: floor.id, title: ev.sessionTitle, status: 'idle',
117
160
  startedAt: now, lastActivity: now, agents: {}, pendingSubagents: [], ended: false, firstPrompt: '', lastPrompt: '',
118
- transcriptPath: ev.transcriptPath, lastStatAt: 0,
161
+ transcriptPath: ev.transcriptPath, lastStatAt: 0, titleMtime: 0, firstPromptChecked: false, hookTitle: '', fileTitle: '',
119
162
  cwd: ev.cwd || '', branch: proj ? proj.branch : '', worktree: proj && proj.worktree ? path.basename(proj.worktreeDir) : '',
120
163
  };
121
164
  sessions.set(s.id, s);
122
165
  floor.sessions.add(s.id);
123
166
  }
124
- if (ev.sessionTitle && !s.title) s.title = ev.sessionTitle;
167
+ // 제목: 훅의 session_title 이 바뀔 때마다 따른다 (분리된 세션은 부모 제목으로 시작했다가 이름이 바뀐다)
168
+ if (ev.sessionTitle && ev.sessionTitle !== s.hookTitle) { s.hookTitle = ev.sessionTitle; retitle(s, floor, ev.sessionTitle, now); }
125
169
  if (ev.transcriptPath) s.transcriptPath = ev.transcriptPath;
126
170
  s.lastActivity = now;
127
171
  floor.lastActivity = now;
@@ -183,6 +227,15 @@ function createState({ me = 'me', statFile = defaultStat, styleFile = null } = {
183
227
 
184
228
  function schedule(at, fn) { timers.push({ at, fn }); }
185
229
 
230
+ /** 세션 제목 변경. 이전 제목이 있었으면 로그 한 줄 + update 를 보낸다. 바뀌었으면 true */
231
+ function retitle(s, floor, title, now) {
232
+ if (!title || title === s.title) return false;
233
+ const had = !!s.title;
234
+ s.title = title;
235
+ if (had && floor) emitUpdate(s, floor, pushLog(now, floor, s, s.agents.main || null, '🏷️', `제목: ${cut(title, 60)}`));
236
+ return true;
237
+ }
238
+
186
239
  // ---------- 전이 ----------
187
240
  function apply(payload, now = Date.now()) {
188
241
  if (!payload || typeof payload !== 'object') return;
@@ -354,12 +407,27 @@ function createState({ me = 'me', statFile = defaultStat, styleFile = null } = {
354
407
  if (s.ended) continue;
355
408
  const idle = now - s.lastActivity;
356
409
  if (idle >= SESSION_TTL_MS) { endSilently(s, now, '2시간 무활동'); continue; }
357
- // transcript 가 더 이상 갱신되지 않으면 터미널이 닫힌 것으로 본다
358
- if (idle >= GONE_CHECK_MS && s.transcriptPath && now - s.lastStatAt >= STAT_INTERVAL_MS) {
410
+ // transcript 확인은 세션당 1분에 한 번 (stat 한 번을 제목 · 퇴근 감지가 같이 쓴다)
411
+ if (s.transcriptPath && (!s.lastStatAt || now - s.lastStatAt >= STAT_INTERVAL_MS)) {
359
412
  s.lastStatAt = now;
360
413
  const st = statFile(s.transcriptPath);
361
- if (!st) { endSilently(s, now, 'transcript 없음'); continue; }
362
- if (st.mtimeMs <= s.lastActivity && idle >= GONE_MS) { endSilently(s, now, '터미널 종료 추정'); continue; }
414
+ // 첫 요청: 저널은 최근 2시간만 재생하므로 오래된 세션은 첫 요청 자리에 최근 프롬프트가 들어간다 → transcript 에서 한 번 복원
415
+ if (st && !s.firstPromptChecked) {
416
+ s.firstPromptChecked = true;
417
+ const fp = firstPromptOf(s.transcriptPath);
418
+ if (fp) s.firstPrompt = cut(fp, 120);
419
+ }
420
+ // 제목: 훅 없이 이름만 바뀐 경우 transcript 의 마지막 custom-title 로 따라간다 (파일이 바뀌었을 때만 읽음)
421
+ if (st && st.mtimeMs !== s.titleMtime) {
422
+ s.titleMtime = st.mtimeMs;
423
+ const t = titleOf(s.transcriptPath);
424
+ if (t && t !== s.fileTitle) { s.fileTitle = t; retitle(s, floors.get(s.floorId), t, now); }
425
+ }
426
+ // transcript 가 더 이상 갱신되지 않으면 터미널이 닫힌 것으로 본다
427
+ if (idle >= GONE_CHECK_MS) {
428
+ if (!st) { endSilently(s, now, 'transcript 없음'); continue; }
429
+ if (st.mtimeMs <= s.lastActivity && idle >= GONE_MS) { endSilently(s, now, '터미널 종료 추정'); continue; }
430
+ }
363
431
  }
364
432
  if (idle >= AWAY_MS && s.status !== 'away') {
365
433
  s.status = 'away';
@@ -390,4 +458,4 @@ function createState({ me = 'me', statFile = defaultStat, styleFile = null } = {
390
458
  return { apply, remove, removeDemo, sweep, snapshot, setFloorStyle, on: emitter.on.bind(emitter), get: (id) => sessions.get(id) };
391
459
  }
392
460
 
393
- module.exports = { createState, restRoomFor, REST_ROOMS, REST_BUBBLE, REST_ICON, LOG_LIMIT, SESSION_TTL_MS, SUBAGENT_LEAVE_MS, SESSION_LEAVE_MS, AWAY_MS, GONE_CHECK_MS, GONE_MS, STAT_INTERVAL_MS };
461
+ module.exports = { defaultTitle, defaultFirstPrompt, createState, restRoomFor, REST_ROOMS, REST_BUBBLE, REST_ICON, LOG_LIMIT, SESSION_TTL_MS, SUBAGENT_LEAVE_MS, SESSION_LEAVE_MS, AWAY_MS, GONE_CHECK_MS, GONE_MS, STAT_INTERVAL_MS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-ai-office",
3
- "version": "0.25.1",
3
+ "version": "0.25.6",
4
4
  "description": "Claude Code 세션 활동을 아이소메트릭 사무실로 실시간 시각화",
5
5
  "keywords": [
6
6
  "claude",
package/public/js/i18n.js CHANGED
@@ -4,8 +4,16 @@
4
4
  // 서버가 보내는 말풍선·로그도 한국어라 같은 규칙으로 번역한다.
5
5
  const KEY = 'aiOffice.lang';
6
6
  export const LANGS = [['ko', '한국어'], ['en', 'English']];
7
+ /** 브라우저면 localStorage, Node(테스트)면 테스트가 직접 넣은 것만 쓴다 — Node 25+ 의 실험용 localStorage getter 는 접근만 해도 경고를 찍는다 */
8
+ function storage() {
9
+ try {
10
+ if (typeof window !== 'undefined') return window.localStorage;
11
+ const d = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
12
+ return d && 'value' in d ? d.value : null;
13
+ } catch { return null; }
14
+ }
7
15
  let lang = (() => {
8
- try { const v = localStorage.getItem(KEY); if (v === 'ko' || v === 'en') return v; } catch {}
16
+ try { const v = storage()?.getItem(KEY); if (v === 'ko' || v === 'en') return v; } catch {}
9
17
  return (typeof navigator !== 'undefined' && /^ko/i.test(navigator.language || '')) ? 'ko' : 'en';
10
18
  })();
11
19
  export function getLang() { return lang; }
@@ -85,6 +93,7 @@ const RULES = [
85
93
  [/^(.+) 사용 중$/, (m) => `using ${m[1]}`],
86
94
  [/^(.+) 실패: (.+)$/, (m) => `${m[1]} failed: ${m[2]}`],
87
95
  [/^허락 요청: (.+)$/, (m) => `permission request: ${m[1]}`],
96
+ [/^제목: (.+)$/, (m) => `title: ${m[1]}`],
88
97
  [/^완료: (.+)$/, (m) => `done: ${m[1]}`],
89
98
  [/^알림 수신 \((.+)\)$/, (m) => `notification (${m[1]})`],
90
99
  [/^세션 종료 \((.+)\)$/, (m) => `session ended (${m[1]})`],
@@ -193,7 +202,7 @@ export function setLang(v) {
193
202
  if (v !== 'ko' && v !== 'en') return;
194
203
  if (v === lang) return;
195
204
  lang = v;
196
- try { localStorage.setItem(KEY, v); } catch {}
205
+ try { storage()?.setItem(KEY, v); } catch {}
197
206
  cache.clear();
198
207
  if (typeof document !== 'undefined') { document.documentElement.lang = v; walk(document.body); }
199
208
  for (const fn of listeners) fn(v);
@@ -6,6 +6,7 @@
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
8
  const os = require('os');
9
+ const { stableNodePath } = require('../lib/nodepath');
9
10
 
10
11
  const EVENTS = [
11
12
  'SessionStart', 'SessionEnd', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse',
@@ -21,7 +22,8 @@ const argNode = (() => { const i = process.argv.indexOf('--node'); return i >= 0
21
22
 
22
23
  // Windows/macOS/Linux 공통: "<node 절대경로>" "<app>/hooks/emit.js" (공백·역슬래시 경로 대비 따옴표)
23
24
  const q = (s) => `"${s}"`;
24
- const COMMAND = buildCommand(argNode || process.execPath, path.join(APP_DIR, 'hooks', 'emit.js'), argPort);
25
+ // --node 가 없으면 업그레이드에 안 깨지는 경로 (앱이 복사한 ~/.ai-office/node > Homebrew opt 링크 > 실행 중인 Node)
26
+ const COMMAND = buildCommand(argNode || stableNodePath(), path.join(APP_DIR, 'hooks', 'emit.js'), argPort);
25
27
  function buildCommand(nodePath, emitPath, port) { return `${q(nodePath)} ${q(emitPath)}${port && port !== 4777 ? ` --port ${port}` : ''}`; }
26
28
 
27
29
  function isOurs(h) {