tabby-agentdeck 1.1.3 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -1
- package/README.ko.md +26 -574
- package/README.md +25 -545
- package/dist/agents.d.ts +15 -0
- package/dist/codexHooks.d.ts +23 -0
- package/dist/codexMeta.d.ts +87 -0
- package/dist/config.d.ts +120 -2
- package/dist/cursorVisibility.d.ts +33 -1
- package/dist/deck.service.d.ts +182 -19
- package/dist/devReload.service.d.ts +48 -0
- package/dist/devReload.stub.d.ts +19 -0
- package/dist/dock.d.ts +15 -0
- package/dist/i18n/af.d.ts +83 -0
- package/dist/i18n/bg.d.ts +83 -0
- package/dist/i18n/cs.d.ts +83 -0
- package/dist/i18n/da.d.ts +83 -0
- package/dist/i18n/de.d.ts +83 -0
- package/dist/i18n/en.d.ts +83 -0
- package/dist/i18n/es.d.ts +83 -0
- package/dist/i18n/fr.d.ts +83 -0
- package/dist/i18n/hr.d.ts +83 -0
- package/dist/i18n/id.d.ts +83 -0
- package/dist/i18n/it.d.ts +83 -0
- package/dist/i18n/ja.d.ts +83 -0
- package/dist/i18n/ko.d.ts +83 -0
- package/dist/i18n/pl.d.ts +83 -0
- package/dist/i18n/pt.d.ts +83 -0
- package/dist/i18n/ru.d.ts +83 -0
- package/dist/i18n/sr.d.ts +83 -0
- package/dist/i18n/sv.d.ts +83 -0
- package/dist/i18n/tr.d.ts +83 -0
- package/dist/i18n/uk.d.ts +83 -0
- package/dist/i18n/zh-CN.d.ts +83 -0
- package/dist/i18n/zh-TW.d.ts +83 -0
- package/dist/i18n.d.ts +3618 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -2
- package/dist/keybind.d.ts +121 -0
- package/dist/meta.d.ts +83 -0
- package/dist/nav.d.ts +29 -0
- package/dist/notify.service.d.ts +149 -1
- package/dist/project-root.d.ts +1 -1
- package/dist/reload.service.d.ts +57 -0
- package/dist/sessionLedger.d.ts +11 -6
- package/dist/settings.component.d.ts +133 -8
- package/dist/status.service.d.ts +30 -2
- package/dist/statusLine.d.ts +27 -0
- package/dist/update.d.ts +19 -5
- package/dist/update.service.d.ts +29 -20
- package/dist/viewPanel.d.ts +13 -3
- package/hooks/agentdeck-codex-notify.ps1 +5 -2
- package/hooks/agentdeck-label.js +1 -1
- package/hooks/agentdeck-notify.ps1 +32 -12
- package/hooks/agentdeck-statusline.mjs +351 -0
- package/package.json +4 -2
- package/updater/agentdeck-update.ps1 +50 -27
- package/dist/index.js.map +0 -1
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 설정 창 **단축키** 절의 계산부 — 키 이벤트를 Tabby 식 문자열로 바꾸고, 겹치는 핫키를 찾고,
|
|
3
|
+
* 표에 써넣을 내용을 만든다.
|
|
4
|
+
*
|
|
5
|
+
* 왜 따로 있나 — README 첫 화면에 올린 키들은 사람이 바꾸고 싶어지는 것들이고, Tabby 순정
|
|
6
|
+
* `단축키` 탭은 항목이 200개 가까워 `agentdeck-*` 를 찾는 것부터 일이다. 그리고 그 탭은
|
|
7
|
+
* **겹침을 말해 주지 않는다** — 같은 키가 두 id 에 매이면 HotkeysService 가 하나만 고르는데
|
|
8
|
+
* 어느 쪽인지 화면에는 안 보인다(2026-09-17 유저 지시: "겹치면 ~~랑 겹칩니다 하고 알려주고
|
|
9
|
+
* 바꾸게").
|
|
10
|
+
*
|
|
11
|
+
* **Tabby 의 규칙을 그대로 따른다** (`tabby-core/src/services/hotkeys.util.ts`):
|
|
12
|
+
* - 키 이름: 수식키는 `Ctrl` · 메타(`Win`/`⌘`/`Super`) · 알트(`Alt`/`⌥`) · `Shift`.
|
|
13
|
+
* 라틴 글자는 `event.key` 대문자(드보락 대응), 나머지는 `event.code` 에서 `Key`·`Arrow`·`Digit`
|
|
14
|
+
* 접두를 떼고 구두점만 표로 바꾼다.
|
|
15
|
+
* - 한 번 누름(keystroke) = 수식키를 `Ctrl, 메타, 알트, Shift` 순으로 앞에 세우고 `-` 로 잇는다.
|
|
16
|
+
* - 매칭은 문자열 **대소문자 무시 비교**다. 설정 파일에는 `Ctrl-Shift-Alt-1` 처럼 순서가 다른
|
|
17
|
+
* 값도 있어서, 겹침 판정은 양쪽을 **정규 순서로 되돌린 뒤** 비교한다 — 그래야 사람이 보기에
|
|
18
|
+
* 같은 키를 "다르다" 고 놓치지 않는다.
|
|
19
|
+
*
|
|
20
|
+
* **순수 모듈이다** — DOM·Angular·fs 를 건드리지 않는다(`i18n.ts`·`nav.ts` 와 같은 규칙).
|
|
21
|
+
* 화면에 붙이는 일은 `settings.component.ts`, 검증은 `test/keybind.test.js`.
|
|
22
|
+
*/
|
|
23
|
+
export type Platform = 'win32' | 'darwin' | 'linux';
|
|
24
|
+
/** 메타·알트 키의 표기 — Tabby 와 같다 (`hotkeys.util.ts` 의 `metaKeyName`·`altKeyName`) */
|
|
25
|
+
export declare const META_NAME: Record<Platform, string>;
|
|
26
|
+
export declare const ALT_NAME: Record<Platform, string>;
|
|
27
|
+
/** KeyboardEvent 에서 우리가 보는 것만 — 테스트가 진짜 이벤트 없이 만들 수 있게 */
|
|
28
|
+
export interface KeyEventLike {
|
|
29
|
+
key: string;
|
|
30
|
+
code: string;
|
|
31
|
+
ctrlKey?: boolean;
|
|
32
|
+
altKey?: boolean;
|
|
33
|
+
shiftKey?: boolean;
|
|
34
|
+
metaKey?: boolean;
|
|
35
|
+
}
|
|
36
|
+
/** 수식키가 아닌 키 하나의 이름 — Tabby `getKeyName` 의 비수식 갈래 */
|
|
37
|
+
export declare function keyNameOf(e: KeyEventLike): string;
|
|
38
|
+
/**
|
|
39
|
+
* 키 이벤트 하나를 Tabby 식 keystroke 로. **수식키만 눌렸으면 `null`** — 그건 아직 입력 중이다.
|
|
40
|
+
*/
|
|
41
|
+
export declare function keystrokeFromEvent(e: KeyEventLike, platform: Platform): string | null;
|
|
42
|
+
/**
|
|
43
|
+
* keystroke 문자열을 `{ mods, key }` 로 가른다.
|
|
44
|
+
*
|
|
45
|
+
* 함정 — 키 자체가 `-` 인 경우(`Ctrl--`). `-` 로 잇는 규칙이라 그냥 split 하면 빈 조각이 남는다.
|
|
46
|
+
* 끝이 `--` 면 키는 `-` 다.
|
|
47
|
+
*/
|
|
48
|
+
export declare function parseStroke(stroke: string): {
|
|
49
|
+
mods: string[];
|
|
50
|
+
key: string;
|
|
51
|
+
};
|
|
52
|
+
/** 수식키를 Tabby 의 고정 순서(Ctrl · 메타 · 알트 · Shift)로 세우고 나머지는 그대로 */
|
|
53
|
+
export declare function normalizeStroke(stroke: string, platform: Platform): string;
|
|
54
|
+
/** Tabby 와 같은 판정 — 정규 순서로 맞춘 뒤 대소문자 무시 */
|
|
55
|
+
export declare function strokesEqual(a: string, b: string, platform: Platform): boolean;
|
|
56
|
+
/** 사람이 읽는 표기 — `Ctrl-Shift-T` → `Ctrl+Shift+T` (키가 `-` 여도 안 깨진다) */
|
|
57
|
+
export declare function displayStroke(stroke: string, platform: Platform): string;
|
|
58
|
+
/**
|
|
59
|
+
* 설정값 하나를 **누름 열의 목록**으로. Tabby `getHotkeysConfigRecursive` 와 같다 —
|
|
60
|
+
* 문자열 하나 = 한 번 누름 한 열, 배열 항목이 문자열이면 한 번 누름, 배열이면 연속 누름.
|
|
61
|
+
*/
|
|
62
|
+
export declare function sequencesOf(value: unknown): string[][];
|
|
63
|
+
/**
|
|
64
|
+
* 이 키를 **한 번 누름으로** 쓰는 다른 id 들. `exceptIds` 는 자기 자신(같은 항목의 형제 id 포함).
|
|
65
|
+
*
|
|
66
|
+
* 두 번 이상 누르는 열의 마지막이 같은 경우는 겹침으로 치지 않는다 — Tabby 는 더 긴 열을
|
|
67
|
+
* 우선하므로 둘 다 살고, 그건 사용자가 의도한 조합이다.
|
|
68
|
+
*/
|
|
69
|
+
export declare function findConflicts(stroke: string, table: Record<string, unknown>, exceptIds: string[], platform: Platform): string[];
|
|
70
|
+
/** 설정 창에 늘어놓는 항목 — README 첫 화면의 단축키 표와 같은 순서 */
|
|
71
|
+
export interface KeyItem {
|
|
72
|
+
/** 핫키 id. `jump` 항목은 가상 id 라 실제로는 `agentdeck-jump-1…9` 에 쓴다 */
|
|
73
|
+
id: string;
|
|
74
|
+
/** 문구 키 (`i18n`) */
|
|
75
|
+
label: string;
|
|
76
|
+
/** Tabby 순정 id 인가 — 배지로 표시한다 (우리 처리자가 아니라 순정 처리자가 받는다) */
|
|
77
|
+
stock?: boolean;
|
|
78
|
+
/** N 번째 세션 묶음 — 숫자 자리만 바꿔 아홉 개에 같이 쓴다 */
|
|
79
|
+
jump?: boolean;
|
|
80
|
+
}
|
|
81
|
+
export declare const JUMP_ITEM_ID = "agentdeck-jump";
|
|
82
|
+
export declare const KEY_ITEMS: KeyItem[];
|
|
83
|
+
/** `agentdeck-jump-1` … `agentdeck-jump-9` */
|
|
84
|
+
export declare function jumpIds(): string[];
|
|
85
|
+
/** 이 항목이 실제로 쓰는 id 들 */
|
|
86
|
+
export declare function targetIds(item: KeyItem): string[];
|
|
87
|
+
/**
|
|
88
|
+
* 누름이 숫자(1~9)로 끝나면 `{ prefix, digit }`. `Ctrl-3` → `Ctrl-`·3, 맨 `3` → ``·3.
|
|
89
|
+
* 숫자로 안 끝나면 `null` — N 번째 세션 키는 숫자가 곧 번호라 다른 키는 받을 수 없다.
|
|
90
|
+
*/
|
|
91
|
+
export declare function splitDigit(stroke: string): {
|
|
92
|
+
prefix: string;
|
|
93
|
+
digit: number;
|
|
94
|
+
} | null;
|
|
95
|
+
/** 지금 설정된 키를 사람이 읽는 표기로. `jump` 는 `Ctrl+1 … Ctrl+9` 한 줄로 접는다 */
|
|
96
|
+
export declare function displayStrokes(table: Record<string, unknown>, item: KeyItem, platform: Platform): string[];
|
|
97
|
+
/** 바꾸기 계획 — 무엇을 어디에 쓰고, 누구와 겹치는가 */
|
|
98
|
+
export interface BindingPlan {
|
|
99
|
+
item: KeyItem;
|
|
100
|
+
/** id → 새로 쓸 누름 (jump 는 아홉 개) */
|
|
101
|
+
writes: Record<string, string>;
|
|
102
|
+
/** 겹치는 다른 id (중복 없이) */
|
|
103
|
+
conflicts: string[];
|
|
104
|
+
/** 사용자가 누른 것의 표기 */
|
|
105
|
+
display: string;
|
|
106
|
+
/** jump 항목에 숫자로 안 끝나는 키를 눌렀다 */
|
|
107
|
+
error?: 'digit';
|
|
108
|
+
}
|
|
109
|
+
export declare function planBinding(table: Record<string, unknown>, item: KeyItem, stroke: string, platform: Platform): BindingPlan;
|
|
110
|
+
/**
|
|
111
|
+
* 계획을 표에 쓴다. `force` 면 겹치는 id 에서 **그 누름만** 뗀다(다른 바인딩은 남긴다) —
|
|
112
|
+
* 기동 때 순정 `new-tab`·`rename-tab` 에서 키를 떼는 것과 같은 방식이고, 그래야 한 번 눌러
|
|
113
|
+
* 둘이 도는 상태가 생기지 않는다. `force` 가 아니면 겹침이 있어도 겹치는 쪽은 건드리지 않는다.
|
|
114
|
+
*
|
|
115
|
+
* 반환 = 실제로 바뀐 id 목록 (진단 로그용).
|
|
116
|
+
*/
|
|
117
|
+
export declare function applyBinding(table: Record<string, unknown>, plan: BindingPlan, force: boolean, platform: Platform): string[];
|
|
118
|
+
/** 항목을 기본값으로 — `defaults` 는 id → 기본 바인딩 표 (없는 id 는 비운다) */
|
|
119
|
+
export declare function resetBinding(table: Record<string, unknown>, item: KeyItem, defaults: Record<string, unknown>): void;
|
|
120
|
+
/** 지금 값이 기본값과 같은가 (버튼 잠금용) */
|
|
121
|
+
export declare function isDefaultBinding(table: Record<string, unknown>, item: KeyItem, defaults: Record<string, unknown>, platform: Platform): boolean;
|
package/dist/meta.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 사이드바 하단 "지금 이 탭" 줄의 **문구 규칙**.
|
|
3
|
+
*
|
|
4
|
+
* 왜 따로 빼나 — 이 줄은 200px 폭에 모델·effort·계정·컨텍스트%·한도 2종을 넣어야 해서
|
|
5
|
+
* 줄이는 규칙(어디를 먼저 버리나)이 곧 화면의 전부다. `deck.service` 안에 두면 DOM 없이는
|
|
6
|
+
* 검증할 수 없고, 그 파일은 이미 5천 줄이 넘는다. 여기는 **순수 모듈이다** — DOM·fs·tabby 를
|
|
7
|
+
* 건드리지 않으므로 노드에서 바로 돌려볼 수 있다 (`agents.ts` 와 같은 규칙).
|
|
8
|
+
*/
|
|
9
|
+
/** `notify.service` 의 AgentMeta 중 이 모듈이 쓰는 부분만 (역참조를 만들지 않으려고 구조만 맞춘다) */
|
|
10
|
+
export interface MetaInput {
|
|
11
|
+
agent?: string;
|
|
12
|
+
model?: string;
|
|
13
|
+
modelId?: string;
|
|
14
|
+
effort?: string;
|
|
15
|
+
version?: string;
|
|
16
|
+
fastMode?: boolean;
|
|
17
|
+
account?: string;
|
|
18
|
+
org?: string;
|
|
19
|
+
cwd?: string;
|
|
20
|
+
contextPct?: number | null;
|
|
21
|
+
limits?: {
|
|
22
|
+
fiveHourPct?: number | null;
|
|
23
|
+
fiveHourResetsAt?: number;
|
|
24
|
+
sevenDayPct?: number | null;
|
|
25
|
+
sevenDayResetsAt?: number;
|
|
26
|
+
/**
|
|
27
|
+
* 모델별 주간 한도 (Fable 등). statusLine stdin 에는 **안 내려온다** —
|
|
28
|
+
* 래퍼가 `/api/oauth/usage` 를 따로 물어봐 채운다 (hooks/agentdeck-statusline.mjs).
|
|
29
|
+
* 이름은 계정마다 다를 수 있으므로 화면에 쓸 표시명도 같이 받는다.
|
|
30
|
+
*/
|
|
31
|
+
scopedName?: string;
|
|
32
|
+
scopedPct?: number | null;
|
|
33
|
+
scopedResetsAt?: number;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** 한도 한 칸 — 화면에 그릴 글자와 "얼마나 급한가" */
|
|
37
|
+
export interface MetaGauge {
|
|
38
|
+
/** `5h` / `7d` / `ctx` */
|
|
39
|
+
key: string;
|
|
40
|
+
text: string;
|
|
41
|
+
pct: number;
|
|
42
|
+
/** ok / warn(60%+) / hot(85%+) — 색만 가른다 */
|
|
43
|
+
level: 'ok' | 'warn' | 'hot';
|
|
44
|
+
/** 리셋까지 남은 시간을 말로 (없으면 빈 문자열) */
|
|
45
|
+
resetText: string;
|
|
46
|
+
/**
|
|
47
|
+
* 막대 **아래 줄**에 그대로 찍는 글자 — `4시간 13분 뒤`. 마우스를 올리지 않아도 보여야 하는데
|
|
48
|
+
* (2026-09-14 유저 요청), 막대 뒤에 붙이면 막대가 그만큼 짧아지고 글자도 작아진다.
|
|
49
|
+
* `resetText` 에서 `리셋` 만 뺀 것이라 tooltip 과 값이 어긋날 수 없다.
|
|
50
|
+
*/
|
|
51
|
+
resetLine: string;
|
|
52
|
+
}
|
|
53
|
+
export interface MetaView {
|
|
54
|
+
/** 첫 줄 — `Opus 5 · high`. 비면 줄 자체를 그리지 않는다 */
|
|
55
|
+
title: string;
|
|
56
|
+
/** 둘째 줄 — 계정. 없으면 빈 문자열 */
|
|
57
|
+
account: string;
|
|
58
|
+
gauges: MetaGauge[];
|
|
59
|
+
/** 마우스를 올렸을 때 나올 전문 */
|
|
60
|
+
tooltip: string;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 리셋까지 남은 시간. `resets_at` 은 **초** 단위 epoch 다 (statusLine stdin 실측).
|
|
64
|
+
*
|
|
65
|
+
* 절대 시각(`12시`) 대신 남은 시간으로 쓰는 이유 — 이 줄은 "지금 얼마나 여유가 있나" 를
|
|
66
|
+
* 보는 자리다. 시각을 보려면 머릿속에서 한 번 빼야 한다.
|
|
67
|
+
*/
|
|
68
|
+
export declare function formatReset(resetsAt: number, now: number): string;
|
|
69
|
+
/**
|
|
70
|
+
* 막대 아래 줄에 쓰는 꼴 — `4시간 13분 뒤`. 없으면 빈 문자열.
|
|
71
|
+
*
|
|
72
|
+
* `formatReset` 에서 끝의 `리셋` 만 뗀다. 같은 규칙을 두 번 적지 않으려는 것이다 —
|
|
73
|
+
* 두 벌이면 한쪽만 고쳐 놓고 화면과 tooltip 이 다른 시각을 말하는 날이 온다.
|
|
74
|
+
*/
|
|
75
|
+
export declare function formatResetLine(resetsAt: number, now: number): string;
|
|
76
|
+
/**
|
|
77
|
+
* 보고 하나를 화면에 그릴 것으로 바꾼다.
|
|
78
|
+
*
|
|
79
|
+
* **없는 값은 지어내지 않는다** — 모델을 모르면 빈 제목을 내고, 부르는 쪽이 줄을 통째로
|
|
80
|
+
* 접는다. "알 수 없음" 을 그려 두면 그 줄이 항상 자리를 차지해서 "래퍼가 안 걸렸다" 와
|
|
81
|
+
* "래퍼는 걸렸는데 값이 안 온다" 가 화면에서 같아 보인다.
|
|
82
|
+
*/
|
|
83
|
+
export declare function formatMeta(meta: MetaInput | null | undefined, now: number): MetaView | null;
|
package/dist/nav.d.ts
CHANGED
|
@@ -21,6 +21,19 @@
|
|
|
21
21
|
* 탭이 하나 닫히거나 검색이 걸리면 같은 인덱스가 **다른 줄**을 가리키게 되고
|
|
22
22
|
* (`dropTargetAt` 이 드래그 중에 겪은 것과 같은 함정), 그 사이 사람은 다른 세션을 열게 된다.
|
|
23
23
|
*/
|
|
24
|
+
/**
|
|
25
|
+
* `Ctrl-1` … `Ctrl-N` 으로 바로 갈 수 있는 줄 수.
|
|
26
|
+
*
|
|
27
|
+
* 선언(`hotkeys.ts`)·기본 묶기(`config.ts`)·처리(`deck.service.ts`) 세 곳이 같은 값을 봐야 해서
|
|
28
|
+
* 한 곳에 둔다 — 한쪽만 늘리면 **묶이지 않은 핫키**나 **처리되지 않는 선언**이 생기고 둘 다 조용하다
|
|
29
|
+
* (세 곳의 일치는 `test/hotkeys.test.js` 가 소스로 대조한다).
|
|
30
|
+
*
|
|
31
|
+
* **이 파일에 두는 이유는 순환 의존이다.** `hotkeys.ts` 에 두면 `deck.service` 가 그것을 가져오면서
|
|
32
|
+
* deck → hotkeys → devReload.service → reload.service → deck 고리가 생긴다. 이 모듈은 import 가
|
|
33
|
+
* 하나도 없어서(Angular·Tabby·DOM 어느 것도 안 쓴다) 누가 가져가도 고리가 안 생기고, 이 값을
|
|
34
|
+
* 쓰는 규칙(`pickJumpTarget`)도 바로 아래 있다.
|
|
35
|
+
*/
|
|
36
|
+
export declare const JUMP_SLOTS = 9;
|
|
24
37
|
export type NavRow<T> = {
|
|
25
38
|
kind: 'head';
|
|
26
39
|
key: string | null;
|
|
@@ -90,3 +103,19 @@ export declare function pickCloseTarget<T>(opts: {
|
|
|
90
103
|
tabs: readonly T[];
|
|
91
104
|
}): T | null;
|
|
92
105
|
export declare function stepNavIndex(count: number, index: number, step: number, wrap: boolean): number;
|
|
106
|
+
/**
|
|
107
|
+
* `Ctrl-N` 이 고를 탭 — **보이는 줄 중 탭 줄만 세어** N 번째(1부터).
|
|
108
|
+
*
|
|
109
|
+
* 헤더를 빼고 세는 이유: 사람이 "위에서 세 번째 세션" 이라고 할 때 그룹 제목은 세지 않는다.
|
|
110
|
+
* 헤더를 세면 그룹이 하나 생기거나 접히는 것만으로 같은 키가 다른 세션을 연다 — 그룹 구성은
|
|
111
|
+
* 폴더가 바뀔 때마다 저절로 움직이는 값이라(`group.ts`) 사람 머릿속의 번호와 곧 어긋난다.
|
|
112
|
+
*
|
|
113
|
+
* **접힌 그룹 안의 탭은 애초에 `rows` 에 없다**(`navRowsOf`). 화면에 안 보이는 줄에 번호를
|
|
114
|
+
* 주면 그 번호로 간 사람은 자기가 어디로 왔는지 알 수 없다 — 이 목록이 "보이는 순서" 라는
|
|
115
|
+
* 계약(`navRowsOf` 주석)을 그대로 따른다.
|
|
116
|
+
*
|
|
117
|
+
* 범위 밖(줄이 모자람 · slot 이 0 이하거나 정수가 아님)은 `null` 이고, 호출부는 **아무 일도
|
|
118
|
+
* 하지 않는다** — 없는 자리를 눌렀을 때 가장 가까운 탭으로 보내면, 목록이 줄어든 줄 모르고
|
|
119
|
+
* 누른 사람이 엉뚱한 세션에 입력하게 된다.
|
|
120
|
+
*/
|
|
121
|
+
export declare function pickJumpTarget<T>(rows: ReadonlyArray<NavRow<T>>, slot: number): T | null;
|
package/dist/notify.service.d.ts
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
import { AppService, BaseTabComponent, ConfigService, PlatformService } from 'tabby-core';
|
|
2
2
|
import { WorkStatusService } from './status.service';
|
|
3
|
+
import { AgentId } from './agents';
|
|
3
4
|
import { SubagentCall } from './subagents';
|
|
5
|
+
/**
|
|
6
|
+
* statusLine 래퍼(`hooks/agentdeck-statusline.mjs`)가 보내는 스냅샷.
|
|
7
|
+
*
|
|
8
|
+
* **훅(NotifyFile)과 원천이 다르다.** 여기 있는 값(모델·effort·컨텍스트%·5h/7d 한도%)은
|
|
9
|
+
* 훅 JSON 에 아예 없고 statusLine stdin 에만 내려온다. 그래서 전달 통로도 폴더도 따로 둔다.
|
|
10
|
+
* 계정만 예외로 래퍼가 설정 파일에서 읽어 함께 싣는다 (stdin 에 없다).
|
|
11
|
+
*/
|
|
12
|
+
export interface AgentMeta {
|
|
13
|
+
sessionId: string;
|
|
14
|
+
/** 래퍼가 쓴 시각 (epoch ms) — 같은 값을 두 번 반영하지 않기 위한 판별자 */
|
|
15
|
+
ts: number;
|
|
16
|
+
agent: string;
|
|
17
|
+
/** 사람이 읽는 이름 (`Opus 5`). 없으면 modelId 로 떨어진다 */
|
|
18
|
+
model: string;
|
|
19
|
+
modelId: string;
|
|
20
|
+
/** `high` / `medium` / … — 없을 수 있다 */
|
|
21
|
+
effort: string;
|
|
22
|
+
version: string;
|
|
23
|
+
fastMode: boolean;
|
|
24
|
+
/** 로그인 계정 메일. 래퍼가 `CLAUDE_CONFIG_DIR` 을 먼저 보므로 탭마다 갈라 써도 맞는다 */
|
|
25
|
+
account: string;
|
|
26
|
+
org: string;
|
|
27
|
+
configDir: string;
|
|
28
|
+
cwd: string;
|
|
29
|
+
/** 컨텍스트 사용률(%) — 모르면 null */
|
|
30
|
+
contextPct: number | null;
|
|
31
|
+
limits: {
|
|
32
|
+
fiveHourPct: number | null;
|
|
33
|
+
fiveHourResetsAt: number;
|
|
34
|
+
sevenDayPct: number | null;
|
|
35
|
+
sevenDayResetsAt: number;
|
|
36
|
+
/**
|
|
37
|
+
* 모델별 주간 한도 (Fable 등) — Claude 래퍼만 채운다. statusLine stdin 에는 없어서
|
|
38
|
+
* 래퍼가 `/api/oauth/usage` 를 따로 물어본 값이고, 없으면 칸이 안 생긴다 (meta.ts)
|
|
39
|
+
*/
|
|
40
|
+
scopedName?: string;
|
|
41
|
+
scopedPct?: number | null;
|
|
42
|
+
scopedResetsAt?: number;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
4
45
|
/** 사이드바(`deck.service` renderTab)에 넘기는 결론 */
|
|
5
46
|
export interface SubagentView {
|
|
6
47
|
sessionId: string;
|
|
@@ -49,6 +90,8 @@ export declare class WorkNotifyService {
|
|
|
49
90
|
private platform;
|
|
50
91
|
private root;
|
|
51
92
|
private dir;
|
|
93
|
+
/** statusLine 래퍼(`hooks/agentdeck-statusline.mjs`)가 모델·계정·한도를 남기는 곳 */
|
|
94
|
+
private metaDir;
|
|
52
95
|
/** 훅이 접속할 포트를 알려주는 파일 */
|
|
53
96
|
private portFile;
|
|
54
97
|
private server;
|
|
@@ -81,11 +124,20 @@ export declare class WorkNotifyService {
|
|
|
81
124
|
* 사라지면 같이 사라진다 — 탭 닫힘을 따로 청소할 필요가 없다.
|
|
82
125
|
*/
|
|
83
126
|
private hookCwd;
|
|
127
|
+
/**
|
|
128
|
+
* 탭(루트) -> 훅이 말해 준 CLI (`claude` / `codex` / `gemini`).
|
|
129
|
+
*
|
|
130
|
+
* 이게 있으면 deck.service 는 프로세스·제목·화면으로 추측하지 않는다(`NotifyFile.agent` 주석).
|
|
131
|
+
* 같은 탭에서 다른 CLI 를 띄우면 그쪽 훅의 첫 보고가 값을 덮는다 — 훅이 없는 CLI 로
|
|
132
|
+
* 갈아타면 옛 값이 남지만, 그건 추측 경로로 떨어지는 것보다 낫다(적어도 한 번은 사실이었다).
|
|
133
|
+
*/
|
|
134
|
+
private hookAgent;
|
|
135
|
+
private rejectedMetaSession;
|
|
84
136
|
/**
|
|
85
137
|
* 탭(루트) -> 이번 세션에서 에이전트가 고친 파일들 (훅이 보고한 절대경로, 최신이 앞).
|
|
86
138
|
*
|
|
87
139
|
* `변경` 탭이 "저장소 전체" 대신 "이 세션이 만진 것" 을 그리는 근거다 — P4 워크스페이스 위에
|
|
88
|
-
* git 을 얹은 저장소에서는 전체 diff 가
|
|
140
|
+
* git 을 얹은 저장소에서는 전체 diff 가 수백 개씩 나와 방금 고친 것을 찾을 수 없다
|
|
89
141
|
* (2026-09-11 유저 지적). 훅이 없는 에이전트는 이 목록이 비고, 그때는 전체를 그린다.
|
|
90
142
|
*
|
|
91
143
|
* 상한을 두는 이유 — 오래 도는 세션에서 무한히 자란다. 넘치면 오래된 것부터 버린다.
|
|
@@ -110,6 +162,24 @@ export declare class WorkNotifyService {
|
|
|
110
162
|
/** tabAdded$ 를 이미 구독한 분할 루트 — 루트가 사라지면 함께 사라진다 */
|
|
111
163
|
private watchedSplits;
|
|
112
164
|
private pollCount;
|
|
165
|
+
/**
|
|
166
|
+
* 세션 id -> statusLine 래퍼가 보고한 모델·계정·한도.
|
|
167
|
+
*
|
|
168
|
+
* **탭이 아니라 세션을 키로 둔다** (`scans` 와 같은 이유) — 같은 탭에서 claude 를 다시 띄우면
|
|
169
|
+
* 세션 id 가 새로 생기고, 그때 옛 값이 따라붙으면 안 된다. 탭 ↔ 세션 매칭은 훅이 확정한
|
|
170
|
+
* `tabSession` 하나만 쓴다 (활성 탭 추측은 이 저장소가 이미 한 번 크게 데었다).
|
|
171
|
+
*/
|
|
172
|
+
private metaBySession;
|
|
173
|
+
/**
|
|
174
|
+
* 세션 id -> Codex 세션 기록 증분 스캔. Codex 에는 statusLine 이 없어 기록에서 직접 뽑는다
|
|
175
|
+
* (`scanCodexMeta`). 키를 세션으로 두는 이유는 `scans` 와 같다.
|
|
176
|
+
*/
|
|
177
|
+
private codexScans;
|
|
178
|
+
/** `auth.json` 에서 푼 Codex 계정 — 파일이 바뀔 때만 다시 읽는다 */
|
|
179
|
+
private codexAuth;
|
|
180
|
+
private codexHome;
|
|
181
|
+
/** 모델·한도 값이 바뀌면 부른다 (사이드바 다시 그리기). deck.service 가 꽂는다 */
|
|
182
|
+
onMetaChange: (() => void) | null;
|
|
113
183
|
/** 같은 매칭 실패를 폴링마다 diag 에 찍지 않기 위한 마지막 메시지 */
|
|
114
184
|
private lastBindLog;
|
|
115
185
|
/**
|
|
@@ -251,6 +321,20 @@ export declare class WorkNotifyService {
|
|
|
251
321
|
* 집어 오는 실사고가 있었다(NotifyFile.cwd 주석).
|
|
252
322
|
*/
|
|
253
323
|
cwdOf(tab: BaseTabComponent | null | undefined): string | null;
|
|
324
|
+
/**
|
|
325
|
+
* 이 탭에서 도는 CLI — **훅이 직접 말한 것**. 훅이 없는 탭(gemini·맨 셸)은 null.
|
|
326
|
+
*
|
|
327
|
+
* null 은 "에이전트가 없다" 가 아니라 **"훅이 없어서 모른다"** 다. 그때만 추측 경로
|
|
328
|
+
* (프로세스·명령줄·제목·화면)가 돈다 — 이 값이 있으면 그쪽은 아예 부르지 않는다.
|
|
329
|
+
*/
|
|
330
|
+
agentOf(tab: BaseTabComponent | null | undefined): AgentId | null;
|
|
331
|
+
/**
|
|
332
|
+
* 훅이 말한 정체를 버린다 — 그 에이전트가 끝났다는 증거를 deck.service 가 찾았을 때
|
|
333
|
+
* (프로세스 트리에 **다른 에이전트가 확실히** 떴다, `detectAgentApp`).
|
|
334
|
+
*
|
|
335
|
+
* 훅에는 "세션이 끝났다" 가 없어서 이 값은 스스로 낡는다. 다음 훅 보고가 오면 다시 채워진다.
|
|
336
|
+
*/
|
|
337
|
+
forgetAgent(tab: BaseTabComponent | null | undefined): void;
|
|
254
338
|
/**
|
|
255
339
|
* 이번 세션에서 에이전트가 고친 파일들 (절대경로, 최신 순). 훅이 없는 탭은 빈 배열.
|
|
256
340
|
*
|
|
@@ -259,6 +343,70 @@ export declare class WorkNotifyService {
|
|
|
259
343
|
* 목록을 좁히지 않고 저장소 전체를 그린다(빈 화면은 거짓말이 된다).
|
|
260
344
|
*/
|
|
261
345
|
touchedOf(tab: BaseTabComponent | null | undefined): string[];
|
|
346
|
+
/**
|
|
347
|
+
* 이 탭에서 도는 에이전트의 **지금 모델·계정·한도**. statusLine 래퍼가 보고한 것 그대로.
|
|
348
|
+
* 래퍼가 안 걸렸거나 세션이 탭에 안 묶였으면 null (사이드바는 그 줄을 아예 안 그린다).
|
|
349
|
+
*
|
|
350
|
+
* 여기서 값을 꾸미지 않는다 — 화면 문구는 부르는 쪽(deck.service)이 만든다.
|
|
351
|
+
* 이 서비스는 "누가 무엇을 보고했나" 만 들고 있는다.
|
|
352
|
+
*/
|
|
353
|
+
metaOf(tab: BaseTabComponent | null | undefined): AgentMeta | null;
|
|
354
|
+
/**
|
|
355
|
+
* statusLine 래퍼가 남긴 스냅샷을 읽는다 (`%LOCALAPPDATA%\tabby-agentdeck\meta`).
|
|
356
|
+
*
|
|
357
|
+
* 상태 보고(`status/`)와 폴더를 나눈 이유 — 그쪽 폴링은 `apply()` 의 병합·매칭 규칙을
|
|
358
|
+
* 타는데, 이건 상태를 바꾸지 않는 **부가 정보**라 같은 통로에 태우면 그 규칙(중복 ts 래치,
|
|
359
|
+
* 미매칭 보고 처리)을 둘 다 헝클어뜨린다.
|
|
360
|
+
*
|
|
361
|
+
* 탭에 안 묶인 세션의 보고도 **버리지 않고 들고 있는다** — statusLine 은 훅보다 먼저 돌 수
|
|
362
|
+
* 있어서(첫 렌더가 첫 프롬프트보다 이르다), 그때 버리면 세션이 묶인 뒤 첫 갱신까지
|
|
363
|
+
* 줄이 비어 보인다.
|
|
364
|
+
*/
|
|
365
|
+
private pollMeta;
|
|
366
|
+
/**
|
|
367
|
+
* Codex 탭의 모델·effort·컨텍스트%·한도 — **세션 기록(rollout jsonl)에서 직접 뽑는다.**
|
|
368
|
+
*
|
|
369
|
+
* Claude 쪽(`pollMeta`)과 원천이 다른 이유는 `codexMeta.ts` 머리주석에 있다: Codex 에는
|
|
370
|
+
* statusLine 이 없고 대신 같은 값을 자기 기록에 남긴다.
|
|
371
|
+
*
|
|
372
|
+
* **훅이 묶어 준 세션만 본다.** 탭↔세션을 화면·폴더로 추측하지 않는다 — 이 저장소는 그 추측으로
|
|
373
|
+
* 남의 탭에 남의 상태를 박은 사고를 이미 겪었다(`resolveTab` 주석). 그래서 Codex 훅을 설치하지
|
|
374
|
+
* 않으면 이 줄도 안 나온다(서브에이전트 개수와 같은 조건).
|
|
375
|
+
*
|
|
376
|
+
* 읽기는 대화기록 스캔과 같은 방식 — `stat` 으로 자란 만큼만, `StringDecoder` 로 UTF-8 경계를 잇는다.
|
|
377
|
+
*/
|
|
378
|
+
private scanCodexMeta;
|
|
379
|
+
/** 스캔 상태를 사이드바가 쓰는 모양으로 (`AgentMeta`) */
|
|
380
|
+
private codexMetaOf;
|
|
381
|
+
/**
|
|
382
|
+
* Codex 계정 메일. 파일이 바뀔 때만 다시 읽는다 — 2초마다 JWT 를 풀 이유가 없다.
|
|
383
|
+
* 토큰 자체는 들고 있지 않는다(메일만 남긴다).
|
|
384
|
+
*/
|
|
385
|
+
private codexAccount;
|
|
386
|
+
/**
|
|
387
|
+
* `~/.codex/sessions/<년>/<월>/<일>/rollout-<시각>-<세션id>.jsonl` 을 찾는다.
|
|
388
|
+
*
|
|
389
|
+
* 대화기록 찾기(`findTranscript`)와 같은 규칙 — **경로 규칙을 흉내내지 않고** 훑어서 이름으로 맞춘다.
|
|
390
|
+
* 날짜 폴더를 계산하면 시간대·자정 경계에서 어긋난다.
|
|
391
|
+
*/
|
|
392
|
+
private findCodexRollout;
|
|
393
|
+
/**
|
|
394
|
+
* 기억에서 지울 것 — 닫힌 탭에 묶여 있던 세션과, 한 번도 묶이지 않은 채 오래된 보고.
|
|
395
|
+
*
|
|
396
|
+
* `pruneScans` 처럼 "묶이지 않았으면 버린다" 로 하지 않는다 — 그러면 statusLine 이 훅보다
|
|
397
|
+
* 먼저 도는 정상 경우(첫 렌더 > 첫 프롬프트)에 첫 값이 매번 버려진다.
|
|
398
|
+
*/
|
|
399
|
+
private pruneMeta;
|
|
400
|
+
/**
|
|
401
|
+
* 메모리에서 버린 보고를 **디스크에서도 지운다.**
|
|
402
|
+
*
|
|
403
|
+
* 안 지우면 다음 폴링이 같은 파일을 다시 읽어 `metaBySession` 에 넣고(`ts` 비교에 안 걸린다)
|
|
404
|
+
* `onMetaChange` → 사이드바 전체 리렌더, 그리고 그 호출 끝의 `pruneMeta` 가 또 지운다 —
|
|
405
|
+
* **닫은 탭 하나마다 400ms 리렌더 루프가 하나씩 영구히 는다** (2026-09-15 코드리뷰 지적).
|
|
406
|
+
* `pollMeta` 의 unlink 는 **지난 실행이 남긴 파일**(`ts < startedAt`)만 치우므로
|
|
407
|
+
* 이번 실행 중에 생긴 것은 여기서 치워야 한다.
|
|
408
|
+
*/
|
|
409
|
+
private dropMetaFile;
|
|
262
410
|
/**
|
|
263
411
|
* 이 탭에서 도는 서브에이전트 — **사이드바가 쓰는 유일한 창구**. 없으면 null.
|
|
264
412
|
*
|
package/dist/project-root.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* cwd 가 속한 **프로젝트 루트**를 찾는다 — 사이드바 그룹 경계의 유일한 출처.
|
|
3
3
|
*
|
|
4
4
|
* 예전에는 사람이 설정(`groupRoot`)에 상위 폴더를 적어 두고 "그 직하 한 단계" 를 그룹으로
|
|
5
|
-
* 삼았다. 그 규칙은 프로젝트들이 한 폴더에 나란히 있을 때만 맞고, `D:/Project/
|
|
5
|
+
* 삼았다. 그 규칙은 프로젝트들이 한 폴더에 나란히 있을 때만 맞고, `D:/Project/demo/app` 과
|
|
6
6
|
* `D:/Project/tabby-agentdeck` 처럼 깊이가 다르면 어떤 값을 적어도 한쪽이 틀린다.
|
|
7
7
|
* 그래서 설정을 없애고 **저장소 루트를 직접 찾는다** — 사람이 프로젝트라고 부르는 경계는
|
|
8
8
|
* 사실상 버전관리의 경계이기 때문이다.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { AppService, NotificationsService } from 'tabby-core';
|
|
2
|
+
import { AgentDeckService } from './deck.service';
|
|
3
|
+
/**
|
|
4
|
+
* **세션을 살린 채 플러그인을 갈아끼운다** — 창(renderer)만 다시 띄우고 pty 는 그대로 둔다.
|
|
5
|
+
*
|
|
6
|
+
* Angular 모듈은 부트스트랩 후 교체할 수 없다(플러그인이 RootModule imports 에 박히고
|
|
7
|
+
* multi-provider 가 고정된다 — app `src/entry.ts`). 그래서 모듈을 바꾸는 대신 **창을
|
|
8
|
+
* 통째로 리로드**한다. pty 는 main 프로세스 `PTYManager` 소유라 renderer 가 새로 떠도 살아 있고,
|
|
9
|
+
* main 은 `ready` 를 `ipcMain.on` 으로 받아 매번 `start` 를 다시 보내므로 정상 부팅된다.
|
|
10
|
+
* 리로드하면 Tabby 가 `dist/index.js` 를 **디스크에서 다시 읽으므로** 새 코드가 뜬다.
|
|
11
|
+
*
|
|
12
|
+
* 탭은 Tabby 의 복구 토큰(`includeState: true` → `restoreFromPTYID` + `savedState`)으로 되살린다.
|
|
13
|
+
* 순정 `saveTabs` 는 `recoverTabs=false` 면 아무것도 안 하므로(사용자 설정) 토큰을 **직접** 만든다.
|
|
14
|
+
* 실측(2026-09-14): 리로드 전후 ptyID·셸 `$PID` 동일, 화면 원문 복원.
|
|
15
|
+
*
|
|
16
|
+
* 쓰는 곳은 둘이다 —
|
|
17
|
+
* - **자동 업데이트**(`update.service.ts`): 새 버전을 제자리에 깔고 이 리로드로 갈아탄다.
|
|
18
|
+
* 개발자 옵션과 무관하게 돈다.
|
|
19
|
+
* - **개발용 라이브 리로드**(`devReload.service.ts`): `npm run build` 를 감시해 같은 일을 한다.
|
|
20
|
+
* 이쪽은 개발 설치에서만 존재한다.
|
|
21
|
+
*/
|
|
22
|
+
export declare class AgentDeckReloadService {
|
|
23
|
+
private app;
|
|
24
|
+
private notifications;
|
|
25
|
+
private deck;
|
|
26
|
+
private reloading;
|
|
27
|
+
constructor(app: AppService, notifications: NotificationsService, deck: AgentDeckService);
|
|
28
|
+
init(): void;
|
|
29
|
+
/**
|
|
30
|
+
* 복구 토큰을 남기고 창을 리로드한다. **리로드를 실제로 예약했으면 `true`.**
|
|
31
|
+
*
|
|
32
|
+
* 성공/실패를 돌려주는 이유 — 자동 업데이트는 이 값으로 사용자에게 할 말을 가른다
|
|
33
|
+
* ("창을 새로 고쳐 갈아탄다" vs "다시 켜면 적용된다"). 예전처럼 예외를 삼키고 `void` 를
|
|
34
|
+
* 돌려주면 **그 갈림길이 도달 불가능한 코드**가 되어, 리로드가 안 일어났는데도 화면은
|
|
35
|
+
* 갈아탔다고 말한다 (2026-09-15 코드리뷰 지적).
|
|
36
|
+
*/
|
|
37
|
+
reload(reason: string): Promise<boolean>;
|
|
38
|
+
/** 리로드 직후 — 짐이 있으면 탭을 되살리고 세션을 하나씩 붙인다 */
|
|
39
|
+
private restore;
|
|
40
|
+
/** 토큰 안의 모든 pty 가 main 에 살아 있는가 (분할 탭은 children 을 따라간다) */
|
|
41
|
+
private ptysAlive;
|
|
42
|
+
/**
|
|
43
|
+
* 기동하면서 순정이 연 탭(Welcome, autoOpen 새 셸)은 치운다 — 되살린 탭만 남긴다.
|
|
44
|
+
* 순정 탭은 `ready$` 구독 순서에 따라 우리 복구보다 늦게 생길 수도 있어 몇 번 나눠 본다.
|
|
45
|
+
*
|
|
46
|
+
* **사람이 연 탭은 절대 닫지 않는다.** "3초 창이니 사람은 아직 못 열었다" 는 전제는 틀렸다 —
|
|
47
|
+
* 복구 루프는 탭 하나당 최대 4.25초(`ATTACH_TIMEOUT_MS`)를 기다리므로 스윕이 도는 동안
|
|
48
|
+
* 창은 이미 조작 가능한 상태다. 그 사이 사람이 연 탭을 경고 없이 닫았다
|
|
49
|
+
* (2026-09-15 코드리뷰 지적).
|
|
50
|
+
*
|
|
51
|
+
* 그래서 **후보를 시각으로 가른다** — 스윕을 걸 때 이미 있던 탭과, 그 뒤 짧은 유예
|
|
52
|
+
* (`STRAY_GRACE_MS`) 안에 나타난 탭까지만 순정이 연 것으로 본다. 유예가 지난 뒤 처음 보이는
|
|
53
|
+
* 탭은 사람 것이므로 건드리지 않는다.
|
|
54
|
+
*/
|
|
55
|
+
private sweepStrays;
|
|
56
|
+
private waitAttached;
|
|
57
|
+
}
|
package/dist/sessionLedger.d.ts
CHANGED
|
@@ -94,13 +94,18 @@ export declare function mergeSessions(ledger: readonly SessionRecord[], scanned:
|
|
|
94
94
|
export declare function pruneLedger(ledger: readonly SessionRecord[], aliveIds: ReadonlySet<string>): SessionRecord[];
|
|
95
95
|
export interface ResumeListOptions {
|
|
96
96
|
/**
|
|
97
|
-
* 이 줄 묶음이 **덮는 그룹 키들**.
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
97
|
+
* 이 줄 묶음이 **덮는 그룹 키들**. `null` 이면 **거르지 않는다** (전부).
|
|
98
|
+
*
|
|
99
|
+
* 서랍이 그룹마다 하나씩 달려 있던 시절의 값이다. 지금은 사이드바 바닥에 하나만 있고
|
|
100
|
+
* 거기에는 `null` 이 들어간다 (2026-09-14 유저: "각자 저장한거 전부 ... 찾을 수 있게").
|
|
101
|
+
* 그래도 인자를 남겨 두는 이유 — 그룹별로 좁혀 보는 화면이 돌아올 수 있고,
|
|
102
|
+
* 무엇보다 이 함수의 회귀 시험이 그 경로를 그대로 들고 있다.
|
|
103
|
+
*
|
|
104
|
+
* 목록을 받는(키 하나가 아닌) 까닭: 사이드바는 그룹이 하나뿐이면 헤더를 그리지 않고
|
|
105
|
+
* 목록을 **평면 한 덩이**로 만드는데(deck.service `renderPlan`), 그때 그 덩이의 `key` 는
|
|
106
|
+
* `null`(기타)이 된다. 거기에 `null` 하나만 넘기면 어떤 세션도 안 걸린다.
|
|
102
107
|
*/
|
|
103
|
-
groupKeys: readonly (string | null)[];
|
|
108
|
+
groupKeys: readonly (string | null)[] | null;
|
|
104
109
|
/** cwd -> 그룹 키. 호출부가 `groupKeyOf` + `projectRootOf` 를 엮어 넘긴다 */
|
|
105
110
|
keyOf: (cwd: string | null) => string | null;
|
|
106
111
|
/** 키 비교용 폴딩 (대소문자·구분자) — `group.ts` 의 `foldGroupKey` */
|