@cocorograph/hub-agent 0.5.32 → 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 +2 -1
- package/src/claude-history.mjs +169 -0
- package/src/claude-stream-bridge.mjs +363 -0
- package/src/main.mjs +156 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cocorograph/hub-agent",
|
|
3
|
-
"version": "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",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"LICENSE"
|
|
33
33
|
],
|
|
34
34
|
"dependencies": {
|
|
35
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.152",
|
|
35
36
|
"commander": "^12.1.0",
|
|
36
37
|
"node-pty": "^1.0.0",
|
|
37
38
|
"pino": "^9.0.0",
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code stream-json モードのブリッジ (Sprint G: Web UI 対応)。
|
|
3
|
+
*
|
|
4
|
+
* - 1 stream = 1 Claude セッション。`Map<stream_id, ClaudeStreamSession>` で多重管理
|
|
5
|
+
* - 公式 `@anthropic-ai/claude-agent-sdk` の `query()` を async iterable で駆動
|
|
6
|
+
* - `attach({ stream_id, cwd, model, permissionMode, resumeSessionId? })` でセッション起動
|
|
7
|
+
* - `input({ stream_id, message })` で stdin 相当のユーザーメッセージを push
|
|
8
|
+
* - `permissionReply({ stream_id, request_id, allow, updatedInput?, denyMessage? })` で
|
|
9
|
+
* `canUseTool` callback への応答を browser から返す
|
|
10
|
+
* - `interrupt({ stream_id })` / `detach({ stream_id })` で中断・停止
|
|
11
|
+
* - 出力イベントは `'event'`(SDK message)/ `'permission'`(canUseTool)/
|
|
12
|
+
* `'exit'` / `'error'` で emit。EventEmitter 経由
|
|
13
|
+
*
|
|
14
|
+
* PtyBridge と並走させる設計。`pty.*` 系メッセージは無傷で、新規 `claude.*` のみ
|
|
15
|
+
* 受け持つ。テスト時は SDK を `{ query }` shape で stub 注入する。
|
|
16
|
+
*/
|
|
17
|
+
import { EventEmitter } from "node:events"
|
|
18
|
+
import { randomUUID } from "node:crypto"
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 1 stream に対応する Claude セッション。
|
|
22
|
+
*
|
|
23
|
+
* SDK の `query()` は prompt を AsyncIterable として受け取り、ユーザーメッセージを
|
|
24
|
+
* 流し続ける限り interactive multi-turn として動作する。pushInput / endInput で
|
|
25
|
+
* iterator の挙動を制御する。
|
|
26
|
+
*/
|
|
27
|
+
class ClaudeStreamSession {
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} args
|
|
30
|
+
* @param {string} args.stream_id
|
|
31
|
+
* @param {string} args.cwd
|
|
32
|
+
* @param {string|null} args.model
|
|
33
|
+
* @param {string|null} args.permissionMode
|
|
34
|
+
* @param {string|null} args.resumeSessionId
|
|
35
|
+
* @param {{ query: Function }} args.sdk
|
|
36
|
+
* @param {import('pino').Logger} [args.logger]
|
|
37
|
+
* @param {(event: object) => void} [args.onEvent]
|
|
38
|
+
* @param {(req: {tool_name: string, input: object, request_id: string}) => void} [args.onPermission]
|
|
39
|
+
* @param {(info: {code: number, reason?: string, session_id: string|null}) => void} [args.onExit]
|
|
40
|
+
* @param {(err: Error) => void} [args.onError]
|
|
41
|
+
*/
|
|
42
|
+
constructor({
|
|
43
|
+
stream_id,
|
|
44
|
+
cwd,
|
|
45
|
+
model,
|
|
46
|
+
permissionMode,
|
|
47
|
+
resumeSessionId,
|
|
48
|
+
sdk,
|
|
49
|
+
logger,
|
|
50
|
+
onEvent,
|
|
51
|
+
onPermission,
|
|
52
|
+
onExit,
|
|
53
|
+
onError,
|
|
54
|
+
}) {
|
|
55
|
+
this.stream_id = stream_id
|
|
56
|
+
this.cwd = cwd
|
|
57
|
+
this.model = model || null
|
|
58
|
+
this.permissionMode = permissionMode || null
|
|
59
|
+
this.resumeSessionId = resumeSessionId || null
|
|
60
|
+
this.sdk = sdk
|
|
61
|
+
this.logger = logger
|
|
62
|
+
this.onEvent = onEvent
|
|
63
|
+
this.onPermission = onPermission
|
|
64
|
+
this.onExit = onExit
|
|
65
|
+
this.onError = onError
|
|
66
|
+
|
|
67
|
+
/** Claude が `system/init` イベントで返してくる session_id を保持する。
|
|
68
|
+
* resume 時はその値で上書きされる。frontend が「現在のセッション」を識別する用。 */
|
|
69
|
+
this.sessionId = resumeSessionId || null
|
|
70
|
+
|
|
71
|
+
/** @type {Array<{__end?: true, type?: string, message?: object}>} pending stdin queue */
|
|
72
|
+
this._pendingInputs = []
|
|
73
|
+
/** @type {Array<(v: {value: any, done: boolean}) => void>} 待機中の iterator resolvers */
|
|
74
|
+
this._inputResolvers = []
|
|
75
|
+
/** @type {Map<string, {resolve: (decision: object) => void}>} request_id 別の permission 応答待ち */
|
|
76
|
+
this._permissionResolvers = new Map()
|
|
77
|
+
|
|
78
|
+
this._aborted = false
|
|
79
|
+
this._finished = false
|
|
80
|
+
this._abortController = new AbortController()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** browser → claude へ user メッセージを push。
|
|
84
|
+
* message: `{ role: 'user', content: string | Array }` を期待 (SDK の SDKUserMessage 形式)。 */
|
|
85
|
+
pushInput(message) {
|
|
86
|
+
if (this._finished) return
|
|
87
|
+
const wrapped = { type: "user", message }
|
|
88
|
+
if (this._inputResolvers.length > 0) {
|
|
89
|
+
const resolver = this._inputResolvers.shift()
|
|
90
|
+
resolver({ value: wrapped, done: false })
|
|
91
|
+
} else {
|
|
92
|
+
this._pendingInputs.push(wrapped)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** stdin EOF 相当: prompt iterator を終了させる。 */
|
|
97
|
+
endInput() {
|
|
98
|
+
if (this._inputResolvers.length > 0) {
|
|
99
|
+
const resolver = this._inputResolvers.shift()
|
|
100
|
+
resolver({ value: undefined, done: true })
|
|
101
|
+
} else {
|
|
102
|
+
this._pendingInputs.push({ __end: true })
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** AbortController で実行中の turn を即時中断する。SDK 側は AbortError を投げる。 */
|
|
107
|
+
abort() {
|
|
108
|
+
this._aborted = true
|
|
109
|
+
try {
|
|
110
|
+
this._abortController.abort()
|
|
111
|
+
} catch {
|
|
112
|
+
/* ignore */
|
|
113
|
+
}
|
|
114
|
+
// 未解決の permission 応答も deny で閉じる (SDK 側のループを早期解放するため)
|
|
115
|
+
for (const [, resolver] of this._permissionResolvers) {
|
|
116
|
+
try {
|
|
117
|
+
resolver.resolve({ behavior: "deny", message: "aborted" })
|
|
118
|
+
} catch {
|
|
119
|
+
/* ignore */
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
this._permissionResolvers.clear()
|
|
123
|
+
this.endInput()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** browser からの permission 応答を該当 request_id の Promise に渡す。 */
|
|
127
|
+
resolvePermission(request_id, decision) {
|
|
128
|
+
const r = this._permissionResolvers.get(request_id)
|
|
129
|
+
if (!r) return false
|
|
130
|
+
this._permissionResolvers.delete(request_id)
|
|
131
|
+
r.resolve(decision)
|
|
132
|
+
return true
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** SDK の query() に渡す async iterable。pushInput で入ってきたメッセージを yield する。 */
|
|
136
|
+
async *_promptIterator() {
|
|
137
|
+
while (true) {
|
|
138
|
+
if (this._pendingInputs.length > 0) {
|
|
139
|
+
const next = this._pendingInputs.shift()
|
|
140
|
+
if (next && next.__end) return
|
|
141
|
+
yield next
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
const next = await new Promise((resolve) => {
|
|
145
|
+
this._inputResolvers.push(resolve)
|
|
146
|
+
})
|
|
147
|
+
if (next.done) return
|
|
148
|
+
yield next.value
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** SDK options.canUseTool: tool 起動を許可するかを browser に問い合わせる。 */
|
|
153
|
+
async _canUseTool(toolName, input, _extra) {
|
|
154
|
+
if (!this.onPermission) return { behavior: "allow", updatedInput: input }
|
|
155
|
+
const request_id = randomUUID()
|
|
156
|
+
return await new Promise((resolve) => {
|
|
157
|
+
this._permissionResolvers.set(request_id, { resolve })
|
|
158
|
+
try {
|
|
159
|
+
this.onPermission({ tool_name: toolName, input, request_id })
|
|
160
|
+
} catch (err) {
|
|
161
|
+
this.logger?.warn(
|
|
162
|
+
{ err: err.message, stream_id: this.stream_id, tool: toolName },
|
|
163
|
+
"onPermission callback threw",
|
|
164
|
+
)
|
|
165
|
+
this._permissionResolvers.delete(request_id)
|
|
166
|
+
resolve({ behavior: "deny", message: "permission callback failed" })
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 非同期で SDK を駆動。エラーは onError + onExit に流す。 */
|
|
172
|
+
async run() {
|
|
173
|
+
let code = 0
|
|
174
|
+
let reason
|
|
175
|
+
try {
|
|
176
|
+
const options = {
|
|
177
|
+
cwd: this.cwd,
|
|
178
|
+
canUseTool: (toolName, input, extra) => this._canUseTool(toolName, input, extra),
|
|
179
|
+
includePartialMessages: true,
|
|
180
|
+
abortController: this._abortController,
|
|
181
|
+
}
|
|
182
|
+
if (this.model) options.model = this.model
|
|
183
|
+
if (this.permissionMode) options.permissionMode = this.permissionMode
|
|
184
|
+
if (this.resumeSessionId) options.resume = this.resumeSessionId
|
|
185
|
+
|
|
186
|
+
const generator = this.sdk.query({
|
|
187
|
+
prompt: this._promptIterator(),
|
|
188
|
+
options,
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
for await (const msg of generator) {
|
|
192
|
+
// system/init で session_id が確定する。resume 用に保持。
|
|
193
|
+
if (msg?.type === "system" && msg?.subtype === "init" && typeof msg.session_id === "string") {
|
|
194
|
+
this.sessionId = msg.session_id
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
this.onEvent?.(msg)
|
|
198
|
+
} catch (err) {
|
|
199
|
+
this.logger?.warn(
|
|
200
|
+
{ err: err.message, stream_id: this.stream_id },
|
|
201
|
+
"onEvent callback threw",
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
} catch (err) {
|
|
206
|
+
if (this._aborted) {
|
|
207
|
+
code = 130
|
|
208
|
+
reason = "aborted"
|
|
209
|
+
} else {
|
|
210
|
+
code = 1
|
|
211
|
+
reason = err?.message || String(err)
|
|
212
|
+
try {
|
|
213
|
+
this.onError?.(err)
|
|
214
|
+
} catch {
|
|
215
|
+
/* ignore */
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} finally {
|
|
219
|
+
this._finished = true
|
|
220
|
+
try {
|
|
221
|
+
this.onExit?.({ code, reason, session_id: this.sessionId })
|
|
222
|
+
} catch (err) {
|
|
223
|
+
this.logger?.warn(
|
|
224
|
+
{ err: err.message, stream_id: this.stream_id },
|
|
225
|
+
"onExit callback threw",
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export class ClaudeStreamBridge extends EventEmitter {
|
|
233
|
+
/**
|
|
234
|
+
* @param {object} opts
|
|
235
|
+
* @param {{ query: Function }} opts.sdk - `@anthropic-ai/claude-agent-sdk` の named import 結果
|
|
236
|
+
* (テストでは `{ query: stubQuery }` を渡す)
|
|
237
|
+
* @param {import('pino').Logger} [opts.logger]
|
|
238
|
+
*/
|
|
239
|
+
constructor({ sdk, logger } = {}) {
|
|
240
|
+
super()
|
|
241
|
+
if (!sdk || typeof sdk.query !== "function") {
|
|
242
|
+
throw new TypeError("ClaudeStreamBridge requires { sdk: { query } }")
|
|
243
|
+
}
|
|
244
|
+
this.sdk = sdk
|
|
245
|
+
this.logger = logger
|
|
246
|
+
/** @type {Map<string, ClaudeStreamSession>} */
|
|
247
|
+
this.sessions = new Map()
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* 新しい Claude セッションを開始する。
|
|
252
|
+
*
|
|
253
|
+
* @param {{
|
|
254
|
+
* stream_id: string,
|
|
255
|
+
* cwd?: string,
|
|
256
|
+
* model?: string|null,
|
|
257
|
+
* permissionMode?: string|null,
|
|
258
|
+
* resumeSessionId?: string|null,
|
|
259
|
+
* }} args
|
|
260
|
+
* @returns {{ stream_id: string, resuming: boolean }}
|
|
261
|
+
*/
|
|
262
|
+
attach({ stream_id, cwd, model, permissionMode, resumeSessionId }) {
|
|
263
|
+
if (!stream_id) throw new TypeError("attach requires stream_id")
|
|
264
|
+
if (this.sessions.has(stream_id)) {
|
|
265
|
+
throw new Error(`stream_id "${stream_id}" は既に attach 済みです`)
|
|
266
|
+
}
|
|
267
|
+
const session = new ClaudeStreamSession({
|
|
268
|
+
stream_id,
|
|
269
|
+
cwd: cwd || process.env.HOME || process.cwd(),
|
|
270
|
+
model: model || null,
|
|
271
|
+
permissionMode: permissionMode || null,
|
|
272
|
+
resumeSessionId: resumeSessionId || null,
|
|
273
|
+
sdk: this.sdk,
|
|
274
|
+
logger: this.logger,
|
|
275
|
+
onEvent: (event) => {
|
|
276
|
+
this.emit("event", { stream_id, session_id: session.sessionId, event })
|
|
277
|
+
},
|
|
278
|
+
onPermission: ({ tool_name, input, request_id }) => {
|
|
279
|
+
this.emit("permission", { stream_id, request_id, tool_name, input })
|
|
280
|
+
},
|
|
281
|
+
onExit: ({ code, reason, session_id }) => {
|
|
282
|
+
this.sessions.delete(stream_id)
|
|
283
|
+
this.emit("exit", { stream_id, code, reason, session_id })
|
|
284
|
+
},
|
|
285
|
+
onError: (err) => {
|
|
286
|
+
this.emit("error", { stream_id, error: err?.message || String(err) })
|
|
287
|
+
},
|
|
288
|
+
})
|
|
289
|
+
this.sessions.set(stream_id, session)
|
|
290
|
+
this.logger?.info(
|
|
291
|
+
{ stream_id, cwd: session.cwd, model: session.model, resume: !!resumeSessionId },
|
|
292
|
+
"claude stream attached",
|
|
293
|
+
)
|
|
294
|
+
// 非同期で run。run 内で onExit → sessions から自動削除。
|
|
295
|
+
session.run().catch((err) => {
|
|
296
|
+
this.logger?.error(
|
|
297
|
+
{ stream_id, err: err?.message },
|
|
298
|
+
"claude stream run threw unexpectedly",
|
|
299
|
+
)
|
|
300
|
+
})
|
|
301
|
+
return { stream_id, resuming: !!resumeSessionId }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** browser → claude の user メッセージ。message は SDKUserMessage の message フィールド (`{ role, content }`)。 */
|
|
305
|
+
input({ stream_id, message }) {
|
|
306
|
+
const s = this.sessions.get(stream_id)
|
|
307
|
+
if (!s) {
|
|
308
|
+
this.logger?.warn({ stream_id }, "claude.input but stream missing")
|
|
309
|
+
return false
|
|
310
|
+
}
|
|
311
|
+
s.pushInput(message)
|
|
312
|
+
return true
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** browser → claude の permission 応答。allow/deny + 加工された input を SDK に返す。 */
|
|
316
|
+
permissionReply({ stream_id, request_id, allow, updatedInput, denyMessage }) {
|
|
317
|
+
const s = this.sessions.get(stream_id)
|
|
318
|
+
if (!s) {
|
|
319
|
+
this.logger?.warn({ stream_id, request_id }, "claude.permission.reply but stream missing")
|
|
320
|
+
return false
|
|
321
|
+
}
|
|
322
|
+
const decision = allow
|
|
323
|
+
? { behavior: "allow", updatedInput: updatedInput || {} }
|
|
324
|
+
: { behavior: "deny", message: denyMessage || "denied by user" }
|
|
325
|
+
return s.resolvePermission(request_id, decision)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** turn 中断 (AbortController)。セッションは exit に到達する。 */
|
|
329
|
+
interrupt({ stream_id }) {
|
|
330
|
+
const s = this.sessions.get(stream_id)
|
|
331
|
+
if (!s) return false
|
|
332
|
+
s.abort()
|
|
333
|
+
return true
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** セッション停止。Map から即時削除し、abort で SDK ループを解放する。 */
|
|
337
|
+
detach({ stream_id }) {
|
|
338
|
+
const s = this.sessions.get(stream_id)
|
|
339
|
+
if (!s) return false
|
|
340
|
+
s.abort()
|
|
341
|
+
// onExit を待たずに Map から外す (再 attach を即座に許可するため)
|
|
342
|
+
this.sessions.delete(stream_id)
|
|
343
|
+
return true
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** 全セッションを停止 (agent shutdown 用)。 */
|
|
347
|
+
shutdown() {
|
|
348
|
+
for (const stream_id of Array.from(this.sessions.keys())) {
|
|
349
|
+
this.detach({ stream_id })
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** 現在 attach 中の stream_id 一覧 (debug 用)。 */
|
|
354
|
+
list() {
|
|
355
|
+
return Array.from(this.sessions.keys())
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** 該当 stream の session_id (Claude SDK 由来) を返す。resume 用。 */
|
|
359
|
+
getSessionId(stream_id) {
|
|
360
|
+
const s = this.sessions.get(stream_id)
|
|
361
|
+
return s ? s.sessionId : null
|
|
362
|
+
}
|
|
363
|
+
}
|
package/src/main.mjs
CHANGED
|
@@ -21,6 +21,8 @@ import { readConfig, writeConfig } from "./config.mjs"
|
|
|
21
21
|
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
|
+
import { ClaudeStreamBridge } from "./claude-stream-bridge.mjs"
|
|
25
|
+
import { fetchSessionHistory } from "./claude-history.mjs"
|
|
24
26
|
import { listAgents } from "./agents.mjs"
|
|
25
27
|
import { listSkills } from "./skills.mjs"
|
|
26
28
|
import { listSessionStates } from "./state.mjs"
|
|
@@ -97,7 +99,33 @@ function readBundleVersionSync() {
|
|
|
97
99
|
}
|
|
98
100
|
}
|
|
99
101
|
|
|
100
|
-
|
|
102
|
+
/**
|
|
103
|
+
* `@anthropic-ai/claude-agent-sdk` を lazy import する。テストでは sdk arg で差し替え。
|
|
104
|
+
*
|
|
105
|
+
* SDK 未インストール (旧 hub-agent インストール) 環境でも pty モードは動くべきなので、
|
|
106
|
+
* import 失敗を warn に留めて null を返す。stream モードを使う attach が来たら
|
|
107
|
+
* その時点で error を browser に返す設計。
|
|
108
|
+
*/
|
|
109
|
+
async function loadClaudeSdk(logger) {
|
|
110
|
+
try {
|
|
111
|
+
const mod = await import("@anthropic-ai/claude-agent-sdk")
|
|
112
|
+
if (typeof mod?.query !== "function") {
|
|
113
|
+
logger?.warn(
|
|
114
|
+
"@anthropic-ai/claude-agent-sdk loaded but no query export; stream mode disabled",
|
|
115
|
+
)
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
return mod
|
|
119
|
+
} catch (err) {
|
|
120
|
+
logger?.warn(
|
|
121
|
+
{ err: err.message },
|
|
122
|
+
"@anthropic-ai/claude-agent-sdk not installed; stream mode disabled",
|
|
123
|
+
)
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
101
129
|
const config = await readConfig()
|
|
102
130
|
if (!config) {
|
|
103
131
|
throw new Error(
|
|
@@ -113,6 +141,13 @@ export async function startDaemon({ version, ptyModule } = {}) {
|
|
|
113
141
|
const resolvedPty = ptyModule || (await import("node-pty"))
|
|
114
142
|
const ptyBridge = new PtyBridge({ ptyModule: resolvedPty, logger, plugins })
|
|
115
143
|
|
|
144
|
+
// Claude SDK は optional dep 扱い (未インストール時は stream モードのみ無効化)。
|
|
145
|
+
// テストでは引数で stub を差し込める。
|
|
146
|
+
const resolvedSdk = claudeSdk !== undefined ? claudeSdk : await loadClaudeSdk(logger)
|
|
147
|
+
const claudeBridge = resolvedSdk
|
|
148
|
+
? new ClaudeStreamBridge({ sdk: resolvedSdk, logger })
|
|
149
|
+
: null
|
|
150
|
+
|
|
116
151
|
const bundleVersion = await readBundleVersion()
|
|
117
152
|
if (bundleVersion) {
|
|
118
153
|
logger.info({ bundleVersion }, "hub bundle version detected")
|
|
@@ -139,6 +174,30 @@ export async function startDaemon({ version, ptyModule } = {}) {
|
|
|
139
174
|
client.send({ type: "pty.exit", stream_id, code })
|
|
140
175
|
})
|
|
141
176
|
|
|
177
|
+
// stream-json モード (Sprint G): Claude Code を SDK 経由で起動し、stream イベントを
|
|
178
|
+
// そのまま browser に転送する。SDK 未インストール時は claudeBridge=null で全 attach が
|
|
179
|
+
// claude.error を返す経路に分岐 (dispatch 側で判定)。
|
|
180
|
+
if (claudeBridge) {
|
|
181
|
+
claudeBridge.on("event", ({ stream_id, session_id, event }) => {
|
|
182
|
+
client.send({ type: "claude.event", stream_id, session_id, event })
|
|
183
|
+
})
|
|
184
|
+
claudeBridge.on("permission", ({ stream_id, request_id, tool_name, input }) => {
|
|
185
|
+
client.send({
|
|
186
|
+
type: "claude.permission.request",
|
|
187
|
+
stream_id,
|
|
188
|
+
request_id,
|
|
189
|
+
tool_name,
|
|
190
|
+
input,
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
claudeBridge.on("exit", ({ stream_id, code, reason, session_id }) => {
|
|
194
|
+
client.send({ type: "claude.exit", stream_id, code, reason, session_id })
|
|
195
|
+
})
|
|
196
|
+
claudeBridge.on("error", ({ stream_id, error }) => {
|
|
197
|
+
client.send({ type: "claude.error", stream_id, error })
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
142
201
|
// Hub からのメッセージ dispatch は **直列実行** する。
|
|
143
202
|
//
|
|
144
203
|
// `EventEmitter.emit` は async リスナーの完了を待たないため、`async (msg) =>
|
|
@@ -160,7 +219,7 @@ export async function startDaemon({ version, ptyModule } = {}) {
|
|
|
160
219
|
let dispatchChain = Promise.resolve()
|
|
161
220
|
client.on("message", (msg) => {
|
|
162
221
|
dispatchChain = dispatchChain
|
|
163
|
-
.then(() => dispatch(msg, { ...ctx, client, ptyBridge }))
|
|
222
|
+
.then(() => dispatch(msg, { ...ctx, client, ptyBridge, claudeBridge }))
|
|
164
223
|
.catch((err) => {
|
|
165
224
|
logger.error(
|
|
166
225
|
{ err: err.message, type: msg?.type },
|
|
@@ -185,13 +244,14 @@ export async function startDaemon({ version, ptyModule } = {}) {
|
|
|
185
244
|
stateLoop.stop()
|
|
186
245
|
sessionEventLoop?.stop?.()
|
|
187
246
|
ptyBridge.shutdown()
|
|
247
|
+
claudeBridge?.shutdown?.()
|
|
188
248
|
client.stop()
|
|
189
249
|
process.exit(0)
|
|
190
250
|
}
|
|
191
251
|
process.on("SIGINT", () => shutdown("SIGINT"))
|
|
192
252
|
process.on("SIGTERM", () => shutdown("SIGTERM"))
|
|
193
253
|
|
|
194
|
-
return { client, plugins, ptyBridge }
|
|
254
|
+
return { client, plugins, ptyBridge, claudeBridge }
|
|
195
255
|
}
|
|
196
256
|
|
|
197
257
|
const SESSION_EVENTS_DIR =
|
|
@@ -504,6 +564,99 @@ async function dispatch(msg, ctx) {
|
|
|
504
564
|
// が飛ぶので無害。
|
|
505
565
|
handleStreamsSyncResponse(msg, ctx)
|
|
506
566
|
return
|
|
567
|
+
case "claude.attach": {
|
|
568
|
+
// Sprint G: stream-json モードで Claude Code を起動。
|
|
569
|
+
// browser 採番の stream_id をそのまま使う (pty.attach と同じ流儀)。
|
|
570
|
+
const stream_id = msg.stream_id
|
|
571
|
+
if (!ctx.claudeBridge) {
|
|
572
|
+
// SDK 未インストール時はその旨を即時 error で返す。browser 側は
|
|
573
|
+
// 「ターミナルモードで開いてください」と表示するなどの fallback を想定。
|
|
574
|
+
ctx.client.send({
|
|
575
|
+
type: "claude.error",
|
|
576
|
+
stream_id,
|
|
577
|
+
error: "stream_mode_unavailable: @anthropic-ai/claude-agent-sdk が agent にインストールされていません",
|
|
578
|
+
})
|
|
579
|
+
return
|
|
580
|
+
}
|
|
581
|
+
try {
|
|
582
|
+
// cwd は明示指定が無ければ agent.json の cwd → HOME の順で fallback
|
|
583
|
+
// (将来 cockpit から worktree path を指定する用)。
|
|
584
|
+
// model / permissionMode は agent.json の値も使えるよう fallback。
|
|
585
|
+
const info = ctx.claudeBridge.attach({
|
|
586
|
+
stream_id,
|
|
587
|
+
cwd: msg.cwd || ctx.config?.claude_cwd || undefined,
|
|
588
|
+
model: msg.model || ctx.config?.claude_model || null,
|
|
589
|
+
permissionMode:
|
|
590
|
+
msg.permission_mode ||
|
|
591
|
+
ctx.config?.claude_permission_mode ||
|
|
592
|
+
null,
|
|
593
|
+
resumeSessionId: msg.resume_session_id || null,
|
|
594
|
+
})
|
|
595
|
+
ctx.client.send({
|
|
596
|
+
type: "claude.ready",
|
|
597
|
+
stream_id,
|
|
598
|
+
resuming: info.resuming,
|
|
599
|
+
})
|
|
600
|
+
} catch (err) {
|
|
601
|
+
ctx.client.send({
|
|
602
|
+
type: "claude.error",
|
|
603
|
+
stream_id,
|
|
604
|
+
error: err.message,
|
|
605
|
+
})
|
|
606
|
+
}
|
|
607
|
+
return
|
|
608
|
+
}
|
|
609
|
+
case "claude.input":
|
|
610
|
+
// message: { role: 'user', content: string | Array } を期待
|
|
611
|
+
if (!ctx.claudeBridge) return
|
|
612
|
+
ctx.claudeBridge.input({
|
|
613
|
+
stream_id: msg.stream_id,
|
|
614
|
+
message: msg.message,
|
|
615
|
+
})
|
|
616
|
+
return
|
|
617
|
+
case "claude.permission.reply":
|
|
618
|
+
if (!ctx.claudeBridge) return
|
|
619
|
+
ctx.claudeBridge.permissionReply({
|
|
620
|
+
stream_id: msg.stream_id,
|
|
621
|
+
request_id: msg.request_id,
|
|
622
|
+
allow: !!msg.allow,
|
|
623
|
+
updatedInput: msg.updated_input,
|
|
624
|
+
denyMessage: msg.deny_message,
|
|
625
|
+
})
|
|
626
|
+
return
|
|
627
|
+
case "claude.interrupt":
|
|
628
|
+
if (!ctx.claudeBridge) return
|
|
629
|
+
ctx.claudeBridge.interrupt({ stream_id: msg.stream_id })
|
|
630
|
+
return
|
|
631
|
+
case "claude.detach":
|
|
632
|
+
if (!ctx.claudeBridge) return
|
|
633
|
+
ctx.claudeBridge.detach({ stream_id: msg.stream_id })
|
|
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
|
+
}
|
|
507
660
|
case "tmux.exec": {
|
|
508
661
|
const args = Array.isArray(msg.args) ? msg.args : []
|
|
509
662
|
const hookResult = await runHookChain(ctx.plugins, "interceptTmuxExec", {
|