@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -127,12 +127,55 @@ export async function fetchSessionHistory({ cwd, session_id, maxLines, projectsR
127
127
  }
128
128
 
129
129
  /**
130
- * cwd 配下の全 jsonl ファイルから session 一覧を返す (Phase 2 用、現状未使用)。
131
- * 各ファイルから session_id / 最終更新時刻 / 最初の user message の冒頭を抽出。
130
+ * jsonl ファイルから最初の user メッセージ本文 (preview 用) を抽出する。
131
+ * 大きいファイルでも先頭付近で見つかるので、先頭 64KB だけ読んで探す。
132
132
  *
133
- * @param {{cwd: string, projectsRoot?: string, logger?: import('pino').Logger}} args
133
+ * @param {string} filePath
134
+ * @returns {Promise<string>} 先頭 user メッセージの冒頭 (最大 80 文字)、無ければ ""
134
135
  */
135
- export async function listSessions({ cwd, projectsRoot, logger }) {
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 sessions = []
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
- sessions.push({
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
- sessions.sort((a, b) => b.mtime - a.mtime)
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
- <dict>
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>