tabby-agentdeck 0.2.0 → 0.2.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.
- package/dist/bind.d.ts +23 -0
- package/dist/index.js +177 -11
- package/dist/index.js.map +1 -1
- package/dist/notify.service.d.ts +34 -5
- package/hooks/agentdeck-notify.ps1 +61 -0
- package/package.json +2 -2
package/dist/bind.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 훅 보고를 어느 탭에 붙일지 고르는 순수 규칙 — Angular/Tabby 에 기대지 않아 단위 테스트가 된다.
|
|
3
|
+
*
|
|
4
|
+
* 왜 PID 인가 — 예전 규칙 "처음 보고할 때의 활성 탭" 은 사용자가 방금 프롬프트를 넣은 탭이
|
|
5
|
+
* 곧 활성 탭이라는 가정인데, 파일 폴링이 400ms 마다 못 묶은 보고를 다시 시도하는 탓에
|
|
6
|
+
* **끝난 세션의 done 이 다음에 열리는 빈 탭을 붙잡았다** (2026-09-02 실측: 작업 중인 탭이
|
|
7
|
+
* "완료" 로, 그 탭의 진짜 세션은 주인이 있다며 거절돼 running 이 버려졌다).
|
|
8
|
+
*
|
|
9
|
+
* 훅은 자기 조상 프로세스 PID 를 함께 보낸다 — claude.exe → 탭 셸 → Tabby.exe 순서다.
|
|
10
|
+
* 탭마다 pty 가 띄운 셸 PID 를 알고 있으면 어느 탭의 보고인지 추측 없이 확정된다.
|
|
11
|
+
*/
|
|
12
|
+
export type BindMode = 'pid' | 'pid-miss' | 'legacy';
|
|
13
|
+
export interface BindPick<T> {
|
|
14
|
+
tab: T | null;
|
|
15
|
+
/**
|
|
16
|
+
* pid — 조상 PID 가 어떤 탭의 셸 PID 와 맞았다 (확정)
|
|
17
|
+
* pid-miss — pids 도 왔고 탭 PID 표도 있는데 맞는 탭이 없다. 활성 탭으로 추측하지 **않는다**
|
|
18
|
+
* (탭 PID 표가 아직 안 채워진 것일 수 있으니 호출자는 다음 폴링에 다시 본다)
|
|
19
|
+
* legacy — pids 가 없거나(옛 훅·수동 호출) 탭 PID 를 하나도 모른다. 호출자가 예전 활성 탭 규칙을 쓴다
|
|
20
|
+
*/
|
|
21
|
+
mode: BindMode;
|
|
22
|
+
}
|
|
23
|
+
export declare function pickTabByPids<T>(pids: number[] | undefined, tabPids: Map<T, number[]>): BindPick<T>;
|
package/dist/index.js
CHANGED
|
@@ -845,6 +845,40 @@ const STATUS_STYLES = {
|
|
|
845
845
|
};
|
|
846
846
|
|
|
847
847
|
|
|
848
|
+
/***/ },
|
|
849
|
+
|
|
850
|
+
/***/ "./src/bind.ts"
|
|
851
|
+
/*!*********************!*\
|
|
852
|
+
!*** ./src/bind.ts ***!
|
|
853
|
+
\*********************/
|
|
854
|
+
(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
|
|
855
|
+
|
|
856
|
+
__webpack_require__.r(__webpack_exports__);
|
|
857
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
858
|
+
/* harmony export */ pickTabByPids: () => (/* binding */ pickTabByPids)
|
|
859
|
+
/* harmony export */ });
|
|
860
|
+
function pickTabByPids(pids, tabPids) {
|
|
861
|
+
const wanted = (pids !== null && pids !== void 0 ? pids : []).filter(n => Number.isInteger(n) && n > 0);
|
|
862
|
+
let anyInfo = false;
|
|
863
|
+
for (const list of tabPids.values()) {
|
|
864
|
+
if (list.length) {
|
|
865
|
+
anyInfo = true;
|
|
866
|
+
break;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
if (!wanted.length || !anyInfo) {
|
|
870
|
+
return { tab: null, mode: 'legacy' };
|
|
871
|
+
}
|
|
872
|
+
const set = new Set(wanted);
|
|
873
|
+
for (const [tab, list] of tabPids) {
|
|
874
|
+
if (list.some(p => set.has(p))) {
|
|
875
|
+
return { tab, mode: 'pid' };
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
return { tab: null, mode: 'pid-miss' };
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
|
|
848
882
|
/***/ },
|
|
849
883
|
|
|
850
884
|
/***/ "./src/claudeHooks.ts"
|
|
@@ -4094,6 +4128,7 @@ __webpack_require__.r(__webpack_exports__);
|
|
|
4094
4128
|
/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_5___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_5__);
|
|
4095
4129
|
/* harmony import */ var _status_service__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ./status.service */ "./src/status.service.ts");
|
|
4096
4130
|
/* harmony import */ var _claudeHooks__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(/*! ./claudeHooks */ "./src/claudeHooks.ts");
|
|
4131
|
+
/* harmony import */ var _bind__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(/*! ./bind */ "./src/bind.ts");
|
|
4097
4132
|
var __decorate = (undefined && undefined.__decorate) || function (decorators, target, key, desc) {
|
|
4098
4133
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4099
4134
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -4111,6 +4146,8 @@ var __metadata = (undefined && undefined.__metadata) || function (k, v) {
|
|
|
4111
4146
|
|
|
4112
4147
|
|
|
4113
4148
|
|
|
4149
|
+
|
|
4150
|
+
const DIAG_PATH = path__WEBPACK_IMPORTED_MODULE_5__.join(os__WEBPACK_IMPORTED_MODULE_4__.homedir(), '.agentdeck-diag.log');
|
|
4114
4151
|
/**
|
|
4115
4152
|
* 에이전트 훅 -> 사이드바 상태 채널. **기본으로 켜져 있다** (`notifyChannel`).
|
|
4116
4153
|
*
|
|
@@ -4162,6 +4199,15 @@ let WorkNotifyService = class WorkNotifyService {
|
|
|
4162
4199
|
this.timer = null;
|
|
4163
4200
|
/** 폴링을 시작한 시각 — 이보다 오래된 파일은 지난 실행의 잔재다 */
|
|
4164
4201
|
this.startedAt = 0;
|
|
4202
|
+
/**
|
|
4203
|
+
* 탭(루트) -> 그 안 패널들의 셸 PID. 훅이 보낸 조상 PID 와 맞춰 어느 탭의 보고인지 확정한다.
|
|
4204
|
+
* pty 에 물어보는 비동기 작업이라 표로 들고 있고, 탭이 열릴 때와 2초마다 새로 채운다
|
|
4205
|
+
*/
|
|
4206
|
+
this.tabPids = new Map();
|
|
4207
|
+
this.pidRefreshing = false;
|
|
4208
|
+
this.pollCount = 0;
|
|
4209
|
+
/** 같은 매칭 실패를 폴링마다 diag 에 찍지 않기 위한 마지막 메시지 */
|
|
4210
|
+
this.lastBindLog = new Map();
|
|
4165
4211
|
}
|
|
4166
4212
|
/**
|
|
4167
4213
|
* 훅이 안 걸려 있으면 기동할 때 한 번 물어본다.
|
|
@@ -4238,9 +4284,15 @@ let WorkNotifyService = class WorkNotifyService {
|
|
|
4238
4284
|
this.startedAt = Date.now();
|
|
4239
4285
|
this.listen();
|
|
4240
4286
|
this.timer = setInterval(() => this.poll(), 400);
|
|
4287
|
+
void this.refreshPids();
|
|
4288
|
+
});
|
|
4289
|
+
// 새 탭의 셸 PID 는 세션이 붙은 뒤에야 나온다 — 조금 뒤에 한 번 더 채운다 (2초 주기 폴링도 있다)
|
|
4290
|
+
this.app.tabOpened$.subscribe(() => {
|
|
4291
|
+
setTimeout(() => { void this.refreshPids(); }, 1500);
|
|
4241
4292
|
});
|
|
4242
4293
|
// 탭이 닫히면 점유를 풀어야 그 자리를 다음 세션이 쓸 수 있다
|
|
4243
4294
|
this.app.tabClosed$.subscribe(tab => {
|
|
4295
|
+
this.tabPids.delete(tab);
|
|
4244
4296
|
const sid = this.tabSession.get(tab);
|
|
4245
4297
|
if (sid) {
|
|
4246
4298
|
this.tabSession.delete(tab);
|
|
@@ -4248,6 +4300,60 @@ let WorkNotifyService = class WorkNotifyService {
|
|
|
4248
4300
|
}
|
|
4249
4301
|
});
|
|
4250
4302
|
}
|
|
4303
|
+
/** 진단 한 줄 — deck.service 와 같은 파일(`~/.agentdeck-diag.log`)에 남긴다 */
|
|
4304
|
+
diag(line) {
|
|
4305
|
+
try {
|
|
4306
|
+
fs__WEBPACK_IMPORTED_MODULE_2__.appendFileSync(DIAG_PATH, `${new Date().toISOString()} ${line}\n`);
|
|
4307
|
+
}
|
|
4308
|
+
catch {
|
|
4309
|
+
// 진단일 뿐이다
|
|
4310
|
+
}
|
|
4311
|
+
}
|
|
4312
|
+
tabName(tab) {
|
|
4313
|
+
const anyTab = tab;
|
|
4314
|
+
return JSON.stringify(String(anyTab.customTitle || anyTab.title || '').slice(0, 40));
|
|
4315
|
+
}
|
|
4316
|
+
/**
|
|
4317
|
+
* 탭별 셸 PID 표를 새로 채운다. 로컬 pty 는 `session.pty.getPID()` 로 셸 PID 를 준다
|
|
4318
|
+
* (tabby-local Session — gracefullyKillProcess 가 쓰는 그 경로). SSH 등 PID 가 없는 탭은 빈 목록.
|
|
4319
|
+
*/
|
|
4320
|
+
async refreshPids() {
|
|
4321
|
+
var _a;
|
|
4322
|
+
if (this.pidRefreshing) {
|
|
4323
|
+
return;
|
|
4324
|
+
}
|
|
4325
|
+
this.pidRefreshing = true;
|
|
4326
|
+
try {
|
|
4327
|
+
for (const root of [...this.app.tabs]) {
|
|
4328
|
+
const anyRoot = root;
|
|
4329
|
+
const panes = typeof anyRoot.getAllTabs === 'function' ? anyRoot.getAllTabs() : [root];
|
|
4330
|
+
const pids = [];
|
|
4331
|
+
for (const pane of panes) {
|
|
4332
|
+
const pty = (_a = pane === null || pane === void 0 ? void 0 : pane.session) === null || _a === void 0 ? void 0 : _a.pty;
|
|
4333
|
+
if (!pty || typeof pty.getPID !== 'function') {
|
|
4334
|
+
continue;
|
|
4335
|
+
}
|
|
4336
|
+
try {
|
|
4337
|
+
const pid = await pty.getPID();
|
|
4338
|
+
if (Number.isInteger(pid) && pid > 0) {
|
|
4339
|
+
pids.push(pid);
|
|
4340
|
+
}
|
|
4341
|
+
}
|
|
4342
|
+
catch {
|
|
4343
|
+
// 세션이 닫히는 중이다
|
|
4344
|
+
}
|
|
4345
|
+
}
|
|
4346
|
+
const prev = this.tabPids.get(root);
|
|
4347
|
+
if (!prev || prev.join(',') !== pids.join(',')) {
|
|
4348
|
+
this.tabPids.set(root, pids);
|
|
4349
|
+
this.diag(`pid-map tab=${this.tabName(root)} pids=[${pids.join(',')}]`);
|
|
4350
|
+
}
|
|
4351
|
+
}
|
|
4352
|
+
}
|
|
4353
|
+
finally {
|
|
4354
|
+
this.pidRefreshing = false;
|
|
4355
|
+
}
|
|
4356
|
+
}
|
|
4251
4357
|
/** 진단용 */
|
|
4252
4358
|
get debugDir() { return this.dir; }
|
|
4253
4359
|
/** 진단용 — 듣고 있는 포트 (0 = TCP 채널이 안 떴다) */
|
|
@@ -4329,6 +4435,10 @@ let WorkNotifyService = class WorkNotifyService {
|
|
|
4329
4435
|
}
|
|
4330
4436
|
}
|
|
4331
4437
|
poll() {
|
|
4438
|
+
// 셸 PID 표는 2초마다 — 탭 안에서 셸을 다시 띄우거나 분할하면 바뀐다
|
|
4439
|
+
if (++this.pollCount % 5 === 0) {
|
|
4440
|
+
void this.refreshPids();
|
|
4441
|
+
}
|
|
4332
4442
|
let names;
|
|
4333
4443
|
try {
|
|
4334
4444
|
names = fs__WEBPACK_IMPORTED_MODULE_2__.readdirSync(this.dir);
|
|
@@ -4374,6 +4484,7 @@ let WorkNotifyService = class WorkNotifyService {
|
|
|
4374
4484
|
* 들고 있는 이쪽이 한다 — 그래야 어느 경로로 들어오든 결과가 같다.
|
|
4375
4485
|
*/
|
|
4376
4486
|
apply(data) {
|
|
4487
|
+
var _a;
|
|
4377
4488
|
if (!data || !data.sessionId) {
|
|
4378
4489
|
return;
|
|
4379
4490
|
}
|
|
@@ -4385,8 +4496,16 @@ let WorkNotifyService = class WorkNotifyService {
|
|
|
4385
4496
|
if (this.applied.get(data.sessionId) === data.ts) {
|
|
4386
4497
|
return;
|
|
4387
4498
|
}
|
|
4388
|
-
const tab = this.resolveTab(data.sessionId);
|
|
4499
|
+
const tab = this.resolveTab(data.sessionId, data.pids);
|
|
4389
4500
|
if (!tab) {
|
|
4501
|
+
// pids 없는 보고(옛 훅·수동 호출)는 다시 봐도 결과가 같다. 그런데 폴링이 400ms 마다
|
|
4502
|
+
// 되풀이하면 "활성 탭에 묶는다" 규칙이 다음에 열리는 빈 탭을 붙잡는다 — 끝난 세션의
|
|
4503
|
+
// done 이 새 탭에 박히고, 그 탭의 진짜 세션은 주인이 있다며 거절됐다 (2026-09-02 실측).
|
|
4504
|
+
// 적용한 것으로 쳐서 멈춘다. 다음 보고(새 ts)가 오면 그때 다시 시도한다.
|
|
4505
|
+
// pids 가 있는 보고는 탭 PID 표가 채워지면 맞을 수 있으니 다음 폴링에 다시 본다.
|
|
4506
|
+
if (!((_a = data.pids) === null || _a === void 0 ? void 0 : _a.length)) {
|
|
4507
|
+
this.applied.set(data.sessionId, data.ts);
|
|
4508
|
+
}
|
|
4390
4509
|
return;
|
|
4391
4510
|
}
|
|
4392
4511
|
this.applied.set(data.sessionId, data.ts);
|
|
@@ -4406,17 +4525,40 @@ let WorkNotifyService = class WorkNotifyService {
|
|
|
4406
4525
|
// 제목과 그 라벨을 각자 줄에 나눠 그린다(renderTab). 여기서 제목을 덮어쓸 이유가 없다.
|
|
4407
4526
|
}
|
|
4408
4527
|
/**
|
|
4409
|
-
* 세션에 묶인 탭을 찾는다.
|
|
4528
|
+
* 세션에 묶인 탭을 찾는다.
|
|
4529
|
+
*
|
|
4530
|
+
* 1순위는 **프로세스 계보**다 — 훅이 보낸 조상 PID(claude.exe → 탭 셸 → Tabby) 에 어떤 탭의
|
|
4531
|
+
* 셸 PID 가 들어 있으면 그 탭이다(bind.ts). 이미 다른 세션이 그 탭을 갖고 있어도 넘겨받는다:
|
|
4532
|
+
* 같은 탭에서 claude 를 다시 띄우면 세션 id 가 바뀌는 게 정상이고, 이전 주인은 이미 죽은 세션이다.
|
|
4533
|
+
* 알던 탭과 다른 탭이 맞으면 옮긴다 (`claude --resume` 을 다른 탭에서 띄운 경우).
|
|
4534
|
+
*
|
|
4535
|
+
* pids 가 왔는데 맞는 탭이 없으면 **추측하지 않고** null 이다 — 탭 PID 표가 아직 안 채워진 것일 수
|
|
4536
|
+
* 있고, 그 경우 다음 폴링(400ms)에 다시 온다. 활성 탭으로 떨어지던 예전 규칙이 낸 사고가
|
|
4537
|
+
* "끝난 세션의 done 이 새 탭에 박힘" 이었다 (2026-09-02 실측).
|
|
4410
4538
|
*
|
|
4411
|
-
*
|
|
4412
|
-
* 여러 세션이 한 탭에 겹쳐
|
|
4413
|
-
*
|
|
4414
|
-
* 순간(= 다음 보고 때) 제 짝을 찾는다.
|
|
4539
|
+
* pids 가 없는 보고(옛 훅·셸에서 사람이 부른 것)만 예전 규칙을 탄다 — 처음 보는 세션이면 지금 활성 탭.
|
|
4540
|
+
* 단 그 탭을 이미 다른 세션이 점유했으면 묶지 않는다 (여러 세션이 한 탭에 겹쳐 붙어 서로의
|
|
4541
|
+
* 상태를 덮어쓰던 2026-08-28 사고의 방지책).
|
|
4415
4542
|
*/
|
|
4416
|
-
resolveTab(sessionId) {
|
|
4543
|
+
resolveTab(sessionId, pids) {
|
|
4417
4544
|
const known = this.sessionTab.get(sessionId);
|
|
4418
|
-
|
|
4419
|
-
|
|
4545
|
+
const alive = known && this.app.tabs.includes(known) ? known : null;
|
|
4546
|
+
const pick = (0,_bind__WEBPACK_IMPORTED_MODULE_8__.pickTabByPids)(pids, this.tabPids);
|
|
4547
|
+
if (pick.mode === 'pid' && pick.tab) {
|
|
4548
|
+
if (pick.tab !== alive) {
|
|
4549
|
+
this.bind(sessionId, pick.tab, alive ? 'pid-move' : 'pid');
|
|
4550
|
+
}
|
|
4551
|
+
return pick.tab;
|
|
4552
|
+
}
|
|
4553
|
+
if (alive) {
|
|
4554
|
+
return alive;
|
|
4555
|
+
}
|
|
4556
|
+
if (pick.mode === 'pid-miss') {
|
|
4557
|
+
const table = [...this.tabPids.entries()]
|
|
4558
|
+
.map(([tab, list]) => `${this.tabName(tab)}:[${list.join(',')}]`)
|
|
4559
|
+
.join(' ');
|
|
4560
|
+
this.bindLog(sessionId, `unbound (pid-miss) pids=[${(pids !== null && pids !== void 0 ? pids : []).join(',')}] tabs=${table}`);
|
|
4561
|
+
return null;
|
|
4420
4562
|
}
|
|
4421
4563
|
const active = this.app.activeTab;
|
|
4422
4564
|
if (!active) {
|
|
@@ -4424,12 +4566,36 @@ let WorkNotifyService = class WorkNotifyService {
|
|
|
4424
4566
|
}
|
|
4425
4567
|
const owner = this.tabSession.get(active);
|
|
4426
4568
|
if (owner && owner !== sessionId) {
|
|
4569
|
+
this.bindLog(sessionId, `unbound (legacy: active tab owned by ${owner.slice(0, 8)})`);
|
|
4427
4570
|
return null;
|
|
4428
4571
|
}
|
|
4429
|
-
this.
|
|
4430
|
-
this.tabSession.set(active, sessionId);
|
|
4572
|
+
this.bind(sessionId, active, 'legacy');
|
|
4431
4573
|
return active;
|
|
4432
4574
|
}
|
|
4575
|
+
/** 세션 <-> 탭 을 묶고, 그 탭의 이전 주인과 이 세션의 이전 탭은 풀어 준다 */
|
|
4576
|
+
bind(sessionId, tab, how) {
|
|
4577
|
+
const prevOwner = this.tabSession.get(tab);
|
|
4578
|
+
if (prevOwner && prevOwner !== sessionId) {
|
|
4579
|
+
this.sessionTab.delete(prevOwner);
|
|
4580
|
+
}
|
|
4581
|
+
const prevTab = this.sessionTab.get(sessionId);
|
|
4582
|
+
if (prevTab && prevTab !== tab && this.tabSession.get(prevTab) === sessionId) {
|
|
4583
|
+
this.tabSession.delete(prevTab);
|
|
4584
|
+
}
|
|
4585
|
+
this.sessionTab.set(sessionId, tab);
|
|
4586
|
+
this.tabSession.set(tab, sessionId);
|
|
4587
|
+
this.lastBindLog.delete(sessionId);
|
|
4588
|
+
this.diag(`bind sid=${sessionId.slice(0, 8)} via=${how} tab=${this.tabName(tab)}`
|
|
4589
|
+
+ (prevOwner && prevOwner !== sessionId ? ` (took over from ${prevOwner.slice(0, 8)})` : ''));
|
|
4590
|
+
}
|
|
4591
|
+
/** 매칭 실패는 폴링마다 반복되므로 같은 내용은 한 번만 남긴다 */
|
|
4592
|
+
bindLog(sessionId, msg) {
|
|
4593
|
+
if (this.lastBindLog.get(sessionId) === msg) {
|
|
4594
|
+
return;
|
|
4595
|
+
}
|
|
4596
|
+
this.lastBindLog.set(sessionId, msg);
|
|
4597
|
+
this.diag(`bind sid=${sessionId.slice(0, 8)} ${msg}`);
|
|
4598
|
+
}
|
|
4433
4599
|
};
|
|
4434
4600
|
WorkNotifyService = __decorate([
|
|
4435
4601
|
(0,_angular_core__WEBPACK_IMPORTED_MODULE_0__.Injectable)({ providedIn: 'root' }),
|