claude-code-watcher 1.0.1 → 1.0.3

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.md CHANGED
@@ -1,6 +1,17 @@
1
1
  # claude-code-watcher (ccw)
2
2
 
3
- ![screenshot](./screenshot.png)
3
+ <table>
4
+ <tr>
5
+ <td align="center">
6
+ <img src="./screenshot1.png" width="560"/><br/>
7
+ <sub>전체 뷰</sub>
8
+ </td>
9
+ <td align="center">
10
+ <img src="./screenshot2.png" width="220"/><br/>
11
+ <sub>컴팩트 뷰</sub>
12
+ </td>
13
+ </tr>
14
+ </table>
4
15
 
5
16
  VS Code 터미널에서 실행 중인 Claude Code 세션을 실시간으로 모니터링하는 CLI 대시보드입니다.
6
17
 
@@ -9,10 +20,11 @@ VS Code 터미널에서 실행 중인 Claude Code 세션을 실시간으로 모
9
20
  ## 기능
10
21
 
11
22
  - **실시간 세션 목록** — 열려 있는 모든 Claude Code 세션의 상태를 TTY 오름차순으로 표시
12
- - **상태 추적** — `working` / `waiting` / `notification` / `stale` 상태 자동 전환
23
+ - **상태 추적** — `대기중` / `작업중` 🔵 / `응답 요청` 🔴 상태 자동 전환
13
24
  - **서브에이전트 추적** — Agent 툴 실행 중인 서브에이전트 수 표시
14
- - **알림** — `Stop`(응답 완료) 또는 `Notification`(권한 요청) 이벤트 발생 시 소리 알림 (사운드 커스터마이징 가능)
25
+ - **알림** — `Stop`(응답 완료) 또는 `Notification`(응답 요청) 이벤트 발생 시 소리 알림 (사운드 커스터마이징 가능)
15
26
  - **세션 상세보기** — 선택한 세션의 마지막 메시지, 경로, 타임스탬프 등 상세 정보
27
+ - **반응형 레이아웃** — 터미널 너비에 따라 자동으로 뷰 전환 (전체 → 컴팩트 → 상태 색상만)
16
28
  - **프로세스 재시작** — `R` 키로 대시보드 자체를 재실행
17
29
 
18
30
  ## 요구사항
@@ -87,39 +99,21 @@ ccw /sound stop Blow
87
99
 
88
100
  ## 세션 상태
89
101
 
90
- | 상태 | 색상 | 설명 |
102
+ | 상태 | 표시 | 설명 |
91
103
  |------|------|------|
92
- | `working` | 노랑 | Claude가 응답을 처리 중 |
93
- | `waiting` | 초록 | 사용자 입력 대기 중 |
94
- | `notification` | 청록 | 권한 요청 등 알림 발생 |
95
- | `stale` | 회색 | 10분 이상 업데이트 없음 |
96
- | `error` | 빨강 | 오류 발생 |
104
+ | 대기중 | 흰색 | 사용자 입력 대기 중 |
105
+ | 작업중 | 🔵 파랑 | Claude가 응답을 처리 중 |
106
+ | 응답 요청 | 🔴 빨강 | 사용자 권한 요청 등 알림 발생 |
97
107
 
98
- ## 동작 원리
108
+ ## 반응형 레이아웃
99
109
 
100
- Claude Code의 라이프사이클 훅을 통해 세션 상태를 추적합니다.
110
+ 터미널 너비에 따라 표시 방식이 자동으로 전환됩니다.
101
111
 
102
- ```
103
- Claude Code 이벤트 발생
104
-
105
- ~/.claude/hooks/session-tracker.mjs 실행
106
-
107
- ~/.claude/dashboard/active/<sessionId>.json 업데이트
108
-
109
- ccw 대시보드가 파일 변경 감지 → 화면 갱신
110
- ```
112
+ | 너비 | 뷰 | 표시 내용 |
113
+ |------|----|-----------|
114
+ | 60 cols 이상 | 전체 대시보드 | 프로젝트명, 상태, 메시지, 시간 등 전체 정보 |
115
+ | 60 cols 미만 | 컴팩트 뷰 | `● 프로젝트명` (상태 색상 + 이름) |
111
116
 
