@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/src/cache.mjs ADDED
@@ -0,0 +1,78 @@
1
+ // 큰 세션 기록을 매번 처음부터 다시 읽지 않도록, 파일별로 "어디까지 읽었고 그때 합계가 얼마였는지"를
2
+ // ~/.cache/aitop 에 저장한다. 이 프로그램이 디스크에 쓰는 곳은 여기와 --debug 때의 디버그 로그(log.mjs)뿐이다.
3
+ import { readFileSync, writeFileSync, mkdirSync, renameSync, statSync, readdirSync, rmSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { homedir } from 'node:os';
6
+ import { createHash } from 'node:crypto';
7
+ import { PRICE_TAG } from './pricing.mjs';
8
+ import { debug, debugIo } from './log.mjs';
9
+
10
+ // 캐시 형식이 바뀌면 VERSION 을, 가격표가 바뀌면 PRICE_TAG 가 폴더 이름을 바꿔서 예전 캐시를 버린다.
11
+ const VERSION = 13;
12
+ const ROOT = join(process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache'), 'aitop');
13
+ const NAME = `v${VERSION}-${PRICE_TAG}`;
14
+ const DIR = join(ROOT, NAME);
15
+ export const CACHE_DIR = DIR;
16
+ const dirty = new Map();
17
+ let cleaned = false;
18
+
19
+ // 다른 버전의 aitop 이 같이 쓰고 있을 수 있으니, 같은 형식의 다른 폴더는 일주일 넘게 손대지 않았을 때만 지운다.
20
+ const STALE_DIR_MS = 7 * 24 * 3600 * 1000;
21
+
22
+ function removeOldDirs() {
23
+ cleaned = true;
24
+ try {
25
+ for (const d of readdirSync(ROOT)) {
26
+ if (d === NAME) continue;
27
+ const p = join(ROOT, d);
28
+ // 이 버전보다 낮은 형식(v2, v6-… 등)은 더 이상 어떤 버전도 쓰지 않으므로 바로 지운다.
29
+ const ver = Number(/^v(\d+)(?:-|$)/.exec(d)?.[1]);
30
+ if (ver < VERSION || Date.now() - statSync(p).mtimeMs > STALE_DIR_MS) rmSync(p, { recursive: true, force: true });
31
+ }
32
+ } catch (e) {
33
+ debugIo('cache', 'old cache cleanup failed', e);
34
+ }
35
+ }
36
+
37
+ // kind: 같은 기록 파일을 다른 용도로 읽는 tracker 가 각자 캐시를 갖도록 구분한다 (예: 세션 화면용, 날짜별 사용량용).
38
+ const keyOf = (path, kind) => createHash('sha1').update(`${kind}\0${path}`).digest('hex');
39
+
40
+ // 파일이 바뀌지 않고 뒤에만 붙었을 때(크기가 같거나 커졌고 inode 가 같을 때)만 캐시를 쓴다.
41
+ export function loadCache(path, kind = '') {
42
+ try {
43
+ const c = JSON.parse(readFileSync(join(DIR, keyOf(path, kind) + '.json'), 'utf8'));
44
+ const st = statSync(path);
45
+ if (c.path !== path || c.ino !== st.ino || st.size < c.offset) return null;
46
+ return c;
47
+ } catch (e) {
48
+ debugIo('cache', 'cache load failed', e);
49
+ return null;
50
+ }
51
+ }
52
+
53
+ export function markDirty(path, snapshot, kind = '') {
54
+ dirty.set(keyOf(path, kind), { path, build: snapshot });
55
+ }
56
+
57
+ export function flushCache() {
58
+ if (!dirty.size) return;
59
+ try {
60
+ mkdirSync(DIR, { recursive: true, mode: 0o700 });
61
+ } catch (e) {
62
+ debug('cache', 'cache folder create failed', e);
63
+ return;
64
+ }
65
+ if (!cleaned) removeOldDirs();
66
+ for (const [key, { path, build }] of dirty) {
67
+ try {
68
+ const data = build();
69
+ if (!data) continue;
70
+ const file = join(DIR, key + '.json');
71
+ writeFileSync(file + '.tmp', JSON.stringify({ ...data, path, ino: statSync(path).ino }), { mode: 0o600 });
72
+ renameSync(file + '.tmp', file);
73
+ } catch (e) {
74
+ debugIo('cache', 'cache write failed', e);
75
+ }
76
+ }
77
+ dirty.clear();
78
+ }
@@ -0,0 +1,51 @@
1
+ import { collectClaude } from './sources/claude.mjs';
2
+ import { collectCodex } from './sources/codex.mjs';
3
+ import { collectGemini } from './sources/gemini.mjs';
4
+ import { collectAgy } from './sources/antigravity.mjs';
5
+ import { collectLedger } from './sources/claude-ledger.mjs';
6
+ import { createBudget } from './tail.mjs';
7
+
8
+ const RANK = { waiting: 0, busy: 1, shell: 2, live: 3, idle: 4, ended: 5 };
9
+
10
+ // 상태 순서가 먼저다. 같은 상태 안에서는 갱신마다 자리가 바뀌지 않도록 바뀌지 않는 값으로 줄 세운다:
11
+ // 늦게 시작한 세션이 위, 시작 시각이 없으면 아래, 그다음 도구·id 순.
12
+ // 종료 세션은 더 바뀌지 않으므로 최근에 끝난 세션이 위로 오게 마지막 활동 시각으로 줄 세운다.
13
+ export function bySession(a, b) {
14
+ const at = (s) => (s.status === 'ended' ? s.updatedAt : s.startedAt) ?? -Infinity;
15
+ return (
16
+ (RANK[a.status] ?? 9) - (RANK[b.status] ?? 9) ||
17
+ at(b) - at(a) ||
18
+ String(a.tool ?? '').localeCompare(String(b.tool ?? '')) ||
19
+ String(a.id ?? '').localeCompare(String(b.id ?? ''))
20
+ );
21
+ }
22
+
23
+ // budgetBytes·budgetMs: 이번 갱신에서 세션 기록을 읽을 최대 바이트와 시간. 실시간 화면은 작게 잡아 큰 기록을 여러 번에 나눠 읽는다.
24
+ // 돌려주는 more 가 true 면 한도 때문에 덜 읽은 기록이 있다.
25
+ // 도구마다, 그리고 날짜별 사용량 장부에 한도를 따로 줘서 서로의 읽기를 막지 않게 한다.
26
+ export function collect({ budgetBytes = Infinity, budgetMs = Infinity, ...opts } = {}) {
27
+ const budgets = [];
28
+ const budgetOf = (bytes, ms) => {
29
+ const b = createBudget(bytes, ms);
30
+ budgets.push(b);
31
+ return b;
32
+ };
33
+ const sessions = [];
34
+ const errors = [];
35
+ for (const [tool, fn] of [['claude', collectClaude], ['codex', collectCodex], ['gemini', collectGemini], ['agy', collectAgy]]) {
36
+ try {
37
+ sessions.push(...fn({ ...opts, budget: budgetOf(budgetBytes, budgetMs) }));
38
+ } catch (err) {
39
+ errors.push(`${tool}: ${err.message}`);
40
+ }
41
+ }
42
+ // 장부는 세션 화면보다 덜 급하니 한 번 갱신에 세션 한도의 1/3 만 읽는다 (처음 한 번 다 읽은 뒤에는 캐시와 새 줄만 읽는다).
43
+ let usage = null;
44
+ try {
45
+ usage = collectLedger({ budget: budgetOf(budgetBytes / 3, budgetMs / 2) });
46
+ } catch (err) {
47
+ errors.push(`usage: ${err.message}`);
48
+ }
49
+ sessions.sort(bySession);
50
+ return { sessions, usage, errors, at: Date.now(), more: budgets.some((b) => b.exhausted) };
51
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,169 @@
1
+ // 설정: 옵션마다 명령줄 옵션(flag) > 환경 변수(env) > 설정 파일(file) > 기본값(default) 순서로 고른다.
2
+ // 설정 파일은 $XDG_CONFIG_HOME/aitop/config.json (없으면 ~/.config/aitop/config.json). aitop 은 이 파일을 읽기만 한다.
3
+ // 잘못된 JSON, 모르는 키, 잘못된 값은 그 키만 무시하고 problems 에 남긴다 (--doctor 와 --print-config 가 보인다).
4
+ // 화면 모듈은 불러올 때 테마·아이콘을 고르므로, 이 모듈은 다른 aitop 모듈을 불러오지 않는다.
5
+ import { closeSync, constants, fstatSync, openSync, readSync, statSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { isAbsolute, join } from 'node:path';
8
+ import { stripControl } from './text.mjs';
9
+
10
+ const bool = (v) => typeof v === 'boolean';
11
+ const oneOf = (...xs) => (v) => xs.includes(v);
12
+ // 달러 금액 글자("50", "12.5", "$50")를 0 보다 큰 수로 바꾼다. 아니면 null.
13
+ const usd = (v) => {
14
+ const t = String(v).trim().replace(/^\$/, '');
15
+ const n = /^\d+(\.\d+)?$|^\.\d+$/.test(t) ? Number(t) : NaN;
16
+ return Number.isFinite(n) && n > 0 ? n : null;
17
+ };
18
+
19
+ // argv 에서 --budget-day <usd>, --budget-day=<usd>, --no-budget-day 를 꺼낸다. 뒤에 준 쪽을 따른다.
20
+ // value: 금액, null(이번 실행만 끔), undefined(주지 않음). 나머지 인자는 rest 로 돌려준다.
21
+ export function budgetDayOption(argv) {
22
+ let value;
23
+ let error = null;
24
+ const rest = [];
25
+ for (let i = 0; i < argv.length; i++) {
26
+ const a = argv[i];
27
+ if (a === '--no-budget-day') {
28
+ value = null;
29
+ continue;
30
+ }
31
+ const m = /^--budget-day(?:=(.*))?$/.exec(a);
32
+ if (!m) {
33
+ rest.push(a);
34
+ continue;
35
+ }
36
+ // 다음 인자가 옵션이면 값으로 먹지 않는다. -5 처럼 수로 보이면 값으로 받아 잘못된 값으로 알린다.
37
+ const next = argv[i + 1];
38
+ const raw = m[1] ?? (next == null || (next.startsWith('-') && !/^-[\d.]/.test(next)) ? '' : argv[++i]);
39
+ const n = usd(raw);
40
+ if (n == null) error ??= raw === '' ? '--budget-day 값이 없습니다 (하루 예산, 달러)' : `--budget-day 값 ${JSON.stringify(raw)} 이 잘못되었습니다 (0 보다 큰 수)`;
41
+ else value = n;
42
+ }
43
+ return { value, rest, error };
44
+ }
45
+
46
+ // env: 환경 변수 이름과 그 값을 옵션 값으로 바꾸는 함수 (undefined 면 설정하지 않은 것으로 본다).
47
+ // envProblem: 환경 변수 값이 잘못되면 무시하고 problems 에 남긴다 (--doctor, --print-config 가 보인다).
48
+ // notify, onWaiting 은 응답 대기 알림(notify.mjs)이 쓴다. debug 는 디버그 로그(log.mjs)를 켠다. budgetDay 는 하루 예산(달러)으로, 헤더와 --json 의 예상 비용 경고(budget.mjs)가 쓴다.
49
+ export const KEYS = {
50
+ theme: { ok: oneOf('light', 'dark'), expect: 'light 또는 dark', def: null, env: ['AITOP_THEME', (v) => (v === 'light' || v === 'dark' ? v : undefined)] },
51
+ icons: { ok: oneOf('plain', 'ascii'), expect: 'plain 또는 ascii', def: 'full', env: ['AITOP_ICONS', (v) => (v === 'plain' || v === 'ascii' ? v : undefined)] },
52
+ bell: { ok: bool, expect: 'true 또는 false', def: false },
53
+ showRequests: { ok: bool, expect: 'true 또는 false', def: false, env: ['AITOP_SHOW_REQUESTS', (v) => (v === '1' ? true : v === '0' ? false : undefined)] },
54
+ all: { ok: bool, expect: 'true 또는 false', def: false },
55
+ notify: { ok: bool, expect: 'true 또는 false', def: false },
56
+ onWaiting: { ok: (v) => typeof v === 'string' && v.trim() !== '', expect: '실행할 명령 문자열', def: null },
57
+ debug: { ok: bool, expect: 'true 또는 false', def: false, env: ['AITOP_DEBUG', (v) => (v === '1' ? true : v === '0' ? false : undefined)], envProblem: true, envExpect: '1 또는 0' },
58
+ budgetDay: { ok: (v) => typeof v === 'number' && Number.isFinite(v) && v > 0, expect: '0 보다 큰 수', def: null, env: ['AITOP_BUDGET_DAY', (v) => usd(v) ?? undefined], envProblem: true },
59
+ // colorBlind 는 적록 색약 팔레트(ui/theme.mjs)를 고른다. theme(밝기)과 따로 정한다.
60
+ colorBlind: { ok: bool, expect: 'true 또는 false', def: false, env: ['AITOP_COLOR_BLIND', (v) => (v === '1' ? true : v === '0' ? false : undefined)], envProblem: true, envExpect: '1 또는 0' },
61
+ // 실행 중 세션이 없고 7일 동안 쓰지 않은 도구를 도구 줄에서 숨긴다. false 면 늘 셋 다 보인다.
62
+ hideIdleTools: { ok: bool, expect: 'true 또는 false', def: true },
63
+ // 폭이 모호한 기호(·, →, ─, ● 등)를 2칸으로 그리는 터미널이면 wide. 표 칸이 밀리지 않게 1칸 기호로 바꾸고 폭을 2칸으로 센다.
64
+ ambiguous: { ok: oneOf('narrow', 'wide'), expect: 'narrow 또는 wide', def: 'narrow', env: ['AITOP_AMBIGUOUS', (v) => (v === 'narrow' || v === 'wide' ? v : undefined)], envProblem: true },
65
+ };
66
+
67
+ export function configPath(env = process.env, home = homedir()) {
68
+ const xdg = env.XDG_CONFIG_HOME;
69
+ return join(xdg && isAbsolute(xdg) ? xdg : join(home, '.config'), 'aitop', 'config.json');
70
+ }
71
+
72
+ // 설정 파일은 작다. 이보다 크면 읽지 않는다.
73
+ export const MAX_BYTES = 64 * 1024;
74
+
75
+ const show = (v) => JSON.stringify(v)?.slice(0, 60) ?? String(v);
76
+
77
+ // 파일을 읽어 올바른 키만 values 에 담는다. uid: 현재 사용자 (테스트에서 바꿔 끼운다).
78
+ export function readConfigFile(path, { uid = process.getuid?.() } = {}) {
79
+ const res = { path, exists: false, values: {}, problems: [] };
80
+ let fd;
81
+ let text;
82
+ let st;
83
+ const notFile = () => res.problems.push('설정 파일이 일반 파일이 아니라 읽지 않습니다');
84
+ const tooBig = () => res.problems.push(`설정 파일이 ${MAX_BYTES / 1024}KB 를 넘어 읽지 않습니다`);
85
+ try {
86
+ // FIFO 나 장치 파일을 열면 멈출 수 있으므로 열기 전에 확인하고, 열 때도 기다리지 않게 연다.
87
+ st = statSync(path);
88
+ res.exists = true;
89
+ if (!st.isFile()) return (notFile(), res);
90
+ if (st.size > MAX_BYTES) return (tooBig(), res);
91
+ fd = openSync(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
92
+ st = fstatSync(fd);
93
+ if (!st.isFile()) return (notFile(), res);
94
+ if (st.size > MAX_BYTES) return (tooBig(), res);
95
+ // 확인한 뒤에 파일이 커져도 한도 + 1 바이트까지만 읽는다.
96
+ const buf = Buffer.alloc(MAX_BYTES + 1);
97
+ let n = 0;
98
+ for (let got; n < buf.length && (got = readSync(fd, buf, n, buf.length - n, n)) > 0; ) n += got;
99
+ if (n > MAX_BYTES) return (tooBig(), res);
100
+ text = buf.subarray(0, n).toString('utf8');
101
+ } catch (err) {
102
+ if (err.code !== 'ENOENT') {
103
+ res.exists = err.code !== 'ENOTDIR';
104
+ res.problems.push(`설정 파일을 읽을 수 없습니다 (${err.code ?? err.message})`);
105
+ }
106
+ return res;
107
+ } finally {
108
+ if (fd != null) closeSync(fd);
109
+ }
110
+ let obj;
111
+ try {
112
+ obj = JSON.parse(text);
113
+ } catch (err) {
114
+ // 오류 문구에 파일 내용 일부가 들어가므로 제어문자를 지우고 줄인다.
115
+ res.problems.push(`설정 파일이 올바른 JSON 이 아니라 무시합니다 (${stripControl(err.message).slice(0, 120)})`);
116
+ return res;
117
+ }
118
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
119
+ res.problems.push('설정 파일의 맨 위는 JSON 객체({ ... })여야 합니다');
120
+ return res;
121
+ }
122
+ for (const [key, value] of Object.entries(obj)) {
123
+ const spec = Object.hasOwn(KEYS, key) ? KEYS[key] : null;
124
+ if (!spec) res.problems.push(`모르는 키 ${show(key)} 는 무시합니다`);
125
+ else if (!spec.ok(value)) res.problems.push(`${key} 값 ${show(value)} 이 잘못되어 무시합니다 (${spec.expect})`);
126
+ else res.values[key] = value;
127
+ }
128
+ // 응답 대기 때 실행할 명령은 다른 사용자가 바꿀 수 없는 파일에서만 받는다.
129
+ if ('onWaiting' in res.values) {
130
+ const mine = uid == null || st.uid === uid;
131
+ const shared = (st.mode & 0o022) !== 0;
132
+ if (!mine || shared) {
133
+ delete res.values.onWaiting;
134
+ const why = [!mine && '파일 소유자가 현재 사용자가 아님', shared && `그룹·다른 사용자가 쓸 수 있음 (mode ${(st.mode & 0o777).toString(8).padStart(3, '0')})`].filter(Boolean).join(', ');
135
+ res.problems.push(`onWaiting 은 무시합니다: ${why}. chmod 600 으로 고치세요`);
136
+ }
137
+ }
138
+ return res;
139
+ }
140
+
141
+ // flags: 명령줄에서 정한 값만 담는다 (주지 않은 옵션은 넣지 않는다).
142
+ export function resolveConfig({ flags = {}, env = process.env, file } = {}) {
143
+ file ??= readConfigFile(configPath(env));
144
+ const values = {};
145
+ const problems = [...file.problems];
146
+ for (const [key, spec] of Object.entries(KEYS)) {
147
+ const fromEnv = spec.env && env[spec.env[0]] != null ? spec.env[1](env[spec.env[0]]) : undefined;
148
+ if (spec.envProblem && env[spec.env[0]] != null && fromEnv === undefined) problems.push(`환경 변수 ${spec.env[0]} 값 ${show(env[spec.env[0]])} 이 잘못되어 무시합니다 (${spec.envExpect ?? spec.expect})`);
149
+ if (flags[key] !== undefined) values[key] = { value: flags[key], source: 'flag' };
150
+ else if (fromEnv !== undefined) values[key] = { value: fromEnv, source: 'env' };
151
+ else if (key in file.values) values[key] = { value: file.values[key], source: 'file' };
152
+ else values[key] = { value: spec.def, source: 'default' };
153
+ }
154
+ return { path: file.path, exists: file.exists, values, problems };
155
+ }
156
+
157
+ // --print-config 의 사람용 출력.
158
+ export function configText(cfg) {
159
+ const out = [`설정 파일: ${stripControl(cfg.path)} (${cfg.exists ? '있음' : '없음'})`, ''];
160
+ for (const [key, { value, source }] of Object.entries(cfg.values)) {
161
+ const v = value == null ? (key === 'theme' ? 'auto' : '-') : typeof value === 'string' ? JSON.stringify(value) : String(value);
162
+ out.push(`${key.padEnd(14)}${v.padEnd(12)} ${source}`);
163
+ }
164
+ if (cfg.problems.length) out.push('', ...cfg.problems.map((p) => `! ${stripControl(p)}`));
165
+ return out.join('\n');
166
+ }
167
+
168
+ // --print-config --json, --doctor --json 용.
169
+ export const configJson = (cfg) => ({ path: cfg.path, exists: cfg.exists, values: cfg.values, problems: cfg.problems });
package/src/doctor.mjs ADDED
@@ -0,0 +1,218 @@
1
+ // 도구별 연결 점검: 기록 폴더를 찾았는지, 기록이 몇 개이고 언제 마지막으로 쓰였는지, 지금 몇 개가 실행 중인지,
2
+ // 가장 최근 기록을 읽을 수 있는지. aitop --doctor, 빈 화면, --json 의 tools 가 같은 결과를 쓴다.
3
+ // 기록 내용은 읽어 내보내지 않는다 (Gemini 는 형식 확인용으로 JSON 키 이름만 보인다).
4
+ import { readdirSync, statSync, existsSync, openSync, readSync, closeSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { homedir, platform } from 'node:os';
7
+ import { findProcesses, isCodex, isGemini, isAgy, canListProcesses, isAlive } from './procs.mjs';
8
+ import { AGY_ROOT } from './sources/antigravity.mjs';
9
+ import { loadSqlite, openReadOnly } from './sqlite.mjs';
10
+ import { fmtAgo, pad, stripControl } from './text.mjs';
11
+ import { BRAND } from './brand.mjs';
12
+ import { findNotifier, isWsl } from './notify.mjs';
13
+
14
+ const UNIT = { s: '초', m: '분', h: '시간', d: '일' };
15
+ const ago = (ms) => fmtAgo(ms).replace(/[smhd]$/, (u) => UNIT[u]);
16
+
17
+ const HOME = homedir();
18
+ const CLAUDE = process.env.CLAUDE_CONFIG_DIR ?? join(HOME, '.claude');
19
+ const CODEX = join(process.env.CODEX_HOME ?? join(HOME, '.codex'), 'sessions');
20
+ const GEMINI = join(process.env.GEMINI_HOME ?? join(HOME, '.gemini'), 'tmp');
21
+
22
+ const tilde = (p) => (p.startsWith(HOME + '/') ? '~' + p.slice(HOME.length) : p);
23
+ const ls = (dir) => {
24
+ try {
25
+ return readdirSync(dir, { withFileTypes: true });
26
+ } catch {
27
+ return [];
28
+ }
29
+ };
30
+
31
+ // 폴더 아래 depth 단계까지 내려가며 조건에 맞는 파일을 모은다.
32
+ function walk(dir, depth, match, out = []) {
33
+ for (const e of ls(dir)) {
34
+ const p = join(dir, e.name);
35
+ if (e.isDirectory() && depth > 0) walk(p, depth - 1, match, out);
36
+ else if (e.isFile() && match(e.name)) out.push(p);
37
+ }
38
+ return out;
39
+ }
40
+
41
+ function newest(files) {
42
+ let best = null;
43
+ for (const path of files) {
44
+ try {
45
+ const m = statSync(path).mtimeMs;
46
+ if (!best || m > best.mtime) best = { path, mtime: m };
47
+ } catch {}
48
+ }
49
+ return best;
50
+ }
51
+
52
+ // 파일 첫 줄이 JSON 으로 읽히면 true, 아니면 false. 비어 있거나 첫 줄이 64KB 를 넘으면 판정하지 않는다(null).
53
+ function firstLineIsJson(path) {
54
+ let fd;
55
+ try {
56
+ fd = openSync(path, 'r');
57
+ const buf = Buffer.alloc(65536);
58
+ const n = readSync(fd, buf, 0, buf.length, 0);
59
+ const text = buf.subarray(0, n).toString('utf8');
60
+ const end = text.indexOf('\n');
61
+ if (!n || (end < 0 && n === buf.length)) return null;
62
+ JSON.parse(end < 0 ? text : text.slice(0, end));
63
+ return true;
64
+ } catch {
65
+ return false;
66
+ } finally {
67
+ if (fd != null) closeSync(fd);
68
+ }
69
+ }
70
+
71
+ // 키 이름도 사용자 글이나 경로일 수 있으므로 식별자처럼 생긴 것만 보인다.
72
+ const KEY_NAME = /^[A-Za-z_][\w.-]{0,40}$/;
73
+
74
+ // JSON 파일 맨 위 키 이름만 (값은 보지 않는다). 앞 64KB 로 읽지 못하면(큰 파일) 맨 위 수준 들여쓰기의 "키": 줄에서 찾는다.
75
+ function topKeys(path) {
76
+ let fd;
77
+ try {
78
+ fd = openSync(path, 'r');
79
+ const buf = Buffer.alloc(65536);
80
+ const n = readSync(fd, buf, 0, buf.length, 0);
81
+ const text = buf.subarray(0, n).toString('utf8');
82
+ let keys;
83
+ try {
84
+ let obj = JSON.parse(text);
85
+ if (Array.isArray(obj)) obj = obj[0];
86
+ keys = obj && typeof obj === 'object' ? Object.keys(obj) : [];
87
+ } catch {
88
+ try {
89
+ keys = Object.keys(JSON.parse(text.split('\n')[0]));
90
+ } catch {
91
+ keys = [...text.matchAll(/^ {0,2}\t?"([^"\n]{1,60})"\s*:/gm)].map((m) => m[1]);
92
+ }
93
+ }
94
+ return [...new Set(keys)].filter((k) => KEY_NAME.test(k)).slice(0, 12);
95
+ } catch {
96
+ return [];
97
+ } finally {
98
+ if (fd != null) closeSync(fd);
99
+ }
100
+ }
101
+
102
+ function claude() {
103
+ const dir = join(CLAUDE, 'projects');
104
+ const files = existsSync(dir) ? walk(dir, 1, (n) => n.endsWith('.jsonl')) : null;
105
+ const last = files && newest(files);
106
+ const running = canListProcesses()
107
+ ? ls(join(CLAUDE, 'sessions')).filter((e) => /^\d+\.json$/.test(e.name) && isAlive(Number(e.name.split('.')[0]))).length
108
+ : null;
109
+ if (!files) return { tool: 'claude', state: 'missing', path: tilde(dir), records: 0, lastAt: null, running, readable: null, hint: 'claude 를 한 번 실행하면 생깁니다' };
110
+ return { tool: 'claude', state: 'ok', path: tilde(dir), records: files.length, lastAt: last?.mtime ?? null, running, readable: last ? firstLineIsJson(last.path) : null, hint: null };
111
+ }
112
+
113
+ function codex() {
114
+ const files = existsSync(CODEX) ? walk(CODEX, 3, (n) => n.startsWith('rollout-') && n.endsWith('.jsonl')) : null;
115
+ const last = files && newest(files);
116
+ const running = canListProcesses() ? findProcesses(isCodex).length : null;
117
+ if (!files) return { tool: 'codex', state: 'missing', path: tilde(CODEX), records: 0, lastAt: null, running, readable: null, hint: 'codex 를 한 번 실행하면 생깁니다' };
118
+ return { tool: 'codex', state: 'ok', path: tilde(CODEX), records: files.length, lastAt: last?.mtime ?? null, running, readable: last ? firstLineIsJson(last.path) : null, hint: null };
119
+ }
120
+
121
+ // Gemini 는 세션 기록 형식을 아직 모른다. 프로세스만 세고, 기록 폴더가 있으면 형식 확인용 키 이름을 남긴다.
122
+ function gemini() {
123
+ const files = existsSync(GEMINI) ? walk(GEMINI, 3, (n) => n.endsWith('.json') || n.endsWith('.jsonl')) : null;
124
+ const last = files && newest(files);
125
+ return {
126
+ tool: 'gemini',
127
+ state: 'limited',
128
+ path: tilde(GEMINI),
129
+ found: Boolean(files),
130
+ records: files?.length ?? 0,
131
+ lastAt: last?.mtime ?? null,
132
+ running: canListProcesses() ? findProcesses(isGemini).length : null,
133
+ readable: null,
134
+ keys: last ? topKeys(last.path) : [],
135
+ hint: '프로세스만 감지합니다 (세션·토큰은 아직 지원하지 않음)',
136
+ };
137
+ }
138
+
139
+ // Antigravity CLI: 요약 DB 를 열어 대화 목록을 읽을 수 있는지 본다. node:sqlite 가 없으면 프로세스만 센다.
140
+ function agy() {
141
+ const dir = join(AGY_ROOT, 'conversations');
142
+ const running = canListProcesses() ? findProcesses(isAgy).length : null;
143
+ const files = existsSync(dir) ? walk(dir, 0, (n) => n.endsWith('.db')) : null;
144
+ if (!files) return { tool: 'agy', state: 'missing', path: tilde(AGY_ROOT), records: 0, lastAt: null, running, readable: null, hint: 'agy 를 한 번 실행하면 생깁니다 (Gemini 탭에 보입니다)' };
145
+ const last = newest(files.flatMap((f) => [f, `${f}-wal`]).filter((f) => existsSync(f)));
146
+ if (!loadSqlite()) return { tool: 'agy', state: 'limited', path: tilde(AGY_ROOT), records: files.length, lastAt: last?.mtime ?? null, running, readable: null, hint: `Node ${process.versions.node} 에서는 기록을 읽기 전용으로 열 수 없어 프로세스만 감지합니다 (Node 22.15 이상 필요)` };
147
+ let readable = false;
148
+ let db;
149
+ try {
150
+ db = openReadOnly(join(AGY_ROOT, 'conversation_summaries.db'));
151
+ db.prepare('select count(*) n from conversation_summaries').get();
152
+ readable = true;
153
+ } catch {
154
+ } finally {
155
+ db?.close();
156
+ }
157
+ return { tool: 'agy', state: 'ok', path: tilde(AGY_ROOT), records: files.length, lastAt: last?.mtime ?? null, running, readable, hint: null };
158
+ }
159
+
160
+ export function checkTools() {
161
+ const tools = {};
162
+ for (const fn of [claude, codex, gemini, agy]) {
163
+ const r = fn();
164
+ tools[r.tool] = r;
165
+ }
166
+ const n = findNotifier();
167
+ return { platform: platform(), processes: canListProcesses(), tools, notifier: n ? { kind: n.kind, path: tilde(n.path) } : null };
168
+ }
169
+
170
+ // --notify 에 쓸 데스크톱 알림 도구.
171
+ export function notifierText(n, plat = platform()) {
172
+ if (n) return `✓ 알림 ${n.kind} (${n.path}) · --notify 로 켬`;
173
+ const want = plat === 'darwin' ? 'osascript' : 'notify-send';
174
+ const wsl = plat === 'linux' && isWsl() ? ' (WSL 은 notify-send 만 쓴다)' : '';
175
+ return `○ 알림 ${want} 없음 · --notify 를 켜도 알림을 건너뜀${wsl}`;
176
+ }
177
+
178
+ // JSON 용: 시각은 ISO 문자열로.
179
+ export function toolsJson(tools) {
180
+ if (!tools) return null;
181
+ return Object.fromEntries(Object.entries(tools).map(([k, t]) => [k, { ...t, lastAt: t.lastAt ? new Date(t.lastAt).toISOString() : null }]));
182
+ }
183
+
184
+ // 점검 결과를 사람이 읽는 조각으로 바꾼다. 빈 화면과 --doctor 가 같은 문구를 쓴다.
185
+ export const MARK = { ok: '✓', missing: '✗', limited: '○' };
186
+ export const STATE_TEXT = { ok: '찾음', missing: '없음', limited: '일부 지원' };
187
+
188
+ export function describe(t, now = Date.now()) {
189
+ const parts = [];
190
+ if (t.found === false && t.state === 'limited') parts.push('기록 폴더 없음');
191
+ else if (t.state !== 'missing') parts.push(`기록 ${t.records.toLocaleString('en-US')}개`);
192
+ if (t.lastAt) parts.push(`마지막 ${ago(now - t.lastAt)} 전`);
193
+ parts.push(t.running == null ? '실행 수 확인 불가 (Linux·WSL·macOS 만 지원)' : `실행 ${t.running}`);
194
+ if (t.readable === false) parts.push('최근 기록 읽기 실패');
195
+ if (t.keys?.length) parts.push(`키: ${t.keys.join(', ')}`);
196
+ return { mark: MARK[t.state], state: STATE_TEXT[t.state], path: t.path, detail: parts.join(' · '), hint: t.hint };
197
+ }
198
+
199
+ // aitop --doctor 의 사람용 출력.
200
+ export function doctorText(result, version, now = Date.now()) {
201
+ const os = `${result.platform}, 프로세스 ${result.processes ? '확인 가능' : '확인 불가'}`;
202
+ const out = [`${BRAND.logo} aitop ${version} 연결 점검 (${os})`, ''];
203
+ for (const t of Object.values(result.tools)) {
204
+ const d = describe(t, now);
205
+ out.push(`${d.mark} ${pad(t.tool, 7)}${pad(d.state, 10)}${d.path}`);
206
+ out.push(` ${d.detail}`);
207
+ if (d.hint) out.push(` → ${d.hint}`);
208
+ }
209
+ if ('notifier' in result) out.push('', notifierText(result.notifier, result.platform));
210
+ // 설정 파일: 무시한 키와 값, 받지 않은 onWaiting 을 알린다.
211
+ const cfg = result.config;
212
+ if (cfg) {
213
+ out.push('', `${cfg.problems.length ? '!' : '✓'} 설정 ${pad(cfg.exists ? '있음' : '없음 (기본값)', 10)}${tilde(cfg.path)}`);
214
+ for (const p of cfg.problems) out.push(` ! ${p}`);
215
+ }
216
+ if (result.log) out.push('', `${result.log.on ? '✓' : '○'} 디버그 ${pad(result.log.on ? '켬' : '꺼짐', 10)}${tilde(result.log.path)}${result.log.on ? '' : ' · --debug 로 켬'}`);
217
+ return stripControl(out.join('\n'));
218
+ }
package/src/frames.mjs ADDED
@@ -0,0 +1,24 @@
1
+ // 실시간 화면의 애니메이션 타이머. 움직이는 표시가 있을 때만 ms 마다 onFrame 을 부르고, 없으면 멈춘다.
2
+ // sync() 는 화면을 그릴 때마다 불러 상태에 맞춰 타이머를 켜고 끈다. timers 는 테스트에서 바꿔 끼운다.
3
+ export function frameTimer({ animating, onFrame, ms = 120, timers = globalThis }) {
4
+ let id = null;
5
+ const stop = () => {
6
+ if (id == null) return;
7
+ timers.clearInterval(id);
8
+ id = null;
9
+ };
10
+ const fire = () => {
11
+ if (!animating()) return stop();
12
+ onFrame();
13
+ };
14
+ return {
15
+ sync() {
16
+ if (!animating()) stop();
17
+ else if (id == null) id = timers.setInterval(fire, ms);
18
+ },
19
+ stop,
20
+ get running() {
21
+ return id != null;
22
+ },
23
+ };
24
+ }
package/src/help.mjs ADDED
@@ -0,0 +1,111 @@
1
+ // 키 안내의 한 곳: 화면 안 도움말(ui/chrome.mjs 의 help)과 aitop --help 의 키 줄이 이 표에서 만들어진다.
2
+ // 화면 모듈을 불러오지 않는다 (bin 이 색·테마를 정하기 전에 불러도 되게).
3
+
4
+ // 화면별 키. [키, 설명, 짧은 이름, --help 에 쓸 키(없으면 첫 글자 묶음)]
5
+ export const VIEWS = {
6
+ table: {
7
+ title: '세션 표',
8
+ keys: [
9
+ ['/', '세션 검색 (이름·폴더·모델). Enter 적용, Esc 지우기', '검색'],
10
+ ['↑↓ j k', '세션 선택', '선택', '↑↓/jk'],
11
+ ['n', '다음 볼 세션으로 (응답 대기 → 완료 · 확인 전)', '다음 볼 세션'],
12
+ ['s', '정렬: 기본 → 비용순 → 컨텍스트순 → 최근순. 응답 대기는 늘 맨 위', '정렬'],
13
+ ['g', '묶기: 없음 → 프로젝트 → 도구. 묶음 머리에 세션 수·비용', '묶기'],
14
+ ['c', '비용 기준: API 환산 ↔ 캐시 제외. 표·헤더·상세가 같이 바뀐다 (예산은 늘 API 환산)', '비용 기준'],
15
+ ['p', '고른 세션을 맨 위(응답 대기 아래)에 고정·해제. 여러 개는 고정한 순서, 이번 실행에만', '고정'],
16
+ ['f Tab', '고른 세션의 변경 파일 목록으로', '변경 파일'],
17
+ ['d', '상세 패널 열기·닫기', '상세'],
18
+ ['e', '종료된 세션(24시간) 보이기·숨기기', '종료 세션 토글'],
19
+ ['Esc', '한 단계 뒤로 (도움말·검색 닫기, diff → 파일 목록 → 세션). 종료하지 않음', '뒤로'],
20
+ ],
21
+ },
22
+ detail: {
23
+ title: '상세 패널',
24
+ keys: [
25
+ ['↑↓ j k', '다른 세션을 고르면 상세도 따라 바뀐다', '세션 바꾸기', '↑↓/jk'],
26
+ ['f Tab', '변경 파일 목록으로', '변경 파일'],
27
+ ['d', '상세 패널 닫기', '닫기'],
28
+ ],
29
+ },
30
+ files: {
31
+ title: '변경 파일 목록',
32
+ keys: [
33
+ ['↑↓ j k', '파일 고르기', '고르기', '↑↓/jk'],
34
+ ['←→', '지시 탭: 이번 지시 · 편집이 있던 이전 지시 T번호 · 전체', '지시 탭'],
35
+ ['Enter l', '고른 파일의 diff 보기', 'diff'],
36
+ ['Esc h f Tab', '세션 표로', '뒤로'],
37
+ ],
38
+ },
39
+ diff: {
40
+ title: 'diff',
41
+ keys: [
42
+ ['↑↓ j k', '한 줄씩 스크롤', '스크롤', '↑↓/jk'],
43
+ ['space PgDn PgUp', '한 페이지씩', '페이지', 'space'],
44
+ ['[ ]', '이전·다음 파일', '이전·다음 파일', '[ ]'],
45
+ ['Esc h ← f Tab', '파일 목록으로', '뒤로'],
46
+ ],
47
+ },
48
+ filtered: {
49
+ title: '검색어가 걸린 표',
50
+ keys: [
51
+ ['↑↓ j k', '검색 결과 안에서 세션 선택', '선택', '↑↓/jk'],
52
+ ['Esc', '검색어 지우기 (표 전체로)', '검색어 지우기'],
53
+ ['/', '지금 검색어를 지우고 새로 입력', '새 검색'],
54
+ ],
55
+ },
56
+ // 검색어를 입력하는 동안은 ? 도 검색어로 들어가므로 도움말에서는 '다른 화면' 과 --help 에만 나온다.
57
+ search: {
58
+ title: '검색 입력 중',
59
+ keys: [
60
+ ['글자', '검색어 입력 (이름·폴더·모델, 대소문자 무시)', '입력'],
61
+ ['Backspace', '한 글자 지우기', '지우기'],
62
+ ['Enter', '검색어를 남기고 표로', '적용'],
63
+ ['Esc', '검색 취소 (검색어 지우기)', '취소'],
64
+ ],
65
+ },
66
+ };
67
+
68
+ // 어느 화면에서나 듣는 키. 화면마다 되풀이하지 않고 도움말에서는 지금 화면 키 바로 아래 한 번만 보인다.
69
+ export const COMMON = {
70
+ title: '어디서나',
71
+ keys: [
72
+ ['?', '이 도움말 열기·닫기', '도움말'],
73
+ ['q Ctrl-C', '나가기', '나가기'],
74
+ ],
75
+ };
76
+
77
+ // 화면 안 도움말의 옵션 묶음. 자세한 설명은 aitop --help.
78
+ export const OPTIONS = [
79
+ ['--notify', '응답 대기가 생기면 데스크톱 알림'],
80
+ ['--on-waiting <명령>', '응답 대기가 생기면 명령 실행'],
81
+ ['--budget-day <달러>', '하루 예산. 헤더에 예상 비용 경고'],
82
+ ['--show-requests', '응답 대기 요청 전체를 상세·--json 에'],
83
+ ['--color-blind', '적록 색약용 색 (대기·돈·경고를 노랑·파랑 계열로)'],
84
+ ['--debug', '디버그 로그를 남긴다'],
85
+ ['--print-config', '설정 값과 출처, 설정 파일 위치'],
86
+ ['AITOP_AMBIGUOUS=wide', '·→─● 를 2칸으로 그리는 터미널용 (1칸 기호로)'],
87
+ ];
88
+
89
+ // 지금 보는 화면: 파일 목록·diff 에 들어가 있으면 그 화면, 아니면 세션 표. 검색어는 표에 붙는 것이라 helpSections 가 따로 본다.
90
+ export function helpContext(view) {
91
+ if (view.focus === 'diff' || view.focus === 'files') return view.focus;
92
+ return 'table';
93
+ }
94
+
95
+ const cliKey = ([k, , , cli]) => cli ?? k.split(' ')[0];
96
+ // 한 화면의 키를 한 줄로: '/ 검색 · ↑↓/jk 선택 · ...'
97
+ // 'common' 은 어디서나 쓰는 키. 없는 화면 이름은 예전처럼 오류로 드러낸다.
98
+ export const keysLine = (name) => (name === 'common' ? COMMON : VIEWS[name]).keys.map((e) => `${cliKey(e)} ${e[2]}`).join(' · ');
99
+
100
+ // aitop --help 끝의 키 안내.
101
+ export function keysText() {
102
+ return [`키: ${keysLine('table')} · ${keysLine('common')}`, ...['files', 'diff', 'filtered', 'search'].map((n) => ` ${VIEWS[n].title}: ${keysLine(n)}`)].join('\n');
103
+ }
104
+
105
+ // 지금 설정 값 한 줄 (예: 예산 $50 · 알림 켜짐 · 디버그 꺼짐). settings 가 없으면 null.
106
+ export function settingsLine(s) {
107
+ if (!s) return null;
108
+ const onOff = (v) => (v ? '켜짐' : '꺼짐');
109
+ const usd = (x) => (Number.isInteger(x) ? `$${x}` : `$${x.toFixed(2)}`);
110
+ return [`예산 ${s.budgetDay > 0 ? usd(s.budgetDay) : '없음'}`, `알림 ${onOff(s.notify)}`, ...(s.onWaiting ? ['대기 명령 있음'] : []), `디버그 ${onOff(s.debug)}`].join(' · ');
111
+ }