@cocorograph/hub-agent 0.6.42 → 0.6.44
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/main.mjs +66 -1
- package/src/tui-permission-bridge.mjs +166 -0
- package/src/tui-viewer-registry.mjs +185 -0
package/package.json
CHANGED
package/src/main.mjs
CHANGED
|
@@ -49,6 +49,8 @@ import {
|
|
|
49
49
|
listWorktreeStubs,
|
|
50
50
|
removeWorktree as removeWorktreeDir,
|
|
51
51
|
} from "./tmux.mjs"
|
|
52
|
+
import { TuiPermissionBridge } from "./tui-permission-bridge.mjs"
|
|
53
|
+
import { TuiViewerRegistry } from "./tui-viewer-registry.mjs"
|
|
52
54
|
import {
|
|
53
55
|
contextWindowSize,
|
|
54
56
|
getSessionUsages,
|
|
@@ -400,11 +402,50 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
400
402
|
// text マーカー判定 (detectStatusFromText) より精度が高い「ターン境界」の signal。
|
|
401
403
|
const sessionEventLoop = await startSessionEventWatcher({ client, logger })
|
|
402
404
|
|
|
405
|
+
// TUI 権限ブリッジ: 対話 TUI の PreToolUse フックが書く権限要求を fs.watch で
|
|
406
|
+
// 拾い、既存 SDK 経路と同じ `claude.permission.request` として push する。
|
|
407
|
+
// 決定 (claude.permission.reply) は dispatch 側で request_id を見て
|
|
408
|
+
// tuiPermissionBridge.resolve() に優先ルーティングする。
|
|
409
|
+
const tuiPermissionBridge = new TuiPermissionBridge({ logger })
|
|
410
|
+
tuiPermissionBridge.on(
|
|
411
|
+
"permission",
|
|
412
|
+
({ request_id, session_id, cwd, tool_name, input }) => {
|
|
413
|
+
if (cwd) {
|
|
414
|
+
try {
|
|
415
|
+
recordChatActivity(cwd, { inputPending: true })
|
|
416
|
+
} catch {
|
|
417
|
+
/* ignore */
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
client.send({
|
|
421
|
+
type: "claude.permission.request",
|
|
422
|
+
stream_id: null,
|
|
423
|
+
session_id,
|
|
424
|
+
cwd,
|
|
425
|
+
request_id,
|
|
426
|
+
tool_name,
|
|
427
|
+
input,
|
|
428
|
+
})
|
|
429
|
+
},
|
|
430
|
+
)
|
|
431
|
+
await tuiPermissionBridge.start()
|
|
432
|
+
ctx.tuiPermissionBridge = tuiPermissionBridge
|
|
433
|
+
|
|
434
|
+
// TUI 閲覧マーカー registry: browser の claude.tui.viewing ハートビートを受けて
|
|
435
|
+
// 「閲覧中の session」をローカルのマーカーファイルとして保持する。承認フックは
|
|
436
|
+
// その鮮度を見て「閲覧者がいる session だけブリッジ、いなければ即 ask」する
|
|
437
|
+
// (watcher ゲート, WATCHER-GATE.md)。
|
|
438
|
+
const tuiViewerRegistry = new TuiViewerRegistry({ logger })
|
|
439
|
+
await tuiViewerRegistry.start()
|
|
440
|
+
ctx.tuiViewerRegistry = tuiViewerRegistry
|
|
441
|
+
|
|
403
442
|
const shutdown = async (signal) => {
|
|
404
443
|
logger.info({ signal }, "shutting down")
|
|
405
444
|
await runHookBroadcast(plugins, "onAgentStop", ctx)
|
|
406
445
|
stateLoop.stop()
|
|
407
446
|
sessionEventLoop?.stop?.()
|
|
447
|
+
tuiPermissionBridge.stop()
|
|
448
|
+
tuiViewerRegistry.stop()
|
|
408
449
|
ptyBridge.shutdown()
|
|
409
450
|
claudeBridge?.shutdown?.()
|
|
410
451
|
client.stop()
|
|
@@ -960,7 +1001,17 @@ async function dispatch(msg, ctx) {
|
|
|
960
1001
|
}
|
|
961
1002
|
return
|
|
962
1003
|
}
|
|
963
|
-
case "claude.permission.reply":
|
|
1004
|
+
case "claude.permission.reply": {
|
|
1005
|
+
// TUI ブリッジの pending なら .decision を書いて返す (TUI 経路優先)。
|
|
1006
|
+
// 管理外 (= SDK チャットの request) なら従来の permissionReply へ。
|
|
1007
|
+
const handledByTui = ctx.tuiPermissionBridge
|
|
1008
|
+
? await ctx.tuiPermissionBridge.resolve(
|
|
1009
|
+
msg.request_id,
|
|
1010
|
+
!!msg.allow,
|
|
1011
|
+
msg.deny_message,
|
|
1012
|
+
)
|
|
1013
|
+
: false
|
|
1014
|
+
if (handledByTui) return
|
|
964
1015
|
if (!ctx.claudeBridge) return
|
|
965
1016
|
ctx.claudeBridge.permissionReply({
|
|
966
1017
|
stream_id: msg.stream_id,
|
|
@@ -970,10 +1021,24 @@ async function dispatch(msg, ctx) {
|
|
|
970
1021
|
denyMessage: msg.deny_message,
|
|
971
1022
|
})
|
|
972
1023
|
return
|
|
1024
|
+
}
|
|
973
1025
|
case "claude.interrupt":
|
|
974
1026
|
if (!ctx.claudeBridge) return
|
|
975
1027
|
ctx.claudeBridge.interrupt({ stream_id: msg.stream_id })
|
|
976
1028
|
return
|
|
1029
|
+
case "claude.tui.viewing":
|
|
1030
|
+
// T04778 watcher ゲート: browser が TUI チャットでこの session を閲覧中。
|
|
1031
|
+
// 閲覧マーカーを更新し、承認フックが「閲覧者あり」と判定できるようにする。
|
|
1032
|
+
ctx.tuiViewerRegistry
|
|
1033
|
+
?.note({ session_id: msg.session_id, cwd: msg.cwd })
|
|
1034
|
+
.catch(() => {})
|
|
1035
|
+
return
|
|
1036
|
+
case "claude.tui.unviewing":
|
|
1037
|
+
// 閲覧終了。session_id マーカーを即失効させ、以降の Bash を即 ask に倒す。
|
|
1038
|
+
ctx.tuiViewerRegistry
|
|
1039
|
+
?.unview({ session_id: msg.session_id })
|
|
1040
|
+
.catch(() => {})
|
|
1041
|
+
return
|
|
977
1042
|
case "claude.mcp.status":
|
|
978
1043
|
case "claude.mcp.reconnect":
|
|
979
1044
|
case "claude.mcp.authenticate":
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI 権限ブリッジ (G3 spike)。
|
|
3
|
+
*
|
|
4
|
+
* 対話 TUI (tmux/PTY) で動く claude の PreToolUse フックが書き出す権限要求
|
|
5
|
+
* ファイルを fs.watch で拾い、既存 SDK 経路と同じ `claude.permission.request`
|
|
6
|
+
* として browser へ push する。browser の `claude.permission.reply` 決定を
|
|
7
|
+
* `.decision` ファイルとして書き戻し、フックがそれを読んで permissionDecision
|
|
8
|
+
* を返す。これにより TUI 自身の権限メニューを抑止しつつチャット UI で承認できる。
|
|
9
|
+
*
|
|
10
|
+
* ファイル IPC を採用する理由: hub-agent はローカル HTTP サーバを持たず、
|
|
11
|
+
* `/tmp/cockpit_session_events/` の fs.watch 前例 (main.mjs) があるため、同じ
|
|
12
|
+
* パターンに揃える。atomic write (tmp+rename) / 全エラー握りつぶしも踏襲する。
|
|
13
|
+
*
|
|
14
|
+
* 契約: D00000_hub.cocorograph.com:tools/cockpit-tui-chat/SPIKE-G3.md
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { EventEmitter } from "node:events"
|
|
18
|
+
import { watch } from "node:fs"
|
|
19
|
+
import { mkdir, readFile, writeFile, rename, unlink, readdir } from "node:fs/promises"
|
|
20
|
+
import path from "node:path"
|
|
21
|
+
|
|
22
|
+
export const PERMISSION_REQUESTS_DIR =
|
|
23
|
+
process.env.COCKPIT_PERMISSION_DIR || "/tmp/cockpit_permission_requests"
|
|
24
|
+
|
|
25
|
+
/** pending request の TTL。これを超えた seen エントリは GC して再発火を許す。 */
|
|
26
|
+
const PENDING_TTL_MS = 5 * 60 * 1000
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* TUI フック ⇄ browser の権限往復をファイル IPC で仲介する。
|
|
30
|
+
*
|
|
31
|
+
* イベント:
|
|
32
|
+
* - "permission": { request_id, session_id, cwd, tool_name, input }
|
|
33
|
+
* フックが書いた要求を 1 件読んだとき。main.mjs がこれを
|
|
34
|
+
* `claude.permission.request` として client.send する。
|
|
35
|
+
*/
|
|
36
|
+
export class TuiPermissionBridge extends EventEmitter {
|
|
37
|
+
/**
|
|
38
|
+
* @param {{dir?: string, logger?: object}} [opts]
|
|
39
|
+
*/
|
|
40
|
+
constructor({ dir = PERMISSION_REQUESTS_DIR, logger } = {}) {
|
|
41
|
+
super()
|
|
42
|
+
this.dir = dir
|
|
43
|
+
this.logger = logger
|
|
44
|
+
/** @type {Map<string, number>} request_id -> 受理時刻(ms) */
|
|
45
|
+
this._pending = new Map()
|
|
46
|
+
/** @type {Set<string>} 二重発火防止 (request_id) */
|
|
47
|
+
this._seen = new Set()
|
|
48
|
+
this._watcher = null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 監視ディレクトリを作成し、既存要求を sweep してから fs.watch を張る。 */
|
|
52
|
+
async start() {
|
|
53
|
+
await mkdir(this.dir, { recursive: true })
|
|
54
|
+
await this._sweep()
|
|
55
|
+
try {
|
|
56
|
+
this._watcher = watch(this.dir, (_event, filename) => {
|
|
57
|
+
if (filename && filename.endsWith(".json")) {
|
|
58
|
+
this._ingest(filename).catch(() => {})
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
} catch (err) {
|
|
62
|
+
this.logger?.warn(
|
|
63
|
+
{ err: err?.message },
|
|
64
|
+
"tui permission watcher: fs.watch failed, watcher disabled",
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 起動時 / 取りこぼし保険: dir 内の全 .json 要求を読む。 */
|
|
70
|
+
async _sweep() {
|
|
71
|
+
let names = []
|
|
72
|
+
try {
|
|
73
|
+
names = await readdir(this.dir)
|
|
74
|
+
} catch {
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
for (const n of names) {
|
|
78
|
+
if (n.endsWith(".json")) await this._ingest(n).catch(() => {})
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 要求ファイル 1 件を読み "permission" を emit する。partial write や
|
|
84
|
+
* 既読は無視する。
|
|
85
|
+
* @param {string} filename `<request_id>.json`
|
|
86
|
+
*/
|
|
87
|
+
async _ingest(filename) {
|
|
88
|
+
const request_id = filename.slice(0, -".json".length)
|
|
89
|
+
if (this._seen.has(request_id)) return
|
|
90
|
+
let body
|
|
91
|
+
try {
|
|
92
|
+
body = JSON.parse(await readFile(path.join(this.dir, filename), "utf-8"))
|
|
93
|
+
} catch {
|
|
94
|
+
// partial write / 既に消えた → 次の watch イベントで拾い直す。
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
this._gc()
|
|
98
|
+
this._seen.add(request_id)
|
|
99
|
+
this._pending.set(request_id, Date.now())
|
|
100
|
+
this.emit("permission", {
|
|
101
|
+
request_id,
|
|
102
|
+
session_id: body.session_id ?? null,
|
|
103
|
+
cwd: body.cwd ?? null,
|
|
104
|
+
tool_name: body.tool_name ?? "",
|
|
105
|
+
input: body.tool_input ?? {},
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* browser の決定をフックへ返す。対象 request_id なら `.decision` を atomic
|
|
111
|
+
* 書き込みし、要求ファイルを掃除して true を返す。対象外なら false
|
|
112
|
+
* (呼び出し側は SDK 経路へフォールバックする)。
|
|
113
|
+
*
|
|
114
|
+
* @param {string} request_id
|
|
115
|
+
* @param {boolean} allow
|
|
116
|
+
* @param {string} [denyMessage]
|
|
117
|
+
* @returns {Promise<boolean>} この bridge の管理下だったか
|
|
118
|
+
*/
|
|
119
|
+
async resolve(request_id, allow, denyMessage) {
|
|
120
|
+
if (!this._pending.has(request_id)) return false
|
|
121
|
+
this._pending.delete(request_id)
|
|
122
|
+
const decision = allow ? "allow" : "deny"
|
|
123
|
+
const fp = path.join(this.dir, `${request_id}.decision`)
|
|
124
|
+
const tmp = `${fp}.tmp`
|
|
125
|
+
try {
|
|
126
|
+
await writeFile(
|
|
127
|
+
tmp,
|
|
128
|
+
JSON.stringify({ decision, deny_message: denyMessage ?? null }),
|
|
129
|
+
)
|
|
130
|
+
await rename(tmp, fp)
|
|
131
|
+
} catch (err) {
|
|
132
|
+
// 書けなくてもこの request は我々のもの: フックは TTL で ask 縮退する。
|
|
133
|
+
this.logger?.warn({ err: err?.message, request_id }, "decision write failed")
|
|
134
|
+
return true
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
await unlink(path.join(this.dir, `${request_id}.json`))
|
|
138
|
+
} catch {
|
|
139
|
+
/* フック側でも消す。二重防御。 */
|
|
140
|
+
}
|
|
141
|
+
return true
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** TTL 超過の seen/pending を掃除する。 */
|
|
145
|
+
_gc() {
|
|
146
|
+
const now = Date.now()
|
|
147
|
+
for (const [id, at] of this._pending) {
|
|
148
|
+
if (now - at > PENDING_TTL_MS) {
|
|
149
|
+
this._pending.delete(id)
|
|
150
|
+
this._seen.delete(id)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** watcher を閉じる。 */
|
|
156
|
+
stop() {
|
|
157
|
+
try {
|
|
158
|
+
this._watcher?.close()
|
|
159
|
+
} catch {
|
|
160
|
+
/* ignore */
|
|
161
|
+
}
|
|
162
|
+
this._watcher = null
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export default TuiPermissionBridge
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI 閲覧マーカー registry (T04778 watcher ゲート)。
|
|
3
|
+
*
|
|
4
|
+
* Cockpit の TUI チャットでブラウザが「ある session を閲覧中」であることを、
|
|
5
|
+
* ローカルのマーカーファイルとして保持する。対話 TUI (tmux/PTY) で動く claude の
|
|
6
|
+
* PreToolUse 承認フック (bridge_permission.py) は、ツール実行前にこのマーカーの
|
|
7
|
+
* 鮮度を読み、「閲覧者がいる session だけ承認を Cockpit へブリッジ、いなければ
|
|
8
|
+
* 即 ask 縮退」する。これにより、承認フックを全ユーザーの settings.json に常設
|
|
9
|
+
* しても、非閲覧の対話セッション (リーダー / cockpit-orch サブ / ターミナルモード)
|
|
10
|
+
* の Bash がブリッジ応答 TTL ぶんハングする地雷を回避する。
|
|
11
|
+
*
|
|
12
|
+
* ブラウザは閲覧中 `claude.tui.viewing {session_id, cwd}` を周期送信し、main.mjs が
|
|
13
|
+
* note() を呼んでマーカーを更新する。アンマウント時は `claude.tui.unviewing` で
|
|
14
|
+
* unview() を呼び、sid マーカーを即失効させる。鮮度切れは sweep で掃除する。
|
|
15
|
+
*
|
|
16
|
+
* ファイル IPC / atomic write (tmp+rename) / 全エラー握りつぶしは
|
|
17
|
+
* tui-permission-bridge.mjs の前例に揃える。
|
|
18
|
+
*
|
|
19
|
+
* 契約: D00000_hub.cocorograph.com:tools/cockpit-tui-chat/WATCHER-GATE.md
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createHash } from "node:crypto"
|
|
23
|
+
import { mkdir, writeFile, rename, unlink, readdir, readFile } from "node:fs/promises"
|
|
24
|
+
import path from "node:path"
|
|
25
|
+
|
|
26
|
+
export const VIEWERS_DIR =
|
|
27
|
+
process.env.COCKPIT_VIEWERS_DIR || "/tmp/cockpit_tui_viewers"
|
|
28
|
+
|
|
29
|
+
/** マーカー鮮度 (秒)。ブラウザのハートビート間隔 (5s) の 3 倍を既定とする。 */
|
|
30
|
+
const VIEWER_TTL_SEC = Number(process.env.COCKPIT_VIEWER_TTL_SEC) || 15
|
|
31
|
+
|
|
32
|
+
/** 失効マーカーを掃除する周期 (ms)。判定はフック側が expires_at で行うので衛生のみ。 */
|
|
33
|
+
const SWEEP_INTERVAL_MS = 30 * 1000
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* session_id をフック側 (`bridge_permission.py`) と同一規則でファイル名へ正規化する。
|
|
37
|
+
* Python 側は ``c.isalnum() or c in "-_"`` を残す。UUID は ASCII 英数 + ハイフンなので
|
|
38
|
+
* 実質 no-op だが、両者が一致しないとマーカーを引けないため厳密に揃える。
|
|
39
|
+
*
|
|
40
|
+
* @param {string} sessionId
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
function sanitizeSessionId(sessionId) {
|
|
44
|
+
return String(sessionId).replace(/[^A-Za-z0-9_-]/g, "")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* cwd をフック側と同一規則でハッシュする (sha1 hex 先頭 16 文字)。
|
|
49
|
+
*
|
|
50
|
+
* @param {string} cwd
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
function hashCwd(cwd) {
|
|
54
|
+
return createHash("sha1").update(String(cwd), "utf8").digest("hex").slice(0, 16)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* TUI 閲覧マーカーをローカル FS に保持する registry。
|
|
59
|
+
*/
|
|
60
|
+
export class TuiViewerRegistry {
|
|
61
|
+
/**
|
|
62
|
+
* @param {{dir?: string, ttlSec?: number, logger?: object}} [opts]
|
|
63
|
+
*/
|
|
64
|
+
constructor({ dir = VIEWERS_DIR, ttlSec = VIEWER_TTL_SEC, logger } = {}) {
|
|
65
|
+
this.dir = dir
|
|
66
|
+
this.ttlSec = ttlSec
|
|
67
|
+
this.logger = logger
|
|
68
|
+
this._sweepTimer = null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** ディレクトリを作成し、起動時の残骸を掃除して周期 sweep を張る。 */
|
|
72
|
+
async start() {
|
|
73
|
+
try {
|
|
74
|
+
await mkdir(this.dir, { recursive: true })
|
|
75
|
+
} catch (err) {
|
|
76
|
+
this.logger?.warn({ err: err?.message }, "tui viewer registry: mkdir failed")
|
|
77
|
+
}
|
|
78
|
+
await this._sweep()
|
|
79
|
+
this._sweepTimer = setInterval(() => {
|
|
80
|
+
this._sweep().catch(() => {})
|
|
81
|
+
}, SWEEP_INTERVAL_MS)
|
|
82
|
+
// 他の timer 同様 unref して agent のアイドル終了を妨げない。
|
|
83
|
+
this._sweepTimer?.unref?.()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 閲覧ハートビートを受けてマーカーを更新する。session_id マーカー (厳密) と
|
|
88
|
+
* cwd マーカー (フォールバック) の両方を ``expires_at = now + ttl`` で atomic 更新する。
|
|
89
|
+
*
|
|
90
|
+
* @param {{session_id?: string|null, cwd?: string|null}} info
|
|
91
|
+
* @returns {Promise<void>}
|
|
92
|
+
*/
|
|
93
|
+
async note({ session_id, cwd } = {}) {
|
|
94
|
+
// expires_at は epoch **秒** (フック側 time.time() と比較するため)。
|
|
95
|
+
const expiresAt = Date.now() / 1000 + this.ttlSec
|
|
96
|
+
const updatedAt = Date.now() / 1000
|
|
97
|
+
const body = JSON.stringify({
|
|
98
|
+
session_id: session_id ?? null,
|
|
99
|
+
cwd: cwd ?? null,
|
|
100
|
+
expires_at: expiresAt,
|
|
101
|
+
updated_at: updatedAt,
|
|
102
|
+
})
|
|
103
|
+
const targets = []
|
|
104
|
+
if (session_id) {
|
|
105
|
+
const safe = sanitizeSessionId(session_id)
|
|
106
|
+
if (safe) targets.push(path.join(this.dir, `sid-${safe}.json`))
|
|
107
|
+
}
|
|
108
|
+
if (cwd) targets.push(path.join(this.dir, `cwd-${hashCwd(cwd)}.json`))
|
|
109
|
+
for (const fp of targets) {
|
|
110
|
+
await this._atomicWrite(fp, body)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 閲覧終了を受けて session_id マーカーを即削除する。cwd マーカーは同 cwd を
|
|
116
|
+
* 他のブラウザが閲覧している可能性があるため即削除せず、TTL 失効に委ねる。
|
|
117
|
+
*
|
|
118
|
+
* @param {{session_id?: string|null}} info
|
|
119
|
+
* @returns {Promise<void>}
|
|
120
|
+
*/
|
|
121
|
+
async unview({ session_id } = {}) {
|
|
122
|
+
if (!session_id) return
|
|
123
|
+
const safe = sanitizeSessionId(session_id)
|
|
124
|
+
if (!safe) return
|
|
125
|
+
try {
|
|
126
|
+
await unlink(path.join(this.dir, `sid-${safe}.json`))
|
|
127
|
+
} catch {
|
|
128
|
+
/* 既に無い / 失効済み。 */
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* atomic write (tmp+rename)。書けなくても agent は落とさない。
|
|
134
|
+
* @param {string} fp
|
|
135
|
+
* @param {string} body
|
|
136
|
+
*/
|
|
137
|
+
async _atomicWrite(fp, body) {
|
|
138
|
+
const tmp = `${fp}.tmp`
|
|
139
|
+
try {
|
|
140
|
+
await writeFile(tmp, body)
|
|
141
|
+
await rename(tmp, fp)
|
|
142
|
+
} catch (err) {
|
|
143
|
+
this.logger?.warn({ err: err?.message, fp }, "viewer marker write failed")
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** expires_at を過ぎたマーカーを unlink する (衛生のみ)。 */
|
|
148
|
+
async _sweep() {
|
|
149
|
+
let names = []
|
|
150
|
+
try {
|
|
151
|
+
names = await readdir(this.dir)
|
|
152
|
+
} catch {
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
const now = Date.now() / 1000
|
|
156
|
+
for (const n of names) {
|
|
157
|
+
if (!n.endsWith(".json")) continue
|
|
158
|
+
const fp = path.join(this.dir, n)
|
|
159
|
+
let body
|
|
160
|
+
try {
|
|
161
|
+
body = JSON.parse(await readFile(fp, "utf-8"))
|
|
162
|
+
} catch {
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
const exp = body?.expires_at
|
|
166
|
+
if (typeof exp === "number" && exp <= now) {
|
|
167
|
+
try {
|
|
168
|
+
await unlink(fp)
|
|
169
|
+
} catch {
|
|
170
|
+
/* ignore */
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** 周期 sweep を停止する。 */
|
|
177
|
+
stop() {
|
|
178
|
+
if (this._sweepTimer) {
|
|
179
|
+
clearInterval(this._sweepTimer)
|
|
180
|
+
this._sweepTimer = null
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export default TuiViewerRegistry
|