tabby-agentdeck 1.1.1 → 1.1.3

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.
@@ -74,6 +74,39 @@ export declare class WorkNotifyService {
74
74
  * 실패해 같은 options 로 새 셸을 띄우든 셸 환경은 그 값이다
75
75
  */
76
76
  private tabIds;
77
+ /**
78
+ * 탭(루트) -> 훅이 알려 준 작업 폴더. 상대경로 풀기·`변경` 탭의 git 기준이 된다.
79
+ *
80
+ * **Tabby 의 추정값보다 앞세우려고 따로 든다** (NotifyFile.cwd 주석 참고). 약참조라 탭이
81
+ * 사라지면 같이 사라진다 — 탭 닫힘을 따로 청소할 필요가 없다.
82
+ */
83
+ private hookCwd;
84
+ /**
85
+ * 탭(루트) -> 이번 세션에서 에이전트가 고친 파일들 (훅이 보고한 절대경로, 최신이 앞).
86
+ *
87
+ * `변경` 탭이 "저장소 전체" 대신 "이 세션이 만진 것" 을 그리는 근거다 — P4 워크스페이스 위에
88
+ * git 을 얹은 저장소에서는 전체 diff 가 405개씩 나와 방금 고친 것을 찾을 수 없다
89
+ * (2026-09-11 유저 지적). 훅이 없는 에이전트는 이 목록이 비고, 그때는 전체를 그린다.
90
+ *
91
+ * 상한을 두는 이유 — 오래 도는 세션에서 무한히 자란다. 넘치면 오래된 것부터 버린다.
92
+ * 버려진 파일은 '전체' 로 바꾸면 여전히 보인다.
93
+ */
94
+ private touched;
95
+ /**
96
+ * 탭(루트) -> 훅이 "지금 돌고 있다" 고 알려 준 서브에이전트들 (`agent_id` -> 정보).
97
+ *
98
+ * **이 표가 있는 탭에서는 이것이 개수의 진실이고**, 대화기록 훑기(`scans`)는 훅이 없는
99
+ * 세션의 폴백으로만 쓴다. 두 원천을 섞지 않는 이유: 배지 숫자와 툴팁 목록이 서로 다른
100
+ * 원천에서 나오면 "4개라면서 5줄" 같은 화면이 나온다 — 사용자가 어긋남을 본 바로 그 모양이다.
101
+ */
102
+ private liveAgents;
103
+ /**
104
+ * 서브에이전트 훅 보고를 **한 번이라도 받은** 탭. 이 표에 있으면 개수를 훅으로만 센다.
105
+ *
106
+ * "지금 0개" 와 "훅이 없어서 모른다" 를 가르는 유일한 자리다. 이게 없으면 에이전트가 전부
107
+ * 끝난 순간(집합이 빔) 폴백이 되살아나 옛 기록을 세어 유령 숫자가 다시 뜬다.
108
+ */
109
+ private agentHookSeen;
77
110
  /** tabAdded$ 를 이미 구독한 분할 루트 — 루트가 사라지면 함께 사라진다 */
78
111
  private watchedSplits;
79
112
  private pollCount;
@@ -211,6 +244,21 @@ export declare class WorkNotifyService {
211
244
  private bindLog;
212
245
  /** 서브에이전트 개수를 세고 보일지 (`agentDeck.subagentCount`) */
213
246
  private get subagentEnabled();
247
+ /**
248
+ * 이 탭에서 도는 에이전트가 알려 준 작업 폴더. 훅이 붙지 않은 탭(Codex·Gemini·맨 셸)은 null.
249
+ *
250
+ * 부르는 쪽(deck.service)은 이 값이 있으면 Tabby 의 추정을 쓰지 않는다 — 추정이 파일 경로를
251
+ * 집어 오는 실사고가 있었다(NotifyFile.cwd 주석).
252
+ */
253
+ cwdOf(tab: BaseTabComponent | null | undefined): string | null;
254
+ /**
255
+ * 이번 세션에서 에이전트가 고친 파일들 (절대경로, 최신 순). 훅이 없는 탭은 빈 배열.
256
+ *
257
+ * **빈 배열은 "아무것도 안 고쳤다" 가 아니라 "모른다" 로 읽어야 한다** — 훅이 없는
258
+ * 에이전트(Codex·Gemini)와 아직 안 고친 세션이 같은 값이다. 그래서 `변경` 탭은 이게 비면
259
+ * 목록을 좁히지 않고 저장소 전체를 그린다(빈 화면은 거짓말이 된다).
260
+ */
261
+ touchedOf(tab: BaseTabComponent | null | undefined): string[];
214
262
  /**
215
263
  * 이 탭에서 도는 서브에이전트 — **사이드바가 쓰는 유일한 창구**. 없으면 null.
216
264
  *
@@ -0,0 +1,24 @@
1
+ import { AgentId } from './agents';
2
+ /** Match the current UI, never a banner left in scrollback. Both the composer
3
+ * and its model/context footer are required; a shell prompt below it rejects
4
+ * an old Codex screen after exit. Trailing empty terminal rows are harmless.
5
+ */
6
+ export declare function isLiveCodexScreen(lines: string[]): boolean;
7
+ /** Synchronous fallback while the OS process probe is pending or unavailable.
8
+ * Only application version banners count, not mentions in ordinary output.
9
+ * A newer shell prompt or a Claude banner supersedes an old Codex banner.
10
+ */
11
+ export declare function identifyScreenAgent(lines: string[]): AgentId;
12
+ /** Observe banners before xterm renders them, and retain the identity when a
13
+ * resumed transcript replaces the banner. Bound parser state even for corrupt
14
+ * or split CSI/OSC sequences. Child processes can also change the console
15
+ * title, so require a shell prompt before treating a shell title as an exit.
16
+ */
17
+ export declare class TerminalAgentTracker {
18
+ id: AgentId | undefined;
19
+ private line;
20
+ private control;
21
+ private shellTitle;
22
+ private mode;
23
+ write(data: string): void;
24
+ }
@@ -16,6 +16,8 @@
16
16
  */
17
17
  /** 원장 한 줄 — 세션 하나가 무엇이었는지 */
18
18
  export interface SessionRecord {
19
+ /** Older ledger entries default to Claude. */
20
+ agent?: 'claude' | 'codex';
19
21
  /** Claude Code 세션 id (기록 파일 이름이기도 하다) */
20
22
  sessionId: string;
21
23
  /** 그 세션이 돌던 작업 폴더. **프로젝트 루트가 아니라 cwd 다** (`--resume` 이 이걸로 세션을 묶는다) */
@@ -59,6 +61,7 @@ export declare const LABEL_MAX = 140;
59
61
  export declare function clampLabel(text: string | null | undefined): string;
60
62
  /** 기록 앞부분에서 읽어낸 것 */
61
63
  export interface HeadInfo {
64
+ isSubagent?: boolean;
62
65
  cwd: string | null;
63
66
  label: string;
64
67
  /** 앞부분에서 본 가장 이른 timestamp — 파일 mtime 이 없을 때의 폴백 */
@@ -146,4 +149,4 @@ export declare function sessionIdFromFile(name: string): string | null;
146
149
  * 세션 id 는 우리가 파일 이름에서 읽은 값이고 `SESSION_FILE_RE` 를 통과한 것만 여기 온다 —
147
150
  * 그래도 명령을 만드는 자리이므로 한 번 더 막는다. 통과 못 하면 빈 문자열이고 호출부가 아무것도 보내지 않는다.
148
151
  */
149
- export declare function resumeCommand(sessionId: string, fork: boolean): string;
152
+ export declare function resumeCommand(sessionId: string, fork: boolean, agent?: 'claude' | 'codex'): string;
@@ -37,6 +37,8 @@ export declare class AgentDeckSettingsTabComponent {
37
37
  hooksSupported: boolean;
38
38
  /** 지금 걸려 있나 — 파일을 매번 읽지 않도록 화면용으로 들고 있는다 */
39
39
  hooksOn: boolean;
40
+ codexHooksOn: boolean;
41
+ codexHookError: string;
40
42
  /** 훅을 쓰다 실패한 이유 — 조용히 삼키면 사용자는 버튼이 먹통인 줄 안다 */
41
43
  hookError: string;
42
44
  /** 신고 안내에 그대로 보여줄 진단 로그 경로 */
@@ -67,6 +69,7 @@ export declare class AgentDeckSettingsTabComponent {
67
69
  * 사용자 입장에서는 "설치했는데 아무 일도 안 일어난다" 로만 보인다.
68
70
  */
69
71
  toggleHooks(): void;
72
+ toggleCodexHooks(): void;
70
73
  /**
71
74
  * OS 폴더 선택 창을 띄워 작업 폴더를 고른다.
72
75
  *
@@ -110,6 +110,37 @@ export declare function summarizeSubagents(state: SubagentState | null | undefin
110
110
  * 200px 사이드바(config `sidebarMin`)의 툴팁이 화면을 덮지 않게 `max` 개까지만 적고 나머지는 수로 줄인다.
111
111
  */
112
112
  export declare function formatSubagentTooltip(summary: SubagentSummary | null | undefined, max?: number): string;
113
+ /** 훅이 알려 준, 지금 도는 서브에이전트 하나 */
114
+ export interface LiveAgent {
115
+ /** 훅 payload 의 `agent_id` — start 와 stop 이 같은 값을 쓴다 */
116
+ id: string;
117
+ /** `general-purpose` · `Explore` 등. 없으면 빈 문자열 */
118
+ type: string;
119
+ /** start 를 받은 시각 (epoch ms) */
120
+ at: number;
121
+ }
122
+ export interface LiveAgentSummary {
123
+ running: number;
124
+ /** 종류별 개수 — 많은 것부터, 같으면 이름순 (화면 순서가 흔들리지 않게) */
125
+ byType: {
126
+ type: string;
127
+ count: number;
128
+ }[];
129
+ /** 가장 먼저 뜬 것의 시작 시각. 없으면 0 */
130
+ oldestAt: number;
131
+ }
132
+ /**
133
+ * 훅으로 받은 목록을 화면이 쓸 모양으로 줄인다.
134
+ *
135
+ * 정렬을 못 박는 이유 — 툴팁은 매초 다시 그려진다. `Map` 순서(삽입순)를 그대로 쓰면 에이전트
136
+ * 하나가 끝날 때마다 줄 순서가 바뀌어 읽는 중에 흔들린다.
137
+ */
138
+ export declare function summarizeLiveAgents(list: Iterable<LiveAgent> | null | undefined): LiveAgentSummary;
139
+ /**
140
+ * 훅 집계로 만드는 툴팁 머리. 대화기록 쪽(`formatSubagentTooltip`)과 **문구를 맞춘다** —
141
+ * 같은 자리에 뜨는 글이 원천에 따라 달라 보이면 사용자는 그것을 버그로 읽는다.
142
+ */
143
+ export declare function formatLiveAgentTooltip(summary: LiveAgentSummary | null | undefined): string;
113
144
  /**
114
145
  * 이 파일명이 그 세션의 대화기록인가 — 호출부가 `~/.claude/projects` 의 각 프로젝트 폴더를 훑을 때 쓰는 판정.
115
146
  *
@@ -0,0 +1,10 @@
1
+ /** Older xterm builds ignore synchronized-output mode (DEC 2026).
2
+ * ConPTY exposes a temporary cursor between the begin/end packets. Buffer the
3
+ * complete frame before handing it to xterm. The caller bounds missing ends.
4
+ */
5
+ export declare class SynchronizedOutputStream {
6
+ private pending;
7
+ write(data: string, enabled: boolean): string;
8
+ get waiting(): boolean;
9
+ flush(): string;
10
+ }
@@ -0,0 +1,6 @@
1
+ /** Windows ConPTY drops CSI-u. Alt+Enter survives as a native modified Enter.
2
+ * Confirmed against Codex 0.154.0's empty composer on 2026-09-13: row 11 -> 12.
3
+ */
4
+ export declare function newlineSequence(agent?: string): string;
5
+ /** Only Claude's bordered composer has a measured repair layout. */
6
+ export declare function canRepairComposer(agent?: string): boolean;
@@ -44,6 +44,14 @@ export declare class AgentDeckUpdateService {
44
44
  * 설치는커녕 Tabby 를 다시 띄우지도 못한다.
45
45
  */
46
46
  private launchUpdater;
47
+ /**
48
+ * 이 Tabby 인스턴스의 **메인 프로세스 PID**.
49
+ *
50
+ * 플러그인은 렌더러에서 돌고, Electron 렌더러의 부모가 메인이다. 메인이 죽으면 렌더러·gpu 도
51
+ * 함께 내려가므로 "이 인스턴스가 완전히 꺼졌나" 를 재는 기준점으로 이것이 맞다.
52
+ * `ppid` 를 못 읽는 환경(테스트 하네스 등)에서는 0 을 주고, 스크립트가 옛 방식으로 물러난다.
53
+ */
54
+ private mainProcessPid;
47
55
  /** `<Tabby 설정폴더>/plugins` — npm 이 `--prefix` 로 받을 경로 */
48
56
  private pluginsDir;
49
57
  /**
@@ -35,6 +35,13 @@ export interface ViewHost {
35
35
  getFollowMode(): FollowMode;
36
36
  /** 상대경로를 풀 기준 디렉토리 — 그 탭의 cwd. 모르면 null */
37
37
  cwdFor(tab: object): string | null;
38
+ /**
39
+ * 이번 세션에서 에이전트가 고친 파일(절대경로). `변경` 탭의 '세션' 목록이 이걸 쓴다.
40
+ *
41
+ * **빈 배열 = "모른다"** (훅 없는 에이전트이거나 아직 안 고쳤다). 그래서 비면 좁히지 않는다.
42
+ * 선택(optional)인 이유는 `sendText` 와 같다 — 배선이 없어도 패널은 동작해야 한다.
43
+ */
44
+ touchedFor?(tab: object): string[];
38
45
  /** 드래그가 끝났을 때 한 번 — config.save() 를 부르라는 뜻 */
39
46
  commit(): void;
40
47
  /** 크기·열림 상태가 바뀌었으니 레이아웃을 다시 잡으라는 뜻 */
@@ -65,6 +72,20 @@ export declare class ViewPanel {
65
72
  private pathRow;
66
73
  private pathInput;
67
74
  private modesEl;
75
+ private scopeBtn;
76
+ /**
77
+ * `변경` 목록의 범위. `session` = 이번 세션이 만진 파일만, `all` = 저장소의 커밋 안 된 전부.
78
+ *
79
+ * **기본이 `session` 인 이유** — `git diff HEAD` 는 저장소 사정에 따라 수백 개가 나온다
80
+ * (P4 워크스페이스 위의 git 오버레이에서 405개 실측, 2026-09-11). 그 안에서 방금 에이전트가
81
+ * 고친 것을 찾을 수는 없다. 설정에 저장하지 않는다 — 모드(`file`/`diff`)와 같은 이유로
82
+ * 기동 때는 늘 기본값에서 시작한다.
83
+ */
84
+ private diffScope;
85
+ /** 마지막 `변경` 갱신에서 범위 때문에 접힌 파일 수 — 안내줄이 쓴다 */
86
+ private diffHidden;
87
+ /** 마지막 좁히기의 입력·결과 (진단용, `__agentdeck.view().diffDebug`) */
88
+ private diffDebug;
68
89
  private commitEl;
69
90
  private commitInput;
70
91
  private commitBtn;
@@ -199,6 +220,15 @@ export declare class ViewPanel {
199
220
  get viewMode(): ViewMode;
200
221
  /** 스위치 모양만 맞춘다 — 그리기는 부르는 쪽이 한다 (두 번 그리지 않기 위해서) */
201
222
  private applyMode;
223
+ /**
224
+ * `변경` 목록의 범위를 바꾼다 (세션 ↔ 전체).
225
+ *
226
+ * git 을 다시 돌리지 않고 **이미 읽어 둔 결과를 다시 거르기만** 할 수도 있지만, 그러려면
227
+ * 마지막 diff 원문을 들고 있어야 하고 그 사이에 파일이 또 바뀌면 화면이 과거를 보여준다.
228
+ * git 한 번은 싸다(로컬 저장소 5초 상한) — 다시 읽는다.
229
+ */
230
+ private toggleScope;
231
+ private applyScopeLabel;
202
232
  setMode(mode: ViewMode): void;
203
233
  /**
204
234
  * `파일` ↔ `변경` 전환. 핫키(`agentdeck-view-mode`) 배선이 한 줄로 끝나게 public 으로 둔다 —
@@ -300,6 +330,8 @@ export declare class ViewPanel {
300
330
  * 스테이지에 올라간 변경을 감춰 버린다.
301
331
  */
302
332
  private refreshDiff;
333
+ /** 지금 탭에서 에이전트가 만진 파일 (없거나 배선이 없으면 빈 배열 = 좁히지 않는다) */
334
+ private touchedNow;
303
335
  /** 늦게 온 응답이 새 화면을 덮지 않게 — 번호와 모드가 그대로일 때만 그린다 */
304
336
  private finishDiff;
305
337
  /**
@@ -308,6 +340,24 @@ export declare class ViewPanel {
308
340
  * 그 딱지를 누르면 지금 보고 있지 않은 저장소에 `git add` 가 나간다.
309
341
  */
310
342
  private failDiff;
343
+ /**
344
+ * git 이 없거나 저장소가 아닐 때 — **그래도 이 세션이 만진 파일은 보여준다.**
345
+ *
346
+ * 왜 필요한가: 이 패널의 목적은 "에이전트가 방금 무엇을 건드렸나" 이고, 그 답은 훅이 이미
347
+ * 알고 있다(`touchedFor`). git 은 거기에 **무엇이 어떻게 바뀌었는지**를 더해 주는 도구일 뿐이다.
348
+ * 그런데 예전에는 git 이 없으면 안내 한 줄만 띄우고 끝나서, git 을 안 쓰는 폴더(스크립트 모음·
349
+ * 기획 문서 폴더·임시 작업 폴더)에서 탭이 통째로 죽은 기능이 됐다 (2026-09-11 유저 지적).
350
+ *
351
+ * **줄 내용(diff)은 만들지 않는다** — 기준(HEAD)이 없으니 "무엇이 바뀌었다" 를 말할 근거가
352
+ * 없고, 없는 근거를 지어내는 것보다 목록과 파일 크기·시각까지만 말하는 편이 정직하다.
353
+ * 클릭하면 `파일` 탭에서 내용을 본다(추적 안 됨 목록과 같은 `data-ad-open` 경로).
354
+ *
355
+ * 훅이 없는 에이전트(Codex·Gemini)는 만진 목록이 비어 있으므로 예전처럼 안내만 남는다 —
356
+ * 그 경우엔 정말로 보여줄 근거가 하나도 없다.
357
+ */
358
+ private noGit;
359
+ /** 커밋 줄은 git 이 있을 때만 뜻이 있다 — 모드와 별개로 여닫는다 */
360
+ private setCommitVisible;
311
361
  private renderDiffNote;
312
362
  private renderDiff;
313
363
  /**
package/dist/viewer.d.ts CHANGED
@@ -30,6 +30,10 @@ export declare function stripAnsi(data: string): string;
30
30
  * 걸러내는 것: 확장자를 모르는 것 / 잘려서 `…` 이 들어간 것 / URL / 버전 번호처럼 보이는 것.
31
31
  */
32
32
  export declare function extractPaths(data: string): string[];
33
+ /**
34
+ * 이 파일을 **스스로 띄워도 되는가** (`false` = 칩에만 쌓고 화면은 그대로 둔다).
35
+ */
36
+ export declare function isAutoFollowable(file: string): boolean;
33
37
  /**
34
38
  * 최근 목록에 하나를 얹는다 (같은 것은 위로 올리고, 최대 개수를 넘으면 뒤를 버린다).
35
39
  * 원본을 바꾸지 않고 새 배열을 돌려준다 — 렌더가 이전 배열과 비교할 수 있어야 한다.
@@ -0,0 +1,25 @@
1
+ # Codex lifecycle -> existing AgentDeck file/TCP transport. Never emit model context.
2
+ $ErrorActionPreference = 'Stop'
3
+ try {
4
+ $reader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), (New-Object System.Text.UTF8Encoding($false)))
5
+ $raw = $reader.ReadToEnd()
6
+ $reader.Dispose()
7
+ $hook = $raw | ConvertFrom-Json
8
+ if (-not $hook.session_id) { exit 0 }
9
+ $statusByEvent = @{
10
+ UserPromptSubmit = 'running'; PreToolUse = 'running'
11
+ PermissionRequest = 'waiting'; PostToolUse = 'running'
12
+ Stop = 'done'; Interrupt = 'idle'; SessionEnd = 'idle'
13
+ }
14
+ $status = $statusByEvent[[string]$hook.hook_event_name]
15
+ if (-not $status) { exit 0 }
16
+ # The shared transport's Notification filter is Claude-specific. An explicit
17
+ # Codex PermissionRequest must be accepted even after the previous turn ended.
18
+ if ($status -eq 'waiting') {
19
+ $hook | Add-Member -NotePropertyName message -NotePropertyValue ('Approval: ' + [string]$hook.tool_name) -Force
20
+ }
21
+ $hook | ConvertTo-Json -Depth 30 -Compress | & (Join-Path $PSScriptRoot 'agentdeck-notify.ps1') -Status $status -Agent codex
22
+ } catch {
23
+ # Status reporting must not block the agent if Tabby is unavailable.
24
+ exit 0
25
+ }
@@ -33,13 +33,24 @@
33
33
  # "Stop": -Status done 답이 끝났다
34
34
  # "StopFailure": -Status error 턴이 실패로 끝났다 (rate_limit 이면 limited)
35
35
  #>
36
- param(
36
+ param(
37
+ [ValidateSet('claude', 'codex')]
38
+ [string]$Agent = 'claude',
39
+
37
40
  [ValidateSet('running', 'waiting', 'limited', 'done', 'error', 'idle')]
38
41
  [string]$Status,
39
42
 
40
43
  # 사람이 셸에서 직접 붙이는 작업 이름. 이때만 라벨을 보낸다
41
44
  [string]$Label,
42
45
 
46
+ # 서브에이전트 생명주기. SubagentStart -> start / SubagentStop -> stop.
47
+ # `-Agent` 는 이미 어느 CLI 가 보냈나(claude/codex)에 쓰이므로 이름을 따로 둔다.
48
+ # **상태(-Status)와 섞지 않는다** — 이 이벤트는 세션 상태를 바꾸지 않고 개수만 움직인다.
49
+ # 훅 JSON 의 `agent_id` 가 함께 오므로 플러그인이 id 로 정확히 켜고 끈다(실측 payload:
50
+ # SubagentStart = agent_id·agent_type·session_id, SubagentStop = 같은 agent_id).
51
+ [ValidateSet('start', 'stop')]
52
+ [string]$Subagent,
53
+
43
54
  # stdin 훅 JSON 없이 부를 때 대상 세션 (없으면 가장 최근에 갱신된 세션)
44
55
  [string]$SessionId,
45
56
 
@@ -57,7 +68,7 @@ try {
57
68
  # PS 5.1 은 stdin 을 시스템 ANSI(CP949) 로 읽어 UTF-8 JSON 의 한글을 깨뜨린다.
58
69
  # 표준입력 스트림을 UTF-8 로 직접 열어서 읽는다
59
70
  $reader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), (New-Object System.Text.UTF8Encoding($false)))
60
- $raw = $reader.ReadToEnd()
71
+ $raw = if ($MyInvocation.ExpectingInput) { ($input | Out-String) } else { $reader.ReadToEnd() }
61
72
  $reader.Close()
62
73
  $hook = if ($raw) { $raw | ConvertFrom-Json } else { $null }
63
74
  } catch {
@@ -108,11 +119,26 @@ if ($hook -and [string]$hook.hook_event_name -eq 'StopFailure') {
108
119
  $failReason = if ($Status -eq 'limited' -and $hook.last_assistant_message) { [string]$hook.last_assistant_message } else { $code }
109
120
  }
110
121
 
122
+ # --- 이번 도구가 고친 파일 ---
123
+ # `변경` 탭이 "이 세션이 만진 것만" 을 그리는 근거다. 저장소 전체 diff 는 P4 워크스페이스 위에
124
+ # git 을 얹은 곳에서 405개씩 나와 쓸모가 없다 (2026-09-11 유저 지적).
125
+ # Edit/Write/NotebookEdit 은 전부 `tool_input.file_path` 를 쓴다. 다른 도구에는 없으므로 그냥 비고,
126
+ # 훅이 아예 없는 에이전트는 이 키가 영영 안 와서 플러그인이 전체 목록으로 떨어진다(그게 폴백이다).
127
+ $touchedFile = ''
128
+ if ($hook -and $hook.tool_input -and $hook.tool_input.file_path) {
129
+ $touchedFile = [string]$hook.tool_input.file_path
130
+ }
131
+
111
132
  # --- 진행중 중복 보고 생략 ---
112
133
  # PostToolUse 는 도구가 돌 때마다 발화한다. 이미 running 인데 running 을 또 쓰면 파일·TCP 만 흔들고
113
134
  # 사이드바는 변하지 않는다(경과시간이 매 도구마다 0 으로 되돌아가기까지 한다). 라벨 지정도 없으면 여기서 끝낸다 —
114
- # 아래 계보 조회(~450ms)까지 건너뛰므로 도구마다 붙는 비용이 사실상 없다
115
- if ($Status -eq 'running' -and $prevStatus -eq 'running' -and -not $Label) { exit 0 }
135
+ # 아래 계보 조회(~450ms)까지 건너뛰므로 도구마다 붙는 비용이 사실상 없다.
136
+ # **고친 파일이 있으면 건너뛰지 않는다** — 그 보고가 곧 `변경` 탭의 목록이라 하나라도 빠지면
137
+ # 그 파일이 화면에서 사라진다. 대신 그 경우에도 계보 조회는 하지 않는다(아래 tabId 분기):
138
+ # 세션은 이미 탭에 묶여 있어 sessionId 만으로 찾아간다(notify.service `resolveTab` 의 `alive`).
139
+ # **서브에이전트 이벤트도 건너뛰지 않는다** — 하나 빠지면 개수가 영구히 어긋난다(start 를 놓치면
140
+ # 적게, stop 을 놓치면 많게 굳는다). 이쪽도 계보 조회는 하지 않는다(아래 tabId 분기).
141
+ if ($Status -eq 'running' -and $prevStatus -eq 'running' -and -not $Label -and -not $touchedFile -and -not $Subagent) { exit 0 }
116
142
 
117
143
  # --- 어느 탭인가: tabId (1순위) / 프로세스 계보 pids (폴백) ---
118
144
  # 사이드바가 이 보고를 어느 탭에 붙일지 정하는 근거. 예전 규칙 "처음 보고할 때의 활성 탭" 은
@@ -139,9 +165,12 @@ $tabId = ([string]$env:AGENTDECK_TAB).Trim()
139
165
  $pids = @()
140
166
  $claudePid = 0
141
167
  $claudeName = ''
168
+ # 이미 running 인 세션의 "파일만 알리는" 보고와 서브에이전트 이벤트에서는 계보를 재지 않는다 —
169
+ # 450ms 짜리 Win32_Process 전체 조회를 Edit·에이전트마다 낼 수는 없다. 그 세션은 앞선 보고로
170
+ # 이미 탭에 묶여 있어 sessionId 만으로 찾아간다 (notify.service `resolveTab` 의 `alive` 경로).
142
171
  if ($tabId) {
143
172
  # 탭이 확정됐다 — 계보는 계산하지 않는다
144
- } elseif ($hook) {
173
+ } elseif ($hook -and -not $Subagent -and -not ($Status -eq 'running' -and $prevStatus -eq 'running')) {
145
174
  $reuse = $false
146
175
  if ($prev -and $prev.pids -and $prev.claudePid) {
147
176
  $aliveProc = Get-Process -Id ([int]$prev.claudePid) -ErrorAction SilentlyContinue
@@ -186,7 +215,7 @@ if ($tabId) {
186
215
  # 그래서 Stop(done) 뒤에 유휴 알림이 오면 완료된 탭이 '승인대기' 로 뒤집힌다 (2026-08-28 실측:
187
216
  # status 폴더의 두 세션이 작업을 끝냈는데도 waiting 으로 박혀 있었다).
188
217
  # 승인 요청이 아닌 알림이면 상태를 건드리지 않는다.
189
- if ($Status -eq 'waiting') {
218
+ if ($Status -eq 'waiting' -and $Agent -eq 'claude') {
190
219
  $msg = if ($hook -and $hook.message) { [string]$hook.message } else { '' }
191
220
  $idleNotice = $msg -match '(?i)waiting for your input|idle'
192
221
  $alreadyFinished = $prevStatus -eq 'done' -or $prevStatus -eq 'idle' -or $prevStatus -eq 'limited'
@@ -230,12 +259,60 @@ $effectiveStatus = if ($Status) { $Status } elseif ($prevStatus) { $prevStatus }
230
259
 
231
260
  $payload = [ordered]@{
232
261
  sessionId = $targetId
233
- status = $effectiveStatus
234
262
  ts = [int64](([datetime]::UtcNow - [datetime]'1970-01-01').TotalMilliseconds)
235
263
  }
264
+ # **서브에이전트 이벤트는 상태를 싣지 않는다.** 이 훅에는 `-Status` 가 없어서 직전 상태를
265
+ # 되쓰게 되는데, 그러면 이미 `done` 인 세션에 백그라운드 에이전트의 stop 이 도착할 때
266
+ # 세션이 `running` 으로 되살아난다 (백그라운드 에이전트는 턴이 끝난 뒤에도 돈다).
267
+ if (-not $Subagent) { $payload['status'] = $effectiveStatus }
236
268
  if ($newLabel) { $payload['label'] = $newLabel }
237
269
  if ($reason) { $payload['reason'] = $reason }
238
270
  if ($tabId) { $payload['tabId'] = $tabId }
271
+ # --- 작업 폴더 ---
272
+ # 훅 JSON 의 `cwd` 는 에이전트가 실제로 돌고 있는 폴더다. 이걸 보내는 이유는 Tabby 순정의 cwd 추정이
273
+ # 윈도우에서 못 믿을 값을 내기 때문이다 — `guessWindowsCWD` 가 PTY 출력에서 `X:\...` 처럼 보이는
274
+ # 첫 토큰을 그대로 cwd 로 삼는다(tabby-local/dist/index.js:1316, 정규식 /([a-zA-Z]:[^\:\[\]\?\"\<\>\|]+)/mi).
275
+ # 에이전트가 화면에 찍은 **파일 경로**도 걸려서, 미리보기 `변경` 탭이 `git -C <파일.py>` 를 돌리고
276
+ # `fatal: cannot change to ...: Invalid argument` 로 끝났다 (2026-09-11 실측).
277
+ if ($hook -and $hook.cwd) { $payload['cwd'] = [string]$hook.cwd }
278
+ # 이번 도구가 고친 파일 — 플러그인이 탭별로 모아 `변경` 탭의 '세션' 목록을 만든다
279
+ if ($touchedFile) { $payload['file'] = $touchedFile }
280
+
281
+ # --- 지금 도는 서브에이전트 (스냅샷) ---
282
+ # **증분(이번에 하나 떴다/졌다)이 아니라 목록 전체를 보낸다.** 이유는 상태 파일이 "마지막 보고
283
+ # 한 건" 만 담기 때문이다 — TCP 가 막혀 폴링(400ms)으로만 전달되는 환경에서 start 와 stop 이
284
+ # 한 주기 안에 겹치면 앞의 것이 덮여 **영영 유실**되고, 누적으로 세는 개수는 그대로 어긋난다
285
+ # (상태값은 최신 하나만 의미가 있어서 이 구조로 충분했지만, 개수는 아니다).
286
+ # 목록을 통째로 실으면 덮여도 마지막 파일이 곧 정답이라 유실이 개수를 틀리게 만들지 못한다.
287
+ #
288
+ # 직전 목록은 이 파일에서 읽는다(= 훅 호출 사이의 저장소). 그래서 상태 보고(running/done)에서도
289
+ # 그대로 다시 실어 줘야 한다 — 안 실으면 다음 에이전트 이벤트가 빈 목록에서 시작한다.
290
+ $agents = @()
291
+ if ($prev -and $prev.agents) {
292
+ foreach ($a in @($prev.agents)) {
293
+ if ($a -and $a.id) {
294
+ $agents += , ([ordered]@{ id = [string]$a.id; type = [string]$a.type; at = [int64]$a.at })
295
+ }
296
+ }
297
+ }
298
+ if ($Subagent -and $hook -and $hook.agent_id) {
299
+ $aid = [string]$hook.agent_id
300
+ # 같은 id 는 먼저 빼고 — start 가 두 번 와도 하나, stop 은 빼기만 하면 된다
301
+ $agents = @($agents | Where-Object { $_.id -ne $aid })
302
+ if ($Subagent -eq 'start') {
303
+ $agents += , ([ordered]@{
304
+ id = $aid
305
+ type = [string]$hook.agent_type
306
+ at = [int64](([datetime]::UtcNow - [datetime]'1970-01-01').TotalMilliseconds)
307
+ })
308
+ }
309
+ }
310
+ # 빈 목록도 보낸다 — "마지막 하나가 끝났다" 를 말할 방법이 이것뿐이다.
311
+ # 단 에이전트를 한 번도 본 적 없는 세션에서는 키 자체를 빼서, 훅이 개수를 안다고 주장하지 않는다
312
+ # (플러그인은 이 키를 본 탭에서만 대화기록 폴백을 끈다)
313
+ if ($Subagent -or $agents.Count -gt 0) {
314
+ $payload['agents'] = @($agents)
315
+ }
239
316
  if ($pids.Count -gt 0) {
240
317
  $payload['pids'] = [int[]]$pids
241
318
  $payload['claudePid'] = $claudePid
@@ -246,8 +323,11 @@ if ($pids.Count -gt 0) {
246
323
  #
247
324
  # TCP 로 밀어 넣더라도 이 파일 쓰기는 건너뛰지 않는다 — 파일이 곧 상태 저장소라서
248
325
  # 다음 훅 호출이 여기서 직전 상태를 읽는다(위 $prevStatus). 그게 '승인대기 오탐 차단' 의 근거다.
249
- $json = $payload | ConvertTo-Json -Compress
250
- $tmp = "$file.tmp"
326
+ # `-Depth` 를 명시한다 — 기본 2 는 `agents` 안의 객체를 `System.Collections.Specialized...` 문자열로
327
+ # 뭉개 버린다(중첩 한 겹이 더 생겼다). 원소가 하나일 때 배열이 스칼라로 접히는 문제는 받는 쪽에서
328
+ # 배열/단일을 모두 받아 푼다 — PowerShell 의 JSON 변환은 그 접힘을 끌 수 없다
329
+ $json = $payload | ConvertTo-Json -Compress -Depth 5
330
+ $tmp = "$file.$PID.tmp"
251
331
  $enc = New-Object System.Text.UTF8Encoding($false)
252
332
  [System.IO.File]::WriteAllText($tmp, $json, $enc)
253
333
  [System.IO.File]::Copy($tmp, $file, $true)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tabby-agentdeck",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
4
4
  "description": "Run AI coding agents side by side in Tabby - a live deck of agent sessions with status, a preview panel for the files they touch, agent-friendly hotkeys and image paste",
5
5
  "keywords": [
6
6
  "tabby-plugin",
@@ -14,17 +14,23 @@
14
14
  "main": "dist/index.js",
15
15
  "typings": "dist/index.d.ts",
16
16
  "scripts": {
17
- "build": "webpack --progress --color",
17
+ "build": "webpack --mode production",
18
18
  "watch": "webpack --progress --color --watch",
19
- "test": "tsc src/prompt.ts src/detect.ts src/claudeHooks.ts src/screen.ts src/bind.ts src/order.ts src/reason.ts src/tabenv.ts src/viewer.ts src/markdown.ts src/ime.ts src/gitDiff.ts src/agents.ts src/group.ts src/project-root.ts src/gitCommit.ts src/diag.ts src/subagents.ts src/nav.ts src/sessionLedger.ts src/update.ts src/api.ts src/dock.ts src/status.service.ts src/profile.service.ts src/recovery.service.ts --outDir .tmp --module commonjs --target es2019 --skipLibCheck --experimentalDecorators --esModuleInterop && node test/version.test.js && node test/prompt.test.js && node test/detect.test.js && node test/claudeHooks.test.js && node test/screen.test.js && node test/bind.test.js && node test/order.test.js && node test/reason.test.js && node test/tabenv.test.js && node test/viewer.test.js && node test/markdown.test.js && node test/ime.test.js && node test/gitDiff.test.js && node test/agents.test.js && node test/group.test.js && node test/gitCommit.test.js && node test/diag.test.js && node test/subagents.test.js && node test/nav.test.js && node test/sessionLedger.test.js && node test/update.test.js && node test/api.test.js && node test/dock.test.js && node test/status.service.test.js && node test/profile.service.test.js && node test/recovery.service.test.js",
19
+ "test": "npm run test:existing && npm run test:codex",
20
20
  "prebuild": "node tools/sync-version.js",
21
- "sync-version": "node tools/sync-version.js"
21
+ "sync-version": "node tools/sync-version.js",
22
+ "release": "node tools/release.js",
23
+ "test:existing": "tsc src/prompt.ts src/detect.ts src/claudeHooks.ts src/screen.ts src/bind.ts src/order.ts src/reason.ts src/tabenv.ts src/viewer.ts src/markdown.ts src/ime.ts src/gitDiff.ts src/agents.ts src/group.ts src/project-root.ts src/gitCommit.ts src/diag.ts src/subagents.ts src/nav.ts src/sessionLedger.ts src/update.ts src/api.ts src/dock.ts src/status.service.ts src/profile.service.ts src/recovery.service.ts --outDir .tmp --module commonjs --target es2019 --skipLibCheck --experimentalDecorators --esModuleInterop && node test/version.test.js && node test/tags.test.js && node test/prompt.test.js && node test/detect.test.js && node test/claudeHooks.test.js && node test/screen.test.js && node test/bind.test.js && node test/order.test.js && node test/reason.test.js && node test/tabenv.test.js && node test/viewer.test.js && node test/markdown.test.js && node test/ime.test.js && node test/gitDiff.test.js && node test/agents.test.js && node test/group.test.js && node test/gitCommit.test.js && node test/diag.test.js && node test/subagents.test.js && node test/nav.test.js && node test/sessionLedger.test.js && node test/update.test.js && node test/api.test.js && node test/dock.test.js && node test/status.service.test.js && node test/profile.service.test.js && node test/recovery.service.test.js",
24
+ "test:codex": "node test/regression.cjs && node test/resume.cjs && node test/cursor.cjs && node test/wheel.cjs",
25
+ "typecheck": "tsc --noEmit"
22
26
  },
23
27
  "files": [
24
28
  "dist",
25
29
  "hooks",
26
30
  "updater",
27
- "install.ps1"
31
+ "install.ps1",
32
+ "CHANGELOG.md",
33
+ "README.ko.md"
28
34
  ],
29
35
  "license": "MIT",
30
36
  "devDependencies": {
@@ -33,6 +39,7 @@
33
39
  "@angular/forms": "^15.2.6",
34
40
  "@types/node": "^20.19.43",
35
41
  "@types/webpack-env": "^1.18.4",
42
+ "@xterm/headless": "^5.5.0",
36
43
  "css-loader": "^6.10.0",
37
44
  "rxjs": "^7.5.7",
38
45
  "sass": "^1.71.1",
@@ -49,5 +56,13 @@
49
56
  },
50
57
  "author": {
51
58
  "name": "junggon"
59
+ },
60
+ "repository": {
61
+ "type": "git",
62
+ "url": "git+https://github.com/jghwang2/tabby-agentdeck.git"
63
+ },
64
+ "homepage": "https://jghwang2.github.io/tabby-agentdeck/",
65
+ "bugs": {
66
+ "url": "https://github.com/jghwang2/tabby-agentdeck/issues"
52
67
  }
53
68
  }
@@ -16,6 +16,9 @@ param(
16
16
  [Parameter(Mandatory = $true)][string]$Version,
17
17
  [Parameter(Mandatory = $true)][string]$PluginsDir,
18
18
  [Parameter(Mandatory = $true)][string]$TabbyExe,
19
+ # 우리를 띄운 그 Tabby 의 **메인 프로세스 PID**. 이것만 기다린다.
20
+ # 0 이면 옛 방식(이름으로 전부)으로 물러난다 — 옛 버전이 이 인자를 안 보낸다.
21
+ [int]$TabbyPid = 0,
19
22
  [string]$LogFile = '',
20
23
  # Tabby 가 꺼지기를 기다리는 최대 시간(초). 넘으면 설치하지 않고 그냥 끝낸다 —
21
24
  # 떠 있는 상태로 설치하면 반쯤 지워진 플러그인이 남는다
@@ -48,14 +51,33 @@ try {
48
51
  Write-Log "=== update 시작 version=$Version pluginsDir=$PluginsDir"
49
52
 
50
53
  # ── 1) Tabby 가 꺼지기를 기다린다 ────────────────────────────────────────────
51
- # 우리를 띄운 그 Tabby 가 곧 자기를 닫는다. 자식 프로세스(gpu·renderer)까지 다 내려가야
52
- # 파일 잠금이 풀리므로 이름으로 전부 센다.
54
+ #
55
+ # **우리를 띄운 그 인스턴스만 기다린다** (`-TabbyPid` = Electron 메인 프로세스).
56
+ # 이름(`Tabby.exe`)으로 전부 세면 **다른 Tabby 창 하나 때문에 업데이트가 영영 안 된다** —
57
+ # 그쪽은 다른 user-data-dir 을 쓰는 별개 인스턴스라 우리 플러그인 파일과 아무 상관이 없다
58
+ # (2026-09-11: 격리 인스턴스로 검증하려 했더니 실사용 Tabby 가 떠 있어 60초 타임아웃).
59
+ #
60
+ # 파일 잠금을 쥔 것은 플러그인을 require 한 **렌더러**지만, 메인이 죽으면 자식(renderer·gpu)도
61
+ # 함께 내려간다. 그래서 메인 PID 가 사라지고 그 자식들이 정리될 때까지를 한 조건으로 본다.
62
+ function Test-TabbyAlive ([int]$mainPid) {
63
+ if ($mainPid -le 0) {
64
+ # 옛 방식 — 이름으로 전부
65
+ return @(Get-Process -Name 'Tabby' -ErrorAction SilentlyContinue).Count
66
+ }
67
+ $n = 0
68
+ if (Get-Process -Id $mainPid -ErrorAction SilentlyContinue) { $n++ }
69
+ # 메인이 사라져도 자식이 잠깐 남을 수 있다 — 그 자식들까지 없어져야 잠금이 풀린다
70
+ $n += @(Get-CimInstance Win32_Process -Filter "Name='Tabby.exe' AND ParentProcessId=$mainPid" -ErrorAction SilentlyContinue).Count
71
+ return $n
72
+ }
73
+
74
+ Write-Log "대기 대상: $(if ($TabbyPid -gt 0) { "pid=$TabbyPid (+자식)" } else { '이름으로 전부 (옛 방식)' })"
53
75
  $waited = 0
54
76
  while ($true) {
55
- $alive = @(Get-Process -Name 'Tabby' -ErrorAction SilentlyContinue)
56
- if ($alive.Count -eq 0) { break }
77
+ $alive = Test-TabbyAlive $TabbyPid
78
+ if ($alive -eq 0) { break }
57
79
  if ($waited -ge $WaitSeconds) {
58
- Write-Log "FAIL Tabby 가 ${WaitSeconds}초 안에 꺼지지 않았다 (남은 프로세스 $($alive.Count)개) — 설치하지 않는다"
80
+ Write-Log "FAIL Tabby 가 ${WaitSeconds}초 안에 꺼지지 않았다 (남은 프로세스 ${alive}개) — 설치하지 않는다"
59
81
  exit 2
60
82
  }
61
83
  Start-Sleep -Milliseconds 500