@cocorograph/hub-agent 0.6.1 → 0.6.3
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 +57 -13
- package/src/main.mjs +47 -1
- package/templates/co.cocorograph.hub-agent.plist +13 -6
package/package.json
CHANGED
package/src/claude-history.mjs
CHANGED
|
@@ -127,12 +127,55 @@ export async function fetchSessionHistory({ cwd, session_id, maxLines, projectsR
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
/**
|
|
130
|
-
*
|
|
131
|
-
*
|
|
130
|
+
* jsonl ファイルから最初の user メッセージ本文 (preview 用) を抽出する。
|
|
131
|
+
* 大きいファイルでも先頭付近で見つかるので、先頭 64KB だけ読んで探す。
|
|
132
132
|
*
|
|
133
|
-
* @param {
|
|
133
|
+
* @param {string} filePath
|
|
134
|
+
* @returns {Promise<string>} 先頭 user メッセージの冒頭 (最大 80 文字)、無ければ ""
|
|
134
135
|
*/
|
|
135
|
-
|
|
136
|
+
async function extractPreview(filePath) {
|
|
137
|
+
let text
|
|
138
|
+
try {
|
|
139
|
+
const buf = await readFile(filePath, "utf-8")
|
|
140
|
+
// 先頭 64KB だけ見る (preview には十分)
|
|
141
|
+
text = buf.length > 65536 ? buf.slice(0, 65536) : buf
|
|
142
|
+
} catch {
|
|
143
|
+
return ""
|
|
144
|
+
}
|
|
145
|
+
for (const line of text.split("\n")) {
|
|
146
|
+
if (!line) continue
|
|
147
|
+
let obj
|
|
148
|
+
try {
|
|
149
|
+
obj = JSON.parse(line)
|
|
150
|
+
} catch {
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
if (obj?.type === "user" && obj.message) {
|
|
154
|
+
const content = obj.message.content
|
|
155
|
+
let str = ""
|
|
156
|
+
if (typeof content === "string") {
|
|
157
|
+
str = content
|
|
158
|
+
} else if (Array.isArray(content)) {
|
|
159
|
+
const textBlock = content.find((b) => b?.type === "text" && typeof b.text === "string")
|
|
160
|
+
if (textBlock) str = textBlock.text
|
|
161
|
+
}
|
|
162
|
+
str = str.trim().replace(/\s+/g, " ")
|
|
163
|
+
if (str) return str.slice(0, 80)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return ""
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* cwd 配下の全 jsonl ファイルから session 一覧を返す。
|
|
171
|
+
* 各ファイルから session_id / 最終更新時刻 / サイズ / 最初の user message preview を抽出。
|
|
172
|
+
*
|
|
173
|
+
* Cockpit ChatView の「過去セッション一覧」ドロップダウン用 (tmux で作業した
|
|
174
|
+
* セッションも同じ cwd の project dir に並ぶため、Chat 側から読み返せる)。
|
|
175
|
+
*
|
|
176
|
+
* @param {{cwd: string, projectsRoot?: string, limit?: number, logger?: import('pino').Logger}} args
|
|
177
|
+
*/
|
|
178
|
+
export async function listSessions({ cwd, projectsRoot, limit = 30, logger }) {
|
|
136
179
|
if (!cwd) return { sessions: [] }
|
|
137
180
|
const dir = path.join(
|
|
138
181
|
projectsRoot || path.join(os.homedir(), ".claude", "projects"),
|
|
@@ -146,24 +189,25 @@ export async function listSessions({ cwd, projectsRoot, logger }) {
|
|
|
146
189
|
logger?.warn({ err: err.message, dir }, "claude history list failed")
|
|
147
190
|
return { sessions: [], error: err.message }
|
|
148
191
|
}
|
|
149
|
-
const
|
|
192
|
+
const stats = []
|
|
150
193
|
for (const f of files) {
|
|
151
194
|
if (!f.endsWith(".jsonl")) continue
|
|
152
195
|
const session_id = f.slice(0, -".jsonl".length)
|
|
153
196
|
const filePath = path.join(dir, f)
|
|
154
197
|
try {
|
|
155
198
|
const st = await stat(filePath)
|
|
156
|
-
|
|
157
|
-
session_id,
|
|
158
|
-
file_path: filePath,
|
|
159
|
-
mtime: st.mtimeMs,
|
|
160
|
-
size_bytes: st.size,
|
|
161
|
-
})
|
|
199
|
+
stats.push({ session_id, file_path: filePath, mtime: st.mtimeMs, size_bytes: st.size })
|
|
162
200
|
} catch {
|
|
163
201
|
// ignore individual file stat errors
|
|
164
202
|
}
|
|
165
203
|
}
|
|
166
|
-
// 最新順
|
|
167
|
-
|
|
204
|
+
// 最新順 + limit 件に絞ってから preview 抽出 (全ファイル読みを避ける)
|
|
205
|
+
stats.sort((a, b) => b.mtime - a.mtime)
|
|
206
|
+
const top = stats.slice(0, limit)
|
|
207
|
+
const sessions = []
|
|
208
|
+
for (const s of top) {
|
|
209
|
+
const preview = await extractPreview(s.file_path)
|
|
210
|
+
sessions.push({ ...s, preview })
|
|
211
|
+
}
|
|
168
212
|
return { sessions }
|
|
169
213
|
}
|
package/src/main.mjs
CHANGED
|
@@ -22,7 +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
|
+
import { fetchSessionHistory, listSessions } from "./claude-history.mjs"
|
|
26
26
|
import { listAgents } from "./agents.mjs"
|
|
27
27
|
import { listSkills } from "./skills.mjs"
|
|
28
28
|
import { listSessionStates } from "./state.mjs"
|
|
@@ -230,6 +230,16 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
230
230
|
|
|
231
231
|
client.connect()
|
|
232
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
|
+
|
|
233
243
|
// 5s 周期で全 tmux session の状態を捕捉し、変化したものだけ session.state を push。
|
|
234
244
|
// browser がフォーカスしていない session でも常時更新するためのバックグラウンド職人。
|
|
235
245
|
const stateLoop = startStateLoop({ client, plugins, logger, intervalMs: 5_000 })
|
|
@@ -246,11 +256,30 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
246
256
|
ptyBridge.shutdown()
|
|
247
257
|
claudeBridge?.shutdown?.()
|
|
248
258
|
client.stop()
|
|
259
|
+
// 0.6.2 fix: watchdog を解放して event loop を抜けられるようにする
|
|
260
|
+
clearInterval(keepaliveTimer)
|
|
249
261
|
process.exit(0)
|
|
250
262
|
}
|
|
251
263
|
process.on("SIGINT", () => shutdown("SIGINT"))
|
|
252
264
|
process.on("SIGTERM", () => shutdown("SIGTERM"))
|
|
253
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
|
+
|
|
254
283
|
return { client, plugins, ptyBridge, claudeBridge }
|
|
255
284
|
}
|
|
256
285
|
|
|
@@ -657,6 +686,23 @@ async function dispatch(msg, ctx) {
|
|
|
657
686
|
})
|
|
658
687
|
return
|
|
659
688
|
}
|
|
689
|
+
case "claude.sessions.request": {
|
|
690
|
+
// Sprint G 0.6.3: cwd 配下の全 jsonl セッション一覧を返す。
|
|
691
|
+
// tmux で作業したセッションも同じ project dir に並ぶため、Chat 側から
|
|
692
|
+
// 「過去セッション」として読み返せる。
|
|
693
|
+
const stream_id = msg.stream_id
|
|
694
|
+
const cwd = msg.cwd || ""
|
|
695
|
+
const limit = typeof msg.limit === "number" ? msg.limit : undefined
|
|
696
|
+
const result = await listSessions({ cwd, limit, logger: ctx.logger })
|
|
697
|
+
ctx.client.send({
|
|
698
|
+
type: "claude.sessions.response",
|
|
699
|
+
stream_id,
|
|
700
|
+
cwd,
|
|
701
|
+
sessions: result.sessions || [],
|
|
702
|
+
error: result.error,
|
|
703
|
+
})
|
|
704
|
+
return
|
|
705
|
+
}
|
|
660
706
|
case "tmux.exec": {
|
|
661
707
|
const args = Array.isArray(msg.args) ? msg.args : []
|
|
662
708
|
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>
|