claude-ai-office 0.30.1 → 0.31.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/README.en.md CHANGED
@@ -61,7 +61,7 @@ node bin/ai-office.js # or: npm run install-hooks && npm start
61
61
 
62
62
  | Area | Contents |
63
63
  |---|---|
64
- | **Left: Floors** | One floor per git project (repository root). Worktrees share the floor and show their branch (⎇). Opening a floor lists its sessions with `waiting` and `viewing` badges. By default only the selected floor is open; **Expand all ↔ Collapse all** in the header opens or closes every floor's session list at once (clicking a floor while all are collapsed opens that floor; remembered in the browser). 🎨 on the selected floor picks that floor's interior preset (Warm Wood · Cool Gray · Mint · Lavender · Sand · Pastel; assigned automatically by default). "Follow the latest session" (`A`) moves the selection to the most recently active session; when it is off, the last floor and session you looked at are restored next time |
64
+ | **Left: Floors** | One floor per git project (repository root). Worktrees share the floor and show their branch (⎇). A session started in a parent folder that holds several repositories (not itself a git repo) moves to the floor of the repository it actually works in: the one with the most edited files, or the most read files before any edit (ties keep the current floor). Resume commands still use the folder it was started in. Opening a floor lists its sessions with `waiting` and `viewing` badges. By default only the selected floor is open; **Expand all ↔ Collapse all** in the header opens or closes every floor's session list at once (clicking a floor while all are collapsed opens that floor; remembered in the browser). 🎨 on the selected floor picks that floor's interior preset (Warm Wood · Cool Gray · Mint · Lavender · Sand · Pastel; assigned automatically by default). "Follow the latest session" (`A`) moves the selection to the most recently active session; when it is off, the last floor and session you looked at are restored next time |
65
65
  | **Center: Office** | Drag to pan, wheel or buttons to zoom, `⟲` to reset, pinch on touch screens. Click a character to select its session; hover for a tooltip (current action, target file, prompt, branch, path). 📍 **Follow** (`F`) keeps the camera on the selected session's Claude. 🏢 **Building view** (`O`) appears when there are two or more floors |
66
66
  | **Right panel** | Session card (project path · session id · character name · copy the resume command · 🗑 dismiss ended sessions), people in the office (room · speech bubble · elapsed time), activity log (search with `/`, selected session only), legend (click the title to collapse) |
67
67
 
package/README.md CHANGED
@@ -63,7 +63,7 @@ node bin/ai-office.js # 또는 npm run install-hooks && npm start
63
63
 
64
64
  | 영역 | 내용 |
65
65
  |---|---|
66
- | **좌측 층별 안내** | git 프로젝트(저장소 루트) = 층. worktree 는 같은 층으로 묶이고 브랜치(⎇)가 함께 보인다. 층을 열면 세션 목록 — `답 기다림`, `지금 보는 중` 표시. 기본은 선택한 층만 열리고, 머리의 **모두 펼치기 ↔ 모두 접기** 로 모든 층의 세션 목록을 한꺼번에 열고 닫는다(모두 접힌 상태에서 층을 누르면 그 층이 열림, 브라우저에 기억). 선택한 층의 🎨 로 그 층 인테리어 스타일(따뜻한 우드·쿨 그레이·민트·라벤더·샌드·파스텔, 기본은 층마다 자동 배정)을 고른다. "최근 세션 자동으로 따라가기"(`A`)를 켜면 최신 활동 세션으로 선택이 옮겨가고, 끄면 마지막에 보던 층·세션을 다음에 열 때 기억한다 |
66
+ | **좌측 층별 안내** | git 프로젝트(저장소 루트) = 층. worktree 는 같은 층으로 묶이고 브랜치(⎇)가 함께 보인다. 여러 저장소를 담은 상위 폴더(git 저장소 아님)에서 연 세션은 실제로 작업하는 저장소 층으로 옮겨 간다 — 고친 파일이 가장 많은 저장소, 아직 고친 게 없으면 가장 많이 본 저장소(동점이면 그대로). 이어가기 명령은 처음 연 폴더 기준. 층을 열면 세션 목록 — `답 기다림`, `지금 보는 중` 표시. 기본은 선택한 층만 열리고, 머리의 **모두 펼치기 ↔ 모두 접기** 로 모든 층의 세션 목록을 한꺼번에 열고 닫는다(모두 접힌 상태에서 층을 누르면 그 층이 열림, 브라우저에 기억). 선택한 층의 🎨 로 그 층 인테리어 스타일(따뜻한 우드·쿨 그레이·민트·라벤더·샌드·파스텔, 기본은 층마다 자동 배정)을 고른다. "최근 세션 자동으로 따라가기"(`A`)를 켜면 최신 활동 세션으로 선택이 옮겨가고, 끄면 마지막에 보던 층·세션을 다음에 열 때 기억한다 |
67
67
  | **중앙 사무실** | 드래그 이동, 휠/버튼 줌, `⟲` 초기 위치. 캐릭터 클릭 → 세션 선택, 캐릭터에 마우스를 올리면 툴팁(지금 하는 일·대상 파일·요청·브랜치·경로). 📍 **따라가기**(`F`) = 카메라가 선택 세션의 Claude 를 쫓아감. 🏢 **전체 보기**(`O`)는 층이 둘 이상일 때 나타난다 |
