tabby-agentdeck 0.3.0 → 1.1.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.
@@ -0,0 +1,149 @@
1
+ /**
2
+ * **지난 세션 원장** — 죽은 Claude Code 세션을 다시 찾아낼 수 있게 하는 순수 규칙.
3
+ *
4
+ * 왜 필요한가 — `recovery.service.ts` 가 적어 둔 대로 agentdeck 은 Tabby 의 순정 탭 복원을
5
+ * 의도적으로 껐다. 되살아나는 것은 셸뿐이고 그 안의 세션은 이미 죽어 있어 "주인 없는 탭" 만
6
+ * 남기 때문이다. 그 자리에 들어갈 진짜 복원이 `claude --resume <세션id>` 인데, 사람이 막히는
7
+ * 지점은 명령이 아니라 **UUID 목록에서 내 작업을 못 찾는 것** 이다.
8
+ *
9
+ * agentdeck 은 그 답을 이미 갖고 있다 — Enter 시점에 입력창에서 주운 프롬프트 원문(라벨).
10
+ * 문제는 그게 **메모리에만 있다는 것** 이다(`status.service` 의 `TabState.label`). 탭이 닫히면
11
+ * 같이 증발한다. 그래서 이 모듈이 라벨·상태·cwd 를 세션 id 에 묶어 남기고, 목록을 만든다.
12
+ *
13
+ * fs·타이머는 여기 없다 — `group.ts`·`subagents.ts` 와 같은 이유다. 파일을 읽고 주기를
14
+ * 관리하는 것은 호출부(서비스)의 일이고, 이 파일은 **무엇을 어떤 순서로 보여줄지** 만 정한다.
15
+ * 그래야 `npm test` 에서 돌릴 수 있다.
16
+ */
17
+ /** 원장 한 줄 — 세션 하나가 무엇이었는지 */
18
+ export interface SessionRecord {
19
+ /** Claude Code 세션 id (기록 파일 이름이기도 하다) */
20
+ sessionId: string;
21
+ /** 그 세션이 돌던 작업 폴더. **프로젝트 루트가 아니라 cwd 다** (`--resume` 이 이걸로 세션을 묶는다) */
22
+ cwd: string | null;
23
+ /** 사람이 알아보는 이름 — 살아 있을 때 주운 라벨, 없으면 기록에서 읽은 첫 프롬프트 */
24
+ label: string;
25
+ /** 마지막으로 관측된 작업 상태. 화면에 흔적으로만 남는다 */
26
+ lastStatus: string | null;
27
+ /** 마지막 활동 시각 (epoch ms) */
28
+ lastSeen: number;
29
+ /** 라벨의 출처 — `live` 는 사람이 친 원문, `head` 는 기록 앞부분에서 주운 것 */
30
+ from: 'live' | 'head';
31
+ }
32
+ /** 화면에 그릴 한 줄 — `SessionRecord` 에 "지금 열려 있나" 를 더한 것 */
33
+ export interface ResumeRow extends SessionRecord {
34
+ /** 이 세션을 이미 들고 있는 탭 id. 있으면 이어받기 대신 그 탭으로 이동한다 */
35
+ openTabId: string | null;
36
+ }
37
+ /**
38
+ * 기록 파일 앞부분을 얼마나 읽을지.
39
+ *
40
+ * 전부 파싱하면 안 된다 — 실측 세션이 2.6MB(1,021줄)·5.5MB 고, 목록 한 번에 수십 개를 훑는다.
41
+ * 필요한 것은 `cwd` 와 **첫 사람 프롬프트** 둘뿐인데 실측에서 `cwd` 는 5번째 줄,
42
+ * 프롬프트는 65번째 줄에 나왔다(2026-09-11, `f54b20b9`). 앞의 60여 줄은 훅 주입·모드·첨부라
43
+ * 여유를 크게 잡아도 이 정도면 닿는다. 못 찾으면 라벨 없이 두지, 파일을 더 파고들지 않는다.
44
+ */
45
+ export declare const HEAD_LINE_LIMIT = 400;
46
+ /** 줄 수와 별개로 이 바이트를 넘기면 멈춘다 — 한 줄이 통째로 거대한 기록(대용량 첨부)이 있다 */
47
+ export declare const HEAD_BYTE_LIMIT: number;
48
+ /** 사람이 친 프롬프트로 볼 수 있는 문자열인가 */
49
+ export declare function isHumanPrompt(text: unknown): boolean;
50
+ /**
51
+ * 기록 줄(파싱된 JSON) 하나에서 사람이 친 프롬프트를 꺼낸다 — 아니면 null.
52
+ *
53
+ * `message.content` 는 문자열일 때도 있고 블록 배열일 때도 있다. 배열이면 `text` 블록만 잇는다
54
+ * (`tool_result` 가 섞여 들어오면 라벨이 도구 출력으로 오염된다).
55
+ */
56
+ export declare function promptFromRecord(rec: any): string | null;
57
+ /** 라벨로 쓸 길이로 줄인다 — 사이드바 한 줄은 말줄임이 있지만 원장에 소설을 담을 이유는 없다 */
58
+ export declare const LABEL_MAX = 140;
59
+ export declare function clampLabel(text: string | null | undefined): string;
60
+ /** 기록 앞부분에서 읽어낸 것 */
61
+ export interface HeadInfo {
62
+ cwd: string | null;
63
+ label: string;
64
+ /** 앞부분에서 본 가장 이른 timestamp — 파일 mtime 이 없을 때의 폴백 */
65
+ startedAt: number | null;
66
+ }
67
+ /**
68
+ * 기록 앞부분의 줄들에서 cwd·첫 프롬프트를 뽑는다.
69
+ *
70
+ * **폴더 이름 규칙을 흉내내지 않는다.** `~/.claude/projects/<폴더>` 의 이름은 작업 경로를 `-` 로
71
+ * 뭉갠 것이라 드라이브 문자·한글·특수문자에서 되돌릴 수 없다(`notify.service.ts:1006` 이 같은
72
+ * 이유로 폴더 이름 대신 파일명 매칭을 쓴다). 기록 안에 `cwd` 가 그대로 들어 있으니 그걸 읽는다.
73
+ */
74
+ export declare function readHead(lines: readonly string[]): HeadInfo;
75
+ /**
76
+ * 원장과 디스크 스캔을 합친다 — **세션 id 가 키, 원장이 우선.**
77
+ *
78
+ * 두 경로가 다 필요하다. 원장에만 기대면 agentdeck 설치 이전 세션과 다른 터미널에서 띄운
79
+ * 세션이 통째로 안 보이고, 디스크에만 기대면 사람이 친 라벨(가장 좋은 단서)을 잃는다.
80
+ *
81
+ * 원장이 이기는 것은 **라벨과 상태** 뿐이다. `lastSeen` 은 둘 중 큰 값을 쓴다 — 원장은 탭이
82
+ * 닫힐 때 멈추는데 그 뒤 다른 터미널에서 같은 세션을 이어받았을 수 있고, 그때는 디스크가 맞다.
83
+ */
84
+ export declare function mergeSessions(ledger: readonly SessionRecord[], scanned: readonly SessionRecord[]): SessionRecord[];
85
+ /**
86
+ * 기록 파일이 남아 있는 세션만 남긴다 — 원장이 무한히 부풀지 않게.
87
+ *
88
+ * 디스크를 진실로 삼는 이유: 사람이 `~/.claude/projects` 를 지우면 그 세션은 이어받을 수 없고,
89
+ * 그걸 원장이 계속 보여주면 눌렀을 때 빈 세션이 뜬다.
90
+ */
91
+ export declare function pruneLedger(ledger: readonly SessionRecord[], aliveIds: ReadonlySet<string>): SessionRecord[];
92
+ export interface ResumeListOptions {
93
+ /**
94
+ * 이 줄 묶음이 **덮는 그룹 키들**. 보통 한 개지만 하나가 아닐 수 있다 —
95
+ * 사이드바는 그룹이 하나뿐이면 헤더를 그리지 않고 목록을 **평면 한 덩이**로 만드는데
96
+ * (deck.service `renderPlan`), 그때 그 덩이의 `key` 는 `null`(기타)이 된다.
97
+ * 거기에 `null` 하나만 넘기면 어떤 세션도 안 걸려 **가장 흔한 화면(프로젝트 하나)에서
98
+ * 목록이 영영 비어 있다**. 그래서 키가 아니라 키의 집합을 받는다.
99
+ */
100
+ groupKeys: readonly (string | null)[];
101
+ /** cwd -> 그룹 키. 호출부가 `groupKeyOf` + `projectRootOf` 를 엮어 넘긴다 */
102
+ keyOf: (cwd: string | null) => string | null;
103
+ /** 키 비교용 폴딩 (대소문자·구분자) — `group.ts` 의 `foldGroupKey` */
104
+ fold: (key: string | null | undefined) => string;
105
+ /** 지금 탭이 들고 있는 세션 — sessionId -> tabId */
106
+ live: ReadonlyMap<string, string>;
107
+ /** 며칠치까지 보여줄지 (0 이하면 제한 없음) */
108
+ days: number;
109
+ /** 몇 줄까지 (0 이하면 제한 없음) */
110
+ limit: number;
111
+ /** 사람이 숨긴 세션 id */
112
+ hidden: ReadonlySet<string>;
113
+ /** 기준 시각 — 테스트가 고정할 수 있게 주입받는다 */
114
+ now: number;
115
+ }
116
+ /**
117
+ * 한 그룹에 그릴 지난 세션 줄.
118
+ *
119
+ * **살아 있는 세션을 빼지 않는다** — `openTabId` 를 달아 그대로 둔다. 지우면 "내가 방금 보던
120
+ * 그 작업이 목록에 없다" 가 되어 사람이 같은 세션을 다시 찾아 헤맨다. 대신 눌렀을 때
121
+ * 이어받지 않고 그 탭으로 보낸다(같은 세션을 두 번 이어받으면 두 프로세스가 한 기록에 덧쓴다).
122
+ *
123
+ * 정렬은 **마지막 활동 내림차순** 하나뿐이다. 상태별로 묶고 싶어질 수 있는데, 지난 세션에서
124
+ * 사람이 찾는 것은 "방금 하던 것" 이지 "완료된 것" 이 아니다.
125
+ */
126
+ export declare function resumeRowsFor(records: readonly SessionRecord[], opts: ResumeListOptions): ResumeRow[];
127
+ /**
128
+ * 마지막 활동 시각을 사람이 읽는 짧은 꼴로.
129
+ *
130
+ * 오늘이면 시각(`14:12`), 어제면 `어제`, 그보다 오래면 `9/10`. 사이드바 줄의 오른쪽 끝에
131
+ * 들어가므로 길면 제목 폭을 먹는다 — 연도는 넣지 않는다(제한 기간이 기본 7일이라 못 만난다).
132
+ */
133
+ export declare function formatWhen(ts: number, now: number): string;
134
+ /** 파일 이름에서 세션 id 를 꺼낸다 (아니면 null) */
135
+ export declare function sessionIdFromFile(name: string): string | null;
136
+ /**
137
+ * 셸에 보낼 이어받기 명령.
138
+ *
139
+ * **프로필의 `command` 를 갈아끼우지 않고 셸에 문자열로 보낸다.** 이유 둘 —
140
+ * ① 기존 `agentdeck:root` 프로필(`-NoLogo -ExecutionPolicy Bypass`, COLORTERM/FORCE_COLOR)을
141
+ * 그대로 쓴다. npm 전역 `.ps1` 래퍼가 실행정책에 막히는 문제를 그 인자가 이미 풀어 둔다
142
+ * (`profile.service.ts:58`).
143
+ * ② claude 가 끝나도 셸이 남아 사람이 이어서 쓸 수 있다. `command` 로 박으면
144
+ * `behaviorOnSessionEnd` 에 따라 탭이 닫힌다.
145
+ *
146
+ * 세션 id 는 우리가 파일 이름에서 읽은 값이고 `SESSION_FILE_RE` 를 통과한 것만 여기 온다 —
147
+ * 그래도 명령을 만드는 자리이므로 한 번 더 막는다. 통과 못 하면 빈 문자열이고 호출부가 아무것도 보내지 않는다.
148
+ */
149
+ export declare function resumeCommand(sessionId: string, fork: boolean): string;
@@ -0,0 +1,75 @@
1
+ import { ConfigService } from 'tabby-core';
2
+ import { SessionRecord } from './sessionLedger';
3
+ /**
4
+ * 지난 세션 원장의 **파일 담당** — 읽고, 쓰고, 기록 폴더를 훑는다.
5
+ * 무엇을 보여줄지 정하는 규칙은 전부 `sessionLedger.ts` 에 있다(fs 없이 테스트되는 쪽).
6
+ *
7
+ * 저장 위치는 `%LOCALAPPDATA%\tabby-agentdeck\sessions.json` — 훅 상태 파일 폴더 옆이다.
8
+ * **Tabby 설정(`config.yaml`)에 넣지 않는다.** 세션은 계속 쌓이는데 거기 넣으면 남의 설정 파일이
9
+ * 수백 줄 불어나고, 라벨이 바뀔 때마다 `config.save()` 가 돌아 관계없는 설정까지 다시 쓴다.
10
+ */
11
+ export declare class SessionLedgerService {
12
+ private config;
13
+ private root;
14
+ private file;
15
+ /** Claude Code 대화기록이 사는 곳 (`notify.service` 와 같은 자리) */
16
+ private projectsDir;
17
+ /** 세션 id -> 원장 한 줄. 사람이 친 라벨이 여기 남는다 */
18
+ private ledger;
19
+ /** 디스크 스캔 결과 (기록 파일에서 읽은 것) */
20
+ private scanned;
21
+ /**
22
+ * 기록 앞부분 파싱 캐시 — `세션id:mtime` 키. 파일이 자라도 **앞부분은 안 바뀐다**.
23
+ * 그래도 mtime 을 키에 넣는 이유는 세션이 이어받기로 되살아나 새 줄이 앞에 붙는 경우가
24
+ * 아니라, 파일이 통째로 갈렸을 때(삭제 후 재생성) 옛 값을 물고 있지 않기 위해서다.
25
+ */
26
+ private headCache;
27
+ private loaded;
28
+ private saveTimer;
29
+ private scanning;
30
+ private lastScan;
31
+ /** 목록이 바뀌었을 때 호출부(사이드바)가 다시 그리도록 */
32
+ onChange: (() => void) | null;
33
+ constructor(config: ConfigService);
34
+ private load;
35
+ /**
36
+ * 저장을 한 번으로 모은다. 라벨은 사람이 Enter 를 칠 때마다 갱신되므로
37
+ * 그때마다 파일을 쓰면 타자 속도로 디스크를 때린다.
38
+ */
39
+ private scheduleSave;
40
+ private save;
41
+ /**
42
+ * 세션 하나의 지금을 원장에 적는다.
43
+ *
44
+ * 라벨은 **비어 있지 않을 때만** 덮는다 — 탭이 막 열려 아직 프롬프트가 없을 때 빈 문자열로
45
+ * 덮으면 지난 실행에서 적어 둔 좋은 라벨을 지우게 된다.
46
+ */
47
+ note(sessionId: string, patch: {
48
+ cwd?: string | null;
49
+ label?: string | null;
50
+ status?: string | null;
51
+ }): void;
52
+ /**
53
+ * 기록 폴더를 훑어 세션 목록을 다시 만든다.
54
+ *
55
+ * **폴링하지 않는다.** `notify.service.ts:64` 가 적어 둔 대로 `~/.claude/projects` 전체를
56
+ * 2초마다 readdir 하면 안 된다. 목록을 펼칠 때와 탭이 닫힐 때만 부른다.
57
+ */
58
+ refresh(force?: boolean): Promise<void>;
59
+ private scan;
60
+ /**
61
+ * 기록 파일 앞부분만 읽는다 — 전체를 읽지 않는 이유는 `HEAD_LINE_LIMIT` 주석에 있다.
62
+ * 마지막 조각은 줄이 잘려 있을 수 있으니 버린다(파싱 실패로 조용히 넘어가긴 하지만,
63
+ * 버리는 편이 "왜 라벨이 가끔 이상한가" 를 애초에 만들지 않는다).
64
+ */
65
+ private readHeadOf;
66
+ /** 원장 + 디스크를 합친 전체 목록. 렌더 중에 불러도 막히지 않는다(캐시만 본다) */
67
+ records(): SessionRecord[];
68
+ /** 진단용 — `__agentdeck.sessions()` 가 보여준다 */
69
+ snapshot(): {
70
+ ledger: number;
71
+ scanned: number;
72
+ file: string;
73
+ lastScan: number;
74
+ };
75
+ }
@@ -1,19 +1,37 @@
1
1
  import { ConfigService, PlatformService } from 'tabby-core';