112
- 등록되는 훅 이벤트:
113
-
114
- | 이벤트 | 전환 상태 |
115
- |--------|-----------|
116
- | `SessionStart` | `waiting` |
117
- | `UserPromptSubmit` | `working` |
118
- | `PreToolUse` / `PostToolUse` | `notification` → `working` 복원 |
119
- | `Stop` | `waiting` + 알림 |
120
- | `Notification` | `notification` (working 중일 때만) |
121
- | `SubagentStart` / `SubagentStop` | 서브에이전트 목록 관리 |
122
- | `SessionEnd` | 세션 파일 삭제 |
123
117
 
124
118
  ## 알려진 제한사항
125
119
 
@@ -73,10 +73,18 @@ switch (hookEvent) {
73
73
  break;
74
74
  }
75
75
 
76
- case 'PreToolUse':
76
+ case 'PreToolUse': {
77
+ // PreToolUse means Claude is actively about to use a tool — always 'working'.
78
+ // This also covers the case where UserPromptSubmit didn't fire (hook failure, etc.).
79
+ const cur = existing.status ?? 'working';
80
+ status = 'working';
81
+ message = cur === 'waiting' ? 'Processing...' : (existing.message ?? 'Processing...');
82
+ break;
83
+ }
84
+
77
85
  case 'PostToolUse': {
78
- // Restore to 'working' from 'notification' (permission granted).
79
- // Preserve 'waiting' and other states to avoid spurious transitions after Stop.
86
+ // Only restore to 'working' from active states; preserve 'waiting' to avoid
87
+ // spurious transitions when a delayed PostToolUse arrives after Stop.
80
88
  const cur = existing.status ?? 'working';
81
89
  status = (cur === 'notification' || cur === 'working') ? 'working' : cur;
82
90
  message = existing.message ?? 'Processing...';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-watcher",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Claude Code 세션 CLI 대시보드",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,11 +25,11 @@
25
25
  "author": "roy-jung",
26
26
  "repository": {
27
27
  "type": "git",
28
- "url": "https://oss.navercorp.com/roy-jung/claude-code-watcher"
28
+ "url": "https://github.com/roy-jung/claude-code-watcher"
29
29
  },
30
30
  "license": "MIT",
31
31
  "scripts": {
32
32
  "publish:internal": "npm publish --registry https://artifactory.navercorp.com/artifactory/api/npm/npm-naver/",
33
33
  "publish:public": "npm publish --registry https://registry.npmjs.org"
34
34
  }
35
- }
35
+ }
@@ -23,11 +23,9 @@ ${BOLD}Dashboard Keys:${RESET}
23
23
  ${color(FG.CYAN, 'q')} / ${color(FG.CYAN, 'Ctrl+C')} 종료
24
24
 
25
25
  ${BOLD}Status Colors:${RESET}
26
- ${color(FG.YELLOW, 'working')} Claude가 응답을 처리 중
27
- ${color(FG.GREEN, 'waiting')} 사용자 입력 대기 중
28
- ${color(FG.CYAN, 'notification')} 알림 수신됨
29
- ${color(FG.BRIGHT_BLACK, 'stale')} 10분 이상 비활성
30
- ${color(FG.RED, 'error')} 오류 발생
26
+ ${color(FG.BRIGHT_BLUE, 'working')} Claude가 응답을 처리 중
27
+ ${color(FG.BRIGHT_WHITE, 'waiting')} 사용자 입력 대기 중
28
+ ${color(FG.BRIGHT_RED, 'notification')} 알림 수신됨
31
29
 
32
30
  ${BOLD}Session Data:${RESET}
33
31
  ~/.claude/dashboard/active/<sessionId>.json
