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,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code statusLine 을 한 겹 감싸서, 그 stdin JSON 을 AgentDeck 사이드바로 넘긴다.
|
|
3
|
+
*
|
|
4
|
+
* 왜 감싸나 — 모델·effort·컨텍스트%·5h/7d 한도% 는 **statusLine stdin 에만** 내려온다.
|
|
5
|
+
* 훅(JSON)에는 없고 대화기록(jsonl)에는 모델까지만 있다. 사이드바 하단의 "지금 이 탭" 줄이
|
|
6
|
+
* 원하는 값이 전부 여기 있으므로, 이미 걸려 있는 statusLine 명령 앞에 이 스크립트를 끼워
|
|
7
|
+
* 같은 stdin 을 그대로 흘려보내면서 한 벌 베껴 둔다.
|
|
8
|
+
*
|
|
9
|
+
* **이 스크립트의 첫 번째 계약은 "원래 statusLine 을 절대 죽이지 않는다" 이다.**
|
|
10
|
+
* statusLine 은 사용자가 매 렌더마다 보는 것이라, 우리가 실패해도 안쪽 명령의 출력은
|
|
11
|
+
* 그대로 나가야 한다. 그래서 베껴 쓰는 일은 전부 try/catch 로 감싸고, 종료코드도
|
|
12
|
+
* 안쪽 명령의 것을 그대로 쓴다. 안쪽이 없거나 못 띄우면 조용히 아무것도 출력하지 않는다
|
|
13
|
+
* (여기서 에러 문구를 뱉으면 그게 사용자의 statusLine 자리에 박힌다).
|
|
14
|
+
*
|
|
15
|
+
* 안쪽 명령은 설치할 때 `statusline-inner.json` 으로 옮겨 둔 **원래 설정 그대로**다
|
|
16
|
+
* (src/statusLine.ts). 체인 순서는 상관없다 — 이 스크립트는 stdin 을 바꾸지 않고
|
|
17
|
+
* stdout 을 건드리지 않으므로, 누가 우리를 또 감싸도 각 겹이 같은 JSON 을 본다.
|
|
18
|
+
*/
|
|
19
|
+
import * as fs from 'node:fs'
|
|
20
|
+
import * as os from 'node:os'
|
|
21
|
+
import * as path from 'node:path'
|
|
22
|
+
import { spawn } from 'node:child_process'
|
|
23
|
+
import { fileURLToPath } from 'node:url'
|
|
24
|
+
import * as https from 'node:https'
|
|
25
|
+
|
|
26
|
+
const ROOT = path.join(
|
|
27
|
+
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
|
28
|
+
'tabby-agentdeck',
|
|
29
|
+
)
|
|
30
|
+
/** 사이드바가 읽는 곳. 상태 보고(`status/`)와 폴더를 나눈다 — 그쪽 폴링은 자기 형식만 알아본다 */
|
|
31
|
+
const META_DIR = path.join(ROOT, 'meta')
|
|
32
|
+
const INNER_FILE = path.join(ROOT, 'statusline-inner.json')
|
|
33
|
+
/** 모델별 주간 한도(Fable 등) 캐시 — stdin 에 없는 값이라 따로 물어본 것을 여기 둔다 */
|
|
34
|
+
const SCOPED_FILE = path.join(ROOT, 'scoped-limit.json')
|
|
35
|
+
/** 캐시를 낡았다고 보는 기준. statusLine 은 초 단위로 불리므로 매번 물어보면 안 된다 */
|
|
36
|
+
const SCOPED_TTL_MS = 60_000
|
|
37
|
+
/**
|
|
38
|
+
* **못 받았을 때는 더 오래 쉰다.**
|
|
39
|
+
*
|
|
40
|
+
* 실패는 대개 곧 풀리지 않는다 — 토큰이 없거나(API 키·게이트웨이), 그 요금제에 모델별 한도가
|
|
41
|
+
* 없거나, 레지스트리가 우리를 막은 경우다. 1분마다 두드리면 마지막 것은 **더 오래 막힌다**:
|
|
42
|
+
* 2026-09-15 실측으로 이 API 가 실제로 429 를 돌려줬다(앞서 캐시 도장이 없어 렌더마다
|
|
43
|
+
* 요청이 나가던 판의 결과다).
|
|
44
|
+
*/
|
|
45
|
+
const SCOPED_FAIL_TTL_MS = 10 * 60_000
|
|
46
|
+
|
|
47
|
+
function readStdin () {
|
|
48
|
+
try {
|
|
49
|
+
// fd 0 을 통째로 읽는다. Claude Code 는 JSON 한 덩이를 주고 stdin 을 닫는다
|
|
50
|
+
return fs.readFileSync(0)
|
|
51
|
+
} catch {
|
|
52
|
+
return Buffer.alloc(0)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 계정 — statusLine stdin 에는 없다. 설정 파일에서 읽는다.
|
|
58
|
+
*
|
|
59
|
+
* `CLAUDE_CONFIG_DIR` 이 걸려 있으면 그 아래를 먼저 본다. 그 환경변수는 이 프로세스가
|
|
60
|
+
* claude 에서 그대로 물려받은 것이라, **탭마다 계정을 갈라 쓰는 경우에도 이 값이 맞다.**
|
|
61
|
+
* (`$CLAUDE_CONFIG_DIR/.claude.json` 위치는 근거 미확인이라 홈 폴더로 폴백한다.)
|
|
62
|
+
*/
|
|
63
|
+
function readAccount () {
|
|
64
|
+
const dirs = []
|
|
65
|
+
if (process.env.CLAUDE_CONFIG_DIR) {
|
|
66
|
+
dirs.push(process.env.CLAUDE_CONFIG_DIR)
|
|
67
|
+
}
|
|
68
|
+
dirs.push(os.homedir())
|
|
69
|
+
for (const dir of dirs) {
|
|
70
|
+
try {
|
|
71
|
+
const file = path.join(dir, '.claude.json')
|
|
72
|
+
const acc = JSON.parse(fs.readFileSync(file, 'utf8'))?.oauthAccount
|
|
73
|
+
if (acc?.emailAddress) {
|
|
74
|
+
return {
|
|
75
|
+
account: String(acc.emailAddress),
|
|
76
|
+
org: acc.organizationName ? String(acc.organizationName) : '',
|
|
77
|
+
configDir: dir,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// 없거나 아직 못 읽는다 — 다음 후보로
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { account: '', org: '', configDir: '' }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function pct (v) {
|
|
88
|
+
const n = Number(v)
|
|
89
|
+
return Number.isFinite(n) ? Math.round(n) : null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------- 모델별 주간 한도 (Fable 등) ----------
|
|
93
|
+
//
|
|
94
|
+
// statusLine stdin 의 `rate_limits` 는 `five_hour` / `seven_day` 만 조립한다. 모델별 주간 한도
|
|
95
|
+
// (내부명 `seven_day_overage_included`, 화면 이름 `Fable`)는 **거기 없다** — 진본은
|
|
96
|
+
// `GET /api/oauth/usage` 의 `limits[]` 중 `kind=weekly_scoped` 다.
|
|
97
|
+
//
|
|
98
|
+
// 렌더를 막으면 안 되므로 **읽기는 캐시에서만** 한다. 캐시가 낡았으면 자기 자신을
|
|
99
|
+
// `--refresh-scoped` 로 떼어 내 띄우고(detached), 이번 렌더는 낡은 값 그대로 쓴다.
|
|
100
|
+
// 다음 렌더에 새 값이 들어온다 — statusLine 은 몇 초에 한 번씩 다시 불린다.
|
|
101
|
+
|
|
102
|
+
/** OAuth 토큰 — 계정 파일과 같은 후보 폴더를 본다 (탭마다 계정을 갈라 써도 맞는다) */
|
|
103
|
+
function readOauthToken () {
|
|
104
|
+
const dirs = []
|
|
105
|
+
if (process.env.CLAUDE_CONFIG_DIR) {
|
|
106
|
+
dirs.push(process.env.CLAUDE_CONFIG_DIR)
|
|
107
|
+
}
|
|
108
|
+
dirs.push(path.join(os.homedir(), '.claude'), os.homedir())
|
|
109
|
+
for (const dir of dirs) {
|
|
110
|
+
try {
|
|
111
|
+
const raw = fs.readFileSync(path.join(dir, '.credentials.json'), 'utf8')
|
|
112
|
+
const token = JSON.parse(raw)?.claudeAiOauth?.accessToken
|
|
113
|
+
if (token) {
|
|
114
|
+
return String(token)
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
// 없거나 못 읽는다 — 다음 후보로
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return ''
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readScopedCache () {
|
|
124
|
+
try {
|
|
125
|
+
return JSON.parse(fs.readFileSync(SCOPED_FILE, 'utf8'))
|
|
126
|
+
} catch {
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 캐시가 낡았으면 갱신을 떼어 낸다 — 부모(이 렌더)는 기다리지 않는다 */
|
|
132
|
+
function armScopedRefresh (cache) {
|
|
133
|
+
// 직전이 실패였으면(`ok: false`) 더 길게 쉰다 — SCOPED_FAIL_TTL_MS 주석
|
|
134
|
+
const ttl = cache && cache.ok === false ? SCOPED_FAIL_TTL_MS : SCOPED_TTL_MS
|
|
135
|
+
if (cache && Date.now() - Number(cache.ts || 0) < ttl) {
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '--refresh-scoped'], {
|
|
140
|
+
detached: true,
|
|
141
|
+
stdio: 'ignore',
|
|
142
|
+
env: { ...process.env, AGENTDECK_SL_DEPTH: '0' },
|
|
143
|
+
})
|
|
144
|
+
child.unref()
|
|
145
|
+
} catch {
|
|
146
|
+
// 못 띄우면 다음 렌더가 다시 시도한다
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 실제로 물어보고 캐시에 남긴다 (`--refresh-scoped` 로 따로 뜬 프로세스에서만 돈다).
|
|
152
|
+
*
|
|
153
|
+
* `fetch` 가 아니라 `node:https` 를 쓴다 — undici 는 응답을 다 읽은 뒤에도 소켓을 쥐고 있어서,
|
|
154
|
+
* 다 쓰고 `process.exit` 하면 Windows 에서 libuv 가 죽는다 (실측: `Assertion failed:
|
|
155
|
+
* !(handle->flags & UV_HANDLE_CLOSING)`, rc 127). 여기서는 응답이 끝나면 이벤트 루프가
|
|
156
|
+
* 저절로 비어 프로세스가 정상 종료된다.
|
|
157
|
+
*/
|
|
158
|
+
function requestUsage (token) {
|
|
159
|
+
return new Promise((resolve, reject) => {
|
|
160
|
+
const req = https.request({
|
|
161
|
+
host: 'api.anthropic.com',
|
|
162
|
+
path: '/api/oauth/usage',
|
|
163
|
+
method: 'GET',
|
|
164
|
+
headers: {
|
|
165
|
+
'authorization': `Bearer ${token}`,
|
|
166
|
+
'anthropic-beta': 'oauth-2025-04-20',
|
|
167
|
+
'accept': 'application/json',
|
|
168
|
+
},
|
|
169
|
+
timeout: 10_000,
|
|
170
|
+
}, res => {
|
|
171
|
+
const chunks = []
|
|
172
|
+
res.on('data', c => chunks.push(c))
|
|
173
|
+
res.on('end', () => {
|
|
174
|
+
if (res.statusCode !== 200) {
|
|
175
|
+
reject(new Error('http ' + res.statusCode))
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
|
|
180
|
+
} catch (e) {
|
|
181
|
+
reject(e)
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
req.on('timeout', () => req.destroy(new Error('timeout')))
|
|
186
|
+
req.on('error', reject)
|
|
187
|
+
req.end()
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 갱신 결과를 캐시에 남긴다.
|
|
193
|
+
*
|
|
194
|
+
* **못 받았을 때도 반드시 부른다.** `armScopedRefresh` 는 "캐시가 있고 TTL 안" 일 때만 건너뛰는데,
|
|
195
|
+
* 실패했다고 아무것도 안 쓰면 캐시가 영원히 없어서 **statusLine 이 불릴 때마다**(초 단위)
|
|
196
|
+
* 새 프로세스가 뜬다 — 토큰이 없는 계정(API 키·게이트웨이)에서는 영영 성공하지 못하므로
|
|
197
|
+
* 그 상태가 계속된다 (2026-09-15 코드리뷰 지적).
|
|
198
|
+
*/
|
|
199
|
+
/**
|
|
200
|
+
* **실패해도 마지막 정상값은 지운다 — 가 아니라 지킨다.** 예전에는 실패하면 이름·퍼센트를 빈 값으로
|
|
201
|
+
* 덮어써서, 일시적 오류 한 번에 `Fable` 칸이 사라지고 실패 TTL(10분) 동안 빈 채로 있다가
|
|
202
|
+
* 다음 성공에서야 돌아왔다 (2026-09-16 실측: 10:28 실패 → 10:38 복귀, 사용자 "왜 안 나오다 또
|
|
203
|
+
* 나오나"). 몇 분 전 값이라도 빈 칸보다 낫다 — 한도는 분 단위로 크게 안 움직인다.
|
|
204
|
+
* 그래서 `scoped` 가 없으면 `prev` 의 이름·퍼센트·리셋 시각을 그대로 옮기고, 도장(`ts`)과
|
|
205
|
+
* 판정(`ok`)만 새로 찍는다. `ok:false` 는 여전히 재시도 간격을 가르는 데만 쓰인다.
|
|
206
|
+
*
|
|
207
|
+
* `err` 는 왜 비었는지 남기는 자리다 — 상태코드·타임아웃을 삼키면 이런 문제를 영영 못 찾는다.
|
|
208
|
+
*/
|
|
209
|
+
function writeScoped (scoped, prev, err) {
|
|
210
|
+
const name = scoped ? (scoped.scope?.model?.display_name ?? scoped.scope?.model?.id ?? '') : ''
|
|
211
|
+
// `resets_at` 은 여기서만 ISO 문자열이다 (stdin 쪽은 epoch 초) — 초로 맞춰 둔다
|
|
212
|
+
const resetsAt = scoped ? Math.round(Date.parse(scoped.resets_at ?? '') / 1000) || 0 : 0
|
|
213
|
+
const keep = !scoped && prev && prev.name ? prev : null
|
|
214
|
+
fs.mkdirSync(ROOT, { recursive: true })
|
|
215
|
+
const tmp = SCOPED_FILE + '.' + process.pid + '.tmp'
|
|
216
|
+
fs.writeFileSync(tmp, JSON.stringify({
|
|
217
|
+
ts: Date.now(),
|
|
218
|
+
// 다음 갱신 간격을 가르는 값 — 못 받았으면 오래 쉰다 (`armScopedRefresh`)
|
|
219
|
+
ok: !!scoped,
|
|
220
|
+
name: keep ? String(keep.name) : String(name),
|
|
221
|
+
pct: keep ? keep.pct : (scoped ? pct(scoped.percent) : null),
|
|
222
|
+
resetsAt: keep ? Number(keep.resetsAt) || 0 : resetsAt,
|
|
223
|
+
// 실패 사유 (성공이면 비운다). 값을 지킨 경우 `stale: true` 로 표시만 해 둔다
|
|
224
|
+
...(scoped ? {} : { err: String(err ?? 'unknown'), stale: !!keep }),
|
|
225
|
+
}), 'utf8')
|
|
226
|
+
fs.renameSync(tmp, SCOPED_FILE)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function refreshScoped () {
|
|
230
|
+
const prev = readScopedCache()
|
|
231
|
+
const token = readOauthToken()
|
|
232
|
+
if (!token) {
|
|
233
|
+
// 토큰이 없다 (로그인 안 함·API 키 사용) — 칸이 안 생길 뿐이지만 **도장은 찍는다**
|
|
234
|
+
writeScoped(null, prev, 'no-token')
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
let body
|
|
238
|
+
try {
|
|
239
|
+
body = await requestUsage(token)
|
|
240
|
+
} catch (e) {
|
|
241
|
+
// 네트워크·상태코드·타임아웃 — 마지막 정상값을 지키고 사유만 남긴다 (writeScoped 주석)
|
|
242
|
+
writeScoped(null, prev, e?.message || 'request-failed')
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
// 모델별 주간 한도는 여러 개일 수 있다 — 가장 많이 쓴 칸 하나만 보인다 (줄이 하나뿐이다)
|
|
246
|
+
const scoped = (Array.isArray(body?.limits) ? body.limits : [])
|
|
247
|
+
.filter(l => l?.kind === 'weekly_scoped')
|
|
248
|
+
.sort((a, b) => Number(b?.percent ?? 0) - Number(a?.percent ?? 0))[0]
|
|
249
|
+
// 모델별 한도가 없는 요금제도 있다 — 그때도 도장을 찍어야 다시 묻지 않는다.
|
|
250
|
+
// 이 경우는 실패가 아니라 "없다" 가 답이므로 옛 값을 지키지 않는다 (prev 를 안 넘긴다)
|
|
251
|
+
writeScoped(scoped ?? null, null, scoped ? undefined : 'no-scoped-limit')
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** stdin JSON 에서 사이드바가 쓸 것만 뽑는다 — 원문을 통째로 두면 대화 비용·경로까지 남는다 */
|
|
255
|
+
function snapshot (data) {
|
|
256
|
+
const model = data.model ?? {}
|
|
257
|
+
const ctx = data.context_window ?? {}
|
|
258
|
+
const lim = data.rate_limits ?? {}
|
|
259
|
+
const { account, org, configDir } = readAccount()
|
|
260
|
+
const scoped = readScopedCache()
|
|
261
|
+
armScopedRefresh(scoped)
|
|
262
|
+
return {
|
|
263
|
+
sessionId: String(data.session_id ?? ''),
|
|
264
|
+
ts: Date.now(),
|
|
265
|
+
agent: 'claude',
|
|
266
|
+
model: String(model.display_name ?? model.id ?? ''),
|
|
267
|
+
modelId: String(model.id ?? ''),
|
|
268
|
+
effort: String(data.effort?.level ?? ''),
|
|
269
|
+
version: String(data.version ?? ''),
|
|
270
|
+
fastMode: !!data.fast_mode,
|
|
271
|
+
account,
|
|
272
|
+
org,
|
|
273
|
+
configDir,
|
|
274
|
+
cwd: String(data.cwd ?? data.workspace?.current_dir ?? ''),
|
|
275
|
+
contextPct: pct(ctx.used_percentage),
|
|
276
|
+
limits: {
|
|
277
|
+
fiveHourPct: pct(lim.five_hour?.used_percentage),
|
|
278
|
+
fiveHourResetsAt: Number(lim.five_hour?.resets_at) || 0,
|
|
279
|
+
sevenDayPct: pct(lim.seven_day?.used_percentage),
|
|
280
|
+
sevenDayResetsAt: Number(lim.seven_day?.resets_at) || 0,
|
|
281
|
+
scopedName: scoped?.name ?? '',
|
|
282
|
+
scopedPct: scoped?.pct ?? null,
|
|
283
|
+
scopedResetsAt: Number(scoped?.resetsAt) || 0,
|
|
284
|
+
},
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function writeSnapshot (raw) {
|
|
289
|
+
const data = JSON.parse(raw.toString('utf8'))
|
|
290
|
+
const snap = snapshot(data)
|
|
291
|
+
if (!snap.sessionId) {
|
|
292
|
+
return // 누구 것인지 모르면 쓰지 않는다 — 사이드바가 세션 id 로만 탭에 묶는다
|
|
293
|
+
}
|
|
294
|
+
fs.mkdirSync(META_DIR, { recursive: true })
|
|
295
|
+
// 같은 파일을 사이드바가 400ms 마다 읽는다. 쓰다 만 파일을 읽히지 않도록 임시 파일 + rename
|
|
296
|
+
const file = path.join(META_DIR, snap.sessionId + '.json')
|
|
297
|
+
const tmp = file + '.' + process.pid + '.tmp'
|
|
298
|
+
fs.writeFileSync(tmp, JSON.stringify(snap), 'utf8')
|
|
299
|
+
fs.renameSync(tmp, file)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** 원래 걸려 있던 statusLine 을 그대로 실행하고 stdout/stderr 을 넘겨준다 */
|
|
303
|
+
function runInner (raw) {
|
|
304
|
+
let inner = null
|
|
305
|
+
try {
|
|
306
|
+
inner = JSON.parse(fs.readFileSync(INNER_FILE, 'utf8'))
|
|
307
|
+
} catch {
|
|
308
|
+
// 안쪽이 없다 = 원래 statusLine 이 없던 사용자다. 출력 없이 끝낸다
|
|
309
|
+
}
|
|
310
|
+
const command = inner && inner.type === 'command' ? String(inner.command ?? '') : ''
|
|
311
|
+
if (!command || command.includes('agentdeck-statusline')) {
|
|
312
|
+
// 우리 자신을 안쪽으로 두면 무한히 겹친다 — 설치가 꼬였을 때의 안전판
|
|
313
|
+
process.exit(0)
|
|
314
|
+
}
|
|
315
|
+
// 겹 세기. 남의 래퍼(원래 명령을 백업해 두는 종류)가 우리를 자기 안쪽에 넣어
|
|
316
|
+
// 버리면 `우리 -> 남 -> 우리 -> ...` 고리가 생긴다. 직접 검사(위 includes)로는 한 겹밖에
|
|
317
|
+
// 못 보므로, 물려주는 env 에 깊이를 실어 두 겹을 넘으면 조용히 멈춘다. 그 화면은
|
|
318
|
+
// statusLine 이 비어 보이지만(설치를 다시 하면 풀린다) 프로세스가 무한히 늘지는 않는다
|
|
319
|
+
const depth = Number(process.env.AGENTDECK_SL_DEPTH || 0) + 1
|
|
320
|
+
if (depth > 2) {
|
|
321
|
+
process.exit(0)
|
|
322
|
+
}
|
|
323
|
+
// stdout/stderr 은 그대로 물려준다(inherit) — 우리가 중계하면 버퍼 한 겹만 늘어난다
|
|
324
|
+
const child = spawn(command, {
|
|
325
|
+
shell: true,
|
|
326
|
+
stdio: ['pipe', 'inherit', 'inherit'],
|
|
327
|
+
env: { ...process.env, AGENTDECK_SL_DEPTH: String(depth) },
|
|
328
|
+
})
|
|
329
|
+
child.on('error', () => process.exit(0))
|
|
330
|
+
child.on('close', code => process.exit(code ?? 0))
|
|
331
|
+
child.stdin.on('error', () => { /* 안쪽이 stdin 을 안 읽고 끝냈다 — 정상 */ })
|
|
332
|
+
child.stdin.end(raw)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// 갱신 전용 실행 — 떼어 낸 프로세스가 여기로 들어온다. stdin 도 statusLine 도 건드리지 않는다
|
|
336
|
+
if (process.argv.includes('--refresh-scoped')) {
|
|
337
|
+
// exit 을 부르지 않는다 — 응답이 끝나면 루프가 비어 저절로 끝난다 (requestUsage 주석)
|
|
338
|
+
refreshScoped().catch(() => {
|
|
339
|
+
// 네트워크·토큰 문제. **여기서도 도장을 찍는다** — 안 찍으면 TTL 게이트가 비어
|
|
340
|
+
// 렌더마다 새 프로세스가 뜬다 (writeScoped 주석)
|
|
341
|
+
try { writeScoped(null, readScopedCache(), 'unhandled') } catch { /* 디스크까지 막혔으면 할 수 있는 게 없다 */ }
|
|
342
|
+
})
|
|
343
|
+
} else {
|
|
344
|
+
const raw = readStdin()
|
|
345
|
+
try {
|
|
346
|
+
writeSnapshot(raw)
|
|
347
|
+
} catch {
|
|
348
|
+
// 베끼기 실패는 사이드바 한 줄이 안 뜨는 것으로 끝난다. statusLine 은 계속 나가야 한다
|
|
349
|
+
}
|
|
350
|
+
runInner(raw)
|
|
351
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tabby-agentdeck",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
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",
|
|
@@ -15,12 +15,14 @@
|
|
|
15
15
|
"typings": "dist/index.d.ts",
|
|
16
16
|
"scripts": {
|
|
17
17
|
"build": "webpack --mode production",
|
|
18
|
+
"build:release": "node tools/sync-version.js && webpack --mode production --env release",
|
|
19
|
+
"prepack": "npm run build:release && node tools/check-release.js",
|
|
18
20
|
"watch": "webpack --progress --color --watch",
|
|
19
21
|
"test": "npm run test:existing && npm run test:codex",
|
|
20
22
|
"prebuild": "node tools/sync-version.js",
|
|
21
23
|
"sync-version": "node tools/sync-version.js",
|
|
22
24
|
"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",
|
|
25
|
+
"test:existing": "tsc src/codexHooks.ts src/i18n.ts 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/keybind.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 src/meta.ts src/statusLine.ts src/codexMeta.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 && node test/meta.test.js && node test/i18n.test.js && node test/statusLine.test.js && node test/hotkeys.test.js && node test/keybind.test.js && node test/codexMeta.test.js && node test/codexHooks.test.js",
|
|
24
26
|
"test:codex": "node test/regression.cjs && node test/resume.cjs && node test/cursor.cjs && node test/wheel.cjs",
|
|
25
27
|
"typecheck": "tsc --noEmit"
|
|
26
28
|
},
|
|
@@ -1,17 +1,20 @@
|
|
|
1
|
-
# AgentDeck 자동 업데이트 —
|
|
1
|
+
# AgentDeck 자동 업데이트 — 플러그인 폴더에 새 버전을 깐다.
|
|
2
2
|
#
|
|
3
3
|
# powershell -NoProfile -ExecutionPolicy Bypass -File agentdeck-update.ps1 `
|
|
4
|
-
# -Version 1.1.1 -PluginsDir "<APPDATA>\tabby\plugins" -TabbyExe "<...>\Tabby.exe"
|
|
4
|
+
# -Version 1.1.1 -PluginsDir "<APPDATA>\tabby\plugins" -TabbyExe "<...>\Tabby.exe" -InPlace
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
# 1. **돌고 있는 플러그인은 자기를 갈아끼울 수 없다.** Tabby 는 기동할 때 `dist/index.js` 를 한 번
|
|
8
|
-
# 읽어 메모리에 올린다. 그 뒤 디스크를 바꿔도 돌고 있는 코드는 옛것 그대로다.
|
|
9
|
-
# 2. **Windows 는 로드된 파일을 잡고 있다.** Tabby 가 떠 있는 동안 npm 이 그 폴더를 지우려 들면
|
|
10
|
-
# EPERM/EBUSY 로 반쯤 지운 채 실패할 수 있다 — 플러그인이 깨진 상태로 남는 것이 가장 나쁘다.
|
|
6
|
+
# 모드가 둘이다.
|
|
11
7
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
8
|
+
# **-InPlace (지금 쓰는 길)** — Tabby 가 **떠 있는 채로** 깐다. 기다리지도, 다시 띄우지도 않는다.
|
|
9
|
+
# 적용은 플러그인이 창(renderer)만 리로드해서 한다(`src/reload.service.ts`) — 탭과 세션이 산다.
|
|
10
|
+
# 로드된 `.js` 는 Windows 가 잡고 있지 않으므로(require 는 읽고 바로 닫는다) 폴더를 갈아끼워도 된다.
|
|
11
|
+
# 이 모드에서는 부른 쪽이 **종료 코드를 기다린다** — 끝나야 리로드할 수 있기 때문이다.
|
|
12
|
+
#
|
|
13
|
+
# **기본(옛 길)** — Tabby 종료를 기다린다 → 설치한다 → 다시 띄운다. 옛 버전이 띄운 스크립트나
|
|
14
|
+
# 손으로 돌릴 때를 위해 남겨 둔다.
|
|
15
|
+
#
|
|
16
|
+
# 어느 쪽이든 이 스크립트는 **패키지 밖(임시 폴더)에서** 실행돼야 한다. 자기가 사는 폴더를
|
|
17
|
+
# npm 이 통째로 갈아엎기 때문이다 (`update.service.ts` 가 임시 폴더로 복사해 띄운다).
|
|
15
18
|
param(
|
|
16
19
|
[Parameter(Mandatory = $true)][string]$Version,
|
|
17
20
|
[Parameter(Mandatory = $true)][string]$PluginsDir,
|
|
@@ -22,7 +25,12 @@ param(
|
|
|
22
25
|
[string]$LogFile = '',
|
|
23
26
|
# Tabby 가 꺼지기를 기다리는 최대 시간(초). 넘으면 설치하지 않고 그냥 끝낸다 —
|
|
24
27
|
# 떠 있는 상태로 설치하면 반쯤 지워진 플러그인이 남는다
|
|
25
|
-
[int]$WaitSeconds = 60
|
|
28
|
+
[int]$WaitSeconds = 60,
|
|
29
|
+
# Tabby 를 껐다 켜지 않는다 — 기다리지도 않고 다시 띄우지도 않는다
|
|
30
|
+
[switch]$InPlace,
|
|
31
|
+
# npm 에 건넬 대상. 기본은 `tabby-agentdeck@<Version>`(레지스트리)이고,
|
|
32
|
+
# **회귀는 지금 소스로 만든 tgz 경로를 준다** — 이미 배포된 버전에는 검증할 코드가 없다
|
|
33
|
+
[string]$Spec = ''
|
|
26
34
|
)
|
|
27
35
|
|
|
28
36
|
$ErrorActionPreference = 'Stop'
|
|
@@ -71,41 +79,49 @@ function Test-TabbyAlive ([int]$mainPid) {
|
|
|
71
79
|
return $n
|
|
72
80
|
}
|
|
73
81
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
$
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
+
if ($InPlace) {
|
|
83
|
+
Write-Log 'InPlace — Tabby 종료를 기다리지 않는다 (적용은 플러그인이 창 리로드로 한다)'
|
|
84
|
+
} else {
|
|
85
|
+
Write-Log "대기 대상: $(if ($TabbyPid -gt 0) { "pid=$TabbyPid (+자식)" } else { '이름으로 전부 (옛 방식)' })"
|
|
86
|
+
$waited = 0
|
|
87
|
+
while ($true) {
|
|
88
|
+
$alive = Test-TabbyAlive $TabbyPid
|
|
89
|
+
if ($alive -eq 0) { break }
|
|
90
|
+
if ($waited -ge $WaitSeconds) {
|
|
91
|
+
Write-Log "FAIL Tabby 가 ${WaitSeconds}초 안에 꺼지지 않았다 (남은 프로세스 ${alive}개) — 설치하지 않는다"
|
|
92
|
+
exit 2
|
|
93
|
+
}
|
|
94
|
+
Start-Sleep -Milliseconds 500
|
|
95
|
+
$waited += 0.5
|
|
82
96
|
}
|
|
83
|
-
|
|
84
|
-
|
|
97
|
+
Write-Log "Tabby 종료 확인 (${waited}초 대기)"
|
|
98
|
+
# 핸들이 완전히 풀릴 여유를 조금 더 준다
|
|
99
|
+
Start-Sleep -Milliseconds 800
|
|
85
100
|
}
|
|
86
|
-
Write-Log "Tabby 종료 확인 (${waited}초 대기)"
|
|
87
|
-
# 핸들이 완전히 풀릴 여유를 조금 더 준다
|
|
88
|
-
Start-Sleep -Milliseconds 800
|
|
89
101
|
|
|
90
102
|
# ── 2) 설치 ─────────────────────────────────────────────────────────────────
|
|
91
103
|
# Tabby 의 플러그인 설치와 같은 방식이다 — `plugins/package.json` 에 의존성으로 적히고
|
|
92
104
|
# `plugins/node_modules` 아래로 풀린다. `--prefix` 로 그 폴더를 가리킨다.
|
|
105
|
+
$installed = $false
|
|
93
106
|
$npm = (Get-Command npm.cmd -ErrorAction SilentlyContinue)
|
|
94
107
|
if (-not $npm) { $npm = (Get-Command npm -ErrorAction SilentlyContinue) }
|
|
95
108
|
if (-not $npm) {
|
|
96
109
|
Write-Log 'FAIL npm 을 찾지 못했다 (PATH 에 node/npm 이 없다)'
|
|
97
110
|
# npm 이 없어도 Tabby 는 다시 띄워 준다 — 사람의 창을 빼앗은 채 끝내면 안 된다
|
|
98
|
-
Write-Log '설치를
|
|
111
|
+
Write-Log '설치를 건너뛴다'
|
|
99
112
|
} else {
|
|
100
113
|
Write-Log "npm=$($npm.Source)"
|
|
101
114
|
# `$args` 는 PowerShell 자동 변수다 — 이름을 겹치면 조용히 이상하게 돈다
|
|
102
|
-
$
|
|
115
|
+
$target = if ($Spec) { $Spec } else { "$pkg@$Version" }
|
|
116
|
+
Write-Log "설치 대상: $target"
|
|
117
|
+
$npmArgs = @('install', '--prefix', $PluginsDir, '--no-audit', '--no-fund', $target)
|
|
103
118
|
try {
|
|
104
119
|
$out = & $npm.Source @npmArgs 2>&1
|
|
105
120
|
$code = $LASTEXITCODE
|
|
106
121
|
foreach ($line in $out) { Write-Log "npm| $line" }
|
|
107
122
|
if ($code -eq 0) {
|
|
108
123
|
Write-Log "OK 설치 완료 $pkg@$Version"
|
|
124
|
+
$installed = $true
|
|
109
125
|
} else {
|
|
110
126
|
Write-Log "FAIL npm exit=$code — 옛 버전이 그대로 남아 있을 수 있다"
|
|
111
127
|
}
|
|
@@ -115,7 +131,14 @@ if (-not $npm) {
|
|
|
115
131
|
}
|
|
116
132
|
|
|
117
133
|
# ── 3) 다시 띄운다 ──────────────────────────────────────────────────────────
|
|
118
|
-
#
|
|
134
|
+
# InPlace 면 애초에 닫지 않았으므로 띄울 것이 없다 — 부른 쪽이 창만 새로 고친다.
|
|
135
|
+
# 옛 길에서는 **설치가 실패했어도 반드시 띄운다**. 업데이트를 고르느라 터미널을 잃는 일은 없어야 한다.
|
|
136
|
+
if ($InPlace) {
|
|
137
|
+
Write-Log '=== update 끝 (InPlace — 적용은 플러그인이 창 리로드로 한다)'
|
|
138
|
+
# 부른 쪽이 이 코드를 본다. 다만 최종 판정은 디스크의 버전이다 (`installedVersion`)
|
|
139
|
+
exit $(if ($installed) { 0 } else { 1 })
|
|
140
|
+
}
|
|
141
|
+
|
|
119
142
|
try {
|
|
120
143
|
if (Test-Path $TabbyExe) {
|
|
121
144
|
Start-Process -FilePath $TabbyExe
|