@sejuneoh/aitop 0.0.0-stage → 0.21.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SejuneOh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,236 @@
1
- # Temporary Holding Version
1
+ # ⣠⣴⣾ aitop
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 터미널에서 지금 돌고 있는 AI 코딩 세션(Claude Code · Codex CLI · Antigravity CLI · Gemini CLI)과 진행 내용, 토큰을 실시간으로 보는 읽기 전용 모니터.
4
+
5
+ - 포트를 열지 않고, 네트워크 요청을 하지 않는다. 각 CLI 가 내 PC 에 남기는 세션 파일만 읽는다.
6
+ - 세션 파일은 읽기 전용으로만 연다. 디스크에 쓰는 곳은 읽은 위치·합계를 기억하는 `~/.cache/aitop` 뿐이다 (지워도 된다). `--debug` 를 켰을 때만 디버그 로그를 따로 쓴다.
7
+ - 화면에 표시되는 텍스트 중 API 키·토큰처럼 보이는 값은 `••••` 로 가린다.
8
+ - 외부 라이브러리 없음 (Node 22 이상). Antigravity CLI 의 세부 정보는 Node 내장 `node:sqlite` 로 기록을 immutable(파일을 하나도 만들거나 고치지 않는 열기)로 열 수 있는 Node 22.15 이상에서만 읽는다.
9
+
10
+ ## 설치
11
+
12
+ npmjs.com 에 공개로 올린다 (MIT).
13
+
14
+ ```bash
15
+ npm install -g @sejuneoh/aitop # 설치·업데이트
16
+ aitop
17
+ ```
18
+
19
+ 예전에 비공개 GitHub Packages 에서 설치했다면 그 레지스트리 설정을 지워야 npmjs.com 에서 받는다.
20
+
21
+ ```bash
22
+ npm config delete @sejuneoh:registry
23
+ npm config delete //npm.pkg.github.com/:_authToken
24
+ ```
25
+
26
+ 로컬 코드로 바로 써 보려면 저장소에서 `npm install -g .`
27
+
28
+ ## 릴리스
29
+
30
+ | 브랜치 | 하는 일 |
31
+ |---|---|
32
+ | 기능 브랜치 → `dev` | PR 마다 CI(단위 테스트 → e2e → 실행 확인 → `npm pack` 설치 확인, Node 22/22.13/22.15/24, macOS 22) 실행. 병합 후 로컬에서 `npm install -g .` 로 확인. `dev` 에 들어오면 `release-pr` 워크플로가 `dev` → `main` release PR 을 만들거나 제목·본문(포함된 PR, 닫는 이슈)을 갱신한다 |
33
+ | `dev` → `main` | 단위 테스트와 e2e 를 통과하고 `package.json` 의 `version` 이 아직 배포되지 않은 값이면 npmjs.com 에 공개로 배포하고(provenance 포함, 저장소 시크릿 `NPM_TOKEN` 필요), `v<버전>` 릴리스가 없으면 태그·릴리스 생성 (배포 뒤 릴리스 생성만 실패했으면 재실행하면 배포는 건너뛰고 릴리스만 만든다) |
34
+
35
+ 테스트
36
+
37
+ ```
38
+ npm test # 단위 테스트 (test/*.test.mjs)
39
+ npm run test:e2e # e2e: 실제 bin 실행(--json/--once/--help), 가상 터미널에서 키 입력·종료 확인 (Linux, util-linux script 필요)
40
+ ```
41
+
42
+ 버전 올리기는 `dev` 에서 `npm version patch|minor|major --no-git-tag-version` 후 커밋한다. 버전을 올리지 않은 `main` 병합은 테스트만 돌고 배포하지 않는다.
43
+
44
+ `NPM_TOKEN` 은 npmjs.com 에서 `@sejuneoh` 범위에 배포 권한이 있는 토큰(Granular access token)으로 만들어 저장소 Settings → Secrets and variables → Actions 에 넣는다. 0.21.0 까지는 비공개 GitHub Packages 에만 있다.
45
+
46
+ ## 사용
47
+
48
+ ```
49
+ aitop 실시간 화면
50
+ aitop --all 종료된 세션(24시간 이내)도 함께
51
+ aitop --once 현재 상태를 한 번 출력
52
+ aitop --json 세션 목록과 오늘·7일 사용량을 JSON 으로 한 번 출력 (--all 과 함께 가능)
53
+ 0.5.0 부터 files[] 의 줄 수 필드는 writtenLines·deletedLines (편집 누적, 예전 added·removed)
54
+ 0.6.1 부터 비용(cost 등)은 소수 넷째 자리까지 반올림
55
+ aitop --version 설치된 버전
56
+ aitop --doctor 도구별 연결 점검 (기록 폴더, 기록 수, 마지막 기록, 실행 수). --json 과 함께 가능
57
+ aitop --theme light|dark 밝은 배경 / 어두운 배경 색. 없으면 AITOP_THEME 환경변수, 설정 파일의 theme, 그다음 COLORFGBG 로 고르고, 모르면 어두운 배경
58
+ aitop --color-blind 적록 색약(1형·2형)용 색. --theme 과 따로 고르며 밝은·어두운 배경 모두 있다 (AITOP_COLOR_BLIND=1, 설정 파일 colorBlind). 아래 '색약 팔레트' 참고
59
+ aitop --show-requests 응답 대기 요청 전체(명령·경로)를 상세 패널과 --json(waitDetail)에 보인다. AITOP_SHOW_REQUESTS=1 과 같다
60
+ aitop --budget-day <달러> 하루 예산 (AITOP_BUDGET_DAY, 설정 파일 budgetDay). 아래 '하루 예산' 참고
61
+ aitop --no-bell · --no-all · --no-show-requests · --no-notify · --no-on-waiting · --no-budget-day · --no-debug · --no-color-blind 설정 파일에서 켠 값을 이번 실행만 끈다
62
+ aitop --debug 디버그 로그 (AITOP_DEBUG=1, 설정 파일 debug). 아래 '디버그 로그' 참고
63
+ AITOP_AMBIGUOUS=wide 폭이 모호한 기호를 2칸으로 그리는 터미널용 (설정 파일 ambiguous). 아래 '폭이 모호한 기호' 참고
64
+ aitop --print-config 설정 값마다 값과 출처(flag/env/file/default), 설정 파일 위치를 보인다. --json 과 함께 가능
65
+ ```
66
+
67
+ ### 설정 파일
68
+
69
+ `$XDG_CONFIG_HOME/aitop/config.json` (없으면 `~/.config/aitop/config.json`). 옵션 > 환경 변수 > 설정 파일 > 기본값 순서로 정한다.
70
+
71
+ ```json
72
+ { "theme": "light", "icons": "plain", "bell": true, "showRequests": false, "all": false }
73
+ ```
74
+
75
+ | 키 | 값 | 같은 옵션·환경 변수 |
76
+ |---|---|---|
77
+ | `theme` | `light` · `dark` | `--theme`, `AITOP_THEME` |
78
+ | `colorBlind` | true · false | `--color-blind` · `--no-color-blind`, `AITOP_COLOR_BLIND=1`·`0` |
79
+ | `icons` | `plain` · `ascii` | `AITOP_ICONS` |
80
+ | `bell` | true · false | `--bell` · `--no-bell` |
81
+ | `showRequests` | true · false | `--show-requests` · `--no-show-requests`, `AITOP_SHOW_REQUESTS=1`·`0` |
82
+ | `all` | true · false | `--all` · `--no-all` |
83
+ | `notify` | true · false | `--notify` · `--no-notify` |
84
+ | `onWaiting` | 명령 문자열 | `--on-waiting` · `--no-on-waiting` |
85
+ | `debug` | true · false | `--debug` · `--no-debug`, `AITOP_DEBUG=1`·`0` |
86
+ | `budgetDay` | 0 보다 큰 수 (달러) | `--budget-day <usd>` · `--budget-day=<usd>` · `--no-budget-day`, `AITOP_BUDGET_DAY` |
87
+ | `hideIdleTools` | true (기본) · false | 없음. 실행 중 세션이 없고 7일 동안 쓰지 않은 도구를 도구 줄에서 숨긴다 |
88
+ | `ambiguous` | `narrow` (기본) · `wide` | `AITOP_AMBIGUOUS` |
89
+
90
+ 잘못된 JSON, 모르는 키, 잘못된 값은 그 키만 무시하고 `aitop --doctor` 에 알린다. `showRequests` 는 설정 파일로도 켤 수 있다 (내 파일이라 권한 검사는 하지 않는다). 켜면 위의 `--show-requests` 주의가 그대로 적용된다. `onWaiting` 은 파일 소유자가 나이고 그룹·다른 사용자가 쓸 수 없을 때(예: `chmod 600`)만 받는다.
91
+
92
+ `--show-requests` 는 기본으로 꺼져 있다. 비밀값 가림은 최선일 뿐이라, 켜면 명령 속 비밀값이 그대로 보일 수 있다. 가림을 거친 요청 내용은 옵션과 관계없이 로컬 캐시(`~/.cache/aitop`)에 저장된다.
93
+
94
+ ### 디버그 로그
95
+
96
+ CLI 기록 형식이 바뀌어 값이 비어 보이면 `aitop --debug` 로 실행해 `$XDG_STATE_HOME/aitop/aitop.log` (없으면 `~/.local/state/aitop/aitop.log`) 를 본다. 꺼져 있으면 파일을 만들지 않는다.
97
+ 읽기·해석 실패마다 출처, 오류 이름·코드, 파일 이름만 한 줄로 남긴다. 기록 내용·도구 입력은 쓰지 않고 비밀값처럼 보이는 글은 가린다. 같은 실패는 1분에 한 줄, 1MB 를 넘으면 `aitop.log.1` 로 옮겨 하나만 남긴다. 폴더는 0700, 파일은 0600. 위치와 켜짐 여부는 `--doctor` 에 보인다.
98
+
99
+ 동료 PC 에서 Codex · Gemini 가 잡히는지 볼 때는 `aitop --doctor` 결과를 그대로 붙여 넣으면 된다. 기록 내용은 나오지 않는다 (Gemini 는 형식 확인용 JSON 키 이름만).
100
+ `--json` 에는 `version` 과 같은 점검 결과 `tools` 가 들어 있다.
101
+
102
+ 색 없이도(NO_COLOR) 보이도록 비용 상위 두 세션에는 `▲`, 컨텍스트 85% 이상에는 `!` 가 붙는다.
103
+
104
+ ### 색약 팔레트
105
+
106
+ `aitop --color-blind` (또는 `AITOP_COLOR_BLIND=1`, 설정 파일 `"colorBlind": true`) 는 적록 색약이 헷갈리는 색 쌍을 노랑 계열과 파랑 계열로 가른다. Okabe-Ito 색 계열을 바탕으로 했다.
107
+
108
+ | 뜻 | 기본 (어두운 배경) | 색약 (어두운 · 밝은 배경) | 색 말고 구분하는 기호 |
109
+ |---|---|---|---|
110
+ | 응답 대기 | 호박색 | 노랑 `#f0e442` · `#6e5f00` | `◆`, 행 배경, 헤더 `◆ N 응답 대기` |
111
+ | 돈 (비용) | 연두 | 옅은 파랑 `#d4dcff` · `#3a4fc0` | `$`, 비용 상위 `▲` |
112
+ | 작업 중 · diff 더한 줄 | 초록 | 청록 `#3cc8a0` · `#00735a` | 스피너, STATUS 글, 줄 앞 `+` |
113
+ | 경고 · diff 지운 줄 | 빨강 | 주홍 `#ff8060` · `#c03010` | `!`, 줄 앞 `-` |
114
+ | 예산 주의 | 분홍 | 자주 `#e8a0c8` · `#9a3d76` | `예상 $45` (경고는 `예상 $60!`) |
115
+
116
+ 밝기(`--theme`)와는 따로 고르므로 `COLORFGBG` 자동 판별도 그대로 쓴다. 글자 대비 규칙(배경·선택 행·대기 행·띠 위 4.5:1 이상)은 기본 팔레트와 같다. 청황 색약(3형)용 팔레트는 아직 없다.
117
+
118
+ 실시간 화면은 창(탭) 제목을 `󰚩 (2◆ 1●) aitop` 처럼 아이콘과 응답 대기·확인 전 수로 바꾼다. `--bell` 을 주면 응답 대기가 새로 생길 때 벨을 한 번 울린다.
119
+
120
+ 다른 창에서 일할 때는 알림을 켤 수 있다 (둘 다 기본으로 꺼져 있고, 실시간 화면에서만 동작한다. `--bell` 처럼 aitop 을 켰을 때 이미 응답 대기인 세션은 알리지 않는다).
121
+
122
+ ```
123
+ aitop --notify 데스크톱 알림. Linux 는 notify-send, macOS 는 osascript. 없으면 조용히 건너뛴다 (--doctor 에 보임)
124
+ aitop --on-waiting 'my-hook.sh' 응답 대기가 새로 생길 때마다 /bin/sh -c 로 명령 실행 (--on-waiting=<명령> 도 됨)
125
+ ```
126
+
127
+ `--on-waiting` 명령은 **aitop 을 실행한 사용자 권한 그대로** 돈다. 세션 정보는 명령 문자열에 끼워 넣지 않고 환경변수로만 넘긴다 (명령 안에서 쓸 때는 `"$AITOP_PROJECT"` 처럼 따옴표로 감싼다).
128
+
129
+ | 환경변수 | 값 |
130
+ |---|---|
131
+ | `AITOP_TOOL` | claude · codex · agy 등 |
132
+ | `AITOP_PROJECT` | 프로젝트 이름 |
133
+ | `AITOP_SESSION_ID` | 세션 id |
134
+ | `AITOP_CWD` | 작업 폴더 |
135
+ | `AITOP_WAITING_FOR` | 짧은 대기 사유 (예: `Bash`, 비밀값처럼 보이는 것은 가림) |
136
+
137
+ 출력은 버리고, 10초가 지나면 명령(과 그 자식)을 끝낸다. 한 번에 하나만 실행하며, 실행 중에 새 대기가 생기면 가장 최근 것 하나만 기다렸다가 이어서 실행한다 (그 사이 것은 건너뜀). 그래서 여러 세션이 한꺼번에 응답 대기가 되면, 지금 명령이 끝난 뒤 그중 가장 최근 하나만 실행된다. aitop 을 끝내면 실행 중인 명령도 함께 끝낸다. 한 번 갱신에서 여러 세션이 함께 응답 대기가 되면 첫 세션과 마지막 세션만 실행되고 그 사이 세션은 건너뛴다. 명령이 자식을 백그라운드로 띄우고(`cmd &`) 먼저 끝나면, 그 자식은 10초 제한과 종료 정리 밖에 남으므로 정리는 명령이 맡는다. WSL 에서 notify-send 가 없으면 Windows 알림을 대신 띄우지 않는다. 필요하면 `--on-waiting` 으로 직접 부른다.
138
+
139
+ ### 로고와 탭 아이콘
140
+
141
+ 로고 `⣠⣴⣾` 는 점점 높아지는 막대 세 개다 (top 미터, 도구 셋). 점자 글자라 한글 터미널에서도 폭이 1칸으로 정해져 있다.
142
+
143
+ | `AITOP_ICONS` | 헤더 로고 | 창·탭 제목 | WezTerm user var |
144
+ |---|---|---|---|
145
+ | (기본) | `⣠⣴⣾` | `󰚩 (2◆) aitop` (Nerd Font 로봇 U+F06A9) | 내보냄 |
146
+ | `plain` | `⣠⣴⣾` | `⣾ (2◆) aitop` | 끔 |
147
+ | `ascii` | `>_ai` | `(2◆) aitop` | 끔 |
148
+
149
+ Nerd Font 를 쓰지 않는 터미널에서는 탭 제목 아이콘이 빈 네모로 보일 수 있다. 그때는 `export AITOP_ICONS=plain` 으로 끈다.
150
+
151
+ ### 폭이 모호한 기호 (`AITOP_AMBIGUOUS=wide`)
152
+
153
+ `·` `→` `…` `─` `│` `●` `◆` `▃` 같은 글자는 유니코드에서 폭이 '모호(A)' 하다. 동아시아 폭 설정을 켠 터미널(iTerm2 의 'Ambiguous characters are double-width', Windows Terminal·mintty 의 CJK 폭, vim 의 `ambiwidth=double` 과 같은 설정)은 이 글자들을 2칸으로 그려서 표 칸과 상자 테두리가 밀린다. 그런 터미널이면 `export AITOP_AMBIGUOUS=wide` (또는 설정 파일 `"ambiguous": "wide"`) 로 켠다.
154
+
155
+ - 칸 수가 정해진 자리의 기호는 1칸 ASCII 로 바꾼다: 상자 `+ - |`, 가로줄 `-`, 컨텍스트 막대 `#`, 7일·활동 막대 `_.:-=+*#`, 상태 기호 `!`(응답 대기) `*`(확인 전·실행 중) `o`(대기) `.`(종료), 선택 `>`, 고정 `*`(★), 팀원 가지 `|-` `` `- ``.
156
+ - 글 속의 `·` `→` `…` 등은 그대로 두고 2칸으로 세어 줄 끝을 맞춘다.
157
+ - 로고 `⣠⣴⣾` 와 스피너는 점자라 폭이 모호하지 않아 그대로다.
158
+
159
+ WezTerm 은 aitop 이 내보내는 user var(`aitop`, `aitop_waiting`, `aitop_unseen`)로 탭을 꾸밀 수 있다. `wezterm.lua` 예:
160
+
161
+ ```lua
162
+ local wezterm = require 'wezterm'
163
+ wezterm.on('format-tab-title', function(tab)
164
+ local v = tab.active_pane.user_vars
165
+ if v.aitop == '1' then
166
+ local w = tonumber(v.aitop_waiting or '0') or 0
167
+ return { { Foreground = { Color = w > 0 and '#fbbf24' or '#7dd3fc' } }, { Text = ' 󰚩 aitop' .. (w > 0 and (' ' .. w .. '◆') or '') .. ' ' } }
168
+ end
169
+ end)
170
+ ```
171
+
172
+ - `format-tab-title` 핸들러는 하나만 쓰인다. 이미 있으면 그 안에 위 조건을 합친다.
173
+ - tmux 안에서 돌리면 tmux 가 user var 를 전달하지 않아 탭 값이 오지 않는다.
174
+
175
+ - 모르는 옵션을 주면 오류와 도움말을 보이고 2 로 끝난다.
176
+ - `--once` 를 파이프나 파일로 받으면 색 코드를 넣지 않고(`FORCE_COLOR=1` 이면 넣음), 크기는 `COLUMNS`·`LINES` 를 따른다.
177
+
178
+ | 키 | 동작 |
179
+ |---|---|
180
+ | / | 세션 검색 (이름·폴더·모델). Enter 적용, Esc 지우기 |
181
+ | ↑↓ / j k | 세션 선택 |
182
+ | n | 다음 볼 세션으로 이동 (응답 대기 → 완료 · 확인 전) |
183
+ | p | 고른 세션을 표 맨 위(응답 대기 바로 아래)에 고정, 다시 누르면 해제. 여러 개는 고정한 순서로 두고 정렬(`s`)이 바꾸지 않는다. 묶기(`g`)에서는 `고정` 묶음으로 모인다. 고정한 행은 SESSION 칸 앞에 `★` (좁은 화면에서도 이름보다 먼저 남는다). 종료 세션을 함께 볼 때(`e`) 고정한 종료 세션도 실행 중인 세션보다 위에 남는다. 이번 실행에만 기억한다 |
184
+ | f / Tab | 변경 파일 목록으로. ←→ 지시 탭, ↑↓ 파일 고르기, Enter diff 보기 |
185
+ | diff 에서 ↑↓ · space · [ ] | 스크롤 · 페이지 · 이전·다음 파일 |
186
+ | d | 상세 열기·닫기 |
187
+ | e | 종료된 세션 보이기·숨기기 |
188
+ | c | 비용 기준 전환: API 환산(캐시 포함, 기본) ↔ 캐시 제외(입력·출력만). 표·헤더·상세가 같이 바뀐다 |
189
+ | ? | 도움말. 지금 화면(세션 표·검색어가 걸린 표·상세·파일 목록·diff)의 키부터, 다른 화면 · 옵션 · 지금 설정 값 순으로 보인다. 열려 있는 동안은 ↑↓ j k space PgUp PgDn 스크롤, ? · Esc 닫기, q 나가기만 듣는다 |
190
+ | Esc | 한 단계 뒤로 (도움말 닫기, 검색 지우기, diff → 파일 목록 → 세션). 종료하지 않는다 |
191
+ | q / Ctrl-C | 나가기 |
192
+
193
+ ## 화면
194
+
195
+ - 헤더 오른쪽: 오늘·최근 7일 Claude · Codex CLI · Antigravity CLI 사용량(토큰, API 환산 비용). 최근 7일 안에 바뀐 기록(서브에이전트 포함)을 날짜별로 모은다. agy 는 호출마다 그 직전 단계의 만든 시각을 호출 날짜로 본다. Codex 는 `token_count` 누적값이 직전보다 늘어난 만큼을 그 줄의 시각에, 그 앞의 마지막 모델 가격으로 더한다 (누적값이 줄면 새로 시작한 것으로 본다). 처음 실행 때는 `7일 집계 중 N%` 로 채워진다. 폭이 모자라면 토큰 수, 7일, 오늘 순으로 줄인다.
196
+ - 7일 막대: `7일 $644 ▅▁▃█▄▂▃` 는 오래된 날 → 오늘의 날짜별 비용을 7일 중 가장 큰 날 기준(█)으로 그린다. 0 인 날은 흐린 `▁`, 쓴 날은 `▂` 이상이라 색이 없어도 갈린다. 집계 중에는 숨기고, 폭이 모자라면 지출 속도 다음으로 뺀다. `--json` 에는 `usage.last7days.daily: [{ date, cost }]` (API 환산)로 나간다.
197
+ - 지출 속도: `오늘 $194 ($31/h)` 의 괄호는 최근 1시간(5분 단위 근사) 비용이다. 0 이면 숨기고, 폭이 모자라면 가장 먼저 뺀다. `--json` 에는 `usage.lastHour` 로 나간다.
198
+ - 하루 예산(`budgetDay`): 오늘 금액 옆에 `오늘 $12.30/$50` 처럼 보인다. 예상 비용 = 오늘 비용 + 최근 지출 속도 × 로컬 자정까지 남은 시간. 속도는 최근 1시간 중 자정 이후 몫만 시간당으로 늘린 값이고, 자정 뒤 15분이 안 됐거나 집계 중이면 예상 = 오늘 비용이다. 예상이 예산의 80% 이상이면 `· 예상 $45`, 100% 이상이면 경고 색과 `· 예상 $60!`. 예산은 비용 기준(`c`)과 상관없이 늘 API 환산으로 보며, 캐시 제외 화면에서는 `예산 $12.30/$50` 칸이 따로 붙는다. 폭이 모자라면 `/$50`, 오늘 금액 순으로 빼서 예상 경고를 시각 다음으로 오래 남긴다. 잘못된 `AITOP_BUDGET_DAY` 는 무시하고 `--doctor` 에 알린다. `--json` 에는 예산이 있을 때만 `usage.budget: { day, projected, unknown, level: ok·caution·warn }` 로 나간다 (화면과 같은 단계).
199
+ - 도구 줄(헤더 아래 `▍claude 2 · 1.2M codex gemini`): 폭이 모자라면 간격, 토큰 수·`미설치`, 이름(`cla` `cdx` `gem`) 순으로 줄이고, 그래도 넘치면 세션이 없는 도구부터 빼고 끝에 `+N` 을 붙인다. 이름이 글자 중간에서 잘리지 않는다. 실행 중 세션이 없고 7일 동안 세션(기록 폴더의 가장 새 기록 포함)이 없는 도구는 숨기고 줄 끝에 `숨김 codex gemini` (자리가 모자라면 `숨김 2`, 아주 좁으면 뺌) 를 적는다. 기록 폴더가 없는(미설치) 도구도 함께 숨는다. 숨기면 남는 도구가 하나도 없을 때는 숨기지 않고 예전처럼 흐리게 다 보인다 (`미설치` 포함). 끝의 `+N` 은 화면이 좁아 접은 도구 수다. 실행 중 세션이나 프로세스가 있으면 늘 보인다. 설정 파일 `"hideIdleTools": false` 로 끈다.
200
+ - 화면 높이가 30줄 이상이면 세션 표를 `세션 N` 상자로 감싼다. 비용 색은 화면에서 가장 큰 금액 두 개에만 칠한다.
201
+ - 키 안내 줄은 폭이 모자라면 오른쪽부터 줄이고 `?`·`q` 는 늘 남긴다. `NO_COLOR` 이면 키가 `[q]` 처럼 괄호로 보이고, 포커스된 상자는 굵은 선(`┏━┃`)으로 그린다.
202
+ - `● 완료 · 확인 전`: 작업 중이던 세션이 끝났는데 아직 선택해 보지 않은 세션. aitop 을 다시 켜면 초기화된다.
203
+
204
+ - STATUS: 지금 단계 (`도구 실행 · Bash`, `생각 중`, `답변 작성 중`, `팀원 N명 작업 중`, `응답 대기`, `다음 지시 대기`). 큰 기록을 처음 읽는 동안은 `기록 읽는 중 N%`
205
+ - 응답 대기 세션은 맨 위에 배경색으로 보인다. 화면이 작아 표가 잘리면 마지막 줄에 `… +N 세션` 이 나온다.
206
+ - 서브에이전트는 메인 세션 아래 `├─` 행으로 붙고, 하는 일(description) 뒤에 타입이 흐리게 붙는다. 첫 응답 전에는 `시작 중`, 끝나면 6초간 `완료`로 보였다가 사라진다.
207
+ - COST: Claude API 정가로 환산한 비용. 구독 요금제 사용분도 같은 기준으로 계산한다 (`src/pricing.mjs`). 가격표에 없는 모델이 섞이면 `$1.20?` 처럼 `?` 가 붙는다. Codex 는 OpenAI API 정가로 환산한다 (gpt-5.3-codex, gpt-5.1-codex(-mini), codex-mini, gpt-4o-mini). 값은 2026-11 가격 비교 사이트 기준 추정이며, 세션 중 모델이 바뀌면 마지막 모델 가격으로 계산한다. Antigravity CLI 는 Google 공식 Gemini API 가격표(유료 등급 standard, https://aistudio.google.com/docs/pricing, 2026-11-06 확인)로 호출마다 그 호출 시각의 가격으로 환산한다: gemini-4-argon 은 2026-12-01 부터, gemini-3.8-flash·gemini-3.6-flash 는 2027-01-01 부터 두 배 가격이다 (UTC 기준). gemini-3.5-flash-lite, gemini-3.1-flash-lite 도 표에 있다. 모델 이름이 표와 똑같을 때만 가격을 매기고, 그 밖의 모델(gemini-3.5-flash, gemini-3-flash-preview 등)은 추측하지 않고 `?` 로 보인다. 서브에이전트 비용은 부모 세션에 더한다.
208
+ - IDLE: 마지막 활동 후 지난 시간. 작업 중이면 `now`.
209
+ - 상세 패널의 토큰 합계 = 메인 + 서브에이전트 = 입력 + 출력 + 캐시 읽기 + 캐시 쓰기. 비용은 캐시 제외 금액도 함께 보인다.
210
+ - `--all`: 24시간 안에 바뀐 기록을 종료 세션으로 보인다. 본문은 선택했을 때 읽는다.
211
+ - 변경 파일: 세션(서브에이전트 포함)이 Edit/Write(Codex 는 apply_patch)로 고친 파일이 있으면 상세 패널 오른쪽 위에 `src/ui.mjs 편집 ×3 14:02` 처럼 편집 횟수와 마지막 시각이 보인다. git diff 가 아니라 이 세션의 편집 기록이다. diff 머리의 `쓴 줄 · 지운 줄` 은 편집마다 더한 누적값이라 최종 변경량과 다르다.
212
+ - 지시 탭: `[이번 지시] T5 T3 전체`. 처음에는 마지막 지시 뒤에 고친 파일만 보인다. 편집이 있었던 이전 지시는 `T번호` 탭으로, 세션 전체는 `전체` 탭으로 본다. 서브에이전트 편집은 그 서브에이전트를 부른 지시에 들어간다. lock·빌드 결과 파일은 목록 끝에 흐리게 보인다. 지시는 최근 50개까지 기억한다. 파일을 열면 고친 순서의 역순으로 변경 조각마다 `시각 · 누가 · @@ 위치` 를 붙여 보인다. `.env`, `*.pem` 같은 비밀 파일은 목록에만 보이고 내용은 숨긴다.
213
+
214
+ ## 읽는 곳
215
+
216
+ | 도구 | 실행 중 판단 | 진행 내용·토큰 |
217
+ |---|---|---|
218
+ | Claude Code | `~/.claude/sessions/<pid>.json` + pid 가 살아 있는지 | `~/.claude/projects/*/<sessionId>.jsonl`, `…/subagents/agent-*.jsonl` |
219
+ | Codex CLI | `codex` 프로세스와 작업 폴더 (Linux `/proc`, macOS `ps`·`lsof`) | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` |
220
+ | Antigravity CLI (`agy`) | `agy` 프로세스가 열어 둔 `presence/<id>.lock` (Linux `/proc/<pid>/fd`). macOS 는 작업 폴더로 짝짓는다 | `~/.gemini/antigravity-cli/conversation_summaries.db`(제목·상태·서브에이전트), `conversations/<id>.db` 의 `gen_metadata`(모델·토큰)·`steps`(지시·답변·도구 호출) |
221
+ | Gemini CLI | `gemini` 프로세스 | 아직 미지원 |
222
+
223
+ ## 알려진 한계
224
+
225
+ - Linux·WSL·macOS 만 지원한다. Windows(PowerShell)는 설치되지 않는다. macOS 는 `ps`·`lsof` 를 2초에 한 번 부른다.
226
+ - 배경색 자동 판별은 `COLORFGBG` 에 기대는데 Windows Terminal 은 이 값을 넣지 않는다. 밝은 배경의 Windows Terminal 에서는 `aitop --theme light` 로 실행한다.
227
+ - 세션 파일 형식은 각 CLI 의 내부 형식이라, CLI 업데이트로 바뀌면 `src/sources/` 의 해당 파일을 고쳐야 한다.
228
+ - 컨텍스트 창 크기는 기록에 없어서 모델별 표(`src/pricing.mjs`)로 정한다. 표에 없는 모델은 200k 를 넘으면 1M 으로 가정한다.
229
+ - 같은 폴더에서 codex 를 여러 개 띄우면, 최근에 바뀐 세션 파일부터 순서대로 짝짓는다.
230
+ - Antigravity CLI 세션은 Gemini 탭에 보인다. 지시·답변·도구 호출은 `conversations/<id>.db` 의 `steps` 에서 읽지만 변경 파일은 아직 읽지 않는다. 토큰·단계 필드 번호는 실제 기록에서 확인한 값이라(출력 = 생각 + 답변) agy 업데이트로 바뀔 수 있다. Windows Antigravity 앱(`/mnt/c/…/.gemini/antigravity`)은 읽지 않는다.
231
+ - agy 기록은 파일을 하나도 건드리지 않으려고 `-wal` 을 읽지 않는다. 그래서 진행 내용과 응답 대기(도구 허락 대기)는 agy 가 `-wal` 을 본 파일에 합친 뒤에야 보이고, 보통 30초쯤 늦다. 허락한 뒤에는 `-wal` 이 다시 바뀌는 것을 보고 바로 작업 중으로 돌린다.
232
+ - 토큰 합계에는 캐시 읽기 토큰이 포함된다. 세부 구성은 상세 패널에서 본다.
233
+ - 실시간 화면은 한 번 갱신에 기록을 24MB 까지만 읽는다. 아주 큰 기록은 처음 몇 초 동안 합계가 채워지는 중으로 보인다.
234
+ - 캐시 폴더 이름에 가격표 지문이 들어가서, 가격표를 바꾼 버전으로 올리면 첫 실행 때 비용을 다시 계산한다.
235
+ - 변경 파일은 Edit/Write 도구 결과와 Codex 의 성공한 apply_patch 만 본다. Codex 패치에는 줄 번호가 없어 diff 에 `@@ 문맥` 만 보인다. Bash(`sed`, `git checkout` 등)로 바꾼 파일과 사람이 직접 고친 내용은 빠진다. 파일 60개, 파일당 변경 조각 8개, 세션당 1500줄까지만 보관하고, 넘치면 오래된 조각부터 버린다 (줄 수 통계는 남는다).
236
+ - 서브에이전트는 `finished` 표시 없이 끊긴 기록이 남기 때문에, 메인이 결과를 기다리는 중이거나 15분 안에 기록이 바뀐 경우만 실행 중으로 본다.
package/bin/aitop.mjs ADDED
@@ -0,0 +1,360 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { budgetDayOption, configJson, configText, resolveConfig } from '../src/config.mjs';
4
+ import { notifyOptions } from '../src/notify.mjs';
5
+ import { mask, setAmbiguous, stripControl } from '../src/text.mjs';
6
+
7
+ const KNOWN = new Set(['-h', '--help', '-v', '--version', '--once', '--all', '--json', '--doctor', '--bell', '--show-requests', '--print-config', '--no-bell', '--no-all', '--no-show-requests', '--debug', '--no-debug', '--color-blind', '--no-color-blind']);
8
+ const THEMES = ['light', 'dark'];
9
+ const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
10
+
11
+ // --notify, --no-notify, --on-waiting, --no-on-waiting 은 notifyOptions 한 곳에서 해석한다. 여기서는 명령줄에서 준 값만 본다.
12
+ const cliArgs = process.argv.slice(2);
13
+ const cli = notifyOptions(cliArgs);
14
+ if (cli.error) {
15
+ process.stderr.write(`aitop: ${cli.error}\n`);
16
+ process.exit(2);
17
+ }
18
+ // --budget-day <usd>, --budget-day=<usd>, --no-budget-day 는 budgetDayOption 이 꺼낸다.
19
+ const budget = budgetDayOption(cli.rest);
20
+ if (budget.error) {
21
+ process.stderr.write(`aitop: ${budget.error}\n`);
22
+ process.exit(2);
23
+ }
24
+ // --theme light, --theme=light 처럼 값을 받는 옵션은 따로 꺼낸다.
25
+ const argv = budget.rest;
26
+ let theme = null;
27
+ const rest = [];
28
+ for (let i = 0; i < argv.length; i++) {
29
+ const m = /^--theme(?:=(.*))?$/.exec(argv[i]);
30
+ if (!m) rest.push(argv[i]);
31
+ else theme = m[1] ?? (argv[i + 1]?.startsWith('-') ? '' : (argv[++i] ?? ''));
32
+ }
33
+ const args = new Set(rest);
34
+
35
+ // 옵션 > 환경 변수 > 설정 파일 > 기본값. 잘못된 --theme 값은 아래에서 모르는 옵션으로 알린다.
36
+ // --bell 과 --no-bell 처럼 켜고 끄는 옵션은 뒤에 준 쪽을 따른다.
37
+ const flag = (name) => {
38
+ const on = rest.lastIndexOf(`--${name}`);
39
+ const off = rest.lastIndexOf(`--no-${name}`);
40
+ return on < 0 && off < 0 ? undefined : on > off;
41
+ };
42
+ // notify 는 --notify 가 뒤에 오면 true, --no-notify 가 뒤에 오면 false, 둘 다 없으면 정하지 않은 것이다.
43
+ const notifyFlag = cli.notify ? true : notifyOptions(cliArgs, { notify: true }).notify ? undefined : false;
44
+ // onWaiting 은 --on-waiting 이 뒤에 오면 그 명령, --no-on-waiting 이 뒤에 오면 null(끔), 둘 다 없으면 정하지 않은 것이다.
45
+ const onWaitingFlag = cli.onWaiting ?? (notifyOptions(cliArgs, { onWaiting: 'x' }).onWaiting === null ? null : undefined);
46
+ const config = resolveConfig({ flags: { theme: THEMES.includes(theme) ? theme : undefined, colorBlind: flag('color-blind'), bell: flag('bell'), showRequests: flag('show-requests'), all: flag('all'), debug: flag('debug'), notify: notifyFlag, onWaiting: onWaitingFlag, budgetDay: budget.value } });
47
+ const opt = Object.fromEntries(Object.entries(config.values).map(([k, v]) => [k, v.value]));
48
+ // 알림은 설정 파일 값(onWaiting 은 권한 검사를 통과한 것만)을 깔고 명령줄 옵션이 덮는다.
49
+ const fromFile = (k) => (config.values[k].source === 'file' ? config.values[k].value : undefined);
50
+ const alerts = notifyOptions(cliArgs, { notify: fromFile('notify'), onWaiting: fromFile('onWaiting') });
51
+ // 화면 모듈과 표식 모듈은 불러올 때 테마·아이콘을 고르므로 그 전에 넘긴다.
52
+ if (opt.theme) process.env.AITOP_THEME = opt.theme;
53
+ process.env.AITOP_COLOR_BLIND = opt.colorBlind ? '1' : '0';
54
+ if (config.values.icons.source === 'file') process.env.AITOP_ICONS = opt.icons;
55
+ // 폭이 모호한 기호를 2칸으로 셀지는 글자 폭 계산(text.mjs)이 쓴다. 화면 모듈은 그릴 때마다 이 값을 본다.
56
+ setAmbiguous(opt.ambiguous);
57
+ const { BRAND, userVarsSeq } = await import('../src/brand.mjs');
58
+ const { keysText } = await import('../src/help.mjs');
59
+ const { debug, initLog, logPath } = await import('../src/log.mjs');
60
+
61
+ // 실시간 화면에 들어간 뒤에는 restore 가 터미널을 되돌리고 훅을 끝낸다. 그 전에는 할 일이 없다.
62
+ let restore = () => {};
63
+ process.on('unhandledRejection', (reason) => {
64
+ debug('aitop', 'unhandledRejection', reason);
65
+ try {
66
+ restore();
67
+ } catch {}
68
+ const why = reason instanceof Error ? reason.message : String(reason);
69
+ process.stderr.write(`aitop: 처리되지 않은 오류로 끝냅니다 (${stripControl(mask(why)).slice(0, 200)})\n`);
70
+ process.exit(1);
71
+ });
72
+
73
+ const HELP = `${BRAND.logo} aitop - 터미널에서 Claude Code · Codex · Antigravity · Gemini 세션을 실시간으로 본다
74
+
75
+ aitop 실시간 화면
76
+ aitop --once 현재 상태를 한 번 출력하고 끝낸다
77
+ aitop --all 종료된 세션(24시간 이내)도 함께 본다
78
+ aitop --json 세션 목록과 사용량 합계를 JSON 으로 한 번 출력한다 (--all 과 함께 쓸 수 있다)
79
+ aitop --doctor Claude · Codex · Antigravity · Gemini 기록과 실행 중인 프로세스, 설정 파일을 점검한다 (--json 과 함께 쓸 수 있다)
80
+ aitop --bell 응답 대기 세션이 새로 생기면 터미널 벨을 울린다
81
+ aitop --notify 응답 대기 세션이 새로 생기면 데스크톱 알림을 띄운다 (notify-send · osascript, 없으면 건너뜀)
82
+ aitop --on-waiting <명령> 응답 대기 세션이 새로 생기면 /bin/sh -c 로 명령을 실행한다 (내 권한으로 실행, 10초 제한)
83
+ 세션 정보는 환경변수로만: AITOP_TOOL AITOP_PROJECT AITOP_SESSION_ID AITOP_CWD AITOP_WAITING_FOR
84
+ aitop --show-requests 응답 대기 요청 전체를 상세 패널·--json 에 보인다 (AITOP_SHOW_REQUESTS=1). 비밀값 가림은 최선일 뿐이라 명령 속 비밀값이 보일 수 있다
85
+ aitop --budget-day <달러> 하루 예산. 헤더에 오늘 $12.30/$50 처럼 보이고, 오늘 끝 예상 비용(오늘 + 최근 속도 × 자정까지, 늘 API 환산)이
86
+ 80% 를 넘으면 '예상 $45', 100% 를 넘으면 '예상 $60!' 로 알린다 (AITOP_BUDGET_DAY, 설정 파일 budgetDay)
87
+ aitop --theme light|dark 밝은 배경 / 어두운 배경 색 (없으면 COLORFGBG 로 고른다)
88
+ aitop --color-blind 적록 색약용 색. 응답 대기·돈·경고·diff 를 노랑·파랑 계열로 가른다 (AITOP_COLOR_BLIND=1, 설정 파일 colorBlind). --theme 과 함께 쓴다
89
+ aitop --no-bell · --no-all · --no-show-requests · --no-notify · --no-on-waiting · --no-budget-day · --no-debug · --no-color-blind 설정 파일에서 켠 값을 이번 실행만 끈다
90
+ aitop --debug 디버그 로그를 남긴다 (AITOP_DEBUG=1, 설정 파일 debug). 기록 형식이 바뀌어 값이 빌 때 원인 확인용
91
+ ${logPath()} (1MB 넘으면 .1 로 하나만 남김, 오류 이름·파일 이름만 쓰고 비밀값은 가림)
92
+ aitop --print-config 설정 값마다 값과 출처(flag/env/file/default), 설정 파일 위치를 보인다 (--json 과 함께 쓸 수 있다)
93
+ aitop --version 설치된 버전을 보인다
94
+
95
+ AITOP_ICONS=plain 창 제목의 Nerd Font 아이콘과 WezTerm 탭 값을 끈다 (ascii 면 로고도 ASCII)
96
+ AITOP_AMBIGUOUS=wide ·, →, ─, ● 같은 폭이 모호한 기호를 2칸으로 그리는 터미널(동아시아 폭 설정)용. 표·상자 기호를 1칸 기호로 바꿔 줄이 밀리지 않게 한다 (설정 파일 ambiguous)
97
+
98
+ 설정 파일: ${config.path}
99
+ 예) { "theme": "light", "icons": "plain", "bell": true, "notify": true, "onWaiting": "say 대기", "budgetDay": 50, "ambiguous": "wide", "hideIdleTools": false }
100
+ hideIdleTools: 실행 중 세션이 없고 7일 동안 쓰지 않은 도구를 헤더 아래 도구 줄에서 숨긴다 (기본 true, false 면 늘 셋 다)
101
+ onWaiting 은 파일 소유자가 나이고 그룹·다른 사용자가 쓸 수 없을 때(chmod 600)만 받는다.
102
+ 옵션 > 환경 변수 > 설정 파일 순서로 정한다. 잘못된 값은 무시하고 --doctor 에 알린다.
103
+
104
+ ${keysText().replace(/^/gm, ' ')}
105
+ 실시간 화면에서 ? 를 누르면 지금 화면의 키부터 보인다`;
106
+
107
+ if (theme === '') {
108
+ process.stderr.write(`aitop: --theme 값이 없습니다 (light 또는 dark)\n`);
109
+ process.exit(2);
110
+ }
111
+ const unknown = [...args].filter((a) => !KNOWN.has(a));
112
+ if (theme != null && !THEMES.includes(theme)) unknown.push(`--theme ${theme}`);
113
+ if (unknown.length) {
114
+ process.stderr.write(`aitop: 모르는 옵션 ${unknown.join(' ')}\n\n${HELP}\n`);
115
+ process.exit(2);
116
+ }
117
+ if (args.has('-h') || args.has('--help')) {
118
+ console.log(HELP);
119
+ process.exit(0);
120
+ }
121
+ if (args.has('-v') || args.has('--version')) {
122
+ console.log(VERSION);
123
+ process.exit(0);
124
+ }
125
+ if (args.has('--print-config')) {
126
+ console.log(args.has('--json') ? JSON.stringify(configJson(config), null, 2) : configText(config));
127
+ process.exit(0);
128
+ }
129
+
130
+ // 디버그 로그는 켰을 때만 파일을 만든다. --help·--version·--print-config 처럼 바로 끝나는 경우에는 만들지 않는다.
131
+ const log = initLog({ enabled: opt.debug, version: VERSION });
132
+ if (opt.debug && !log.on) process.stderr.write(`aitop: 디버그 로그를 열 수 없어 끕니다 (${log.path})\n`);
133
+
134
+ // 파이프나 파일로 받을 때는 색 코드를 넣지 않는다. 화면 모듈이 불러올 때 NO_COLOR 를 읽으므로 그 전에 정한다.
135
+ const forced = process.env.FORCE_COLOR != null && !['', '0', 'false'].includes(process.env.FORCE_COLOR);
136
+ if (!process.stdout.isTTY && !forced && !process.env.NO_COLOR) process.env.NO_COLOR = '1';
137
+
138
+ // 받는 쪽(| head 등)이 먼저 닫으면 조용히 끝낸다.
139
+ process.stdout.on('error', (err) => process.exit(err.code === 'EPIPE' ? 0 : 1));
140
+ // 다 쓴 뒤에 끝낸다 (파이프로 넘길 때 64KB 넘는 출력이 잘리지 않게).
141
+ const printAndExit = async (text) => {
142
+ await new Promise((resolve) => process.stdout.write(text + '\n', resolve));
143
+ process.exit(0);
144
+ };
145
+
146
+ // 알림은 실시간 화면에서만 동작한다.
147
+ if ((cli.notify || cli.onWaiting) && (args.has('--once') || args.has('--json') || args.has('--doctor') || !process.stdout.isTTY)) {
148
+ process.stderr.write('aitop: --notify, --on-waiting 은 실시간 화면에서만 동작한다 (--once·--json·--doctor·파이프 출력에서는 무시)\n');
149
+ }
150
+
151
+ const { checkTools, doctorText, toolsJson } = await import('../src/doctor.mjs');
152
+ if (args.has('--doctor')) {
153
+ const result = { ...checkTools(), config: configJson(config), log: { on: log.on, path: log.path } };
154
+ await printAndExit(args.has('--json') ? JSON.stringify({ version: VERSION, ...result, tools: toolsJson(result.tools) }, null, 2) : doctorText(result, VERSION));
155
+ }
156
+
157
+ const { collect } = await import('../src/collect.mjs');
158
+ const { render, filterSessions, animating } = await import('../src/ui.mjs');
159
+ const { arrange } = await import('../src/order.mjs');
160
+ const { frameTimer } = await import('../src/frames.mjs');
161
+ const { flushCache } = await import('../src/cache.mjs');
162
+ const { toJson } = await import('../src/json.mjs');
163
+ const { createAttention, updateAttention, markSeen, nextAttention, windowTitle, createWaitWatch, takeNewlyWaiting, attentionCounts } = await import('../src/attention.mjs');
164
+ const { createNotifier } = await import('../src/notify.mjs');
165
+ const { onKey, fixFocus } = await import('../src/keys.mjs');
166
+
167
+ // 요청 전체 보기는 기본으로 꺼 둔다. 켜지 않으면 상세 패널에도 --json 에도 나가지 않는다.
168
+ const showRequests = opt.showRequests;
169
+ // settings: 도움말(?)에 보이는 지금 설정 값.
170
+ const settings = { budgetDay: opt.budgetDay, notify: Boolean(alerts.notify), onWaiting: Boolean(alerts.onWaiting), debug: log.on, configPath: config.path };
171
+ const view = { showRequests, budgetDay: opt.budgetDay, hideIdleTools: opt.hideIdleTools, settings, data: null, selectedId: null, showDetail: 'auto', showEnded: opt.all, frame: 0, filter: '', searching: false, help: false, focus: null, filePath: null, diffScroll: 0, tab: 'now', costMode: 'api', sort: 'default', group: 'none', pins: [] };
172
+ const once = args.has('--once') || args.has('--json') || !process.stdout.isTTY;
173
+ // 실시간 화면에서 한 번 갱신할 때 읽는 기록 양과 시간. 큰 기록은 여러 번에 나눠 읽어서 키 입력이 막히지 않게 한다.
174
+ const TICK_BUDGET = 24 * 1024 * 1024;
175
+ const TICK_MS = 60;
176
+ // 덜 읽은 기록이 있으면 1초를 기다리지 않고 이만큼 쉰 뒤 이어 읽는다 (그 사이 키 입력과 그리기가 돈다).
177
+ const CATCH_UP_MS = 15;
178
+ // 처음 읽는 동안은 캐시를 자주 저장해, 도중에 끝내도 읽은 만큼은 남게 한다.
179
+ const LOADING_FLUSH_MS = 3000;
180
+
181
+ const attention = createAttention();
182
+ let tools = null;
183
+ // --bell · --notify · --on-waiting: 실시간 화면에서만, 첫 갱신에서 이미 응답 대기인 세션은 알리지 않는다.
184
+ const bell = opt.bell;
185
+ const waitWatch = createWaitWatch();
186
+ const notifier = !once && (alerts.notify || alerts.onWaiting) ? createNotifier(alerts) : null;
187
+
188
+ function refresh() {
189
+ const data = collect({ showEnded: view.showEnded, selectedId: view.selectedId, budgetBytes: once ? Infinity : TICK_BUDGET, budgetMs: once ? Infinity : TICK_MS });
190
+ if (!data.sessions.some((s) => s.id === view.selectedId)) {
191
+ view.selectedId = arrange(data.sessions, view).sessions[0]?.id ?? null;
192
+ view.focus = null;
193
+ view.tab = 'now';
194
+ }
195
+ updateAttention(attention, data.sessions, view.selectedId);
196
+ data.sessions = data.sessions.map((s) => (attention.unseen.has(s.id) ? { ...s, unseen: true, phase: { kind: 'unseen', text: '완료 · 확인 전' }, plain: s } : s));
197
+ // 도구별 점검은 기록 폴더를 훑으므로 30초에 한 번만 한다.
198
+ if (!tools || Date.now() - tools.at > 30_000) tools = { at: Date.now(), result: checkTools() };
199
+ data.tools = tools.result;
200
+ view.data = data;
201
+ if (!once && (bell || notifier)) {
202
+ const fresh = takeNewlyWaiting(waitWatch, data.sessions);
203
+ if (fresh.length && bell) process.stdout.write('\x07');
204
+ notifier?.fire(fresh);
205
+ }
206
+ if (view.filter) keepSelectionVisible();
207
+ fixFocus(view);
208
+ }
209
+
210
+ function select(id) {
211
+ if (id !== view.selectedId) {
212
+ view.focus = null;
213
+ view.tab = 'now';
214
+ }
215
+ view.selectedId = id;
216
+ markSeen(attention, id);
217
+ }
218
+
219
+ if (once) {
220
+ refresh();
221
+ flushCache();
222
+ const size = (v) => (Number(v) > 0 ? Math.floor(Number(v)) : 0);
223
+ const W = process.stdout.columns || size(process.env.COLUMNS) || 140;
224
+ const H = process.stdout.rows || size(process.env.LINES) || 40;
225
+ await printAndExit(args.has('--json') ? JSON.stringify(toJson(view.data, VERSION, { showRequests, budgetDay: opt.budgetDay }), null, 2) : render(view, W, H).join('\n'));
226
+ }
227
+
228
+ const out = process.stdout;
229
+ const stdin = process.stdin;
230
+ let last = '';
231
+ let lastTitle = '';
232
+ // 스피너처럼 움직이는 표시가 있을 때만 120ms 마다 다시 그린다. 없으면 데이터 갱신·키 입력·창 크기 변경 때만 그린다.
233
+ const frames = frameTimer({
234
+ ms: 120,
235
+ animating: () => animating(view),
236
+ onFrame: () => {
237
+ view.frame++;
238
+ draw();
239
+ },
240
+ });
241
+
242
+ function draw() {
243
+ frames.sync();
244
+ view.rows = out.rows;
245
+ const frame = render(view, out.columns, out.rows).join('\r\n');
246
+ const title = windowTitle(view.data?.sessions ?? [], BRAND.tab);
247
+ if (title !== lastTitle) {
248
+ lastTitle = title;
249
+ out.write(`\x1b]0;${title}\x07`);
250
+ // WezTerm 탭 제목에서 읽을 수 있게 같은 수를 user var 로도 내보낸다.
251
+ if (BRAND.userVars) out.write(userVarsSeq(attentionCounts(view.data?.sessions ?? [])));
252
+ }
253
+ if (frame === last) return;
254
+ last = frame;
255
+ out.write('\x1b[?2026h\x1b[H' + frame + '\x1b[?2026l');
256
+ }
257
+
258
+ function enterScreen() {
259
+ // 창 제목을 저장해 두었다가 끝낼 때 되돌린다 (지원하지 않는 터미널은 무시한다).
260
+ out.write('\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[2J');
261
+ lastTitle = '';
262
+ stdin.setRawMode(true);
263
+ stdin.resume();
264
+ last = '';
265
+ }
266
+
267
+ function leaveScreen() {
268
+ out.write('\x1b[0m\x1b[?25h\x1b[?1049l\x1b[23;0t' + (BRAND.userVars ? userVarsSeq(null) : ''));
269
+ if (stdin.isTTY) stdin.setRawMode(false);
270
+ }
271
+
272
+ restore = () => {
273
+ notifier?.killAll();
274
+ leaveScreen();
275
+ flushCache();
276
+ };
277
+
278
+ function quit() {
279
+ notifier?.killAll();
280
+ leaveScreen();
281
+ flushCache();
282
+ process.exit(0);
283
+ }
284
+
285
+ enterScreen();
286
+ draw();
287
+ process.on('SIGINT', quit);
288
+ process.on('SIGTERM', quit);
289
+ process.on('SIGHUP', quit);
290
+ process.on('SIGQUIT', quit);
291
+ process.on('SIGCONT', () => {
292
+ enterScreen();
293
+ draw();
294
+ });
295
+ process.on('uncaughtException', (err) => {
296
+ debug('aitop', 'uncaughtException', err);
297
+ notifier?.killAll();
298
+ leaveScreen();
299
+ try {
300
+ flushCache();
301
+ } catch {}
302
+ console.error(err);
303
+ process.exit(1);
304
+ });
305
+ out.on('resize', () => {
306
+ last = '';
307
+ out.write('\x1b[2J');
308
+ draw();
309
+ });
310
+
311
+ // 화면에 그린 순서 그대로 (정렬·묶기 반영). ↑↓ 와 n 이 이 순서를 따른다.
312
+ const visible = () => arrange(filterSessions(view.data?.sessions ?? [], view.filter), view).sessions;
313
+
314
+ function move(delta) {
315
+ const list = visible();
316
+ if (!list.length) return;
317
+ const i = Math.max(0, list.findIndex((s) => s.id === view.selectedId));
318
+ goTo(list[Math.min(list.length - 1, Math.max(0, i + delta))]);
319
+ }
320
+
321
+ function goTo(next) {
322
+ if (!next) return;
323
+ select(next.id);
324
+ // 아직 본문을 읽지 않은 종료 세션을 고르면 다음 갱신을 기다리지 않고 바로 읽는다. 그 밖에는 전체 갱신 없이 화면만 바꾼다.
325
+ if (next.lazy) return refresh();
326
+ if (next.unseen) view.data.sessions = view.data.sessions.map((s) => (s.id === next.id ? s.plain : s));
327
+ }
328
+
329
+ function keepSelectionVisible() {
330
+ const list = visible();
331
+ if (list.length && !list.some((s) => s.id === view.selectedId)) select(list[0].id);
332
+ }
333
+
334
+ stdin.setEncoding('utf8');
335
+ const act = {
336
+ quit,
337
+ refresh,
338
+ move,
339
+ next: () => goTo(nextAttention(attention, visible(), view.selectedId)),
340
+ keepVisible: keepSelectionVisible,
341
+ };
342
+
343
+ stdin.on('data', (key) => {
344
+ onKey(view, key, act);
345
+ draw();
346
+ });
347
+
348
+ let lastFlush = 0;
349
+ function tick() {
350
+ refresh();
351
+ const more = view.data?.more;
352
+ if (more ? Date.now() - lastFlush > LOADING_FLUSH_MS : Date.now() - lastFlush > 15_000) {
353
+ flushCache();
354
+ lastFlush = Date.now();
355
+ }
356
+ draw();
357
+ setTimeout(tick, more ? CATCH_UP_MS : 1000);
358
+ }
359
+
360
+ setTimeout(tick, 30);