@cocorograph/hub-agent 0.6.24 → 0.6.26
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-stream-bridge.mjs +66 -17
- package/src/claude-upload.mjs +226 -0
- package/src/main.mjs +63 -2
package/package.json
CHANGED
|
@@ -195,10 +195,15 @@ class ClaudeStreamSession {
|
|
|
195
195
|
|
|
196
196
|
/** 改修3: per-message セッションで busy 中に届いた送信を退避する pending キュー。
|
|
197
197
|
* 常駐 query 化 (改修2) とは別レイヤー。resume チェーンは維持したまま、現ターン
|
|
198
|
-
* 完了時 (finally) に先頭から drain して次ターンを自動発火する。
|
|
198
|
+
* 完了時 (finally) に先頭から drain して次ターンを自動発火する。
|
|
199
|
+
* キャンセル機能 (0.6.26): 各項目に安定 id を振り、browser から id 指定で
|
|
200
|
+
* 個別削除できるようにする。要素は { id: string, text: string }。 */
|
|
199
201
|
this._pendingMessages = []
|
|
200
|
-
/**
|
|
201
|
-
this.
|
|
202
|
+
/** キャンセル機能: pending 項目へ安定 id を振るための連番カウンタ。 */
|
|
203
|
+
this._queueSeq = 0
|
|
204
|
+
/** 改修3: 直近 browser へ通知した queue 署名 (件数 + id 列)。変化時のみ emit する。
|
|
205
|
+
* 空キューの署名 ("0:") で初期化し、空→空の冗長 emit を抑止する。 */
|
|
206
|
+
this._lastEmittedQueueSig = "0:"
|
|
202
207
|
|
|
203
208
|
/** @type {Map<string, {resolve: (decision: object) => void}>} permission 応答待ち */
|
|
204
209
|
this._permissionResolvers = new Map()
|
|
@@ -420,7 +425,7 @@ class ClaudeStreamSession {
|
|
|
420
425
|
// 次を push すると SDK streaming-input で割り込み扱いになり得るため (公式 interrupt 警告)。
|
|
421
426
|
// pending は queue_state で UI (送信待ちチップ) に出す (per-message と同じ体験)。
|
|
422
427
|
if (this._busy) {
|
|
423
|
-
this.
|
|
428
|
+
this._enqueuePending(prompt)
|
|
424
429
|
this.logger?.info(
|
|
425
430
|
{ stream_id: this.stream_id, queued: this._pendingMessages.length },
|
|
426
431
|
"resident busy, message queued",
|
|
@@ -444,7 +449,7 @@ class ClaudeStreamSession {
|
|
|
444
449
|
// 改修3: busy 中の送信は破棄せず pending キューへ退避し、現ターン完了時に drain する
|
|
445
450
|
// (ターミナル流の「積む→待機→順次実行」)。常駐 query 化はしないので暴走リスクは増えない。
|
|
446
451
|
if (this._busy) {
|
|
447
|
-
this.
|
|
452
|
+
this._enqueuePending(prompt)
|
|
448
453
|
this.logger?.info(
|
|
449
454
|
{ stream_id: this.stream_id, queued: this._pendingMessages.length },
|
|
450
455
|
"claude busy, message queued",
|
|
@@ -573,8 +578,8 @@ class ClaudeStreamSession {
|
|
|
573
578
|
return
|
|
574
579
|
}
|
|
575
580
|
const next = this._pendingMessages.shift()
|
|
576
|
-
this._emitQueueState()
|
|
577
|
-
this._runPerMessage(next).catch((err) => {
|
|
581
|
+
this._emitQueueState([next.text])
|
|
582
|
+
this._runPerMessage(next.text).catch((err) => {
|
|
578
583
|
this.logger?.error(
|
|
579
584
|
{ stream_id: this.stream_id, err: err?.message },
|
|
580
585
|
"drain runPerMessage threw",
|
|
@@ -582,20 +587,53 @@ class ClaudeStreamSession {
|
|
|
582
587
|
})
|
|
583
588
|
}
|
|
584
589
|
|
|
590
|
+
/** キャンセル機能 (0.6.26): pending キューへ安定 id 付きで 1 件積む。 */
|
|
591
|
+
_enqueuePending(prompt) {
|
|
592
|
+
const id = `q${++this._queueSeq}`
|
|
593
|
+
this._pendingMessages.push({ id, text: prompt })
|
|
594
|
+
return id
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** キャンセル機能 (0.6.26): browser から id 指定で送信待ちメッセージを 1 件取り消す。
|
|
598
|
+
* 実行開始済み (既に drain された) メッセージは pending に残っていないので no-op になる。
|
|
599
|
+
* 削除に成功したら queue_state を再 emit して送信待ちチップを更新する。 */
|
|
600
|
+
cancelQueued(id) {
|
|
601
|
+
const before = this._pendingMessages.length
|
|
602
|
+
this._pendingMessages = this._pendingMessages.filter((m) => m.id !== id)
|
|
603
|
+
const removed = before !== this._pendingMessages.length
|
|
604
|
+
if (removed) {
|
|
605
|
+
this.logger?.info(
|
|
606
|
+
{ stream_id: this.stream_id, id, queued: this._pendingMessages.length },
|
|
607
|
+
"queued message canceled",
|
|
608
|
+
)
|
|
609
|
+
this._emitQueueState()
|
|
610
|
+
}
|
|
611
|
+
return removed
|
|
612
|
+
}
|
|
613
|
+
|
|
585
614
|
/** 改修3: pending キューの現状を browser へ通知する (送信待ちチップ表示用)。
|
|
586
|
-
* onEvent 経由で claude.event(event.type="queue_state") として届く。
|
|
587
|
-
|
|
615
|
+
* onEvent 経由で claude.event(event.type="queue_state") として届く。
|
|
616
|
+
* @param {string[]} [started] このタイミングで pending から取り出して実行開始した
|
|
617
|
+
* メッセージ本文。drain 由来の emit でのみ渡す。frontend はこれを user バブルへ
|
|
618
|
+
* 昇格させる。キャンセル / 追加由来の emit では空 (昇格させない)。これにより
|
|
619
|
+
* 「先頭の pending をキャンセルした」のを「実行開始した」と誤認しなくなる (0.6.26)。 */
|
|
620
|
+
_emitQueueState(started = []) {
|
|
588
621
|
const count = this._pendingMessages.length
|
|
589
|
-
//
|
|
590
|
-
|
|
591
|
-
|
|
622
|
+
// 署名 = 件数 + id 列。件数が同じでもキャンセルで中身が変われば通知する。
|
|
623
|
+
const sig = `${count}:${this._pendingMessages.map((m) => m.id).join(",")}`
|
|
624
|
+
// started があるときは drain なので、sig 変化が無くても (理論上起きないが) 通知する。
|
|
625
|
+
if (started.length === 0 && sig === this._lastEmittedQueueSig) return
|
|
626
|
+
this._lastEmittedQueueSig = sig
|
|
592
627
|
try {
|
|
593
628
|
// messages は全文を載せる。frontend は実行開始 (drain) 時にこれを user バブルへ
|
|
594
629
|
// 昇格させるため、ここで切り詰めると本文が欠ける。チップの省略表示は CSS 側で行う。
|
|
630
|
+
// items は id 付きでキャンセルボタンの対象特定に使う (0.6.26)。
|
|
595
631
|
this.onEvent?.({
|
|
596
632
|
type: "queue_state",
|
|
597
633
|
pending: count,
|
|
598
|
-
messages:
|
|
634
|
+
messages: this._pendingMessages.map((m) => m.text),
|
|
635
|
+
items: this._pendingMessages.map((m) => ({ id: m.id, text: m.text })),
|
|
636
|
+
started,
|
|
599
637
|
})
|
|
600
638
|
} catch {
|
|
601
639
|
/* ignore */
|
|
@@ -623,8 +661,8 @@ class ClaudeStreamSession {
|
|
|
623
661
|
}
|
|
624
662
|
const next = this._pendingMessages.shift()
|
|
625
663
|
this._busy = true
|
|
626
|
-
this._inputQueue.push(toSDKUserMessage(next))
|
|
627
|
-
this._emitQueueState()
|
|
664
|
+
this._inputQueue.push(toSDKUserMessage(next.text))
|
|
665
|
+
this._emitQueueState([next.text])
|
|
628
666
|
}
|
|
629
667
|
|
|
630
668
|
/** 改修2+4: 常駐 query を 1 回だけ起動し、streaming input キューから複数ターンを処理する。
|
|
@@ -712,8 +750,8 @@ class ClaudeStreamSession {
|
|
|
712
750
|
const next = this._pendingMessages.shift()
|
|
713
751
|
this._busy = true
|
|
714
752
|
this._residentStarted = true
|
|
715
|
-
this._inputQueue.push(toSDKUserMessage(next))
|
|
716
|
-
this._emitQueueState()
|
|
753
|
+
this._inputQueue.push(toSDKUserMessage(next.text))
|
|
754
|
+
this._emitQueueState([next.text])
|
|
717
755
|
this._startResidentQuery()
|
|
718
756
|
}
|
|
719
757
|
}
|
|
@@ -990,6 +1028,17 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
990
1028
|
return true
|
|
991
1029
|
}
|
|
992
1030
|
|
|
1031
|
+
/** キャンセル機能 (0.6.26): browser → 送信待ち (pending) メッセージを id 指定で取り消す。
|
|
1032
|
+
* 実行中ターンには影響しない (中断は interrupt を使う)。 */
|
|
1033
|
+
cancelQueued({ stream_id, id }) {
|
|
1034
|
+
const s = this.sessions.get(stream_id)
|
|
1035
|
+
if (!s) {
|
|
1036
|
+
this.logger?.warn({ stream_id, id }, "claude.queue.cancel but stream missing")
|
|
1037
|
+
return false
|
|
1038
|
+
}
|
|
1039
|
+
return s.cancelQueued(id)
|
|
1040
|
+
}
|
|
1041
|
+
|
|
993
1042
|
/**
|
|
994
1043
|
* セッション停止 (graceful)。実行中ターンは中断せず完走させ、完走後に
|
|
995
1044
|
* onReap で Map から撤去する。アイドルなら即時撤去。
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cockpit チャットモードの添付ファイル受信 (browser → agent) を司るモジュール。
|
|
3
|
+
*
|
|
4
|
+
* 設計の背景 (WHY):
|
|
5
|
+
* - Claude が実際に動くのは hub-agent が常駐するローカルマシンで、Hub バックエンド
|
|
6
|
+
* とは別ホスト。添付ファイルのバイト列の最終着地点はこのローカル FS であり、
|
|
7
|
+
* そこへの転送経路は WS トンネル (claude.* チャネル) しか無い。
|
|
8
|
+
* - そこで「ファイルを cwd 配下に保存 → 保存パスを browser に返却 → browser は
|
|
9
|
+
* claude.input 本文にパス参照を載せる → Claude が Read ツールで読む」という
|
|
10
|
+
* パス参照方式を採る。Read ツールは画像 / PDF / ipynb 等をネイティブ対応する
|
|
11
|
+
* ため、対応形式を Claude の Read 対応形式に自然に揃えられる。
|
|
12
|
+
* - 大きいファイルで単一 WS フレームが肥大しないよう、browser 側でチャンク分割
|
|
13
|
+
* して送る。本モジュールは upload_id 単位でチャンクをメモリに蓄積し、全チャンク
|
|
14
|
+
* 到着時に結合して 1 ファイルとして書き出す。
|
|
15
|
+
*
|
|
16
|
+
* セキュリティ / 安全策:
|
|
17
|
+
* - 保存先は cwd 配下の `.cockpit-uploads/<日付>/` 固定。ファイル名は basename 化 +
|
|
18
|
+
* パス区切り / 制御文字を除去し、ディレクトリトラバーサルを防ぐ (日本語名は保持)。
|
|
19
|
+
* - 総サイズ上限 (既定 50MB) と最大チャンク数でメモリ枯渇 / DoS を防ぐ。
|
|
20
|
+
* - 未完了アップロードは TTL 経過で破棄する (sweep)。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { mkdir, writeFile } from "node:fs/promises"
|
|
24
|
+
import os from "node:os"
|
|
25
|
+
import path from "node:path"
|
|
26
|
+
|
|
27
|
+
/** 保存先のサブディレクトリ名 (cwd 直下に作る)。 */
|
|
28
|
+
export const UPLOAD_DIRNAME = ".cockpit-uploads"
|
|
29
|
+
|
|
30
|
+
/** 1 ファイルあたりの総サイズ上限 (バイト)。既定 50MB。 */
|
|
31
|
+
const DEFAULT_MAX_TOTAL_BYTES = 50 * 1024 * 1024
|
|
32
|
+
|
|
33
|
+
/** 1 アップロードあたりの最大チャンク数 (異常値ガード)。 */
|
|
34
|
+
const MAX_CHUNKS = 8192
|
|
35
|
+
|
|
36
|
+
/** 未完了アップロードの保持上限 (ミリ秒)。これを超えたら破棄する。 */
|
|
37
|
+
const DEFAULT_UPLOAD_TTL_MS = 30 * 60 * 1000
|
|
38
|
+
|
|
39
|
+
/** 衝突回避用のプロセス内連番。Date.now() と併用してユニーク名を作る。 */
|
|
40
|
+
let _seq = 0
|
|
41
|
+
|
|
42
|
+
/** 除去対象の文字: 制御文字 (0x00-0x1f) + パス区切り + Windows 予約文字。 */
|
|
43
|
+
// eslint-disable-next-line no-control-regex
|
|
44
|
+
const UNSAFE_NAME_CHARS = new RegExp("[\\u0000-\\u001f<>:\"/\\\\|?*]", "g")
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* ファイル名をローカル FS に安全な basename へ正規化する。
|
|
48
|
+
*
|
|
49
|
+
* - ディレクトリ成分は basename で除去 (`../` トラバーサル対策)
|
|
50
|
+
* - パス区切り / Windows 予約文字 / 制御文字を `_` へ置換
|
|
51
|
+
* - 先頭ドット (隠しファイル化) を除去
|
|
52
|
+
* - Unicode 文字は保持する (日本語ファイル名を壊さない)
|
|
53
|
+
*
|
|
54
|
+
* @param {unknown} name 元のファイル名
|
|
55
|
+
* @returns {string} 安全な basename (空になる場合は "file")
|
|
56
|
+
*/
|
|
57
|
+
export function sanitizeFilename(name) {
|
|
58
|
+
let base = path.basename(String(name == null ? "" : name))
|
|
59
|
+
base = base.replace(UNSAFE_NAME_CHARS, "_").replace(/^\.+/, "").trim()
|
|
60
|
+
base = base.slice(0, 150)
|
|
61
|
+
return base || "file"
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `YYYY-MM-DD` 形式の日付文字列を返す (ローカルタイム)。 */
|
|
65
|
+
function todayDir() {
|
|
66
|
+
const d = new Date()
|
|
67
|
+
const pad = (n) => String(n).padStart(2, "0")
|
|
68
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 1 stream に紐付かない、プロセス全体で 1 つのアップロード受信器。
|
|
73
|
+
* upload_id 単位で進行中アップロードを保持する。
|
|
74
|
+
*/
|
|
75
|
+
export class UploadManager {
|
|
76
|
+
/**
|
|
77
|
+
* @param {object} opts
|
|
78
|
+
* @param {object} [opts.logger] pino 互換ロガー
|
|
79
|
+
* @param {number} [opts.maxTotalBytes] 1 ファイルの総サイズ上限
|
|
80
|
+
* @param {number} [opts.ttlMs] 未完了アップロードの保持上限 (ミリ秒)
|
|
81
|
+
*/
|
|
82
|
+
constructor({ logger, maxTotalBytes = DEFAULT_MAX_TOTAL_BYTES, ttlMs = DEFAULT_UPLOAD_TTL_MS } = {}) {
|
|
83
|
+
this.logger = logger
|
|
84
|
+
this.maxTotalBytes = maxTotalBytes
|
|
85
|
+
this.ttlMs = ttlMs
|
|
86
|
+
/** @type {Map<string, {filename:string, media_type:string, total_size:number, total_chunks:number, chunks:Buffer[], received:number, bytes:number, cwd:(string|undefined), createdAt:number}>} */
|
|
87
|
+
this._pending = new Map()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 保存先のベースディレクトリ (cwd 優先、無ければ HOME)。 */
|
|
91
|
+
_resolveBaseDir(cwd) {
|
|
92
|
+
return cwd && typeof cwd === "string" ? cwd : os.homedir()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* チャンク 1 件を受理する。全チャンク到着で結合 + ファイル書き込みまで行い、
|
|
97
|
+
* `{ done: true, path, size, filename, media_type }` を返す。継続中は
|
|
98
|
+
* `{ done: false }` を返す。
|
|
99
|
+
*
|
|
100
|
+
* @param {object} chunk
|
|
101
|
+
* @param {string} chunk.upload_id アップロードの一意 ID (browser 採番)
|
|
102
|
+
* @param {string} [chunk.filename] 元ファイル名
|
|
103
|
+
* @param {string} [chunk.media_type] MIME タイプ
|
|
104
|
+
* @param {number} chunk.total_size 総バイト数 (検証用)
|
|
105
|
+
* @param {number} chunk.total_chunks 総チャンク数
|
|
106
|
+
* @param {number} chunk.chunk_index このチャンクの index (0 始まり)
|
|
107
|
+
* @param {string} chunk.data base64 エンコードされたチャンク本体
|
|
108
|
+
* @param {string} [chunk.cwd] 保存先解決に使う cwd
|
|
109
|
+
* @returns {Promise<{done:boolean, path?:string, size?:number, filename?:string, media_type?:string}>}
|
|
110
|
+
* @throws {Error} バリデーション失敗 / サイズ超過時
|
|
111
|
+
*/
|
|
112
|
+
async addChunk({
|
|
113
|
+
upload_id,
|
|
114
|
+
filename,
|
|
115
|
+
media_type,
|
|
116
|
+
total_size,
|
|
117
|
+
total_chunks,
|
|
118
|
+
chunk_index,
|
|
119
|
+
data,
|
|
120
|
+
cwd,
|
|
121
|
+
}) {
|
|
122
|
+
if (!upload_id || typeof upload_id !== "string") {
|
|
123
|
+
throw new Error("upload_id required")
|
|
124
|
+
}
|
|
125
|
+
let rec = this._pending.get(upload_id)
|
|
126
|
+
if (!rec) {
|
|
127
|
+
const tc = Number(total_chunks)
|
|
128
|
+
const ts = Number(total_size)
|
|
129
|
+
if (!Number.isInteger(tc) || tc < 1 || tc > MAX_CHUNKS) {
|
|
130
|
+
throw new Error("invalid total_chunks")
|
|
131
|
+
}
|
|
132
|
+
if (!Number.isInteger(ts) || ts < 0 || ts > this.maxTotalBytes) {
|
|
133
|
+
throw new Error(`file too large (max ${this.maxTotalBytes} bytes)`)
|
|
134
|
+
}
|
|
135
|
+
rec = {
|
|
136
|
+
filename,
|
|
137
|
+
media_type,
|
|
138
|
+
total_size: ts,
|
|
139
|
+
total_chunks: tc,
|
|
140
|
+
chunks: new Array(tc),
|
|
141
|
+
received: 0,
|
|
142
|
+
bytes: 0,
|
|
143
|
+
cwd,
|
|
144
|
+
createdAt: Date.now(),
|
|
145
|
+
}
|
|
146
|
+
this._pending.set(upload_id, rec)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const idx = Number(chunk_index)
|
|
150
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= rec.total_chunks) {
|
|
151
|
+
throw new Error("invalid chunk_index")
|
|
152
|
+
}
|
|
153
|
+
if (rec.chunks[idx] !== undefined) {
|
|
154
|
+
// 重複チャンク (再送等) は冪等に無視する。
|
|
155
|
+
return { done: false }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const buf = Buffer.from(typeof data === "string" ? data : "", "base64")
|
|
159
|
+
rec.bytes += buf.length
|
|
160
|
+
if (rec.bytes > this.maxTotalBytes) {
|
|
161
|
+
this._pending.delete(upload_id)
|
|
162
|
+
throw new Error(`file too large (max ${this.maxTotalBytes} bytes)`)
|
|
163
|
+
}
|
|
164
|
+
rec.chunks[idx] = buf
|
|
165
|
+
rec.received += 1
|
|
166
|
+
|
|
167
|
+
if (rec.received < rec.total_chunks) {
|
|
168
|
+
return { done: false }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 全チャンク到着 → 結合して書き込み。
|
|
172
|
+
this._pending.delete(upload_id)
|
|
173
|
+
const full = Buffer.concat(rec.chunks)
|
|
174
|
+
const savePath = await this._writeFile(rec, full)
|
|
175
|
+
this.logger?.info?.(
|
|
176
|
+
{ upload_id, path: savePath, size: full.length, filename: rec.filename },
|
|
177
|
+
"cockpit upload saved",
|
|
178
|
+
)
|
|
179
|
+
return {
|
|
180
|
+
done: true,
|
|
181
|
+
path: savePath,
|
|
182
|
+
size: full.length,
|
|
183
|
+
filename: rec.filename,
|
|
184
|
+
media_type: rec.media_type,
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 結合済みバッファを cwd 配下に書き出し、絶対パスを返す。 */
|
|
189
|
+
async _writeFile(rec, buf) {
|
|
190
|
+
const baseDir = this._resolveBaseDir(rec.cwd)
|
|
191
|
+
const dir = path.join(baseDir, UPLOAD_DIRNAME, todayDir())
|
|
192
|
+
await mkdir(dir, { recursive: true })
|
|
193
|
+
const safe = sanitizeFilename(rec.filename)
|
|
194
|
+
_seq = (_seq + 1) % 1_000_000
|
|
195
|
+
const unique = `${Date.now().toString(36)}-${_seq.toString(36)}`
|
|
196
|
+
const finalName = `${unique}_${safe}`
|
|
197
|
+
const full = path.join(dir, finalName)
|
|
198
|
+
await writeFile(full, buf)
|
|
199
|
+
return full
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* TTL を超過した未完了アップロードを破棄する (メモリ保護)。
|
|
204
|
+
* 定期タイマー or 受信契機で呼ぶ想定。
|
|
205
|
+
* @param {number} [now] 現在時刻 (テスト用に注入可能)
|
|
206
|
+
* @returns {number} 破棄した件数
|
|
207
|
+
*/
|
|
208
|
+
sweep(now = Date.now()) {
|
|
209
|
+
let dropped = 0
|
|
210
|
+
for (const [id, rec] of this._pending) {
|
|
211
|
+
if (now - rec.createdAt > this.ttlMs) {
|
|
212
|
+
this._pending.delete(id)
|
|
213
|
+
dropped += 1
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (dropped > 0) {
|
|
217
|
+
this.logger?.info?.({ dropped }, "cockpit upload: swept stale uploads")
|
|
218
|
+
}
|
|
219
|
+
return dropped
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 進行中アップロード件数 (テスト / 監視用)。 */
|
|
223
|
+
get pendingCount() {
|
|
224
|
+
return this._pending.size
|
|
225
|
+
}
|
|
226
|
+
}
|
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 { UploadManager } from "./claude-upload.mjs"
|
|
25
26
|
import { fetchSessionHistory, listSessions } from "./claude-history.mjs"
|
|
26
27
|
import { listAgents } from "./agents.mjs"
|
|
27
28
|
import { listSkills } from "./skills.mjs"
|
|
@@ -154,6 +155,11 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
154
155
|
? new ClaudeStreamBridge({ sdk: resolvedSdk, logger })
|
|
155
156
|
: null
|
|
156
157
|
|
|
158
|
+
// Cockpit チャットモードの添付ファイル受信器 (browser → agent のチャンク送信を
|
|
159
|
+
// ローカル FS に保存し、保存パスを返す)。SDK 有無に関わらず生成してよいが、添付は
|
|
160
|
+
// チャットモード専用機能なので claudeBridge と同じく stream モード前提で使う。
|
|
161
|
+
const uploadManager = new UploadManager({ logger })
|
|
162
|
+
|
|
157
163
|
const bundleVersion = await readBundleVersion()
|
|
158
164
|
if (bundleVersion) {
|
|
159
165
|
logger.info({ bundleVersion }, "hub bundle version detected")
|
|
@@ -257,7 +263,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
257
263
|
let dispatchChain = Promise.resolve()
|
|
258
264
|
client.on("message", (msg) => {
|
|
259
265
|
dispatchChain = dispatchChain
|
|
260
|
-
.then(() => dispatch(msg, { ...ctx, client, ptyBridge, claudeBridge }))
|
|
266
|
+
.then(() => dispatch(msg, { ...ctx, client, ptyBridge, claudeBridge, uploadManager }))
|
|
261
267
|
.catch((err) => {
|
|
262
268
|
logger.error(
|
|
263
269
|
{ err: err.message, type: msg?.type },
|
|
@@ -318,7 +324,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
318
324
|
)
|
|
319
325
|
})
|
|
320
326
|
|
|
321
|
-
return { client, plugins, ptyBridge, claudeBridge }
|
|
327
|
+
return { client, plugins, ptyBridge, claudeBridge, uploadManager }
|
|
322
328
|
}
|
|
323
329
|
|
|
324
330
|
const SESSION_EVENTS_DIR =
|
|
@@ -774,6 +780,56 @@ async function dispatch(msg, ctx) {
|
|
|
774
780
|
message: msg.message,
|
|
775
781
|
})
|
|
776
782
|
return
|
|
783
|
+
case "claude.upload": {
|
|
784
|
+
// Cockpit チャットモードの添付ファイル受信 (チャンク 1 件)。全チャンク到着で
|
|
785
|
+
// cwd 配下の .cockpit-uploads/<日付>/ に保存し、保存パスを browser へ返す。
|
|
786
|
+
// browser はそのパスを claude.input 本文に載せ、Claude が Read ツールで読む。
|
|
787
|
+
if (!ctx.uploadManager) {
|
|
788
|
+
ctx.client.send({
|
|
789
|
+
type: "claude.upload.done",
|
|
790
|
+
stream_id: msg.stream_id,
|
|
791
|
+
upload_id: msg.upload_id,
|
|
792
|
+
ok: false,
|
|
793
|
+
error: "upload_unavailable",
|
|
794
|
+
})
|
|
795
|
+
return
|
|
796
|
+
}
|
|
797
|
+
try {
|
|
798
|
+
const res = await ctx.uploadManager.addChunk({
|
|
799
|
+
upload_id: msg.upload_id,
|
|
800
|
+
filename: msg.filename,
|
|
801
|
+
media_type: msg.media_type,
|
|
802
|
+
total_size: msg.total_size,
|
|
803
|
+
total_chunks: msg.total_chunks,
|
|
804
|
+
chunk_index: msg.chunk_index,
|
|
805
|
+
data: msg.data,
|
|
806
|
+
cwd: msg.cwd || ctx.config?.claude_cwd || undefined,
|
|
807
|
+
})
|
|
808
|
+
if (res.done) {
|
|
809
|
+
// 完了チャンクのみ done を返す。継続中チャンクは応答しない
|
|
810
|
+
// (browser は自分のチャンク送出完了で進捗を把握する)。
|
|
811
|
+
ctx.client.send({
|
|
812
|
+
type: "claude.upload.done",
|
|
813
|
+
stream_id: msg.stream_id,
|
|
814
|
+
upload_id: msg.upload_id,
|
|
815
|
+
ok: true,
|
|
816
|
+
path: res.path,
|
|
817
|
+
filename: res.filename,
|
|
818
|
+
media_type: res.media_type,
|
|
819
|
+
size: res.size,
|
|
820
|
+
})
|
|
821
|
+
}
|
|
822
|
+
} catch (err) {
|
|
823
|
+
ctx.client.send({
|
|
824
|
+
type: "claude.upload.done",
|
|
825
|
+
stream_id: msg.stream_id,
|
|
826
|
+
upload_id: msg.upload_id,
|
|
827
|
+
ok: false,
|
|
828
|
+
error: err?.message || String(err),
|
|
829
|
+
})
|
|
830
|
+
}
|
|
831
|
+
return
|
|
832
|
+
}
|
|
777
833
|
case "claude.permission.reply":
|
|
778
834
|
if (!ctx.claudeBridge) return
|
|
779
835
|
ctx.claudeBridge.permissionReply({
|
|
@@ -788,6 +844,11 @@ async function dispatch(msg, ctx) {
|
|
|
788
844
|
if (!ctx.claudeBridge) return
|
|
789
845
|
ctx.claudeBridge.interrupt({ stream_id: msg.stream_id })
|
|
790
846
|
return
|
|
847
|
+
case "claude.queue.cancel":
|
|
848
|
+
// 送信待ち (pending) メッセージを id 指定で取り消す (0.6.26)。
|
|
849
|
+
if (!ctx.claudeBridge) return
|
|
850
|
+
ctx.claudeBridge.cancelQueued({ stream_id: msg.stream_id, id: msg.id })
|
|
851
|
+
return
|
|
791
852
|
case "claude.detach":
|
|
792
853
|
if (!ctx.claudeBridge) return
|
|
793
854
|
ctx.claudeBridge.detach({ stream_id: msg.stream_id })
|