tabby-agentdeck 1.2.0 → 1.2.2
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 +167 -150
- package/LICENSE +21 -21
- package/README.ko.md +52 -42
- package/README.md +52 -42
- package/dist/accountBrowser.d.ts +4 -0
- package/dist/accountSession.d.ts +22 -0
- package/dist/accounts.d.ts +55 -0
- package/dist/agents.d.ts +29 -0
- package/dist/codexKeys.d.ts +3 -0
- package/dist/config.d.ts +12 -20
- package/dist/deck.service.d.ts +32 -10
- package/dist/i18n/sidebar/af.d.ts +95 -0
- package/dist/i18n/sidebar/bg.d.ts +95 -0
- package/dist/i18n/sidebar/cs.d.ts +95 -0
- package/dist/i18n/sidebar/da.d.ts +95 -0
- package/dist/i18n/sidebar/de.d.ts +95 -0
- package/dist/i18n/sidebar/es.d.ts +95 -0
- package/dist/i18n/sidebar/fr.d.ts +95 -0
- package/dist/i18n/sidebar/hr.d.ts +95 -0
- package/dist/i18n/sidebar/id.d.ts +95 -0
- package/dist/i18n/sidebar/it.d.ts +95 -0
- package/dist/i18n/sidebar/ja.d.ts +95 -0
- package/dist/i18n/sidebar/pl.d.ts +95 -0
- package/dist/i18n/sidebar/pt.d.ts +95 -0
- package/dist/i18n/sidebar/ru.d.ts +95 -0
- package/dist/i18n/sidebar/sr.d.ts +95 -0
- package/dist/i18n/sidebar/sv.d.ts +95 -0
- package/dist/i18n/sidebar/tr.d.ts +95 -0
- package/dist/i18n/sidebar/uk.d.ts +95 -0
- package/dist/i18n/sidebar/zh-CN.d.ts +95 -0
- package/dist/i18n/sidebar/zh-TW.d.ts +95 -0
- package/dist/i18n.d.ts +20 -4
- package/dist/index.d.ts +2 -1
- package/dist/index.js +109 -1
- package/dist/notify.service.d.ts +16 -0
- package/dist/performance.d.ts +13 -0
- package/dist/profile.service.d.ts +2 -0
- package/dist/runtimeMigration.d.ts +3 -0
- package/dist/sessionLedger.service.d.ts +6 -3
- package/dist/sessionMailbox.d.ts +31 -0
- package/dist/sessionSearch.d.ts +41 -0
- package/dist/sessionSearchPanel.d.ts +36 -0
- package/dist/sessionSlots.d.ts +9 -0
- package/dist/settings.component.d.ts +14 -1
- package/dist/sidebarI18n.d.ts +9 -0
- package/dist/sidebarLocales.d.ts +1 -0
- package/dist/storageEnvironment.d.ts +6 -0
- package/dist/storageI18n.d.ts +1 -0
- package/dist/storagePaths.d.ts +19 -0
- package/dist/storageSettings.d.ts +28 -0
- package/dist/viewer.d.ts +2 -0
- package/hooks/agentdeck-codex-notify.ps1 +69 -28
- package/hooks/agentdeck-hook-trace.ps1 +57 -0
- package/hooks/agentdeck-label.js +53 -53
- package/hooks/agentdeck-mailbox.mjs +143 -0
- package/hooks/agentdeck-notify.ps1 +87 -8
- package/hooks/agentdeck-runtime.cjs +20 -0
- package/hooks/agentdeck-status.ps1 +33 -33
- package/hooks/agentdeck-statusline.mjs +349 -351
- package/package.json +75 -70
- package/updater/agentdeck-update.ps1 +153 -153
|
@@ -1,351 +1,349 @@
|
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
/**
|
|
34
|
-
const
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
return
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
'
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
req.
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const
|
|
214
|
-
fs.
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const
|
|
257
|
-
const
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
child.on('error', () =>
|
|
330
|
-
child.
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
//
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
runInner(raw)
|
|
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
|
+
import runtimePaths from './agentdeck-runtime.cjs'
|
|
26
|
+
|
|
27
|
+
const ROOT = runtimePaths.runtimeRoot()
|
|
28
|
+
/** 사이드바가 읽는 곳. 상태 보고(`status/`)와 폴더를 나눈다 — 그쪽 폴링은 자기 형식만 알아본다 */
|
|
29
|
+
const META_DIR = path.join(ROOT, 'meta')
|
|
30
|
+
const INNER_FILE = path.join(ROOT, 'statusline-inner.json')
|
|
31
|
+
/** 모델별 주간 한도(Fable 등) 캐시 — stdin 에 없는 값이라 따로 물어본 것을 여기 둔다 */
|
|
32
|
+
const SCOPED_FILE = path.join(ROOT, 'scoped-limit.json')
|
|
33
|
+
/** 캐시를 낡았다고 보는 기준. statusLine 은 초 단위로 불리므로 매번 물어보면 안 된다 */
|
|
34
|
+
const SCOPED_TTL_MS = 60_000
|
|
35
|
+
/**
|
|
36
|
+
* **못 받았을 때는 더 오래 쉰다.**
|
|
37
|
+
*
|
|
38
|
+
* 실패는 대개 곧 풀리지 않는다 — 토큰이 없거나(API 키·게이트웨이), 그 요금제에 모델별 한도가
|
|
39
|
+
* 없거나, 레지스트리가 우리를 막은 경우다. 1분마다 두드리면 마지막 것은 **더 오래 막힌다**:
|
|
40
|
+
* 2026-09-15 실측으로 이 API 가 실제로 429 를 돌려줬다(앞서 캐시 도장이 없어 렌더마다
|
|
41
|
+
* 요청이 나가던 판의 결과다).
|
|
42
|
+
*/
|
|
43
|
+
const SCOPED_FAIL_TTL_MS = 10 * 60_000
|
|
44
|
+
|
|
45
|
+
function readStdin () {
|
|
46
|
+
try {
|
|
47
|
+
// fd 0 을 통째로 읽는다. Claude Code 는 JSON 한 덩이를 주고 stdin 을 닫는다
|
|
48
|
+
return fs.readFileSync(0)
|
|
49
|
+
} catch {
|
|
50
|
+
return Buffer.alloc(0)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 계정 — statusLine stdin 에는 없다. 설정 파일에서 읽는다.
|
|
56
|
+
*
|
|
57
|
+
* `CLAUDE_CONFIG_DIR` 이 걸려 있으면 그 아래를 먼저 본다. 그 환경변수는 이 프로세스가
|
|
58
|
+
* claude 에서 그대로 물려받은 것이라, **탭마다 계정을 갈라 쓰는 경우에도 이 값이 맞다.**
|
|
59
|
+
* (`$CLAUDE_CONFIG_DIR/.claude.json` 위치는 근거 미확인이라 홈 폴더로 폴백한다.)
|
|
60
|
+
*/
|
|
61
|
+
function readAccount () {
|
|
62
|
+
const dirs = []
|
|
63
|
+
if (process.env.CLAUDE_CONFIG_DIR) {
|
|
64
|
+
dirs.push(process.env.CLAUDE_CONFIG_DIR)
|
|
65
|
+
}
|
|
66
|
+
dirs.push(os.homedir())
|
|
67
|
+
for (const dir of dirs) {
|
|
68
|
+
try {
|
|
69
|
+
const file = path.join(dir, '.claude.json')
|
|
70
|
+
const acc = JSON.parse(fs.readFileSync(file, 'utf8'))?.oauthAccount
|
|
71
|
+
if (acc?.emailAddress) {
|
|
72
|
+
return {
|
|
73
|
+
account: String(acc.emailAddress),
|
|
74
|
+
org: acc.organizationName ? String(acc.organizationName) : '',
|
|
75
|
+
configDir: dir,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// 없거나 아직 못 읽는다 — 다음 후보로
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { account: '', org: '', configDir: '' }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function pct (v) {
|
|
86
|
+
const n = Number(v)
|
|
87
|
+
return Number.isFinite(n) ? Math.round(n) : null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------- 모델별 주간 한도 (Fable 등) ----------
|
|
91
|
+
//
|
|
92
|
+
// statusLine stdin 의 `rate_limits` 는 `five_hour` / `seven_day` 만 조립한다. 모델별 주간 한도
|
|
93
|
+
// (내부명 `seven_day_overage_included`, 화면 이름 `Fable`)는 **거기 없다** — 진본은
|
|
94
|
+
// `GET /api/oauth/usage` 의 `limits[]` 중 `kind=weekly_scoped` 다.
|
|
95
|
+
//
|
|
96
|
+
// 렌더를 막으면 안 되므로 **읽기는 캐시에서만** 한다. 캐시가 낡았으면 자기 자신을
|
|
97
|
+
// `--refresh-scoped` 로 떼어 내 띄우고(detached), 이번 렌더는 낡은 값 그대로 쓴다.
|
|
98
|
+
// 다음 렌더에 새 값이 들어온다 — statusLine 은 몇 초에 한 번씩 다시 불린다.
|
|
99
|
+
|
|
100
|
+
/** OAuth 토큰 — 계정 파일과 같은 후보 폴더를 본다 (탭마다 계정을 갈라 써도 맞는다) */
|
|
101
|
+
function readOauthToken () {
|
|
102
|
+
const dirs = []
|
|
103
|
+
if (process.env.CLAUDE_CONFIG_DIR) {
|
|
104
|
+
dirs.push(process.env.CLAUDE_CONFIG_DIR)
|
|
105
|
+
}
|
|
106
|
+
dirs.push(path.join(os.homedir(), '.claude'), os.homedir())
|
|
107
|
+
for (const dir of dirs) {
|
|
108
|
+
try {
|
|
109
|
+
const raw = fs.readFileSync(path.join(dir, '.credentials.json'), 'utf8')
|
|
110
|
+
const token = JSON.parse(raw)?.claudeAiOauth?.accessToken
|
|
111
|
+
if (token) {
|
|
112
|
+
return String(token)
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// 없거나 못 읽는다 — 다음 후보로
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return ''
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function readScopedCache () {
|
|
122
|
+
try {
|
|
123
|
+
return JSON.parse(fs.readFileSync(SCOPED_FILE, 'utf8'))
|
|
124
|
+
} catch {
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 캐시가 낡았으면 갱신을 떼어 낸다 — 부모(이 렌더)는 기다리지 않는다 */
|
|
130
|
+
function armScopedRefresh (cache) {
|
|
131
|
+
// 직전이 실패였으면(`ok: false`) 더 길게 쉰다 — SCOPED_FAIL_TTL_MS 주석
|
|
132
|
+
const ttl = cache && cache.ok === false ? SCOPED_FAIL_TTL_MS : SCOPED_TTL_MS
|
|
133
|
+
if (cache && Date.now() - Number(cache.ts || 0) < ttl) {
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '--refresh-scoped'], {
|
|
138
|
+
detached: true,
|
|
139
|
+
stdio: 'ignore',
|
|
140
|
+
env: { ...process.env, AGENTDECK_SL_DEPTH: '0' },
|
|
141
|
+
})
|
|
142
|
+
child.unref()
|
|
143
|
+
} catch {
|
|
144
|
+
// 못 띄우면 다음 렌더가 다시 시도한다
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 실제로 물어보고 캐시에 남긴다 (`--refresh-scoped` 로 따로 뜬 프로세스에서만 돈다).
|
|
150
|
+
*
|
|
151
|
+
* `fetch` 가 아니라 `node:https` 를 쓴다 — undici 는 응답을 다 읽은 뒤에도 소켓을 쥐고 있어서,
|
|
152
|
+
* 다 쓰고 `process.exit` 하면 Windows 에서 libuv 가 죽는다 (실측: `Assertion failed:
|
|
153
|
+
* !(handle->flags & UV_HANDLE_CLOSING)`, rc 127). 여기서는 응답이 끝나면 이벤트 루프가
|
|
154
|
+
* 저절로 비어 프로세스가 정상 종료된다.
|
|
155
|
+
*/
|
|
156
|
+
function requestUsage (token) {
|
|
157
|
+
return new Promise((resolve, reject) => {
|
|
158
|
+
const req = https.request({
|
|
159
|
+
host: 'api.anthropic.com',
|
|
160
|
+
path: '/api/oauth/usage',
|
|
161
|
+
method: 'GET',
|
|
162
|
+
headers: {
|
|
163
|
+
'authorization': `Bearer ${token}`,
|
|
164
|
+
'anthropic-beta': 'oauth-2025-04-20',
|
|
165
|
+
'accept': 'application/json',
|
|
166
|
+
},
|
|
167
|
+
timeout: 10_000,
|
|
168
|
+
}, res => {
|
|
169
|
+
const chunks = []
|
|
170
|
+
res.on('data', c => chunks.push(c))
|
|
171
|
+
res.on('end', () => {
|
|
172
|
+
if (res.statusCode !== 200) {
|
|
173
|
+
reject(new Error('http ' + res.statusCode))
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
|
|
178
|
+
} catch (e) {
|
|
179
|
+
reject(e)
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
req.on('timeout', () => req.destroy(new Error('timeout')))
|
|
184
|
+
req.on('error', reject)
|
|
185
|
+
req.end()
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 갱신 결과를 캐시에 남긴다.
|
|
191
|
+
*
|
|
192
|
+
* **못 받았을 때도 반드시 부른다.** `armScopedRefresh` 는 "캐시가 있고 TTL 안" 일 때만 건너뛰는데,
|
|
193
|
+
* 실패했다고 아무것도 안 쓰면 캐시가 영원히 없어서 **statusLine 이 불릴 때마다**(초 단위)
|
|
194
|
+
* 새 프로세스가 뜬다 — 토큰이 없는 계정(API 키·게이트웨이)에서는 영영 성공하지 못하므로
|
|
195
|
+
* 그 상태가 계속된다 (2026-09-15 코드리뷰 지적).
|
|
196
|
+
*/
|
|
197
|
+
/**
|
|
198
|
+
* **실패해도 마지막 정상값은 지운다 — 가 아니라 지킨다.** 예전에는 실패하면 이름·퍼센트를 빈 값으로
|
|
199
|
+
* 덮어써서, 일시적 오류 한 번에 `Fable` 칸이 사라지고 실패 TTL(10분) 동안 빈 채로 있다가
|
|
200
|
+
* 다음 성공에서야 돌아왔다 (2026-09-16 실측: 10:28 실패 → 10:38 복귀, 사용자 "왜 안 나오다 또
|
|
201
|
+
* 나오나"). 몇 분 전 값이라도 빈 칸보다 낫다 — 한도는 분 단위로 크게 안 움직인다.
|
|
202
|
+
* 그래서 `scoped` 가 없으면 `prev` 의 이름·퍼센트·리셋 시각을 그대로 옮기고, 도장(`ts`)과
|
|
203
|
+
* 판정(`ok`)만 새로 찍는다. `ok:false` 는 여전히 재시도 간격을 가르는 데만 쓰인다.
|
|
204
|
+
*
|
|
205
|
+
* `err` 는 왜 비었는지 남기는 자리다 — 상태코드·타임아웃을 삼키면 이런 문제를 영영 못 찾는다.
|
|
206
|
+
*/
|
|
207
|
+
function writeScoped (scoped, prev, err) {
|
|
208
|
+
const name = scoped ? (scoped.scope?.model?.display_name ?? scoped.scope?.model?.id ?? '') : ''
|
|
209
|
+
// `resets_at` 은 여기서만 ISO 문자열이다 (stdin 쪽은 epoch 초) — 초로 맞춰 둔다
|
|
210
|
+
const resetsAt = scoped ? Math.round(Date.parse(scoped.resets_at ?? '') / 1000) || 0 : 0
|
|
211
|
+
const keep = !scoped && prev && prev.name ? prev : null
|
|
212
|
+
fs.mkdirSync(ROOT, { recursive: true })
|
|
213
|
+
const tmp = SCOPED_FILE + '.' + process.pid + '.tmp'
|
|
214
|
+
fs.writeFileSync(tmp, JSON.stringify({
|
|
215
|
+
ts: Date.now(),
|
|
216
|
+
// 다음 갱신 간격을 가르는 값 — 못 받았으면 오래 쉰다 (`armScopedRefresh`)
|
|
217
|
+
ok: !!scoped,
|
|
218
|
+
name: keep ? String(keep.name) : String(name),
|
|
219
|
+
pct: keep ? keep.pct : (scoped ? pct(scoped.percent) : null),
|
|
220
|
+
resetsAt: keep ? Number(keep.resetsAt) || 0 : resetsAt,
|
|
221
|
+
// 실패 사유 (성공이면 비운다). 값을 지킨 경우 `stale: true` 로 표시만 해 둔다
|
|
222
|
+
...(scoped ? {} : { err: String(err ?? 'unknown'), stale: !!keep }),
|
|
223
|
+
}), 'utf8')
|
|
224
|
+
fs.renameSync(tmp, SCOPED_FILE)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function refreshScoped () {
|
|
228
|
+
const prev = readScopedCache()
|
|
229
|
+
const token = readOauthToken()
|
|
230
|
+
if (!token) {
|
|
231
|
+
// 토큰이 없다 (로그인 안 함·API 키 사용) — 칸이 안 생길 뿐이지만 **도장은 찍는다**
|
|
232
|
+
writeScoped(null, prev, 'no-token')
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
let body
|
|
236
|
+
try {
|
|
237
|
+
body = await requestUsage(token)
|
|
238
|
+
} catch (e) {
|
|
239
|
+
// 네트워크·상태코드·타임아웃 — 마지막 정상값을 지키고 사유만 남긴다 (writeScoped 주석)
|
|
240
|
+
writeScoped(null, prev, e?.message || 'request-failed')
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
// 모델별 주간 한도는 여러 개일 수 있다 — 가장 많이 쓴 칸 하나만 보인다 (줄이 하나뿐이다)
|
|
244
|
+
const scoped = (Array.isArray(body?.limits) ? body.limits : [])
|
|
245
|
+
.filter(l => l?.kind === 'weekly_scoped')
|
|
246
|
+
.sort((a, b) => Number(b?.percent ?? 0) - Number(a?.percent ?? 0))[0]
|
|
247
|
+
// 모델별 한도가 없는 요금제도 있다 — 그때도 도장을 찍어야 다시 묻지 않는다.
|
|
248
|
+
// 이 경우는 실패가 아니라 "없다" 가 답이므로 옛 값을 지키지 않는다 (prev 를 안 넘긴다)
|
|
249
|
+
writeScoped(scoped ?? null, null, scoped ? undefined : 'no-scoped-limit')
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** stdin JSON 에서 사이드바가 쓸 것만 뽑는다 — 원문을 통째로 두면 대화 비용·경로까지 남는다 */
|
|
253
|
+
function snapshot (data) {
|
|
254
|
+
const model = data.model ?? {}
|
|
255
|
+
const ctx = data.context_window ?? {}
|
|
256
|
+
const lim = data.rate_limits ?? {}
|
|
257
|
+
const { account, org, configDir } = readAccount()
|
|
258
|
+
const scoped = readScopedCache()
|
|
259
|
+
armScopedRefresh(scoped)
|
|
260
|
+
return {
|
|
261
|
+
sessionId: String(data.session_id ?? ''),
|
|
262
|
+
ts: Date.now(),
|
|
263
|
+
agent: 'claude',
|
|
264
|
+
model: String(model.display_name ?? model.id ?? ''),
|
|
265
|
+
modelId: String(model.id ?? ''),
|
|
266
|
+
effort: String(data.effort?.level ?? ''),
|
|
267
|
+
version: String(data.version ?? ''),
|
|
268
|
+
fastMode: !!data.fast_mode,
|
|
269
|
+
account,
|
|
270
|
+
org,
|
|
271
|
+
configDir,
|
|
272
|
+
cwd: String(data.cwd ?? data.workspace?.current_dir ?? ''),
|
|
273
|
+
contextPct: pct(ctx.used_percentage),
|
|
274
|
+
limits: {
|
|
275
|
+
fiveHourPct: pct(lim.five_hour?.used_percentage),
|
|
276
|
+
fiveHourResetsAt: Number(lim.five_hour?.resets_at) || 0,
|
|
277
|
+
sevenDayPct: pct(lim.seven_day?.used_percentage),
|
|
278
|
+
sevenDayResetsAt: Number(lim.seven_day?.resets_at) || 0,
|
|
279
|
+
scopedName: scoped?.name ?? '',
|
|
280
|
+
scopedPct: scoped?.pct ?? null,
|
|
281
|
+
scopedResetsAt: Number(scoped?.resetsAt) || 0,
|
|
282
|
+
},
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function writeSnapshot (raw) {
|
|
287
|
+
const data = JSON.parse(raw.toString('utf8'))
|
|
288
|
+
const snap = snapshot(data)
|
|
289
|
+
if (!snap.sessionId) {
|
|
290
|
+
return // 누구 것인지 모르면 쓰지 않는다 — 사이드바가 세션 id 로만 탭에 묶는다
|
|
291
|
+
}
|
|
292
|
+
fs.mkdirSync(META_DIR, { recursive: true })
|
|
293
|
+
// 같은 파일을 사이드바가 400ms 마다 읽는다. 쓰다 만 파일을 읽히지 않도록 임시 파일 + rename
|
|
294
|
+
const file = path.join(META_DIR, snap.sessionId + '.json')
|
|
295
|
+
const tmp = file + '.' + process.pid + '.tmp'
|
|
296
|
+
fs.writeFileSync(tmp, JSON.stringify(snap), 'utf8')
|
|
297
|
+
fs.renameSync(tmp, file)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** 원래 걸려 있던 statusLine 을 그대로 실행하고 stdout/stderr 을 넘겨준다 */
|
|
301
|
+
function runInner (raw) {
|
|
302
|
+
let inner = null
|
|
303
|
+
try {
|
|
304
|
+
inner = JSON.parse(fs.readFileSync(INNER_FILE, 'utf8'))
|
|
305
|
+
} catch {
|
|
306
|
+
// 안쪽이 없다 = 원래 statusLine 이 없던 사용자다. 출력 없이 끝낸다
|
|
307
|
+
}
|
|
308
|
+
const command = inner && inner.type === 'command' ? String(inner.command ?? '') : ''
|
|
309
|
+
if (!command || command.includes('agentdeck-statusline')) {
|
|
310
|
+
// 우리 자신을 안쪽으로 두면 무한히 겹친다 — 설치가 꼬였을 때의 안전판
|
|
311
|
+
process.exit(0)
|
|
312
|
+
}
|
|
313
|
+
// 겹 세기. 남의 래퍼(원래 명령을 백업해 두는 종류)가 우리를 자기 안쪽에 넣어
|
|
314
|
+
// 버리면 `우리 -> 남 -> 우리 -> ...` 고리가 생긴다. 직접 검사(위 includes)로는 한 겹밖에
|
|
315
|
+
// 못 보므로, 물려주는 env 에 깊이를 실어 두 겹을 넘으면 조용히 멈춘다. 그 화면은
|
|
316
|
+
// statusLine 이 비어 보이지만(설치를 다시 하면 풀린다) 프로세스가 무한히 늘지는 않는다
|
|
317
|
+
const depth = Number(process.env.AGENTDECK_SL_DEPTH || 0) + 1
|
|
318
|
+
if (depth > 2) {
|
|
319
|
+
process.exit(0)
|
|
320
|
+
}
|
|
321
|
+
// stdout/stderr 은 그대로 물려준다(inherit) — 우리가 중계하면 버퍼 한 겹만 늘어난다
|
|
322
|
+
const child = spawn(command, {
|
|
323
|
+
shell: true,
|
|
324
|
+
stdio: ['pipe', 'inherit', 'inherit'],
|
|
325
|
+
env: { ...process.env, AGENTDECK_SL_DEPTH: String(depth) },
|
|
326
|
+
})
|
|
327
|
+
child.on('error', () => process.exit(0))
|
|
328
|
+
child.on('close', code => process.exit(code ?? 0))
|
|
329
|
+
child.stdin.on('error', () => { /* 안쪽이 stdin 을 안 읽고 끝냈다 — 정상 */ })
|
|
330
|
+
child.stdin.end(raw)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// 갱신 전용 실행 — 떼어 낸 프로세스가 여기로 들어온다. stdin 도 statusLine 도 건드리지 않는다
|
|
334
|
+
if (process.argv.includes('--refresh-scoped')) {
|
|
335
|
+
// exit 을 부르지 않는다 — 응답이 끝나면 루프가 비어 저절로 끝난다 (requestUsage 주석)
|
|
336
|
+
refreshScoped().catch(() => {
|
|
337
|
+
// 네트워크·토큰 문제. **여기서도 도장을 찍는다** — 안 찍으면 TTL 게이트가 비어
|
|
338
|
+
// 렌더마다 새 프로세스가 뜬다 (writeScoped 주석)
|
|
339
|
+
try { writeScoped(null, readScopedCache(), 'unhandled') } catch { /* 디스크까지 막혔으면 할 수 있는 게 없다 */ }
|
|
340
|
+
})
|
|
341
|
+
} else {
|
|
342
|
+
const raw = readStdin()
|
|
343
|
+
try {
|
|
344
|
+
writeSnapshot(raw)
|
|
345
|
+
} catch {
|
|
346
|
+
// 베끼기 실패는 사이드바 한 줄이 안 뜨는 것으로 끝난다. statusLine 은 계속 나가야 한다
|
|
347
|
+
}
|
|
348
|
+
runInner(raw)
|
|
349
|
+
}
|