@cocorograph/hub-agent 0.6.21 → 0.6.24
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/package.json +1 -1
- package/src/chat-signals.mjs +85 -0
- package/src/main.mjs +104 -25
- package/src/state.mjs +30 -5
- package/src/usage.mjs +1 -125
package/package.json
CHANGED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* チャット(SDK stream-json)モードの per-session 状態を、Hub ホスト型 Cockpit の
|
|
3
|
+
* サイドバー信号 (session.state / session.event) に橋渡しするための in-process ストア。
|
|
4
|
+
*
|
|
5
|
+
* 背景:
|
|
6
|
+
* Hub ホスト型 Cockpit のサイドバーは以下を「TUI 専用シグナル」から導出している:
|
|
7
|
+
* - ステータスドット / 各行 context%: hub-agent の startStateLoop が
|
|
8
|
+
* detectSessionState (tmux capture-pane スクレイプ) で算出して session.state を push
|
|
9
|
+
* - 時系列ソート: bundle hook (cockpit_session_event_hook.py) が
|
|
10
|
+
* /tmp/cockpit_session_events/<tmux名>.json を書き、hub-agent が watch して
|
|
11
|
+
* session.event を push → frontend の eventAt sort key
|
|
12
|
+
* チャットモードは tmux ペインに Claude TUI を描画せず、hook も TUI 経由でしか
|
|
13
|
+
* 発火しないため、これらが更新されずドット/ドーナツ/ソートが固まる。
|
|
14
|
+
*
|
|
15
|
+
* 解決:
|
|
16
|
+
* claude-stream-bridge の SDK event (assistant / result) を main.mjs で捕捉し、
|
|
17
|
+
* ここに cwd キーで status / context_pct / 最終アクティビティ時刻を記録する。
|
|
18
|
+
* startStateLoop が tmux セッションを走査する際、session の cwd に一致する新鮮な
|
|
19
|
+
* チャット信号があれば status / context_pct を上書きし、turnAt 更新時には
|
|
20
|
+
* session-event ファイルを書いてソートを動かす。
|
|
21
|
+
*
|
|
22
|
+
* cwd をキーにする理由: チャットセッションは tmux セッション名を持たず (SDK 子
|
|
23
|
+
* プロセス)、サイドバー行 (= tmux セッション) とは cwd で対応するため。
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// cwd → { status, context_pct, turnAt, updatedAtMs }
|
|
27
|
+
const _byCwd = new Map()
|
|
28
|
+
|
|
29
|
+
// チャット信号を「生きている」と見なす最大経過時間 (ms)。これを過ぎたら tmux
|
|
30
|
+
// スクレイプ結果へフォールバックさせる (チャット終了/別モード移行の検知)。
|
|
31
|
+
const CHAT_SIGNAL_STALE_MS = 15 * 60 * 1000
|
|
32
|
+
|
|
33
|
+
const VALID_STATUS = new Set(["processing", "waiting", "idle"])
|
|
34
|
+
|
|
35
|
+
function _now() {
|
|
36
|
+
return Date.now()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* チャットのアクティビティを記録する。status / context_pct は与えられた分だけ更新し、
|
|
41
|
+
* turnAt (= 最終アクティビティ時刻 / ソート用) は呼ばれるたびに前進させる。
|
|
42
|
+
*
|
|
43
|
+
* @param {string} cwd セッションの作業ディレクトリ
|
|
44
|
+
* @param {{ status?: string, contextPct?: number|null }} patch
|
|
45
|
+
*/
|
|
46
|
+
export function recordChatActivity(cwd, patch = {}) {
|
|
47
|
+
if (!cwd || typeof cwd !== "string") return
|
|
48
|
+
const now = _now()
|
|
49
|
+
const prev = _byCwd.get(cwd) || {
|
|
50
|
+
status: null,
|
|
51
|
+
context_pct: null,
|
|
52
|
+
turnAt: 0,
|
|
53
|
+
updatedAtMs: 0,
|
|
54
|
+
}
|
|
55
|
+
const next = { ...prev, turnAt: now, updatedAtMs: now }
|
|
56
|
+
if (typeof patch.status === "string" && VALID_STATUS.has(patch.status)) {
|
|
57
|
+
next.status = patch.status
|
|
58
|
+
}
|
|
59
|
+
if (typeof patch.contextPct === "number" && Number.isFinite(patch.contextPct)) {
|
|
60
|
+
next.context_pct = Math.max(0, Math.min(100, patch.contextPct))
|
|
61
|
+
}
|
|
62
|
+
_byCwd.set(cwd, next)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* cwd に対応する生きているチャット信号を返す。stale / 不在なら null。
|
|
67
|
+
*
|
|
68
|
+
* @param {string} cwd
|
|
69
|
+
* @param {number} [now]
|
|
70
|
+
* @returns {{ status: string|null, context_pct: number|null, turnAt: number, updatedAtMs: number } | null}
|
|
71
|
+
*/
|
|
72
|
+
export function getChatSignal(cwd, now = _now()) {
|
|
73
|
+
if (!cwd) return null
|
|
74
|
+
const sig = _byCwd.get(cwd)
|
|
75
|
+
if (!sig) return null
|
|
76
|
+
if (now - sig.updatedAtMs > CHAT_SIGNAL_STALE_MS) return null
|
|
77
|
+
return sig
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** テスト用: ストアを空にする。 */
|
|
81
|
+
export function _resetChatSignals() {
|
|
82
|
+
_byCwd.clear()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export { CHAT_SIGNAL_STALE_MS }
|
package/src/main.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
|
|
12
12
|
*/
|
|
13
13
|
import { readFileSync, watch as fsWatch } from "node:fs"
|
|
14
|
-
import { mkdir, readFile, readdir } from "node:fs/promises"
|
|
14
|
+
import { mkdir, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises"
|
|
15
15
|
import os from "node:os"
|
|
16
16
|
import path from "node:path"
|
|
17
17
|
|
|
@@ -38,12 +38,12 @@ import {
|
|
|
38
38
|
removeWorktree as removeWorktreeDir,
|
|
39
39
|
} from "./tmux.mjs"
|
|
40
40
|
import {
|
|
41
|
+
contextWindowSize,
|
|
41
42
|
getSessionUsages,
|
|
42
43
|
getUsage,
|
|
43
44
|
recordChatRateLimit,
|
|
44
|
-
recordChatSessionContext,
|
|
45
|
-
recordChatSessionStatus,
|
|
46
45
|
} from "./usage.mjs"
|
|
46
|
+
import { getChatSignal, recordChatActivity } from "./chat-signals.mjs"
|
|
47
47
|
|
|
48
48
|
const logger = pino({ name: "hub-agent" })
|
|
49
49
|
|
|
@@ -194,30 +194,22 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
194
194
|
/* ignore */
|
|
195
195
|
}
|
|
196
196
|
}
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
197
|
+
// Hub ホスト型 Cockpit 用: SDK event から per-session の状態をチャット信号として
|
|
198
|
+
// 記録する。startStateLoop が cwd 一致で session.state (ステータスドット/各行
|
|
199
|
+
// ドーナツ) と session.event (ソート) に橋渡しする。チャットは tmux ペインに
|
|
200
|
+
// TUI を出さず、capture-pane スクレイプも bundle hook も発火しないための補完。
|
|
201
|
+
// - assistant (生成中) → processing + usage から context%
|
|
201
202
|
// - result (ターン完了/入力待ち) → waiting
|
|
202
|
-
// webapp のステータスドットは tmux ペインのスクレイプで判定するが、チャットは
|
|
203
|
-
// ペインに TUI を出さないため、この chat_status を優先させて補完する。
|
|
204
|
-
const sid = session_id || event?.session_id
|
|
205
203
|
if (event?.type === "assistant") {
|
|
206
|
-
|
|
207
|
-
try {
|
|
208
|
-
recordChatSessionContext({ sessionId: sid, cwd, message: event.message })
|
|
209
|
-
} catch {
|
|
210
|
-
/* ignore */
|
|
211
|
-
}
|
|
212
|
-
}
|
|
204
|
+
const pct = event.message?.usage ? contextPctFromUsage(event.message.usage) : null
|
|
213
205
|
try {
|
|
214
|
-
|
|
206
|
+
recordChatActivity(cwd, { status: "processing", contextPct: pct })
|
|
215
207
|
} catch {
|
|
216
208
|
/* ignore */
|
|
217
209
|
}
|
|
218
210
|
} else if (event?.type === "result") {
|
|
219
211
|
try {
|
|
220
|
-
|
|
212
|
+
recordChatActivity(cwd, { status: "waiting" })
|
|
221
213
|
} catch {
|
|
222
214
|
/* ignore */
|
|
223
215
|
}
|
|
@@ -332,6 +324,71 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
332
324
|
const SESSION_EVENTS_DIR =
|
|
333
325
|
process.env.COCKPIT_SESSION_EVENTS_DIR || "/tmp/cockpit_session_events"
|
|
334
326
|
|
|
327
|
+
// context 窓サイズ (トークン) の既定。env HUB_CONTEXT_WINDOW があれば最優先 (手動上書き)。
|
|
328
|
+
// 無ければ起動後に statusLine cache の context_window_size (Opus 4.8 の 1M 等) を
|
|
329
|
+
// refreshContextWindow() で解決して resolvedContextWindow を上書きする。
|
|
330
|
+
const CONTEXT_WINDOW_FALLBACK = Number(process.env.HUB_CONTEXT_WINDOW) || 200000
|
|
331
|
+
|
|
332
|
+
// 実際に context% 計算で使う窓サイズ。startStateLoop の tick で定期リフレッシュされる。
|
|
333
|
+
// 200k 固定だと 1M ベータのセッションで分母が 5 倍小さく、ドーナツが常に振り切れる
|
|
334
|
+
// (273k トークン → 137% を 100% にクランプ表示) 不具合があったため、実窓サイズを使う。
|
|
335
|
+
let resolvedContextWindow = CONTEXT_WINDOW_FALLBACK
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* 実コンテキスト窓サイズ (1M ベータ等) を statusLine cache から解決して
|
|
339
|
+
* resolvedContextWindow を更新する。env HUB_CONTEXT_WINDOW 指定時は手動上書きを
|
|
340
|
+
* 尊重して何もしない。ファイル読み失敗時は前回値を保持する。
|
|
341
|
+
*/
|
|
342
|
+
export async function refreshContextWindow() {
|
|
343
|
+
if (process.env.HUB_CONTEXT_WINDOW) return // 手動上書きが最優先
|
|
344
|
+
try {
|
|
345
|
+
const size = await contextWindowSize()
|
|
346
|
+
if (typeof size === "number" && size > 0) resolvedContextWindow = size
|
|
347
|
+
} catch {
|
|
348
|
+
/* 前回値を保持 */
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* SDK assistant メッセージの usage から context 使用率 (%) を概算する。
|
|
354
|
+
* context = input + cache_read + cache_creation + output(直近応答)。
|
|
355
|
+
* プロンプトキャッシュ有効時は cache_* に大半が乗るため 4 種を合算する。
|
|
356
|
+
* 分母は resolvedContextWindow (実窓サイズ。1M ベータ等を自動反映)。
|
|
357
|
+
*/
|
|
358
|
+
export function contextPctFromUsage(u) {
|
|
359
|
+
if (!u) return null
|
|
360
|
+
const tokens =
|
|
361
|
+
(u.input_tokens || 0) +
|
|
362
|
+
(u.cache_read_input_tokens || 0) +
|
|
363
|
+
(u.cache_creation_input_tokens || 0) +
|
|
364
|
+
(u.output_tokens || 0)
|
|
365
|
+
if (tokens <= 0) return null
|
|
366
|
+
return Math.min(100, Math.round((tokens / resolvedContextWindow) * 1000) / 10)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* sort 用の session-event ファイルをチャットのターン境界で書き出す
|
|
371
|
+
* (`/tmp/cockpit_session_events/<tmux名>.json`)。bundle hook が TUI で書くものと
|
|
372
|
+
* 同形式 (`{ event, at }`)。既存の watcher がこれを拾って session.event を push し、
|
|
373
|
+
* frontend の eventAt sort key を更新する。atomic write、全エラー握りつぶし。
|
|
374
|
+
*/
|
|
375
|
+
async function writeSessionEventFile(sessionName, event, at) {
|
|
376
|
+
if (!sessionName || /[/\\]/.test(sessionName)) return
|
|
377
|
+
const fp = path.join(SESSION_EVENTS_DIR, `${sessionName}.json`)
|
|
378
|
+
const tmp = `${fp}.tmp.${process.pid}`
|
|
379
|
+
try {
|
|
380
|
+
await mkdir(SESSION_EVENTS_DIR, { recursive: true })
|
|
381
|
+
await writeFile(tmp, JSON.stringify({ event, at }))
|
|
382
|
+
await rename(tmp, fp)
|
|
383
|
+
} catch {
|
|
384
|
+
try {
|
|
385
|
+
await unlink(tmp)
|
|
386
|
+
} catch {
|
|
387
|
+
/* ignore */
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
335
392
|
/**
|
|
336
393
|
* `/tmp/cockpit_session_events/` 全 .json を読んで session_name → {event, at}
|
|
337
394
|
* の Map を返す (Phase 4: tmux.list_sessions レスポンスに含めて配信する用)。
|
|
@@ -448,28 +505,50 @@ async function startSessionEventWatcher({ client, logger }) {
|
|
|
448
505
|
*/
|
|
449
506
|
function startStateLoop({ client, plugins, logger, intervalMs }) {
|
|
450
507
|
const lastByName = new Map() // session_name → {status, context_pct}
|
|
508
|
+
const lastTurnAtByName = new Map() // session_name → 最後に event ファイル化した turnAt
|
|
451
509
|
let stopped = false
|
|
452
510
|
|
|
453
511
|
const tick = async () => {
|
|
454
512
|
if (stopped) return
|
|
455
513
|
try {
|
|
514
|
+
// 実コンテキスト窓サイズ (1M ベータ等) を反映。contextPctFromUsage の分母を
|
|
515
|
+
// 5s ごとに最新化し、ドーナツが 200k 固定で振り切れるのを防ぐ。
|
|
516
|
+
await refreshContextWindow()
|
|
456
517
|
const states = await listSessionStates({ plugins, logger })
|
|
457
518
|
for (const s of states) {
|
|
519
|
+
let status = s.status
|
|
520
|
+
let contextPct = s.context_pct
|
|
521
|
+
// チャットモード補完: tmux ペインのスクレイプは TUI 前提で効かないため、
|
|
522
|
+
// session の cwd に一致する新鮮なチャット信号があれば status/context% を
|
|
523
|
+
// 上書きする (Hub ホスト型 Cockpit のステータスドット/各行ドーナツ用)。
|
|
524
|
+
const chat = s.cwd ? getChatSignal(s.cwd) : null
|
|
525
|
+
if (chat) {
|
|
526
|
+
if (chat.status) status = chat.status
|
|
527
|
+
if (typeof chat.context_pct === "number") contextPct = chat.context_pct
|
|
528
|
+
// チャットのターン境界 (turnAt 前進) を sort 用 session-event に橋渡し。
|
|
529
|
+
// tmux ペインが動かず bundle hook が発火しないため、ここで代替発火する。
|
|
530
|
+
const prevTurnAt = lastTurnAtByName.get(s.session_name) || 0
|
|
531
|
+
if (chat.turnAt > prevTurnAt) {
|
|
532
|
+
lastTurnAtByName.set(s.session_name, chat.turnAt)
|
|
533
|
+
const ev = chat.status === "processing" ? "prompt_submit" : "stop"
|
|
534
|
+
writeSessionEventFile(s.session_name, ev, chat.turnAt).catch(() => {})
|
|
535
|
+
}
|
|
536
|
+
}
|
|
458
537
|
const prev = lastByName.get(s.session_name)
|
|
459
538
|
if (
|
|
460
539
|
!prev ||
|
|
461
|
-
prev.status !==
|
|
462
|
-
prev.context_pct !==
|
|
540
|
+
prev.status !== status ||
|
|
541
|
+
prev.context_pct !== contextPct
|
|
463
542
|
) {
|
|
464
543
|
lastByName.set(s.session_name, {
|
|
465
|
-
status
|
|
466
|
-
context_pct:
|
|
544
|
+
status,
|
|
545
|
+
context_pct: contextPct,
|
|
467
546
|
})
|
|
468
547
|
client.send({
|
|
469
548
|
type: "session.state",
|
|
470
549
|
session_name: s.session_name,
|
|
471
|
-
status
|
|
472
|
-
context_pct:
|
|
550
|
+
status,
|
|
551
|
+
context_pct: contextPct,
|
|
473
552
|
})
|
|
474
553
|
}
|
|
475
554
|
}
|
package/src/state.mjs
CHANGED
|
@@ -83,6 +83,28 @@ export async function capturePane(sessionName, opts = {}) {
|
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* tmux session の active pane の current_path を取得する (chat 信号との cwd 照合用)。
|
|
88
|
+
* 取れなければ null。
|
|
89
|
+
*/
|
|
90
|
+
export async function getSessionCwd(sessionName, opts = {}) {
|
|
91
|
+
const tmuxBin = opts.tmuxBin || "tmux"
|
|
92
|
+
try {
|
|
93
|
+
const { stdout } = await execFileP(tmuxBin, [
|
|
94
|
+
"display-message",
|
|
95
|
+
"-p",
|
|
96
|
+
"-t",
|
|
97
|
+
`${sessionName}:`,
|
|
98
|
+
"-F",
|
|
99
|
+
"#{pane_current_path}",
|
|
100
|
+
])
|
|
101
|
+
const s = stdout.trim()
|
|
102
|
+
return s || null
|
|
103
|
+
} catch {
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
86
108
|
/**
|
|
87
109
|
* 1 session の現在状態を取得する。plugin hook で上書き可。
|
|
88
110
|
*
|
|
@@ -111,14 +133,17 @@ export async function detectSessionState(sessionName, opts = {}) {
|
|
|
111
133
|
return { status: defaultStatus, context_pct: defaultContextPct }
|
|
112
134
|
}
|
|
113
135
|
|
|
114
|
-
/** 全 session の現在状態を取得する。 */
|
|
136
|
+
/** 全 session の現在状態を取得する。cwd も含める (chat 信号照合用)。 */
|
|
115
137
|
export async function listSessionStates(opts = {}) {
|
|
116
138
|
const names = await listSessionNames(opts)
|
|
117
139
|
return Promise.all(
|
|
118
|
-
names.map(async (name) =>
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
140
|
+
names.map(async (name) => {
|
|
141
|
+
const [state, cwd] = await Promise.all([
|
|
142
|
+
detectSessionState(name, opts),
|
|
143
|
+
getSessionCwd(name, opts),
|
|
144
|
+
])
|
|
145
|
+
return { session_name: name, cwd, ...state }
|
|
146
|
+
}),
|
|
122
147
|
)
|
|
123
148
|
}
|
|
124
149
|
|
package/src/usage.mjs
CHANGED
|
@@ -217,130 +217,6 @@ export function whenChatRateLimitsPersisted() {
|
|
|
217
217
|
return _persistInFlight
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
-
// session 別ファイル書き込みの in-flight promise (テスト/shutdown 用)。
|
|
221
|
-
// context% (assistant) と chat_status (result) の書き込みは read-modify-write の
|
|
222
|
-
// ため、この chain に直列化して lost-update / 競合を防ぐ。
|
|
223
|
-
let _sessionPersistInFlight = Promise.resolve()
|
|
224
|
-
|
|
225
|
-
/** session_id を安全なファイル名としてバリデーション (path traversal 防止)。 */
|
|
226
|
-
function isSafeSessionId(sid) {
|
|
227
|
-
return typeof sid === "string" && /^[A-Za-z0-9_-]{8,64}$/.test(sid)
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const VALID_CHAT_STATUS = new Set(["processing", "waiting", "idle"])
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* session 別ファイル (`~/.hub/usage/sessions/<sid>.json`) へ patch を
|
|
234
|
-
* マージ書き込みする (read-modify-write, atomic rename)。
|
|
235
|
-
*
|
|
236
|
-
* webapp サイドバーは getSessionUsages (context ドーナツ) と listSessions
|
|
237
|
-
* (ステータスドット) がこのディレクトリを読むが、ファイルは TUI statusLine
|
|
238
|
-
* でしか更新されないため、チャットモードでは両方が固まる。チャット中は SDK の
|
|
239
|
-
* usage / busy 状態からここへ書き出すことで両方ライブ更新される。
|
|
240
|
-
*
|
|
241
|
-
* context_window と chat_status は別タイミング (assistant / result) で書かれる
|
|
242
|
-
* ため、既存値を破壊しないよう deep merge する。
|
|
243
|
-
*/
|
|
244
|
-
async function mergeSessionFile(sessionId, cwd, patch) {
|
|
245
|
-
if (!isSafeSessionId(sessionId) || !cwd) return
|
|
246
|
-
const dir = statuslineSessionsDir()
|
|
247
|
-
const fp = path.join(dir, `${sessionId}.json`)
|
|
248
|
-
let base = {}
|
|
249
|
-
const existing = await readOrNull(fp)
|
|
250
|
-
if (existing) {
|
|
251
|
-
try {
|
|
252
|
-
const p = JSON.parse(existing)
|
|
253
|
-
if (p && typeof p === "object") base = p
|
|
254
|
-
} catch {
|
|
255
|
-
/* 壊れたファイルは作り直す */
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
const next = {
|
|
259
|
-
...base,
|
|
260
|
-
...patch,
|
|
261
|
-
session_id: sessionId,
|
|
262
|
-
workspace: { ...(base.workspace || {}), project_dir: cwd },
|
|
263
|
-
_source: "chat",
|
|
264
|
-
}
|
|
265
|
-
// context_window はネストオブジェクトなので明示マージ。
|
|
266
|
-
if (patch.context_window) {
|
|
267
|
-
next.context_window = { ...(base.context_window || {}), ...patch.context_window }
|
|
268
|
-
}
|
|
269
|
-
try {
|
|
270
|
-
await fs.mkdir(dir, { recursive: true })
|
|
271
|
-
} catch {
|
|
272
|
-
/* ignore */
|
|
273
|
-
}
|
|
274
|
-
const tmp = `${fp}.tmp.${process.pid}`
|
|
275
|
-
try {
|
|
276
|
-
await fs.writeFile(tmp, JSON.stringify(next))
|
|
277
|
-
await fs.rename(tmp, fp)
|
|
278
|
-
} catch {
|
|
279
|
-
try {
|
|
280
|
-
await fs.unlink(tmp)
|
|
281
|
-
} catch {
|
|
282
|
-
/* ignore */
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/** session 別ファイル書き込みを直列 chain に積む。 */
|
|
288
|
-
function enqueueSessionWrite(fn) {
|
|
289
|
-
_sessionPersistInFlight = _sessionPersistInFlight.catch(() => {}).then(fn)
|
|
290
|
-
return _sessionPersistInFlight
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* チャット assistant メッセージを取り込んで session 別 context% を更新する
|
|
295
|
-
* (main.mjs の event ハンドラから assistant メッセージで呼ぶ)。fire-and-forget。
|
|
296
|
-
*
|
|
297
|
-
* context トークン = input + cache_read + cache_creation + output(直近応答)。
|
|
298
|
-
* プロンプトキャッシュ有効時は cache_* に大半が乗るため 4 種を必ず合算する。
|
|
299
|
-
*
|
|
300
|
-
* @param {{ sessionId?: string, cwd?: string, message?: { usage?: object } }} arg
|
|
301
|
-
*/
|
|
302
|
-
export function recordChatSessionContext(arg) {
|
|
303
|
-
if (!arg || typeof arg !== "object") return
|
|
304
|
-
const { sessionId, cwd, message } = arg
|
|
305
|
-
const u = message?.usage
|
|
306
|
-
if (!u) return
|
|
307
|
-
const tokens =
|
|
308
|
-
(u.input_tokens || 0) +
|
|
309
|
-
(u.cache_read_input_tokens || 0) +
|
|
310
|
-
(u.cache_creation_input_tokens || 0) +
|
|
311
|
-
(u.output_tokens || 0)
|
|
312
|
-
if (tokens <= 0) return
|
|
313
|
-
enqueueSessionWrite(async () => {
|
|
314
|
-
const windowSize = await contextWindowSize()
|
|
315
|
-
const percent = Math.min(100, Math.round((tokens / windowSize) * 1000) / 10)
|
|
316
|
-
await mergeSessionFile(sessionId, cwd, { context_window: { used_percentage: percent } })
|
|
317
|
-
})
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/**
|
|
321
|
-
* チャットのターン状態 (processing/waiting/idle) を session 別ファイルへ書き出す。
|
|
322
|
-
* main.mjs の event ハンドラから、assistant→processing / result→waiting で呼ぶ。
|
|
323
|
-
* webapp listSessions が tmux スクレイプの代わりにこれを優先する (チャットモードは
|
|
324
|
-
* ペインに TUI を出さずスクレイプが効かないため)。fire-and-forget。
|
|
325
|
-
*
|
|
326
|
-
* @param {{ sessionId?: string, cwd?: string, status?: string }} arg
|
|
327
|
-
*/
|
|
328
|
-
export function recordChatSessionStatus(arg) {
|
|
329
|
-
if (!arg || typeof arg !== "object") return
|
|
330
|
-
const { sessionId, cwd, status } = arg
|
|
331
|
-
if (!VALID_CHAT_STATUS.has(status)) return
|
|
332
|
-
enqueueSessionWrite(() => mergeSessionFile(sessionId, cwd, { chat_status: status }))
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/**
|
|
336
|
-
* 直近の session 別ファイル書き込み (context%/status 両方) の完了を待つ。
|
|
337
|
-
* テスト/shutdown 用。
|
|
338
|
-
* @returns {Promise<void>}
|
|
339
|
-
*/
|
|
340
|
-
export function whenChatSessionContextPersisted() {
|
|
341
|
-
return _sessionPersistInFlight
|
|
342
|
-
}
|
|
343
|
-
|
|
344
220
|
async function readOfficial(now) {
|
|
345
221
|
const text = await readOrNull(statuslineCache())
|
|
346
222
|
if (!text) return null
|
|
@@ -484,7 +360,7 @@ async function statuslineCacheMtime() {
|
|
|
484
360
|
* あればそれを使う (1M ベータ等を自動反映)。無ければ HUB_CONTEXT_WINDOW / 200000。
|
|
485
361
|
* ※ cache が stale でも窓サイズ自体は不変なので利用できる。
|
|
486
362
|
*/
|
|
487
|
-
async function contextWindowSize() {
|
|
363
|
+
export async function contextWindowSize() {
|
|
488
364
|
const text = await readOrNull(statuslineCache())
|
|
489
365
|
if (text) {
|
|
490
366
|
try {
|