@cocorograph/hub-agent 0.6.0 → 0.6.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/package.json +1 -1
- package/src/claude-history.mjs +169 -0
- package/src/main.mjs +55 -0
- package/templates/co.cocorograph.hub-agent.plist +13 -6
package/package.json
CHANGED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code のローカル会話履歴 (`~/.claude/projects/<cwd-encoded>/<session-id>.jsonl`)
|
|
3
|
+
* を読んで browser に返すモジュール (Sprint G hotfix 0.6.1)。
|
|
4
|
+
*
|
|
5
|
+
* 用途: Cockpit ChatView が re-mount された時に、SDK の `--resume <session_id>` で
|
|
6
|
+
* モデル側の文脈は復元されるが UI 側 messages は空のままという違和感を解消するため、
|
|
7
|
+
* 同じ jsonl ファイルを直読みして過去メッセージを UI に hydrate する。
|
|
8
|
+
*
|
|
9
|
+
* 設計:
|
|
10
|
+
* - cwd エンコード規則: `/`, `.`, `_` などの非英数字を `-` 置換 (Claude CLI と同じ)
|
|
11
|
+
* - 1 行 = 1 JSON。`type` が 'user' / 'assistant' / 'system' / 'result' のものだけ抽出
|
|
12
|
+
* (内部 type 'attachment' / 'last-prompt' / 'permission-mode' 等は UI 表示対象外)
|
|
13
|
+
* - 上限 MAX_HISTORY_LINES (デフォルト 500) で末尾から切る
|
|
14
|
+
* - ファイル不在 (新規セッション) なら空配列を返す (エラーにしない)
|
|
15
|
+
*/
|
|
16
|
+
import { readFile, readdir, stat } from "node:fs/promises"
|
|
17
|
+
import os from "node:os"
|
|
18
|
+
import path from "node:path"
|
|
19
|
+
|
|
20
|
+
export const MAX_HISTORY_LINES = 500
|
|
21
|
+
|
|
22
|
+
/** UI 表示対象の SDK message type (それ以外は jsonl 内部メタなので除外)。 */
|
|
23
|
+
const DISPLAY_TYPES = new Set(["user", "assistant", "system", "result"])
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* cwd 文字列を Claude Code の project dir 名に変換する。
|
|
27
|
+
* 例: `/Users/kaz/hub/projects/D00585_partition-lab`
|
|
28
|
+
* → `-Users-kaz-hub-projects-D00585-partition-lab`
|
|
29
|
+
*
|
|
30
|
+
* Claude CLI のエンコード規則は「英数字とハイフン以外を `-` 置換」。連続する非英数字は
|
|
31
|
+
* そのまま連続ハイフンになる (例: `/.claude/` → `--claude-`)。
|
|
32
|
+
*
|
|
33
|
+
* @param {string} cwd
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
export function encodeCwdToDirName(cwd) {
|
|
37
|
+
if (!cwd) return ""
|
|
38
|
+
return cwd.replace(/[^A-Za-z0-9-]/g, "-")
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** `~/.claude/projects/<encoded>/<session_id>.jsonl` の絶対パス。 */
|
|
42
|
+
export function jsonlPath({ cwd, session_id, projectsRoot }) {
|
|
43
|
+
const root = projectsRoot || path.join(os.homedir(), ".claude", "projects")
|
|
44
|
+
return path.join(root, encodeCwdToDirName(cwd), `${session_id}.jsonl`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* jsonl 1 ファイルを読んで UI 表示用イベント配列を返す。
|
|
49
|
+
*
|
|
50
|
+
* 末尾 maxLines 行に絞ってから JSON.parse + DISPLAY_TYPES でフィルタ。
|
|
51
|
+
* 大きいファイルでも先頭から全行 parse する必要はないが、line buffer を最小化
|
|
52
|
+
* するため簡易実装で全文 readFile → split → 末尾 slice する (典型 1MB 未満想定)。
|
|
53
|
+
*
|
|
54
|
+
* @param {string} filePath
|
|
55
|
+
* @param {{maxLines?: number, logger?: import('pino').Logger}} [opts]
|
|
56
|
+
* @returns {Promise<{events: object[], total_lines: number, truncated: boolean}>}
|
|
57
|
+
*/
|
|
58
|
+
export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINES, logger } = {}) {
|
|
59
|
+
let text
|
|
60
|
+
try {
|
|
61
|
+
text = await readFile(filePath, "utf-8")
|
|
62
|
+
} catch (err) {
|
|
63
|
+
if (err.code === "ENOENT") {
|
|
64
|
+
return { events: [], total_lines: 0, truncated: false }
|
|
65
|
+
}
|
|
66
|
+
logger?.warn({ err: err.message, filePath }, "claude history read failed")
|
|
67
|
+
throw err
|
|
68
|
+
}
|
|
69
|
+
// 末尾 newline は捨てる
|
|
70
|
+
const lines = text.split("\n").filter((l) => l.length > 0)
|
|
71
|
+
const total_lines = lines.length
|
|
72
|
+
const truncated = total_lines > maxLines
|
|
73
|
+
const slice = truncated ? lines.slice(-maxLines) : lines
|
|
74
|
+
|
|
75
|
+
const events = []
|
|
76
|
+
for (const line of slice) {
|
|
77
|
+
let obj
|
|
78
|
+
try {
|
|
79
|
+
obj = JSON.parse(line)
|
|
80
|
+
} catch {
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
if (!obj || typeof obj !== "object") continue
|
|
84
|
+
if (!DISPLAY_TYPES.has(obj.type)) continue
|
|
85
|
+
// SDK message と同じ shape にする (余分な meta は落とす)
|
|
86
|
+
const event = { type: obj.type }
|
|
87
|
+
if (obj.message !== undefined) event.message = obj.message
|
|
88
|
+
if (obj.subtype !== undefined) event.subtype = obj.subtype
|
|
89
|
+
if (obj.session_id !== undefined) event.session_id = obj.session_id
|
|
90
|
+
else if (obj.sessionId !== undefined) event.session_id = obj.sessionId
|
|
91
|
+
if (obj.model !== undefined) event.model = obj.model
|
|
92
|
+
if (obj.cwd !== undefined) event.cwd = obj.cwd
|
|
93
|
+
if (obj.tools !== undefined) event.tools = obj.tools
|
|
94
|
+
if (obj.permissionMode !== undefined) event.permissionMode = obj.permissionMode
|
|
95
|
+
if (obj.total_cost_usd !== undefined) event.total_cost_usd = obj.total_cost_usd
|
|
96
|
+
if (obj.duration_ms !== undefined) event.duration_ms = obj.duration_ms
|
|
97
|
+
if (obj.num_turns !== undefined) event.num_turns = obj.num_turns
|
|
98
|
+
if (obj.usage !== undefined) event.usage = obj.usage
|
|
99
|
+
events.push(event)
|
|
100
|
+
}
|
|
101
|
+
return { events, total_lines, truncated }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* cwd + session_id から jsonl を読み、Browser が `claude.history.response` として
|
|
106
|
+
* 受け取れる shape で返す。
|
|
107
|
+
*
|
|
108
|
+
* @param {{cwd: string, session_id: string, maxLines?: number, projectsRoot?: string, logger?: import('pino').Logger}} args
|
|
109
|
+
*/
|
|
110
|
+
export async function fetchSessionHistory({ cwd, session_id, maxLines, projectsRoot, logger }) {
|
|
111
|
+
if (!cwd || !session_id) {
|
|
112
|
+
return { events: [], total_lines: 0, truncated: false, error: "missing_cwd_or_session_id" }
|
|
113
|
+
}
|
|
114
|
+
const filePath = jsonlPath({ cwd, session_id, projectsRoot })
|
|
115
|
+
try {
|
|
116
|
+
const result = await readSessionHistory(filePath, { maxLines, logger })
|
|
117
|
+
return { ...result, file_path: filePath }
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return {
|
|
120
|
+
events: [],
|
|
121
|
+
total_lines: 0,
|
|
122
|
+
truncated: false,
|
|
123
|
+
error: err.message || String(err),
|
|
124
|
+
file_path: filePath,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* cwd 配下の全 jsonl ファイルから session 一覧を返す (Phase 2 用、現状未使用)。
|
|
131
|
+
* 各ファイルから session_id / 最終更新時刻 / 最初の user message の冒頭を抽出。
|
|
132
|
+
*
|
|
133
|
+
* @param {{cwd: string, projectsRoot?: string, logger?: import('pino').Logger}} args
|
|
134
|
+
*/
|
|
135
|
+
export async function listSessions({ cwd, projectsRoot, logger }) {
|
|
136
|
+
if (!cwd) return { sessions: [] }
|
|
137
|
+
const dir = path.join(
|
|
138
|
+
projectsRoot || path.join(os.homedir(), ".claude", "projects"),
|
|
139
|
+
encodeCwdToDirName(cwd),
|
|
140
|
+
)
|
|
141
|
+
let files
|
|
142
|
+
try {
|
|
143
|
+
files = await readdir(dir)
|
|
144
|
+
} catch (err) {
|
|
145
|
+
if (err.code === "ENOENT") return { sessions: [] }
|
|
146
|
+
logger?.warn({ err: err.message, dir }, "claude history list failed")
|
|
147
|
+
return { sessions: [], error: err.message }
|
|
148
|
+
}
|
|
149
|
+
const sessions = []
|
|
150
|
+
for (const f of files) {
|
|
151
|
+
if (!f.endsWith(".jsonl")) continue
|
|
152
|
+
const session_id = f.slice(0, -".jsonl".length)
|
|
153
|
+
const filePath = path.join(dir, f)
|
|
154
|
+
try {
|
|
155
|
+
const st = await stat(filePath)
|
|
156
|
+
sessions.push({
|
|
157
|
+
session_id,
|
|
158
|
+
file_path: filePath,
|
|
159
|
+
mtime: st.mtimeMs,
|
|
160
|
+
size_bytes: st.size,
|
|
161
|
+
})
|
|
162
|
+
} catch {
|
|
163
|
+
// ignore individual file stat errors
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// 最新順
|
|
167
|
+
sessions.sort((a, b) => b.mtime - a.mtime)
|
|
168
|
+
return { sessions }
|
|
169
|
+
}
|
package/src/main.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { loadPlugins, runHookBroadcast, runHookChain } from "./plugin-loader.mjs
|
|
|
22
22
|
import { WsClient } from "./ws-client.mjs"
|
|
23
23
|
import { PtyBridge } from "./pty-bridge.mjs"
|
|
24
24
|
import { ClaudeStreamBridge } from "./claude-stream-bridge.mjs"
|
|
25
|
+
import { fetchSessionHistory } from "./claude-history.mjs"
|
|
25
26
|
import { listAgents } from "./agents.mjs"
|
|
26
27
|
import { listSkills } from "./skills.mjs"
|
|
27
28
|
import { listSessionStates } from "./state.mjs"
|
|
@@ -229,6 +230,16 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
229
230
|
|
|
230
231
|
client.connect()
|
|
231
232
|
|
|
233
|
+
// 0.6.2 fix: event loop を ref されたまま生かしておく watchdog。
|
|
234
|
+
// hub-agent の他の background task (heartbeat / reconnect / state loop / fs.watch)
|
|
235
|
+
// は全て .unref() / persistent:false なので、長時間アイドル + WS 切断 + pty 全 GC
|
|
236
|
+
// が同時発生すると Node が「やることなし」と判断して exit(0) で silent 終了する
|
|
237
|
+
// 既存バグがあった。watchdog を 1 つ ref のまま持つことで exit を防ぐ。
|
|
238
|
+
// shutdown 時に clearInterval して正常終了を許可する。
|
|
239
|
+
const keepaliveTimer = setInterval(() => {
|
|
240
|
+
// no-op. ref されていることだけが重要。
|
|
241
|
+
}, 60_000)
|
|
242
|
+
|
|
232
243
|
// 5s 周期で全 tmux session の状態を捕捉し、変化したものだけ session.state を push。
|
|
233
244
|
// browser がフォーカスしていない session でも常時更新するためのバックグラウンド職人。
|
|
234
245
|
const stateLoop = startStateLoop({ client, plugins, logger, intervalMs: 5_000 })
|
|
@@ -245,11 +256,30 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
245
256
|
ptyBridge.shutdown()
|
|
246
257
|
claudeBridge?.shutdown?.()
|
|
247
258
|
client.stop()
|
|
259
|
+
// 0.6.2 fix: watchdog を解放して event loop を抜けられるようにする
|
|
260
|
+
clearInterval(keepaliveTimer)
|
|
248
261
|
process.exit(0)
|
|
249
262
|
}
|
|
250
263
|
process.on("SIGINT", () => shutdown("SIGINT"))
|
|
251
264
|
process.on("SIGTERM", () => shutdown("SIGTERM"))
|
|
252
265
|
|
|
266
|
+
// 0.6.2 fix: 例外で silent 終了しないよう最後のセーフティネット。
|
|
267
|
+
// Node 24 で unhandledRejection は default で process を kill する仕様のため、
|
|
268
|
+
// warn ログだけ出して継続させる (本来の原因は別途修正必要だが、agent が落ちて
|
|
269
|
+
// ユーザー作業が止まる方が痛い)。
|
|
270
|
+
process.on("unhandledRejection", (reason, promise) => {
|
|
271
|
+
logger.error(
|
|
272
|
+
{ reason: String(reason), stack: reason?.stack },
|
|
273
|
+
"unhandledRejection (agent kept alive by watchdog)",
|
|
274
|
+
)
|
|
275
|
+
})
|
|
276
|
+
process.on("uncaughtException", (err) => {
|
|
277
|
+
logger.error(
|
|
278
|
+
{ err: err?.message, stack: err?.stack },
|
|
279
|
+
"uncaughtException (agent kept alive by watchdog)",
|
|
280
|
+
)
|
|
281
|
+
})
|
|
282
|
+
|
|
253
283
|
return { client, plugins, ptyBridge, claudeBridge }
|
|
254
284
|
}
|
|
255
285
|
|
|
@@ -631,6 +661,31 @@ async function dispatch(msg, ctx) {
|
|
|
631
661
|
if (!ctx.claudeBridge) return
|
|
632
662
|
ctx.claudeBridge.detach({ stream_id: msg.stream_id })
|
|
633
663
|
return
|
|
664
|
+
case "claude.history.request": {
|
|
665
|
+
// Sprint G 0.6.1: ~/.claude/projects/<cwd-encoded>/<session_id>.jsonl を読んで
|
|
666
|
+
// 過去メッセージを返す。ChatView の re-mount 時の UI 復元用。
|
|
667
|
+
// stream_id は per-stream routing のため必須 (browser 側 stream に紐付ける)。
|
|
668
|
+
const stream_id = msg.stream_id
|
|
669
|
+
const cwd = msg.cwd || ""
|
|
670
|
+
const session_id = msg.session_id || ""
|
|
671
|
+
const maxLines = typeof msg.max_lines === "number" ? msg.max_lines : undefined
|
|
672
|
+
const result = await fetchSessionHistory({
|
|
673
|
+
cwd,
|
|
674
|
+
session_id,
|
|
675
|
+
maxLines,
|
|
676
|
+
logger: ctx.logger,
|
|
677
|
+
})
|
|
678
|
+
ctx.client.send({
|
|
679
|
+
type: "claude.history.response",
|
|
680
|
+
stream_id,
|
|
681
|
+
session_id,
|
|
682
|
+
events: result.events,
|
|
683
|
+
total_lines: result.total_lines,
|
|
684
|
+
truncated: result.truncated,
|
|
685
|
+
error: result.error,
|
|
686
|
+
})
|
|
687
|
+
return
|
|
688
|
+
}
|
|
634
689
|
case "tmux.exec": {
|
|
635
690
|
const args = Array.isArray(msg.args) ? msg.args : []
|
|
636
691
|
const hookResult = await runHookChain(ctx.plugins, "interceptTmuxExec", {
|
|
@@ -15,13 +15,20 @@
|
|
|
15
15
|
<key>RunAtLoad</key>
|
|
16
16
|
<true/>
|
|
17
17
|
|
|
18
|
+
<!--
|
|
19
|
+
KeepAlive を無条件 true に変更 (0.6.2 fix)。
|
|
20
|
+
旧設定 `SuccessfulExit=false` は「正常終了 (exit 0) なら再起動しない」だが、
|
|
21
|
+
hub-agent の全 background task (heartbeat / reconnect / state loop / fs.watch) が
|
|
22
|
+
.unref() されているため、長時間アイドル + WS 切断 + pty GC kill で全 ref が
|
|
23
|
+
0 になり Node が exit(0) で「正常終了」する事象が頻発していた。launchctl が
|
|
24
|
+
「正常終了だから restart 不要」と判断し、agent が静かに停止 → ユーザーは
|
|
25
|
+
「offline」表示で気づく。本修正で「どんな exit code でも再起動」に変更し
|
|
26
|
+
ThrottleInterval=10s で spawn loop も防ぐ。
|
|
27
|
+
main.mjs 側にも watchdog setInterval を追加して exit 自体を起こさない設計に
|
|
28
|
+
したが、plist 側の defense in depth として KeepAlive も無条件化する。
|
|
29
|
+
-->
|
|
18
30
|
<key>KeepAlive</key>
|
|
19
|
-
<
|
|
20
|
-
<key>SuccessfulExit</key>
|
|
21
|
-
<false/>
|
|
22
|
-
<key>NetworkState</key>
|
|
23
|
-
<true/>
|
|
24
|
-
</dict>
|
|
31
|
+
<true/>
|
|
25
32
|
|
|
26
33
|
<key>StandardOutPath</key>
|
|
27
34
|
<string>__HOME__/.hub/agent.log</string>
|