2
2
  import { AgentDeckRecoveryService } from './recovery.service';
3
+ import { AgentDeckUpdateService } from './update.service';
3
4
  /**
4
5
  * 설정 > AgentDeck 탭.
5
6
  *
6
- * 사용자가 실제로 바꿀 일이 있는 것만 노출한다 — 레이아웃 on/off, 기동 시 탭 복원 끄기, 작업 루트 프로필.
7
- * 나머지(화면비, 사이드바 폭, 투명도, 상태 감지, 입력 가로채기, 렌더 보정, 통보 채널)는
7
+ * 사용자가 실제로 바꿀 일이 있는 것만 노출한다 — 레이아웃 on/off, 기동 시 탭 복원 끄기,
8
+ * 사이드바 목록(키보드 조작 · 서브에이전트 개수), 세션 그룹, 미리보기 패널, 작업 루트 프로필.
9
+ * 나머지(화면비, 사이드바 폭, 투명도, 상태 감지, 붙여넣기/줄바꿈 가로채기, 렌더 보정)는
8
10
  * 손댈 이유가 거의 없고 잘못 만지면 화면이 깨지는 값이라 `config.ts` 의 기본값으로 고정하고
9
11
  * 설정 창에서는 뺐다. 정말 필요하면 설정 파일의 `agentDeck.*` 를 직접 고친다.
10
12
  *
11
13
  * `enabled` 는 deck.service 가 `config.changed$` 를 구독해 즉시 반영한다(deck.service.ts:256).
12
14
  * 루트 프로필은 기동 시 한 번만 읽으므로 재시작이 필요하다고 적어 둔다.
15
+ *
16
+ * `사이드바 목록` 절을 `세션 그룹` **앞**에 둔 이유 — 그룹도 결국 이 목록의 세부 규칙이라
17
+ * 목록 전체에 걸리는 것(키보드 조작 · 줄에 무엇을 보일지)을 먼저 읽는 편이 순서가 맞는다.
18
+ * 0.14.0 의 `dragAutoScroll` 도 같은 절에 뒀다 — 정하는 것이 "목록을 다루는 방법" 이라
19
+ * 키보드 조작과 성질이 같고, 따로 절을 만들면 항목 하나뿐인 절이 생긴다.
20
+ * 0.13.0 에서 생긴 세 값(`keyboardNav` / `keyboardNavWrap` / `subagentCount`)은
21
+ * 설정 창에 자리가 없어 `config.yaml` 을 손으로 고쳐야만 바꿀 수 있었다.
22
+ *
23
+ * `keyboardNavWrap` 과 `keyboardCloseTab`(0.19.0) 은 `keyboardNav` 를 끄면 아무 뜻이 없으므로 그때는 줄을 아예 감춘다 —
24
+ * 꺼진 값을 회색으로 남겨 두는 것보다 "지금 만질 수 있는 것" 이 짧아진다.
25
+ * 세션 그룹 절에 값이 하나도 없는 것(0.20.0)도 같은 기준이다 — 끌 이유가 없는 스위치와
26
+ * 자동으로 알아낼 수 있는 경로는 설정이 아니라 동작이어야 한다.
27
+ * 이 세 값은 deck.service/notify.service 가 쓸 때마다 store 를 다시 읽으므로
28
+ * (deck.service.ts `navCanFocus`/`moveNav`, notify.service `subagentEnabled`)
29
+ * 토글하면 바로 듣는다 — 그래서 재시작 안내를 붙이지 않았다.
13
30
  */