@@ -1,5 +1,3 @@
1
- export const STALE_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
2
-
3
1
  /**
4
2
  * Parse raw JSON data into a SessionRecord.
5
3
  * Returns null if the data is invalid.
@@ -40,7 +38,7 @@ export function parseSession(raw, filename) {
40
38
  export function deriveSession(record, now = new Date()) {
41
39
  const updatedAt = parseDate(record.updated) || now;
42
40
  const startedAt = parseDate(record.startedAt) || now;
43
- const status = classifyStatus(record.status, updatedAt, now);
41
+ const status = classifyStatus(record.status);
44
42
 
45
43
  return {
46
44
  ...record,
@@ -53,18 +51,11 @@ export function deriveSession(record, now = new Date()) {
53
51
  }
54
52
 
55
53
  /**
56
- * Classify session status, marking as stale if inactive for > STALE_THRESHOLD_MS.
54
+ * Classify session status.
57
55
  * @param {string} status
58
- * @param {Date} updatedAt
59
- * @param {Date} now
60
56
  * @returns {string}
61
57
  */
62
- export function classifyStatus(status, updatedAt, now = new Date()) {
63
- if (status === 'error') return 'error';
64
-
65
- const sinceMs = now - updatedAt;
66
- if (sinceMs > STALE_THRESHOLD_MS && status !== 'working' && status !== 'notification') return 'stale';
67
-
58
+ export function classifyStatus(status) {
68
59
  return status || 'waiting';
69
60
  }
70
61
 
package/src/ui/format.mjs CHANGED
@@ -61,24 +61,30 @@ export function formatTime(date) {
61
61
  export function statusColor(status) {
62
62
  switch (status) {
63
63
  case 'working':
64
- return FG.YELLOW;
64
+ return FG.BRIGHT_BLUE;
65
65
  case 'waiting':
66
- return FG.GREEN;
67
- case 'stale':
68
- return FG.BRIGHT_BLACK;
66
+ return FG.BRIGHT_WHITE;
69
67
  case 'notification':
70
- return FG.CYAN;
71
- case 'error':
72
- return FG.RED;
68
+ return FG.BRIGHT_RED;
73
69
  default:
74
- return FG.WHITE;
70
+ return FG.BRIGHT_BLACK;
75
71
  }
76
72
  }
77
73
 
74
+ const STATUS_LABEL = {
75
+ working: '작업중',
76
+ waiting: '대기중',
77
+ notification: '응답 요청',
78
+ };
79
+
80
+ export function statusLabel(status) {
81
+ return STATUS_LABEL[status] ?? status;
82
+ }
83
+
78
84
  /**
79
85
  * Return a colored status label string.
80
86
  */
81
87
  export function formatStatus(status) {
82
- return color(statusColor(status), status);
88
+ return color(statusColor(status), statusLabel(status));
83
89
  }
84
90
 
@@ -20,6 +20,7 @@ import {
20
20
  padEnd,
21
21
  padStart,
22
22
  statusColor,
23
+ statusLabel,
23
24
  truncate,
24
25
  } from './format.mjs';
25
26
 
@@ -224,28 +225,56 @@ export class Renderer {
224
225
  const lines = [];
225
226
 
226
227
  if (cols < MIN_WIDTH) {
227
- lines.push('');
228
- lines.push(
229
- color(FG.RED, ` Terminal too narrow (min ${MIN_WIDTH} cols)`),
230
- );
231
- lines.push(color(FG.BRIGHT_BLACK, ` Current: ${cols} cols`));
232
- this.#flush(lines, rows);
233
- return;
228
+ this.#renderCompact(lines, cols, rows);
229
+ } else {
230
+ // Use cols-1 so the box never touches the terminal's last column,
231
+ // avoiding auto-wrap artifacts in terminals like Ghostty.
232
+ const w = cols - 1;
233
+ if (this.#view === 'detail' && this.#sessions.length > 0) {
234
+ this.#renderDetail(lines, w, rows);
235
+ } else {
236
+ this.#renderList(lines, w, rows);
237
+ }
234
238
  }
239
+ this.#flush(lines, rows);
240
+ }
241
+
242
+ /** Compact view: project name + colored status indicator (COMPACT_WIDTH <= cols < MIN_WIDTH). */
243
+ #renderCompact(lines, cols, rows) {
244
+ const w = cols - 1; // avoid auto-wrap like full view
245
+ const inner = w - 2; // between │ and │
235
246
 
236
- // Use cols-1 so the box never touches the terminal's last column,
237
- // avoiding auto-wrap artifacts in terminals like Ghostty.
238
- const w = cols - 1;
247
+ // Top border with title: ╭─ Claude Code Watcher ───╮
248
+ const titleStr = '─ Claude Code Watcher ';
249
+ const rightDashes = Math.max(0, inner - titleStr.length);
250
+ lines.push(color(FG.WHITE, `╭${titleStr}${'─'.repeat(rightDashes)}╮`));
239
251
 
240
- if (this.#view === 'detail' && this.#sessions.length > 0) {
241
- this.#renderDetail(lines, w, rows);
252
+ if (this.#sessions.length === 0) {
253
+ lines.push(
254
+ `${color(FG.WHITE, '│')}${padEnd(color(FG.BRIGHT_BLACK, ' ○'), inner)}${color(FG.WHITE, '│')}`,
255
+ );
242
256
  } else {
243
- this.#renderList(lines, w, rows);
257
+ const nameW = inner - 3; // ' ● ' = 3
258
+ for (const session of this.#sessions) {
259
+ const dot = color(statusColor(session.status), '●');
260
+ const name = color(statusColor(session.status), padEnd(truncate(session.displayName, nameW), nameW));
261
+ lines.push(
262
+ `${color(FG.WHITE, '│')} ${dot} ${name}${color(FG.WHITE, '│')}`,
263
+ );
264
+ }
244
265
  }
266
+
267
+ lines.push(color(FG.WHITE, `╰${'─'.repeat(inner)}╯`));
268
+
269
+ const credit = color(FG.BRIGHT_BLACK, '@roy-jung');
270
+ lines.push(
271
+ ' '.repeat(Math.max(0, w - visibleLength('@roy-jung'))) + credit,
272
+ );
273
+
245
274
  this.#flush(lines, rows);
246
275
  }
247
276
 
248
- #renderHeader(lines, cols, now) {
277
+ #renderHeader(lines, cols, now) {
249
278
  const count = this.#sessions.length;
250
279
  const inner = cols - 4;
251
280
  const dot = count > 0 ? color(FG.CYAN, '●') : color(FG.BRIGHT_BLACK, '○');
@@ -304,7 +333,9 @@ export class Renderer {
304
333
  color(isSelected ? FG.CYAN : FG.BRIGHT_BLACK, numStr.padStart(NUM_W)),
305
334
  NUM_W,
306
335
  );
307
- const proj = padEnd(truncate(session.displayName, projW), projW);
336
+ const rc = str => color(statusColor(session.status), str);
337
+
338
+ const proj = rc(padEnd(truncate(session.displayName, projW), projW));
308
339
  const subagents = this.#store.getSubagents(session.sessionId);
309
340
  const activeSubCount = subagents.filter(
310
341
  s => s.status === 'working',
@@ -313,7 +344,7 @@ export class Renderer {
313
344
  if (activeSubCount > 0) {
314
345
  const badge = color(FG.BRIGHT_BLACK, `[${activeSubCount}]`);
315
346
  const badgeW = 1 + String(activeSubCount).length + 2; // ' [N]'
316
- const label = truncate(session.status, STAT_W - badgeW);
347
+ const label = truncate(statusLabel(session.status), STAT_W - badgeW);
317
348
  stat = padEnd(
318
349
  `${coloredStatus(session.status, label)} ${badge}`,
319
350
  STAT_W,
@@ -321,14 +352,11 @@ export class Renderer {
321
352
  } else {
322
353
  stat = padEnd(formatStatus(session.status), STAT_W);
323
354
  }
324
- const msg = padEnd(
355
+ const msg = rc(padEnd(
325
356
  truncate(session.message || session.lastResponse || '', msgW),
326
357
  msgW,
327
- );
328
- const time = color(
329
- FG.BRIGHT_BLACK,
330
- padEnd(session.sinceLabel || '', TIME_W),
331
- );
358
+ ));
359
+ const time = rc(padEnd(session.sinceLabel || '', TIME_W));
332
360
 
333
361
  const rowLine = buildDataLine(sel, num, proj, stat, msg, time);
334
362
  lines.push(