@sejuneoh/aitop 0.0.0-stage → 0.21.2
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 +21 -0
- package/README.md +40 -2
- package/bin/aitop.mjs +360 -0
- package/package.json +36 -4
- package/src/attention.mjs +70 -0
- package/src/brand.mjs +30 -0
- package/src/budget.mjs +29 -0
- package/src/cache.mjs +78 -0
- package/src/collect.mjs +51 -0
- package/src/config.mjs +169 -0
- package/src/doctor.mjs +218 -0
- package/src/frames.mjs +24 -0
- package/src/help.mjs +111 -0
- package/src/json.mjs +57 -0
- package/src/keys.mjs +154 -0
- package/src/log.mjs +130 -0
- package/src/notify.mjs +251 -0
- package/src/order.mjs +69 -0
- package/src/pricing.mjs +130 -0
- package/src/procs.mjs +205 -0
- package/src/sources/agy-ledger.mjs +153 -0
- package/src/sources/antigravity.mjs +763 -0
- package/src/sources/claude-ledger.mjs +181 -0
- package/src/sources/claude-tracker.mjs +183 -0
- package/src/sources/claude.mjs +352 -0
- package/src/sources/codex-ledger.mjs +141 -0
- package/src/sources/codex.mjs +374 -0
- package/src/sources/common.mjs +228 -0
- package/src/sources/files.mjs +116 -0
- package/src/sources/gemini.mjs +26 -0
- package/src/sources/patch.mjs +78 -0
- package/src/sqlite.mjs +50 -0
- package/src/tail.mjs +109 -0
- package/src/text.mjs +256 -0
- package/src/turns.mjs +79 -0
- package/src/ui/chrome.mjs +163 -0
- package/src/ui/detail.mjs +360 -0
- package/src/ui/draw.mjs +152 -0
- package/src/ui/glyphs.mjs +58 -0
- package/src/ui/table.mjs +425 -0
- package/src/ui/theme.mjs +137 -0
- package/src/ui.mjs +99 -0
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,41 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ⣠⣴⣾ aitop
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@sejuneoh/aitop)
|
|
4
|
+
[](https://github.com/SejuneOh/aitop/blob/main/LICENSE)
|
|
5
|
+
|
|
6
|
+
**`top` for your AI coding agents.** A read-only terminal monitor that shows every Claude Code, Codex CLI and Antigravity CLI session on your machine in one live screen: what each one is doing, which ones are waiting for you, and how many tokens they used.
|
|
7
|
+
|
|
8
|
+
- **Live status** of every session and its subagents: thinking, running a tool, waiting for you, idle
|
|
9
|
+
- **Waiting alerts**: blocked sessions jump to the top, with optional bell, desktop notification or hook command
|
|
10
|
+
- **Tokens and cost** per session and for today / last 7 days, at public API list prices
|
|
11
|
+
- **Changed files and diffs** per session and per prompt
|
|
12
|
+
- **Read-only**: no network, no ports, no dependencies. It only reads the session logs each CLI already writes
|
|
13
|
+
|
|
14
|
+
> The interface text is currently in Korean. Keys and options work the same everywhere.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
Requires Node.js 22+ on Linux, macOS or WSL.
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install -g @sejuneoh/aitop
|
|
22
|
+
aitop
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
aitop live screen
|
|
29
|
+
aitop --all include sessions that ended in the last 24 hours
|
|
30
|
+
aitop --json print a snapshot as JSON and exit
|
|
31
|
+
aitop --doctor check which tools and logs aitop can see
|
|
32
|
+
aitop --help all options
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Keys: `↑↓` select · `n` next session to check · `/` search · `s` sort · `g` group · `p` pin · `f` changed files · `?` help · `q` quit
|
|
36
|
+
|
|
37
|
+
Settings can also go in `~/.config/aitop/config.json`. See `aitop --help` or the [detailed manual (Korean)](https://github.com/SejuneOh/aitop/blob/main/docs/README.ko.md).
|
|
38
|
+
|
|
39
|
+
## License
|
|
40
|
+
|
|
41
|
+
[MIT](https://github.com/SejuneOh/aitop/blob/main/LICENSE)
|
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);
|
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sejuneoh/aitop",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.21.2",
|
|
4
|
+
"description": "터미널에서 Claude Code · Codex · Antigravity · Gemini 세션과 토큰을 실시간으로 보는 읽기 전용 모니터",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"aitop": "bin/aitop.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"src",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22"
|
|
18
|
+
},
|
|
19
|
+
"os": [
|
|
20
|
+
"linux",
|
|
21
|
+
"darwin"
|
|
22
|
+
],
|
|
23
|
+
"homepage": "https://github.com/SejuneOh/aitop#readme",
|
|
24
|
+
"bugs": "https://github.com/SejuneOh/aitop/issues",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/SejuneOh/aitop.git"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"registry": "https://registry.npmjs.org",
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"start": "node bin/aitop.mjs",
|
|
35
|
+
"test": "node --test test/*.test.mjs",
|
|
36
|
+
"test:e2e": "node --test test/e2e/*.e2e.mjs"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// "끝났는데 아직 안 본" 세션 추적. aitop 메모리에만 두고 파일에는 쓰지 않는다.
|
|
2
|
+
// 작업 중(busy·shell)이던 세션이 대기(idle)로 바뀌었는데 그때 선택돼 있지 않았으면 확인 전으로 본다.
|
|
3
|
+
|
|
4
|
+
export function createAttention() {
|
|
5
|
+
return { prev: new Map(), unseen: new Set() };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function updateAttention(state, sessions, selectedId) {
|
|
9
|
+
const ids = new Set();
|
|
10
|
+
for (const s of sessions) {
|
|
11
|
+
ids.add(s.id);
|
|
12
|
+
const before = state.prev.get(s.id);
|
|
13
|
+
// 상태를 기록 수정 시각으로 추측한 세션(statusGuessed)은, 작업이 아닌 줄이 붙어도 잠깐 작업 중으로 보이므로 확인 전으로 치지 않는다.
|
|
14
|
+
if (s.id === selectedId || s.status === 'busy' || s.status === 'shell' || !s.live) state.unseen.delete(s.id);
|
|
15
|
+
else if ((before === 'busy' || before === 'shell') && s.status === 'idle' && !s.statusGuessed) state.unseen.add(s.id);
|
|
16
|
+
state.prev.set(s.id, s.status);
|
|
17
|
+
}
|
|
18
|
+
for (const id of state.prev.keys()) if (!ids.has(id)) state.prev.delete(id);
|
|
19
|
+
for (const id of state.unseen) if (!ids.has(id)) state.unseen.delete(id);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function markSeen(state, id) {
|
|
23
|
+
state.unseen.delete(id);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 다음에 봐야 할 세션: 지금 선택한 세션은 빼고, 그 다음 것부터 한 바퀴 돌며 응답 대기 → 확인 전 순서로 찾는다.
|
|
27
|
+
// 선택이 목록에 없으면(검색으로 가려짐 등) 맨 위부터 찾는다.
|
|
28
|
+
export function nextAttention(state, sessions, selectedId) {
|
|
29
|
+
const at = sessions.findIndex((s) => s.id === selectedId);
|
|
30
|
+
const ordered = at < 0 ? sessions : [...sessions.slice(at + 1), ...sessions.slice(0, at)];
|
|
31
|
+
return ordered.find((s) => s.status === 'waiting') ?? ordered.find((s) => state.unseen.has(s.id)) ?? null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 응답 대기(◆)와 확인 전(●) 수.
|
|
35
|
+
export function attentionCounts(sessions) {
|
|
36
|
+
return { waiting: sessions.filter((s) => s.live && s.status === 'waiting').length, unseen: sessions.filter((s) => s.unseen).length };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 창 제목: 맨 앞 아이콘(탭 사이에서 aitop 을 찾게) + 응답 대기·확인 전 수. 예: (2◆ 1●) aitop
|
|
40
|
+
export function windowTitle(sessions, icon = '') {
|
|
41
|
+
const { waiting, unseen } = attentionCounts(sessions);
|
|
42
|
+
const marks = [waiting && `${waiting}◆`, unseen && `${unseen}●`].filter(Boolean);
|
|
43
|
+
return (icon ? icon + ' ' : '') + (marks.length ? `(${marks.join(' ')}) aitop` : 'aitop');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 새로 응답 대기가 된 세션 목록. seen 은 지난번 응답 대기 세션 id 집합이며 여기서 갱신한다.
|
|
47
|
+
export function freshWaiting(seen, sessions) {
|
|
48
|
+
const now = sessions.filter((s) => s.live && s.status === 'waiting');
|
|
49
|
+
const fresh = now.filter((s) => !seen.has(s.id));
|
|
50
|
+
seen.clear();
|
|
51
|
+
for (const s of now) seen.add(s.id);
|
|
52
|
+
return fresh;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 벨: 새로 응답 대기가 된 세션이 있을 때만 true.
|
|
56
|
+
export function newlyWaiting(seen, sessions) {
|
|
57
|
+
return freshWaiting(seen, sessions).length > 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --bell · --notify · --on-waiting 이 함께 쓰는 감시. 첫 갱신에서 이미 응답 대기인 세션은 알리지 않는다.
|
|
61
|
+
export function createWaitWatch() {
|
|
62
|
+
return { seen: new Set(), ready: false };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function takeNewlyWaiting(watch, sessions) {
|
|
66
|
+
const fresh = freshWaiting(watch.seen, sessions);
|
|
67
|
+
const ready = watch.ready;
|
|
68
|
+
watch.ready = true;
|
|
69
|
+
return ready ? fresh : [];
|
|
70
|
+
}
|
package/src/brand.mjs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// aitop 표식: 로고, 창·탭 아이콘, WezTerm 탭용 user var.
|
|
2
|
+
// AITOP_ICONS 로 고른다.
|
|
3
|
+
// (없음) 로고 ⣠⣴⣾, 탭 아이콘은 Nerd Font 로봇(U+F06A9), WezTerm user var 를 내보낸다
|
|
4
|
+
// plain Nerd 아이콘과 user var 를 끈다. 탭 아이콘은 로고의 마지막 막대 ⣾
|
|
5
|
+
// ascii 로고를 >_ai 로, 탭 아이콘 없음, user var 없음 (점자를 못 그리는 환경용)
|
|
6
|
+
// 로고의 점자 글자는 동아시아 폭이 N 이라 한글 터미널에서도 늘 1칸이다.
|
|
7
|
+
|
|
8
|
+
export function iconMode(env = process.env) {
|
|
9
|
+
return env.AITOP_ICONS === 'plain' || env.AITOP_ICONS === 'ascii' ? env.AITOP_ICONS : 'full';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function brand(mode = iconMode()) {
|
|
13
|
+
if (mode === 'ascii') return { mode, logo: '>_ai', tab: '', userVars: false };
|
|
14
|
+
return {
|
|
15
|
+
mode,
|
|
16
|
+
logo: '⣠⣴⣾',
|
|
17
|
+
tab: mode === 'plain' ? '⣾' : '\u{F06A9}',
|
|
18
|
+
userVars: mode === 'full',
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const BRAND = brand();
|
|
23
|
+
|
|
24
|
+
// WezTerm 탭 제목(format-tab-title)에서 pane.user_vars 로 읽는 값. 다른 터미널은 모르는 OSC 를 무시한다.
|
|
25
|
+
// counts 가 null 이면 값을 지운다 (aitop 을 끝낼 때).
|
|
26
|
+
export function userVarsSeq(counts) {
|
|
27
|
+
const set = (name, value) => `\x1b]1337;SetUserVar=${name}=${Buffer.from(value).toString('base64')}\x07`;
|
|
28
|
+
if (!counts) return set('aitop', '') + set('aitop_waiting', '') + set('aitop_unseen', '');
|
|
29
|
+
return set('aitop', '1') + set('aitop_waiting', String(counts.waiting)) + set('aitop_unseen', String(counts.unseen));
|
|
30
|
+
}
|
package/src/budget.mjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// 하루 예산(budgetDay): 오늘 비용에 최근 지출 속도 × 로컬 자정까지 남은 시간을 더해 오늘 끝의 비용을 어림한다.
|
|
2
|
+
// 예산은 화면의 비용 기준(c 키)과 상관없이 늘 API 정가 환산으로 본다. 그래서 화면과 --json 의 단계가 같다.
|
|
3
|
+
// 예상이 예산의 CAUTION 배 이상이면 'caution', 예산 이상이면 'warn'.
|
|
4
|
+
|
|
5
|
+
export const CAUTION = 0.8;
|
|
6
|
+
// 자정 뒤 이만큼이 지나기 전에는 속도를 믿지 않고 예상 = 오늘 비용.
|
|
7
|
+
export const MIN_TODAY_MS = 15 * 60_000;
|
|
8
|
+
|
|
9
|
+
// 지금부터 로컬 자정까지 남은 시간 (시간 단위).
|
|
10
|
+
export function hoursToMidnight(now = Date.now()) {
|
|
11
|
+
const d = new Date(now);
|
|
12
|
+
const midnight = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime();
|
|
13
|
+
return Math.max(0, (midnight - now) / 3600_000);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const levelOf = (projected, day) => (projected >= day ? 'warn' : projected >= day * CAUTION ? 'caution' : 'ok');
|
|
17
|
+
|
|
18
|
+
// usage: collect 의 사용량. recentToday 는 최근 1시간 중 오늘(로컬 자정 이후) 몫과 그 구간 길이(ms)다 (recentTodayOf).
|
|
19
|
+
// 속도 = 그 몫을 시간당으로 늘린 값. 값이 없거나, 아직 집계 중이거나, 자정 뒤 15분이 안 됐으면 예상 = 오늘 비용.
|
|
20
|
+
// 예산이 없거나 사용량이 없으면 null.
|
|
21
|
+
export function projectBudget(usage, day, { now = Date.now() } = {}) {
|
|
22
|
+
if (!(typeof day === 'number' && day > 0) || !usage?.today) return null;
|
|
23
|
+
const r = usage.loading == null && usage.recentToday?.ms >= MIN_TODAY_MS ? usage.recentToday : null;
|
|
24
|
+
const spent = usage.today.cost ?? 0;
|
|
25
|
+
const perHour = r ? (r.cost ?? 0) / (r.ms / 3600_000) : 0;
|
|
26
|
+
const projected = spent + perHour * hoursToMidnight(now);
|
|
27
|
+
const unknown = Boolean(usage.today.unknown || r?.unknown);
|
|
28
|
+
return { day, spent, projected, unknown, level: levelOf(projected, day) };
|
|
29
|
+
}
|