@cocorograph/hub-agent 0.6.2 → 0.6.4
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/claude-stream-bridge.mjs +141 -160
- package/src/main.mjs +18 -1
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
|
}
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Claude Code stream-json モードのブリッジ (Sprint G: Web UI 対応)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* -
|
|
12
|
-
*
|
|
4
|
+
* **0.6.4 で「1 メッセージ = 1 query(resume チェーン)」モデルに変更。**
|
|
5
|
+
*
|
|
6
|
+
* 旧設計 (streaming-input iterator + 常駐 query) は SDK の resume と相性が悪く、
|
|
7
|
+
* resume 時に「Continue from where you left off」の自動継続ターンが走り 1 ターンで
|
|
8
|
+
* query() が終了 → 後続入力が消費されない不具合があった (ユーザー報告 2026-05-28)。
|
|
9
|
+
*
|
|
10
|
+
* 新設計:
|
|
11
|
+
* - attach: query() を起動せず、現在の resumeSessionId を記録するだけ (即 ready)
|
|
12
|
+
* - input: ユーザーメッセージ 1 件ごとに query({ prompt: text, options: { resume } })
|
|
13
|
+
* を起動。1 ターン分のイベントを stream して result で完了。完了時に最新 session_id を
|
|
14
|
+
* 保持し、次の input はその session_id で resume チェーンする
|
|
15
|
+
* - これは Claude CLI の `claude --resume <id> -p "<msg>"` を毎ターン叩くのと同じ
|
|
16
|
+
* モデルで、SDK の挙動と素直に噛み合う
|
|
13
17
|
*
|
|
14
18
|
* PtyBridge と並走させる設計。`pty.*` 系メッセージは無傷で、新規 `claude.*` のみ
|
|
15
19
|
* 受け持つ。テスト時は SDK を `{ query }` shape で stub 注入する。
|
|
@@ -17,28 +21,25 @@
|
|
|
17
21
|
import { EventEmitter } from "node:events"
|
|
18
22
|
import { randomUUID } from "node:crypto"
|
|
19
23
|
|
|
24
|
+
/** message (`{role, content}` or string) からプロンプト文字列を取り出す。 */
|
|
25
|
+
function extractPromptText(message) {
|
|
26
|
+
if (typeof message === "string") return message
|
|
27
|
+
if (!message) return ""
|
|
28
|
+
const content = message.content
|
|
29
|
+
if (typeof content === "string") return content
|
|
30
|
+
if (Array.isArray(content)) {
|
|
31
|
+
return content
|
|
32
|
+
.filter((b) => b && b.type === "text" && typeof b.text === "string")
|
|
33
|
+
.map((b) => b.text)
|
|
34
|
+
.join("\n")
|
|
35
|
+
}
|
|
36
|
+
return ""
|
|
37
|
+
}
|
|
38
|
+
|
|
20
39
|
/**
|
|
21
|
-
* 1 stream に対応する Claude
|
|
22
|
-
*
|
|
23
|
-
* SDK の `query()` は prompt を AsyncIterable として受け取り、ユーザーメッセージを
|
|
24
|
-
* 流し続ける限り interactive multi-turn として動作する。pushInput / endInput で
|
|
25
|
-
* iterator の挙動を制御する。
|
|
40
|
+
* 1 stream に対応する Claude セッション (per-message query モデル)。
|
|
26
41
|
*/
|
|
27
42
|
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
43
|
constructor({
|
|
43
44
|
stream_id,
|
|
44
45
|
cwd,
|
|
@@ -56,7 +57,6 @@ class ClaudeStreamSession {
|
|
|
56
57
|
this.cwd = cwd
|
|
57
58
|
this.model = model || null
|
|
58
59
|
this.permissionMode = permissionMode || null
|
|
59
|
-
this.resumeSessionId = resumeSessionId || null
|
|
60
60
|
this.sdk = sdk
|
|
61
61
|
this.logger = logger
|
|
62
62
|
this.onEvent = onEvent
|
|
@@ -64,93 +64,21 @@ class ClaudeStreamSession {
|
|
|
64
64
|
this.onExit = onExit
|
|
65
65
|
this.onError = onError
|
|
66
66
|
|
|
67
|
-
/**
|
|
68
|
-
* resume 時はその値で上書きされる。frontend が「現在のセッション」を識別する用。 */
|
|
67
|
+
/** 次の query() で resume に使う session_id。各ターンの system/init で更新。 */
|
|
69
68
|
this.sessionId = resumeSessionId || null
|
|
70
69
|
|
|
71
|
-
/** @type {
|
|
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 応答待ち */
|
|
70
|
+
/** @type {Map<string, {resolve: (decision: object) => void}>} permission 応答待ち */
|
|
76
71
|
this._permissionResolvers = new Map()
|
|
77
|
-
|
|
78
|
-
this.
|
|
79
|
-
|
|
80
|
-
this.
|
|
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
|
-
}
|
|
72
|
+
/** 現在ターン実行中か (多重 query 防止) */
|
|
73
|
+
this._busy = false
|
|
74
|
+
/** detach 済みフラグ (新規ターン受付停止) */
|
|
75
|
+
this._closed = false
|
|
76
|
+
/** 現在ターンの AbortController (interrupt 用) */
|
|
77
|
+
this._abortController = null
|
|
150
78
|
}
|
|
151
79
|
|
|
152
80
|
/** SDK options.canUseTool: tool 起動を許可するかを browser に問い合わせる。 */
|
|
153
|
-
async _canUseTool(toolName, input
|
|
81
|
+
async _canUseTool(toolName, input) {
|
|
154
82
|
if (!this.onPermission) return { behavior: "allow", updatedInput: input }
|
|
155
83
|
const request_id = randomUUID()
|
|
156
84
|
return await new Promise((resolve) => {
|
|
@@ -168,29 +96,58 @@ class ClaudeStreamSession {
|
|
|
168
96
|
})
|
|
169
97
|
}
|
|
170
98
|
|
|
171
|
-
/**
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
99
|
+
/** browser からの permission 応答を該当 request_id の Promise に渡す。 */
|
|
100
|
+
resolvePermission(request_id, decision) {
|
|
101
|
+
const r = this._permissionResolvers.get(request_id)
|
|
102
|
+
if (!r) return false
|
|
103
|
+
this._permissionResolvers.delete(request_id)
|
|
104
|
+
r.resolve(decision)
|
|
105
|
+
return true
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* ユーザーメッセージ 1 件を 1 query() として実行する。
|
|
110
|
+
* 既存ターン実行中は無視 (UI 側で送信を抑止する想定だが念のため)。
|
|
111
|
+
*/
|
|
112
|
+
async sendMessage(message) {
|
|
113
|
+
if (this._closed) return
|
|
114
|
+
if (this._busy) {
|
|
115
|
+
this.logger?.warn(
|
|
116
|
+
{ stream_id: this.stream_id },
|
|
117
|
+
"claude session busy, message ignored",
|
|
118
|
+
)
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
const prompt = extractPromptText(message)
|
|
122
|
+
if (!prompt) return
|
|
123
|
+
|
|
124
|
+
this._busy = true
|
|
125
|
+
this._abortController = new AbortController()
|
|
126
|
+
let aborted = false
|
|
185
127
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
128
|
+
const options = {
|
|
129
|
+
cwd: this.cwd,
|
|
130
|
+
canUseTool: (toolName, input) => this._canUseTool(toolName, input),
|
|
131
|
+
includePartialMessages: true,
|
|
132
|
+
abortController: this._abortController,
|
|
133
|
+
}
|
|
134
|
+
if (this.model) options.model = this.model
|
|
135
|
+
if (this.permissionMode) options.permissionMode = this.permissionMode
|
|
136
|
+
// 直前ターンまでの session_id があれば resume チェーン
|
|
137
|
+
if (this.sessionId) options.resume = this.sessionId
|
|
190
138
|
|
|
139
|
+
try {
|
|
140
|
+
const generator = this.sdk.query({ prompt, options })
|
|
191
141
|
for await (const msg of generator) {
|
|
192
|
-
|
|
193
|
-
|
|
142
|
+
if (
|
|
143
|
+
msg?.type === "system" &&
|
|
144
|
+
msg?.subtype === "init" &&
|
|
145
|
+
typeof msg.session_id === "string"
|
|
146
|
+
) {
|
|
147
|
+
this.sessionId = msg.session_id
|
|
148
|
+
}
|
|
149
|
+
// result イベントでも session_id が来ることがある (念のため拾う)
|
|
150
|
+
if (msg?.type === "result" && typeof msg.session_id === "string") {
|
|
194
151
|
this.sessionId = msg.session_id
|
|
195
152
|
}
|
|
196
153
|
try {
|
|
@@ -203,12 +160,9 @@ class ClaudeStreamSession {
|
|
|
203
160
|
}
|
|
204
161
|
}
|
|
205
162
|
} catch (err) {
|
|
206
|
-
if (this.
|
|
207
|
-
|
|
208
|
-
reason = "aborted"
|
|
163
|
+
if (this._abortController?.signal?.aborted) {
|
|
164
|
+
aborted = true
|
|
209
165
|
} else {
|
|
210
|
-
code = 1
|
|
211
|
-
reason = err?.message || String(err)
|
|
212
166
|
try {
|
|
213
167
|
this.onError?.(err)
|
|
214
168
|
} catch {
|
|
@@ -216,17 +170,47 @@ class ClaudeStreamSession {
|
|
|
216
170
|
}
|
|
217
171
|
}
|
|
218
172
|
} finally {
|
|
219
|
-
this.
|
|
173
|
+
this._busy = false
|
|
174
|
+
this._abortController = null
|
|
175
|
+
// 未解決 permission は閉じる
|
|
176
|
+
for (const [, resolver] of this._permissionResolvers) {
|
|
177
|
+
try {
|
|
178
|
+
resolver.resolve({ behavior: "deny", message: "turn ended" })
|
|
179
|
+
} catch {
|
|
180
|
+
/* ignore */
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
this._permissionResolvers.clear()
|
|
184
|
+
if (aborted) {
|
|
185
|
+
this.logger?.info({ stream_id: this.stream_id }, "claude turn aborted")
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 実行中ターンを中断する (次の input は引き続き受け付ける)。 */
|
|
191
|
+
abortTurn() {
|
|
192
|
+
if (this._abortController) {
|
|
220
193
|
try {
|
|
221
|
-
this.
|
|
222
|
-
} catch
|
|
223
|
-
|
|
224
|
-
{ err: err.message, stream_id: this.stream_id },
|
|
225
|
-
"onExit callback threw",
|
|
226
|
-
)
|
|
194
|
+
this._abortController.abort()
|
|
195
|
+
} catch {
|
|
196
|
+
/* ignore */
|
|
227
197
|
}
|
|
228
198
|
}
|
|
229
199
|
}
|
|
200
|
+
|
|
201
|
+
/** セッション終了。新規ターンを止め、実行中なら中断する。 */
|
|
202
|
+
close() {
|
|
203
|
+
this._closed = true
|
|
204
|
+
this.abortTurn()
|
|
205
|
+
for (const [, resolver] of this._permissionResolvers) {
|
|
206
|
+
try {
|
|
207
|
+
resolver.resolve({ behavior: "deny", message: "closed" })
|
|
208
|
+
} catch {
|
|
209
|
+
/* ignore */
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
this._permissionResolvers.clear()
|
|
213
|
+
}
|
|
230
214
|
}
|
|
231
215
|
|
|
232
216
|
export class ClaudeStreamBridge extends EventEmitter {
|
|
@@ -278,10 +262,6 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
278
262
|
onPermission: ({ tool_name, input, request_id }) => {
|
|
279
263
|
this.emit("permission", { stream_id, request_id, tool_name, input })
|
|
280
264
|
},
|
|
281
|
-
onExit: ({ code, reason, session_id }) => {
|
|
282
|
-
this.sessions.delete(stream_id)
|
|
283
|
-
this.emit("exit", { stream_id, code, reason, session_id })
|
|
284
|
-
},
|
|
285
265
|
onError: (err) => {
|
|
286
266
|
this.emit("error", { stream_id, error: err?.message || String(err) })
|
|
287
267
|
},
|
|
@@ -291,24 +271,25 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
291
271
|
{ stream_id, cwd: session.cwd, model: session.model, resume: !!resumeSessionId },
|
|
292
272
|
"claude stream attached",
|
|
293
273
|
)
|
|
294
|
-
//
|
|
295
|
-
|
|
296
|
-
this.logger?.error(
|
|
297
|
-
{ stream_id, err: err?.message },
|
|
298
|
-
"claude stream run threw unexpectedly",
|
|
299
|
-
)
|
|
300
|
-
})
|
|
274
|
+
// per-message モデルでは attach 時点では query() を起動しない (即 ready)。
|
|
275
|
+
// 最初の input で query を起動する。
|
|
301
276
|
return { stream_id, resuming: !!resumeSessionId }
|
|
302
277
|
}
|
|
303
278
|
|
|
304
|
-
/** browser → claude の user メッセージ。
|
|
279
|
+
/** browser → claude の user メッセージ。1 件 = 1 query (resume チェーン)。 */
|
|
305
280
|
input({ stream_id, message }) {
|
|
306
281
|
const s = this.sessions.get(stream_id)
|
|
307
282
|
if (!s) {
|
|
308
283
|
this.logger?.warn({ stream_id }, "claude.input but stream missing")
|
|
309
284
|
return false
|
|
310
285
|
}
|
|
311
|
-
|
|
286
|
+
// 非同期でターン実行 (完了は result イベント + onEvent 経由で browser に届く)
|
|
287
|
+
s.sendMessage(message).catch((err) => {
|
|
288
|
+
this.logger?.error(
|
|
289
|
+
{ stream_id, err: err?.message },
|
|
290
|
+
"claude sendMessage threw unexpectedly",
|
|
291
|
+
)
|
|
292
|
+
})
|
|
312
293
|
return true
|
|
313
294
|
}
|
|
314
295
|
|
|
@@ -325,21 +306,21 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
325
306
|
return s.resolvePermission(request_id, decision)
|
|
326
307
|
}
|
|
327
308
|
|
|
328
|
-
/**
|
|
309
|
+
/** 実行中ターンを中断 (セッションは生存、次の input は受付継続)。 */
|
|
329
310
|
interrupt({ stream_id }) {
|
|
330
311
|
const s = this.sessions.get(stream_id)
|
|
331
312
|
if (!s) return false
|
|
332
|
-
s.
|
|
313
|
+
s.abortTurn()
|
|
333
314
|
return true
|
|
334
315
|
}
|
|
335
316
|
|
|
336
|
-
/** セッション停止。Map
|
|
317
|
+
/** セッション停止。Map から即時削除し、実行中ターンを中断する。 */
|
|
337
318
|
detach({ stream_id }) {
|
|
338
319
|
const s = this.sessions.get(stream_id)
|
|
339
320
|
if (!s) return false
|
|
340
|
-
s.
|
|
341
|
-
// onExit を待たずに Map から外す (再 attach を即座に許可するため)
|
|
321
|
+
s.close()
|
|
342
322
|
this.sessions.delete(stream_id)
|
|
323
|
+
this.emit("exit", { stream_id, code: 0, reason: "detached", session_id: s.sessionId })
|
|
343
324
|
return true
|
|
344
325
|
}
|
|
345
326
|
|
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"
|
|
@@ -686,6 +686,23 @@ async function dispatch(msg, ctx) {
|
|
|
686
686
|
})
|
|
687
687
|
return
|
|
688
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
|
+
}
|
|
689
706
|
case "tmux.exec": {
|
|
690
707
|
const args = Array.isArray(msg.args) ? msg.args : []
|
|
691
708
|
const hookResult = await runHookChain(ctx.plugins, "interceptTmuxExec", {
|