@cocorograph/hub-agent 0.6.0 → 0.6.1

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.0",
3
+ "version": "0.6.1",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -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"
@@ -631,6 +632,31 @@ async function dispatch(msg, ctx) {
631
632
  if (!ctx.claudeBridge) return
632
633
  ctx.claudeBridge.detach({ stream_id: msg.stream_id })
633
634
  return
635
+ case "claude.history.request": {
636
+ // Sprint G 0.6.1: ~/.claude/projects/<cwd-encoded>/<session_id>.jsonl を読んで
637
+ // 過去メッセージを返す。ChatView の re-mount 時の UI 復元用。
638
+ // stream_id は per-stream routing のため必須 (browser 側 stream に紐付ける)。
639
+ const stream_id = msg.stream_id
640
+ const cwd = msg.cwd || ""
641
+ const session_id = msg.session_id || ""
642
+ const maxLines = typeof msg.max_lines === "number" ? msg.max_lines : undefined
643
+ const result = await fetchSessionHistory({
644
+ cwd,
645
+ session_id,
646
+ maxLines,
647
+ logger: ctx.logger,
648
+ })
649
+ ctx.client.send({
650
+ type: "claude.history.response",
651
+ stream_id,
652
+ session_id,
653
+ events: result.events,
654
+ total_lines: result.total_lines,
655
+ truncated: result.truncated,
656
+ error: result.error,
657
+ })
658
+ return
659
+ }
634
660
  case "tmux.exec": {
635
661
  const args = Array.isArray(msg.args) ? msg.args : []
636
662
  const hookResult = await runHookChain(ctx.plugins, "interceptTmuxExec", {