68
68
  | **우측 패널** | 세션 카드(프로젝트 경로 · 세션 id · 캐릭터 이름 · 이어가기 명령 복사 · 끝난 세션 🗑 치우기), 사무실 사람들(방·말풍선·경과), 활동 로그(검색 `/`, 선택 세션만), 범례(제목 클릭으로 접기) |
69
69
 
@@ -48,7 +48,8 @@ Floor { // 프로젝트 = 층. id = git 저장소 루트 (l
48
48
 
49
49
  Session {
50
50
  id: string // hook.session_id
51
- floorId: string // 처음 정해진 층 유지 (cwd 가 하위 폴더로 바뀌어도)
51
+ floorId: string // 처음 정해진 층 유지 (cwd 가 하위 폴더로 바뀌어도). 예외: git 저장소가 아닌 폴더에서 연 세션은
52
+ // 도구 경로(file_path·path·Bash 앞머리 cd)의 저장소를 세어 고친 파일 최다 → 본 횟수 최다 저장소 층으로 옮긴다 (BE-22, lib/state.js follow)
52
53
  title: string // 훅 session_title > transcript 의 마지막 custom-title > 첫 요청 앞부분
53
54
  firstPrompt, lastPrompt // 첫 요청(transcript 앞 1MB 에서 복원), 최근 요청
54
55
  status: 'thinking' | 'working' | 'waiting' | 'idle' | 'ended'
package/lib/state.js CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  const path = require('path');
6
6
  const fs = require('fs');
7
+ const os = require('os');
7
8
  const EventEmitter = require('events');
8
9
  const { normalize, describeTool, ACTIVITY_ICON, ROOMS: ROOM_LABEL, cut } = require('./normalize');
9
10
  const FS = require('./floorStyle');
@@ -36,6 +37,20 @@ function rest(bubble, sessionId, floor) {
36
37
  return { room, activity: 'rest', bubble: bubble || info.bubble, icon: info.icon };
37
38
  }
38
39
 
40
+ // 작업 위치로 층 옮기기 (BE-22): 도구가 다루는 경로 → { p, edit }. 경로가 없는 도구는 null
41
+ const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit']);
42
+ function workPath(ev) {
43
+ const ti = ev.toolInput || {};
44
+ const fp = ti.file_path || ti.notebook_path;
45
+ if (fp) return { p: path.dirname(fp), edit: EDIT_TOOLS.has(ev.toolName) };
46
+ if (ti.path && (ev.toolName === 'Grep' || ev.toolName === 'Glob' || ev.toolName === 'LS')) return { p: ti.path, edit: false };
47
+ if (ev.toolName === 'Bash' && ti.command) {
48
+ const m = /^\s*cd\s+(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))/.exec(ti.command);
49
+ if (m) return { p: m[1] || m[2] || m[3], edit: false };
50
+ }
51
+ return null;
52
+ }
53
+
39
54
  function defaultStat(file) {
40
55
  try { return { mtimeMs: fs.statSync(file).mtimeMs }; } catch { return null; }
41
56
  }
@@ -90,6 +105,8 @@ function createState({ me = 'me', statFile = defaultStat, titleOf = defaultTitle
90
105
  const log = [];
91
106
  const timers = []; // { at, fn } sweep(now) 가 실행
92
107
  let floorSeq = 0;
108
+ // git 저장소가 아닌 폴더(여러 저장소를 담은 묶음 폴더)에서 연 세션만 (BE-22): sessionId → Map(저장소 root → { edits, reads, branch, worktree })
109
+ const roaming = new Map();
93
110
 
94
111
  // ---------- 층 인테리어 프리셋 (FE-36): 직접 선택(styleFile 에 저장) > 층 id 해시 자동 배정 ----------
95
112
  const styleOverrides = new Map(); // floorId → presetId
@@ -163,6 +180,7 @@ function createState({ me = 'me', statFile = defaultStat, titleOf = defaultTitle
163
180
  };
164
181
  sessions.set(s.id, s);
165
182
  floor.sessions.add(s.id);
183
+ if (!proj && ev.cwd && !ev.cwd.startsWith('/demo/')) roaming.set(s.id, new Map());
166
184
  }
167
185
  // 제목: 훅의 session_title 이 바뀔 때마다 따른다 (분리된 세션은 부모 제목으로 시작했다가 이름이 바뀐다)
168
186
  if (ev.sessionTitle && ev.sessionTitle !== s.hookTitle) { s.hookTitle = ev.sessionTitle; retitle(s, floor, ev.sessionTitle, now); }
@@ -172,6 +190,41 @@ function createState({ me = 'me', statFile = defaultStat, titleOf = defaultTitle
172
190
  return { session: s, floor };
173
191
  }
174
192
 
193
+ /**
194
+ * 묶음 폴더에서 연 세션을 실제로 작업하는 저장소 층으로 옮긴다 (BE-22).
195
+ * 층 = 고친 파일이 가장 많은 저장소, 아직 고친 게 없으면 가장 많이 본 저장소. 지금 층보다 많아질 때만 옮긴다(동점은 그대로).
196
+ * 저장소 세션은 옮기지 않는다. s.cwd(이어가기 위치)는 그대로. 옮긴 뒤의 층을 반환한다
197
+ */
198
+ function follow(s, floor, ev, now) {
199
+ const counts = roaming.get(s.id);
200
+ const w = counts && workPath(ev);
201
+ if (!w) return floor;
202
+ let p = w.p.replace(/^~(?=$|[\\/])/, os.homedir());
203
+ if (!path.isAbsolute(p)) { if (!s.cwd) return floor; p = path.resolve(s.cwd, p); }
204
+ const proj = resolveProject(p);
205
+ if (!proj) return floor;
206
+ const c = counts.get(proj.root) || { edits: 0, reads: 0 };
207
+ if (w.edit) c.edits++; else c.reads++;
208
+ c.branch = proj.branch; c.worktree = proj.worktree ? path.basename(proj.worktreeDir) : '';
209
+ counts.set(proj.root, c);
210
+ if (proj.root === s.floorId) return floor;
211
+ const cur = counts.get(s.floorId);
212
+ // 한 번에 한 저장소만 늘어나므로 지금 층(최다)과 그 저장소만 비교하면 된다
213
+ if (cur && (c.edits < cur.edits || (c.edits === cur.edits && c.reads <= cur.reads))) return floor;
214
+ const to = floorFor(proj.root, now);
215
+ floor.sessions.delete(s.id);
216
+ to.sessions.add(s.id);
217
+ s.floorId = to.id; s.branch = c.branch; s.worktree = c.worktree;
218
+ // 새 층 update 를 먼저 보낸다 → 화면이 선택을 새 층으로 옮긴 뒤 빈 층이 사라진다
219
+ emitUpdate(s, to, pushLog(now, to, s, s.agents.main || null, '🛗', `${to.name} 층으로 이동`));
220
+ if (floor.sessions.size === 0) {
221
+ floors.delete(floor.id);
222
+ emitter.emit('remove', { sessionId: null, floorId: floor.id, floorRemoved: true });
223
+ }
224
+ for (const f of new Set([...renameFloors(), ...refreshStyles(), ...(floors.has(floor.id) ? [floor] : [])])) if (f !== to) emitter.emit('floor', publicFloor(f));
225
+ return to;
226
+ }
227
+
175
228
  function mainAgent(s, now) {
176
229
  let a = s.agents.main;
177
230
  if (!a) {
@@ -241,7 +294,8 @@ function createState({ me = 'me', statFile = defaultStat, titleOf = defaultTitle
241
294
  if (!payload || typeof payload !== 'object') return;
242
295
  const ev = normalize(payload);
243
296
  if (ev.event === 'SessionEnd' && !sessions.has(ev.sessionId)) return; // 모르는 세션의 종료(치운 세션 재생 등)로 빈 층을 만들지 않는다
244
- const { session: s, floor } = sessionFor(ev, now);
297
+ const { session: s, floor: home } = sessionFor(ev, now);
298
+ const floor = ev.event === 'PreToolUse' ? follow(s, home, ev, now) : home;
245
299
  let entry = null;
246
300
 
247
301
  switch (ev.event) {
@@ -313,7 +367,7 @@ function createState({ me = 'me', statFile = defaultStat, titleOf = defaultTitle
313
367
  schedule(now + SUBAGENT_LEAVE_MS, () => {
314
368
  if (s.agents[ev.agentId] && s.agents[ev.agentId].leaving) {
315
369
  delete s.agents[ev.agentId];
316
- emitUpdate(s, floor, null);
370
+ emitUpdate(s, floors.get(s.floorId) || floor, null);
317
371
  }
318
372
  });
319
373
  break;
@@ -376,6 +430,7 @@ function createState({ me = 'me', statFile = defaultStat, titleOf = defaultTitle
376
430
  const s = sessions.get(sessionId);
377
431
  if (!s) return;
378
432
  sessions.delete(sessionId);
433
+ roaming.delete(sessionId);
379
434
  const f = floors.get(s.floorId);
380
435
  if (f) {
381
436
  f.sessions.delete(sessionId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-ai-office",
3
- "version": "0.30.1",
3
+ "version": "0.31.0",
4
4
  "description": "Claude Code 세션 활동을 아이소메트릭 사무실로 실시간 시각화",
5
5
  "keywords": [
6
6
  "claude",
package/public/js/i18n.js CHANGED
@@ -94,6 +94,8 @@ const RULES = [
94
94
  [/^(.+) 사용 중$/, (m) => `using ${m[1]}`],
95
95
  [/^(.+) 실패: (.+)$/, (m) => `${m[1]} failed: ${m[2]}`],
96
96
  [/^허락 요청: (.+)$/, (m) => `permission request: ${m[1]}`],
97
+ [/^(.+) 층으로 이동$/, (m) => `moved to ${m[1]}`],
98
+ [/^(.+) 로 이동$/, (m) => `moving to ${m[1]}`],
97
99
  [/^제목: (.+)$/, (m) => `title: ${m[1]}`],
98
100
  [/^완료: (.+)$/, (m) => `done: ${m[1]}`],
99
101
  [/^알림 수신 \((.+)\)$/, (m) => `notification (${m[1]})`],
@@ -250,7 +250,10 @@ export function createActors(store, scene, avatars) {
250
250
  for (const [key, a] of actors) {
251
251
  if (seen.has(key) || a.dead) continue;
252
252
  a.dead = true; a.deadAt = Date.now();
253
- a.bubble = '퇴근합니다'; a.icon = '👋'; a.activity = 'leave';
253
+ // 다른 층으로 옮겨 간 세션(BE-22)은 퇴근이 아니라 엘리베이터로 이동
254
+ const moved = store.sessions.get(a.sessionId);
255
+ const to = moved && moved.floorId !== floorId ? store.floors.get(moved.floorId) : null;
256
+ a.bubble = to ? `${to.no}F ${to.name} 로 이동` : '퇴근합니다'; a.icon = to ? '🛗' : '👋'; a.activity = 'leave';
254
257
  slots.release(key);
255
258
  a.goTo([SPAWN.x, SPAWN.y]);
256
259
  }
@@ -86,6 +86,8 @@ class Store {
86
86
  this.selectedFloorId = floor.id;
87
87
  this.selectedSessionId = session.id;
88
88
  }
89
+ // 선택한 세션이 작업 저장소 층으로 옮겨 가면 선택도 따라간다 (BE-22)
90
+ if (prev && prev.floorId !== session.floorId && this.selectedSessionId === session.id) this.selectedFloorId = session.floorId;
89
91
  this.reconcileSelection();
90
92
  this.emit('update', { session, floor, log });
91
93
  // 도구 실패 / 세션 종료 전이 — 알림 소리 범위 'all' 에서 쓴다 (첫 등장부터 ended 인 세션은 전이가 아니므로 제외)