14
31
  export declare class AgentDeckSettingsTabComponent {
15
32
  config: ConfigService;
16
33
  recovery: AgentDeckRecoveryService;
34
+ private update;
17
35
  private platform;
18
36
  /** 이 환경에서 훅을 걸 수 있나 (통보 스크립트가 PowerShell 이라 Windows 전용) */
19
37
  hooksSupported: boolean;
@@ -21,7 +39,26 @@ export declare class AgentDeckSettingsTabComponent {
21
39
  hooksOn: boolean;
22
40
  /** 훅을 쓰다 실패한 이유 — 조용히 삼키면 사용자는 버튼이 먹통인 줄 안다 */
23
41
  hookError: string;
24
- constructor(config: ConfigService, recovery: AgentDeckRecoveryService, platform: PlatformService);
42
+ /** 신고 안내에 그대로 보여줄 진단 로그 경로 */
43
+ diagPath: string;
44
+ /** 신고 본문에 적을 버전 — 사용자가 어느 버전을 쓰는지 스스로 알 방법이 없었다 */
45
+ diagVersion: string;
46
+ /** 묶음에 화면 원문까지 넣나 (기본 제외 — 터미널에 찍힌 내용이 그대로 들어간다) */
47
+ diagIncludeScreen: boolean;
48
+ /** 마지막으로 만든 묶음 경로 */
49
+ diagBundlePath: string;
50
+ /** 묶음을 만들다 실패한 이유 */
51
+ diagError: string;
52
+ /** `지금 확인` 이 도는 동안 버튼을 잠근다 (레지스트리 응답까지 몇 초) */
53
+ updateBusy: boolean;
54
+ /** 마지막 확인 결과 한 줄 */
55
+ updateMsg: string;
56
+ constructor(config: ConfigService, recovery: AgentDeckRecoveryService, update: AgentDeckUpdateService, platform: PlatformService);
57
+ /**
58
+ * 사람이 직접 누른 확인. `force` 라 간격과 `다시 묻지 않기` 를 건너뛴다 —
59
+ * 눌렀다는 것 자체가 "지금 알고 싶다" 는 뜻이다.
60
+ */
61
+ checkUpdate(): Promise<void>;
25
62
  private refreshHooks;
26
63
  /**
27
64
  * 훅을 걸거나 뗀다.
@@ -39,6 +76,13 @@ export declare class AgentDeckSettingsTabComponent {
39
76
  * 돌려받은 경로는 백슬래시를 슬래시로 바꿔 저장한다. 설정 파일에 그대로 적히는 값이라
40
77
  * 손으로 고칠 때 이스케이프 사고가 나지 않게 하려는 것이고, Windows 도 슬래시 경로를 받는다.
41
78
  */
79
+ /**
80
+ * 진단 묶음을 만들고 그 파일이 있는 폴더를 연다.
81
+ *
82
+ * 폴더를 여는 것까지가 한 동작인 이유 — 경로만 알려 주면 "그 폴더가 어디냐" 가 다시 질문으로
83
+ * 돌아온다. 압축이 안 되는 환경에서는 폴더 경로가 나오므로 그대로 열어 주면 된다.
84
+ */
85
+ collectDiag(): void;
42
86
  /** 배치가 기본값에서 벗어나 있나 — 벗어나 있을 때만 초기화 버튼이 살아 있다 */
43
87
  layoutChanged(): boolean;
44
88
  dockLabel(): string;
@@ -50,5 +94,15 @@ export declare class AgentDeckSettingsTabComponent {
50
94
  * deck.service 가 그걸 받아 곧바로 다시 배치하므로(deck.service.ts:256) 재시작도 필요 없다.
51
95
  */
52
96
  resetLayout(): void;
97
+ /** 접혀 있는 그룹 수 — 버튼을 살릴지와 안내 문장에 쓴다 */
98
+ collapsedCount(): number;
99
+ /**
100
+ * 접어 둔 그룹을 모두 펼친다.
101
+ *
102
+ * 왜 버튼이 필요한가 — 접힌 그룹의 탭을 다 닫아도 키는 남는다(그 프로젝트를 다시 열었을 때
103
+ * 접힘이 유지되는 것이 의도다). 그래서 "예전에 접었는데 지금은 그 그룹이 안 보이는" 상태가
104
+ * 쌓일 수 있고, 그때 되돌릴 방법이 config 파일 편집뿐이었다.
105
+ */
106
+ expandGroups(): void;
53
107
  pickCwd(): Promise<void>;
54
108
  }
@@ -48,6 +48,20 @@ export declare class WorkStatusService {
48
48
  /** 이 탭을 에이전트 TUI 로 볼지 — 아니면 예전처럼 출력 유무로 판단한다 */
49
49
  isBusyMode(tab: BaseTabComponent): boolean;
50
50
  forget(tab: BaseTabComponent): void;
51
+ /**
52
+ * 살아있는 탭만 남긴다 — **닫힌 탭이 `states` 에 쌓이는 것을 막는 유일한 경로다.**
53
+ *
54
+ * `states` 는 WeakMap 이 아니라 Map 이라(틱마다 전 탭을 훑어야 해서 열거가 필요하다)
55
+ * 참조를 우리가 직접 지워야 한다. 지우는 곳은 원래 `decorator.detach` 하나뿐이었는데,
56
+ * 거기 조건이 `root === tab || !app.tabs.includes(root)` 라서 **닫히는 순서에 걸린다** —
57
+ * 분할 탭(`SplitTabComponent`)의 자식 터미널이 detach 될 때 부모가 아직 `app.tabs` 에
58
+ * 남아 있으면 그냥 지나가고, 그 뒤 부모가 사라지면 아무도 지우지 않는다.
59
+ * 2026-09-09 실측(PF9): 탭 11개를 닫았는데 `states` 가 37→37 로 그대로였다.
60
+ *
61
+ * 그래서 탭 목록이 바뀔 때마다 여기서 한 번 훑는다. 비용은 Map 크기만큼의 `includes` 이고,
62
+ * 탭 목록 변화는 사람이 만드는 빈도(초당 몇 번이 최대)라 문제가 되지 않는다.
63
+ */
64
+ sweep(alive: readonly BaseTabComponent[]): number;
51
65
  /** 사이드바가 매 틱 호출 — idle 전환만 담당하고 화면 갱신 필요 여부를 돌려준다 */
52
66
  tickIdle(tab: BaseTabComponent, idleAfterMs: number): boolean;
53
67
  }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * 세션이 지금 **서브에이전트를 몇 개 돌리고 있나** — Claude Code 대화기록(JSONL) 줄만 보고 세는 순수 로직.
3
+ *
4
+ * 왜 대화기록인가 — 훅(`hooks/agentdeck-notify.ps1`)이 알려 주는 것은 세션 하나의 상태
5
+ * (`running`/`waiting`/…)뿐이다. 그 안에서 백그라운드 Agent 가 몇 개 도는지는 훅에 없고,
6
+ * 대화기록에만 남는다. 그리고 탭 ↔ 세션 연결은 이미 `notify.service.ts` 가 갖고 있으므로
7
+ * (status 파일의 `sessionId`·`tabId`) 세션 id 로 대화기록을 찾아 세면 사이드바에 붙일 수 있다.
8
+ *
9
+ * **개수는 id 대조로 나온다** (실측 2026-09-09, 6,395줄/13MB 세션에서 호출 28 · 도는 중 1 = 실제와 일치):
10
+ * - 호출 = `type:"assistant"` 줄의 `message.content[]` 안 `{type:"tool_use", name:"Agent", id:"toolu_…"}`
11
+ * - 종료 = 어딘가의 문자열에 실린 `<task-notification>` 블록의 `<tool-use-id>`
12
+ * - 도는 중 = 호출된 id 중 종료 알림에 안 나온 것
13
+ *
14
+ * **`tool_result` 를 종료로 보면 안 된다.** 백그라운드 Agent 의 `tool_result` 는 호출 **바로 다음 줄**에
15
+ * `Async agent launched successfully.` 로 온다(실측: 28건 전부 호출줄 +1). 이걸 완료로 세면 항상 0개가 된다.
16
+ *
17
+ * **종료 알림은 JSON 모양이 한 가지가 아니다.** 같은 알림이 실측에서 세 모양으로 나왔다 —
18
+ * ① `type:"user"` 줄의 `message.content` (문자열 그대로)
19
+ * ② `type:"queue-operation"` 줄의 최상위 `content`
20
+ * ③ `type:"attachment"` 줄의 `attachment.prompt`
21
+ * 그래서 알림은 **줄 원문에서 태그를 직접 긁는다.** JSON 모양을 따라가면 셋 중 하나만 잡게 되는데,
22
+ * 처음에 ①만 봤을 때 28건 중 16건을 놓쳤다(도는 중 16 = 오답). 태그는 순수 ASCII 라 JSON 이스케이프가
23
+ * 끼어도(`\n`) 그대로 읽힌다.
24
+ *
25
+ * 파일을 읽거나 경로를 뒤지지 않는다 — 줄(또는 청크)을 받아 상태를 갱신하는 것만 한다.
26
+ * `group.ts`·`gitDiff.ts` 와 같은 이유다: fs·타이머가 들어오면 `npm test` 에서 돌릴 수 없고,
27
+ * 폴링 주기·증분 오프셋 관리는 호출부(서비스)의 일이다.
28
+ */
29
+ /**
30
+ * 서브에이전트를 띄우는 도구 이름. `Task` 는 옛 이름(구버전 CLI)이라 같이 받는다 —
31
+ * 최근 기록에는 `Agent` 만 나오지만, 플러그인은 사용자가 깔아 둔 아무 버전과도 붙는다.
32
+ */
33
+ export declare const AGENT_TOOL_NAMES: readonly string[];
34
+ /** 서브에이전트 호출 한 건 */
35
+ export interface SubagentCall {
36
+ /** `tool_use` 의 id (`toolu_…`) — 종료 알림과 맞추는 열쇠 */
37
+ id: string;
38
+ /** `input.description` — 3~5 낱말짜리 표시용 라벨. 없으면 빈 문자열 */
39
+ description: string;
40
+ /** `input.subagent_type` — 없으면 null (기본 에이전트) */
41
+ subagentType: string | null;
42
+ /** 호출 줄의 `timestamp` (ISO 문자열). 줄에 없으면 null */
43
+ startedAt: string | null;
44
+ }
45
+ /**
46
+ * 증분 스캔 누적 상태.
47
+ *
48
+ * **왜 이 모양인가** — 13MB 까지 자라는 파일을 매번 다 파싱할 수 없어서(실측 6,395줄) 호출부가
49
+ * "지난번 오프셋 이후" 만 읽어 넘기고, 이 객체가 그 사이의 기억을 들고 있어야 한다. 들고 있을 것은
50
+ * 정확히 두 집합이다 —
51
+ * - `calls`: 호출 id → 정보. **Map 인 이유는 삽입 순서**다. 툴팁에 호출된 순서(= 시간순)로 적어야
52
+ * 사람이 읽을 수 있고, Set + 별도 배열로 나누면 두 자료구조가 어긋날 자리가 생긴다.
53
+ * - `done`: 종료 알림을 받은 id. **호출을 아직 못 봤어도 넣는다** — 증분 경계에서 알림이 호출보다
54
+ * 먼저 도착하고(청크가 알림 줄에서 시작), 세션을 이어받으면 앞 파일의 호출은 아예 못 본다.
55
+ * 그러니 `done` 은 호출 여부와 무관하게 단조증가하는 집합이어야 한다.
56
+ * 두 집합을 따로 두고 **차집합으로 답을 만드는** 구조라 순서가 어떻게 오든 결과가 같다(멱등).
57
+ *
58
+ * 완료된 호출을 `calls` 에서 지우지 않는 이유 — 총 호출 수를 진단(`diag`)에 쓸 수 있고, 규모가
59
+ * 세션당 수십 건이라 지워서 얻을 것이 없다. 지우면 "완료 먼저 → 호출 나중" 을 되살릴 수도 없다.
60
+ */
61
+ export interface SubagentState {
62
+ calls: Map<string, SubagentCall>;
63
+ done: Set<string>;
64
+ /** 개행으로 끝나지 않은 마지막 조각 — 다음 청크 앞에 이어 붙인다 */
65
+ pending: string;
66
+ /** 지금까지 먹은 줄 수 (진단용) */
67
+ lines: number;
68
+ /** JSON 이 아니거나 깨져서 버린 완성된 줄 수 (진단용) */
69
+ skipped: number;
70
+ /** `MAX_PENDING` 을 넘겨 버린 꼬리 수 (진단용 — 0 이 아니면 뭔가 이상하다) */
71
+ dropped: number;
72
+ }
73
+ /** 사이드바가 쓰는 결론 */
74
+ export interface SubagentSummary {
75
+ /** 도는 중인 개수 */
76
+ running: number;
77
+ /** 도는 중인 것들 — 호출된 순서(시간순) */
78
+ items: SubagentCall[];
79
+ /** 이 상태가 본 총 호출 수 */
80
+ calls: number;
81
+ /** 종료 알림을 받은 id 수 (호출을 못 본 것도 포함) */
82
+ done: number;
83
+ }
84
+ export declare function createSubagentState(): SubagentState;
85
+ /**
86
+ * **완성된 줄들**을 먹인다. 상태를 그 자리에서 고치고 같은 객체를 돌려준다
87
+ * (세션당 상태 하나를 계속 들고 도는 쓰임새라 사본을 만들 이유가 없다).
88
+ *
89
+ * **깨진 줄은 버린다.** 개행이 왔다는 것은 그 줄이 다 쓰였다는 뜻이라, 다시 읽어도 결과가 같다.
90
+ * "다음에 다시 보자" 로 남기면 매 폴링마다 같은 줄을 파싱하는 무한 재시도가 된다.
91
+ * 반대로 **개행 없는 꼬리는 남긴다** — 그건 "깨진 것" 이 아니라 "아직 안 온 것" 이고,
92
+ * 게다가 꼬리에 있는 이벤트는 거의 항상 **가장 최신**(방금 띄운 Agent / 방금 온 종료 알림)이라
93
+ * 버리면 증분 구조에서는 되찾을 기회가 없다(파일을 처음부터 다시 읽지 않는다). 그 구분은
94
+ * `feedChunk` 가 개행으로 판정한다.
95
+ */
96
+ export declare function feedLines(state: SubagentState, lines: readonly string[]): SubagentState;
97
+ /**
98
+ * 파일에서 읽은 **청크**를 먹인다. 개행으로 끝나지 않은 마지막 조각은 다음 호출까지 들고 있는다.
99
+ *
100
+ * 호출부는 "지난 크기 이후만 읽기" 만 하면 되고 줄 경계는 여기서 맞춘다 — 그 경계 처리를 호출부마다
101
+ * 다시 쓰면(서비스·진단구) 사본이 갈린다. 빈 청크(파일이 안 자랐다)는 아무 일도 하지 않는다.
102
+ */
103
+ export declare function feedChunk(state: SubagentState, chunk: string): SubagentState;
104
+ /** 도는 중인 것 = 호출된 것 중 종료 알림에 안 나온 것. 호출 순서를 지킨다 */
105
+ export declare function summarizeSubagents(state: SubagentState | null | undefined): SubagentSummary;
106
+ /**
107
+ * 사이드바 title(툴팁)에 넣을 문장. **도는 것이 없으면 빈 문자열** — 호출부가 title 을 아예 안 붙이게.
108
+ *
109
+ * 개수만으로는 "무엇을 시켜 놨는지" 를 모른다(그게 이 기능의 목적이다). 그래서 설명을 줄줄이 붙이는데,
110
+ * 200px 사이드바(config `sidebarMin`)의 툴팁이 화면을 덮지 않게 `max` 개까지만 적고 나머지는 수로 줄인다.
111
+ */
112
+ export declare function formatSubagentTooltip(summary: SubagentSummary | null | undefined, max?: number): string;
113
+ /**
114
+ * 이 파일명이 그 세션의 대화기록인가 — 호출부가 `~/.claude/projects` 의 각 프로젝트 폴더를 훑을 때 쓰는 판정.
115
+ *
116
+ * 프로젝트 폴더 이름 규칙(경로를 `-` 로 뭉갠 이름)에 기대지 않으려고 폴더를 훑는 방식을 쓴다.
117
+ * 규칙을 흉내내면 드라이브·대소문자·특수문자에서 어긋나는데, 세션 id 로 파일명을 맞추면 폴더를
118
+ * 몰라도 정확히 하나가 잡힌다(실측). 대소문자를 무시하는 것은 윈도우 파일명이라서다.
119
+ */
120
+ export declare function matchesSessionTranscript(fileName: string | null | undefined, sessionId: string | null | undefined): boolean;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * **자동 업데이트의 판단 규칙** — 언제 확인할지, 새 버전인지, 업데이트해도 되는지.
3
+ *
4
+ * 네트워크·fs·타이머는 여기 없다 (`group.ts`·`sessionLedger.ts` 와 같은 이유). 이 파일은
5
+ * "무엇을 근거로 결정하는가" 만 정하고, 실제로 묻고 설치하는 것은 `update.service.ts` 다.
6
+ *
7
+ * ## 왜 "받아 두고 다음 기동에 적용" 인가
8
+ *
9
+ * Tabby 는 플러그인을 **기동할 때 한 번** 읽는다. 돌고 있는 플러그인을 갈아끼울 방법이 없으므로
10
+ * 어떤 방식으로 업데이트하든 재시작이 필요하다. 그래서 이 기능은 조용히 받아만 두고
11
+ * "재시작하면 적용된다" 고 한 번 알린다 — 사람의 작업을 끊지 않는 유일한 모양이다.
12
+ */
13
+ /** npm 레지스트리 기본값. 사내 미러를 쓰는 사람은 설정으로 바꾼다 */
14
+ export declare const DEFAULT_REGISTRY = "https://registry.npmjs.org";
15
+ /** 이 패키지 이름 — 레지스트리에 물어볼 대상이자 `installPlugin` 에 넘길 이름 */
16
+ export declare const PACKAGE_NAME = "tabby-agentdeck";
17
+ /** 확인 간격 기본값 (시간). 기동마다 묻지 않는다 — 레지스트리에도 사람 네트워크에도 예의가 아니다 */
18
+ export declare const DEFAULT_INTERVAL_HOURS = 6;
19
+ /**
20
+ * 버전 문자열을 숫자 3개 + 프리릴리스로 가른다.
21
+ * semver 가 아니면 `null` — 그런 값이 오면 비교하지 않고 "모른다" 로 둔다(업데이트를 강행하지 않는다).
22
+ */
23
+ export declare function parseVersion(v: unknown): {
24
+ nums: number[];
25
+ pre: string | null;
26
+ } | null;
27
+ /**
28
+ * semver 비교 — `a < b` 면 -1, 같으면 0, `a > b` 면 1. 못 읽으면 `null`.
29
+ *
30
+ * 프리릴리스는 같은 숫자의 정식판보다 **낮다**(`1.1.0-beta` < `1.1.0`). 프리릴리스끼리는
31
+ * 점으로 끊어 숫자는 수로, 그 외는 문자열로 비교한다 (semver 규칙 그대로).
32
+ */
33
+ export declare function compareVersions(a: unknown, b: unknown): number | null;
34
+ /** 레지스트리의 것이 지금 것보다 새로운가. 못 읽으면 `false` — 모르면 건드리지 않는다 */
35
+ export declare function isNewer(current: unknown, latest: unknown): boolean;
36
+ /** 레지스트리 응답(JSON 문자열)에서 버전을 꺼낸다 — `<registry>/<pkg>/latest` 의 모양 */
37
+ export declare function parseLatestVersion(body: string): string | null;
38
+ /** 물어볼 주소. 레지스트리 끝의 `/` 가 있든 없든 같은 주소가 나오게 한다 */
39
+ export declare function latestUrl(registry: string | null | undefined, pkg?: string): string;
40
+ /** 지금 확인할 때가 됐나. `lastCheckAt` 이 미래면(시계가 뒤로 갔다) 확인한다 */
41
+ export declare function shouldCheck(lastCheckAt: unknown, now: number, intervalHours: number): boolean;
42
+ /**
43
+ * 이 설치본을 자동으로 갈아끼워도 되나.
44
+ *
45
+ * **개발 설치(소스 트리로의 junction)에는 절대 하면 안 된다.** Tabby 플러그인 폴더의
46
+ * `tabby-agentdeck` 은 개발 중에는 `D:\Project\tabby-agentdeck` 을 가리키는 링크이고
47
+ * (`install.ps1`), 거기에 `npm install` 이 돌면 **작업 중인 소스 트리를 덮어쓴다.**
48
+ *
49
+ * 판정은 두 가지를 본다 — 둘 다 배포본에는 없는 것들이다
50
+ * (`package.json` 의 `files` 는 `dist`·`hooks`·`install.ps1` 뿐이다):
51
+ * - `src/` 나 `webpack.config.js` 가 있다 = 소스 트리
52
+ * - `.git` 이 있다 = 저장소
53
+ * 하나라도 걸리면 확인만 하고 설치는 하지 않는다.
54
+ */
55
+ export declare function canSelfUpdate(marks: {
56
+ hasSrc: boolean;
57
+ hasGit: boolean;
58
+ hasWebpackConfig: boolean;
59
+ isLink: boolean;
60
+ }): boolean;
61
+ export type UpdateOutcome = 'disabled' | 'too-soon' | 'dev-install' | 'unknown' | 'up-to-date' | 'update-ready' | 'skipped' | 'postponed' | 'installing' | 'failed';
62
+ /**
63
+ * 확인 결과로 무엇을 할지 — 순수 결정.
64
+ *
65
+ * 설치 성공/실패는 이 함수가 모르므로(`installPlugin` 이 비동기다) 여기서는
66
+ * `update-ready` 직전까지만 정하고, 실제 설치는 서비스가 하고 실패하면 `failed` 로 덮는다.
67
+ */
68
+ export declare function decide(input: {
69
+ enabled: boolean;
70
+ lastCheckAt: unknown;
71
+ now: number;
72
+ intervalHours: number;
73
+ devInstall: boolean;
74
+ current: string;
75
+ latest: string | null;
76
+ }): UpdateOutcome;
@@ -0,0 +1,61 @@
1
+ import { AppService, ConfigService, NotificationsService, PlatformService } from 'tabby-core';
2
+ import { UpdateOutcome } from './update';
3
+ /**
4
+ * npm 레지스트리를 보고 **Tabby 를 껐다 켜면서** 새 버전을 깐다.
5
+ *
6
+ * 흐름은 넷이다 —
7
+ * ① 기동하고 잠시 뒤 레지스트리에 묻는다
8
+ * ② 새 버전이 있으면 **물어본다** (`지금 업데이트` / `나중에` / `다시 묻지 않기`)
9
+ * ③ 승낙하면 설치 스크립트를 **밖으로 띄우고** Tabby 를 닫는다
10
+ * ④ 스크립트가 Tabby 종료를 기다렸다가 설치하고 다시 띄운다
11
+ *
12
+ * **왜 밖에서 까나.** 돌고 있는 플러그인은 자기를 갈아끼울 수 없다 — Tabby 는 기동할 때
13
+ * `dist/index.js` 를 한 번 읽어 메모리에 올리므로 디스크를 바꿔도 돌던 코드는 옛것이다.
14
+ * 게다가 Windows 는 로드된 파일을 잡고 있어서, 떠 있는 동안 npm 이 그 폴더를 지우려 들면
15
+ * 반쯤 지운 채 실패할 수 있다 — 플러그인이 깨진 채 남는 것이 가장 나쁜 결과다.
16
+ *
17
+ * **개발 설치에는 설치하지 않는다.** 플러그인 폴더의 `tabby-agentdeck` 은 개발 중에는 소스
18
+ * 트리를 가리키는 링크이고(`plugins/package.json` 에 `file:D:/Project/tabby-agentdeck` 로 적힌다),
19
+ * 거기에 `npm install` 이 돌면 작업 중인 트리를 덮어쓴다. 판정은 `update.ts` 의 `canSelfUpdate`.
20
+ */
21
+ export declare class AgentDeckUpdateService {
22
+ private app;
23
+ private config;
24
+ private platform;
25
+ private notifications;
26
+ constructor(app: AppService, config: ConfigService, platform: PlatformService, notifications: NotificationsService);
27
+ init(): void;
28
+ /**
29
+ * 한 번 확인하고, 새 버전이면 물어본 뒤 설치를 건다.
30
+ *
31
+ * `force` 는 설정 창의 `지금 확인` 용 — 간격 제한과 `다시 묻지 않기` 를 건너뛴다
32
+ * (사람이 직접 눌렀으니 그 순간만큼은 묻는 게 맞다). 기능 자체를 끈 것은 존중한다.
33
+ */
34
+ run(reason: string, force?: boolean): Promise<UpdateOutcome>;
35
+ /** ② 물어보고 ③ 설치 스크립트를 띄운 뒤 Tabby 를 닫는다 */
36
+ private confirmAndInstall;
37
+ /**
38
+ * 설치 스크립트를 **패키지 밖에서** 떼어 띄운다.
39
+ *
40
+ * 임시 폴더로 복사하는 이유: 이 스크립트는 곧 npm 이 통째로 갈아엎을 폴더 안에 산다.
41
+ * 제자리에서 돌리면 자기 발밑이 사라진다.
42
+ *
43
+ * `detached` + `unref` 라야 Tabby 가 죽어도 살아남는다 — 부모가 죽을 때 같이 끌려가면
44
+ * 설치는커녕 Tabby 를 다시 띄우지도 못한다.
45
+ */
46
+ private launchUpdater;
47
+ /** `<Tabby 설정폴더>/plugins` — npm 이 `--prefix` 로 받을 경로 */
48
+ private pluginsDir;
49
+ /**
50
+ * 이 설치본이 개발 설치인지 가리는 표식.
51
+ *
52
+ * 배포본에는 `dist`·`hooks`·`updater`·`install.ps1` 만 들어간다(`package.json` 의 `files`).
53
+ * `src/`·`webpack.config.js`·`.git` 중 하나라도 있으면 소스 트리이고, 플러그인 폴더의
54
+ * 항목이 링크면 junction 설치다 — 어느 쪽이든 건드리면 안 된다.
55
+ */
56
+ private installMarks;
57
+ /** Tabby 플러그인 폴더의 우리 항목이 링크인가 — junction 설치의 가장 직접적인 증거 */
58
+ private pluginEntryIsLink;
59
+ /** GET 한 번. 리다이렉트는 따라가지 않는다 — 레지스트리 `/latest` 는 200 으로 답한다 */
60
+ private fetch;
61
+ }