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 +1 -1
- package/README.md +1 -1
- package/docs/ARCHITECTURE.md +2 -1
- package/lib/state.js +57 -2
- package/package.json +1 -1
- package/public/js/i18n.js +2 -0
- package/public/js/scene/actors.js +4 -1
- package/public/js/store.js +2 -0
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
|
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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
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
|
-
|
|
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
|
}
|
package/public/js/store.js
CHANGED
|
@@ -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 인 세션은 전이가 아니므로 제외)
|