@cocorograph/hub-agent 0.8.62 → 0.8.63
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 +43 -1
- package/src/claude-stream-bridge.mjs +110 -0
- package/src/main.mjs +37 -0
package/package.json
CHANGED
package/src/claude-history.mjs
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { randomUUID } from "node:crypto"
|
|
19
19
|
import { constants as fsConstants } from "node:fs"
|
|
20
|
-
import { open, readFile, readdir, stat } from "node:fs/promises"
|
|
20
|
+
import { open, readFile, readdir, rm, stat, unlink } from "node:fs/promises"
|
|
21
21
|
import os from "node:os"
|
|
22
22
|
import path from "node:path"
|
|
23
23
|
|
|
@@ -646,6 +646,48 @@ export async function renameSession({ cwd, session_id, title, projectsRoot, logg
|
|
|
646
646
|
}
|
|
647
647
|
}
|
|
648
648
|
|
|
649
|
+
/** Claude セッションの JSONL と同名の補助ディレクトリを削除する。 */
|
|
650
|
+
export async function deleteSession({
|
|
651
|
+
cwd,
|
|
652
|
+
session_id,
|
|
653
|
+
projectsRoot,
|
|
654
|
+
logger,
|
|
655
|
+
rmImpl = rm,
|
|
656
|
+
unlinkImpl = unlink,
|
|
657
|
+
statImpl = stat,
|
|
658
|
+
}) {
|
|
659
|
+
if (typeof cwd !== "string" || cwd.length === 0) return { ok: false, error: "cwd required" }
|
|
660
|
+
if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(session_id)) {
|
|
661
|
+
return { ok: false, error: "invalid session_id" }
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const filePath = jsonlPath({ cwd, session_id, projectsRoot })
|
|
665
|
+
const dirPath = filePath.slice(0, -".jsonl".length)
|
|
666
|
+
try {
|
|
667
|
+
const fileStat = await statImpl(filePath)
|
|
668
|
+
if (!fileStat.isFile()) return { ok: false, error: "session not found" }
|
|
669
|
+
} catch (err) {
|
|
670
|
+
const notFound = err?.code === "ENOENT" || err?.code === "EISDIR"
|
|
671
|
+
if (notFound) return { ok: false, error: "session not found" }
|
|
672
|
+
logger?.warn({ err: err?.message || String(err), code: err?.code, filePath }, "claude session delete stat failed")
|
|
673
|
+
return { ok: false, error: "delete failed" }
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
try {
|
|
677
|
+
await rmImpl(dirPath, { recursive: true, force: true })
|
|
678
|
+
await unlinkImpl(filePath)
|
|
679
|
+
} catch (err) {
|
|
680
|
+
logger?.warn({ err: err?.message || String(err), code: err?.code, filePath }, "claude session delete failed")
|
|
681
|
+
return { ok: false, error: "delete failed" }
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
hydrateCache.delete(filePath)
|
|
685
|
+
const parentDir = path.dirname(filePath)
|
|
686
|
+
const fileMap = lastTurnDirCache.get(parentDir)
|
|
687
|
+
fileMap?.delete(filePath)
|
|
688
|
+
return { ok: true }
|
|
689
|
+
}
|
|
690
|
+
|
|
649
691
|
/**
|
|
650
692
|
* jsonl 1 ファイルを読んで UI 表示用イベント配列を返す。
|
|
651
693
|
*
|
|
@@ -538,6 +538,15 @@ export class ClaudeStreamSession {
|
|
|
538
538
|
* InputQueue にメッセージが消えることがある。このフラグで「push 直前に本当にまだ
|
|
539
539
|
* 生きているか」を再確認する。新しい query を (再)起動するたびに false へリセットする。 */
|
|
540
540
|
this._residentQueryReturned = false
|
|
541
|
+
/** 削除時に CLI 終了を待つための、活動中 run-loop の完了 Promise 集合。
|
|
542
|
+
* ⚠️ 単一の「現在の Promise」にしないこと (cross-review blocker, 2026-09-05): per-message の
|
|
543
|
+
* finally は `_drainPending()` で次ターンを**同期的に**起動してから自分の完了処理へ進むため、
|
|
544
|
+
* 単一スロットだと旧ターンの完了が新ターンの Promise を誤って resolve し、CLI が走行中でも
|
|
545
|
+
* waitForRunLoopExit() が即 resolve して jsonl を unlink できてしまう (resident の異常再起動も同型)。
|
|
546
|
+
* run-loop ごとに自分の Promise を持ち、自分のものだけを完了させる。 */
|
|
547
|
+
this._activeRunLoops = new Set()
|
|
548
|
+
/** 削除対象では close 後の tmp→rename が JSONL を復活させないよう at-rest mask を止める。 */
|
|
549
|
+
this._suppressAtRestMask = false
|
|
541
550
|
|
|
542
551
|
/** 改修3: per-message セッションで busy 中に届いた送信を退避する pending キュー。
|
|
543
552
|
* 常駐 query 化 (改修2) とは別レイヤー。resume チェーンは維持したまま、現ターン
|
|
@@ -849,6 +858,7 @@ export class ClaudeStreamSession {
|
|
|
849
858
|
* ファイルを掴んでいない「終了後」にのみ呼ぶこと (ライブ追記中の書き換えは会話本体を壊す)。
|
|
850
859
|
* display は _emit で常時マスク済みのため、これは at-rest ファイルの掃除専用。 */
|
|
851
860
|
_maskJsonlAtRest() {
|
|
861
|
+
if (this._suppressAtRestMask) return
|
|
852
862
|
if (this._jsonlMasked || !MASK_ENABLED) return
|
|
853
863
|
if (!this.sessionId || !this.cwd) return
|
|
854
864
|
this._jsonlMasked = true
|
|
@@ -1421,6 +1431,7 @@ export class ClaudeStreamSession {
|
|
|
1421
1431
|
/** per-message 1 ターンを実行する (resume チェーン)。busy 中に届いた送信は sendMessage
|
|
1422
1432
|
* が pending キューへ退避し、本メソッドの finally で drain する。 */
|
|
1423
1433
|
async _runPerMessage(prompt, opts = {}) {
|
|
1434
|
+
const finishRunLoop = this._markRunLoopStarted()
|
|
1424
1435
|
this._busy = true
|
|
1425
1436
|
this._abortController = new AbortController()
|
|
1426
1437
|
let aborted = false
|
|
@@ -1612,6 +1623,7 @@ export class ClaudeStreamSession {
|
|
|
1612
1623
|
// jsonl を掴んでいない) のこの地点で、closed 済みならオンディスク jsonl をマスクする。
|
|
1613
1624
|
// reap 経路は上の close() 内で既にマスク済み (冪等ガードで二重実行されない)。
|
|
1614
1625
|
if (this._closed) this._maskJsonlAtRest()
|
|
1626
|
+
finishRunLoop()
|
|
1615
1627
|
}
|
|
1616
1628
|
}
|
|
1617
1629
|
|
|
@@ -2083,6 +2095,7 @@ export class ClaudeStreamSession {
|
|
|
2083
2095
|
* 常駐では query stream が唯一のイベント源のため watcher は張らない (過去確定分は browser が
|
|
2084
2096
|
* history.request で hydrate 済み。resume で query stream が過去行を replay しない前提)。 */
|
|
2085
2097
|
async _runResidentQuery() {
|
|
2098
|
+
const finishRunLoop = this._markRunLoopStarted()
|
|
2086
2099
|
// SDK-P0-4 (2026-07-13): 常駐にも AbortController を渡す (per-message には元からある)。
|
|
2087
2100
|
// 正規の中断は interrupt() だが、control request が失敗/無応答のときに generator を畳む
|
|
2088
2101
|
// 最終手段が無いと、close()/shutdown() で CLI 子プロセスが残留する。
|
|
@@ -2298,6 +2311,7 @@ export class ClaudeStreamSession {
|
|
|
2298
2311
|
// マスク (2026-06-23): 常駐 query 終了後 (= CLI が jsonl を掴んでいない) のこの地点で、
|
|
2299
2312
|
// closed 済みならオンディスク jsonl をマスクする。reap 経路は close() 内で実行済み。
|
|
2300
2313
|
if (this._closed) this._maskJsonlAtRest()
|
|
2314
|
+
finishRunLoop()
|
|
2301
2315
|
}
|
|
2302
2316
|
}
|
|
2303
2317
|
|
|
@@ -2473,6 +2487,34 @@ export class ClaudeStreamSession {
|
|
|
2473
2487
|
return true
|
|
2474
2488
|
}
|
|
2475
2489
|
|
|
2490
|
+
/** run-loop 開始を登録し、**その run-loop 自身だけ**を完了させる関数を返す (冪等)。 */
|
|
2491
|
+
_markRunLoopStarted() {
|
|
2492
|
+
let resolve
|
|
2493
|
+
const promise = new Promise((r) => {
|
|
2494
|
+
resolve = r
|
|
2495
|
+
})
|
|
2496
|
+
this._activeRunLoops.add(promise)
|
|
2497
|
+
return () => {
|
|
2498
|
+
if (!this._activeRunLoops.delete(promise)) return
|
|
2499
|
+
resolve()
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
/** 削除専用 close。通常 close の at-rest mask だけを抑止する。 */
|
|
2504
|
+
closeForDeletion() {
|
|
2505
|
+
this._suppressAtRestMask = true
|
|
2506
|
+
this.close()
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
/** 呼び出し時点で活動中の run-loop が**すべて**終わるまで待つ。待っている間に新しい run-loop が
|
|
2510
|
+
* 同期起動された (pending drain / resident 再起動) 場合はそれも待つ。close 済みなら
|
|
2511
|
+
* `_drainPending()` / 再起動は `_closed` ガードで始まらないため、連鎖は有限で終わる。 */
|
|
2512
|
+
async waitForRunLoopExit() {
|
|
2513
|
+
while (this._activeRunLoops.size > 0) {
|
|
2514
|
+
await Promise.all([...this._activeRunLoops])
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2476
2518
|
/** セッション終了。新規ターンを止め、実行中なら中断し、watcher も停止する。 */
|
|
2477
2519
|
close() {
|
|
2478
2520
|
this._closed = true
|
|
@@ -2554,6 +2596,12 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
2554
2596
|
/** session_id → 生存セッション。再接続 (再アタッチ) で同一セッションを引く索引。
|
|
2555
2597
|
* browser が切れてもここに残し、走行中ターンの継続 + 再接続でのライブ追従を可能にする。 */
|
|
2556
2598
|
this._liveBySession = new Map()
|
|
2599
|
+
/** key(cwd+session_id) → 削除停止を開始済みで run-loop 終了待ちのセッション集合。
|
|
2600
|
+
* 索引から外した後も再試行が同じ CLI 群を待てるように保持し、各 CLI の終了時に外す。
|
|
2601
|
+
* 同じ key で新しい CLI が起動→再削除→timeout した場合も全件残す (1 件だけだと 2 件目が追跡漏れ)。 */
|
|
2602
|
+
this._deletionTombstones = new Map()
|
|
2603
|
+
/** テストでは短縮可能。実運用では CLI reap を最大 5 秒待つ。 */
|
|
2604
|
+
this._sessionDeletionWaitMs = 5_000
|
|
2557
2605
|
|
|
2558
2606
|
/** MCP control 専用 query (agent-global)。遅延起動・アイドル破棄。session 非依存。 */
|
|
2559
2607
|
this._mcpControlQuery = null
|
|
@@ -2660,6 +2708,68 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
2660
2708
|
return stopped
|
|
2661
2709
|
}
|
|
2662
2710
|
|
|
2711
|
+
/** cwd と session_id が完全一致する live セッションだけを停止し、CLI 終了まで待つ。
|
|
2712
|
+
* 戻り値は今回新たに停止した数 (tombstone の待ち直しは数えない)。timeout は throw。 */
|
|
2713
|
+
async stopSessionForDeletion({ cwd, session_id }) {
|
|
2714
|
+
const key = this._liveKeyFor(cwd, session_id)
|
|
2715
|
+
const newVictims = new Set()
|
|
2716
|
+
for (const s of [...this._liveBySession.values(), ...this.sessions.values()]) {
|
|
2717
|
+
if (s.cwd === cwd && s.sessionId === session_id && !s._closed) newVictims.add(s)
|
|
2718
|
+
}
|
|
2719
|
+
const waitTargets = new Set(newVictims)
|
|
2720
|
+
for (const s of this._deletionTombstones.get(key) ?? []) waitTargets.add(s)
|
|
2721
|
+
|
|
2722
|
+
for (const s of newVictims) {
|
|
2723
|
+
this._dropSessionMappings(s)
|
|
2724
|
+
s.closeForDeletion()
|
|
2725
|
+
this._rememberDeletionTombstone(key, s)
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
let timer
|
|
2729
|
+
try {
|
|
2730
|
+
// 全対象を 1 本の Promise にまとめて待つ (逐次だと先頭の timeout で後続を確認しないまま抜ける)。
|
|
2731
|
+
await Promise.race([
|
|
2732
|
+
Promise.all([...waitTargets].map((s) => s.waitForRunLoopExit())),
|
|
2733
|
+
new Promise((_, reject) => {
|
|
2734
|
+
timer = setTimeout(() => reject(new Error("session deletion stop timeout")), this._sessionDeletionWaitMs)
|
|
2735
|
+
timer.unref?.()
|
|
2736
|
+
}),
|
|
2737
|
+
])
|
|
2738
|
+
} finally {
|
|
2739
|
+
if (timer) clearTimeout(timer)
|
|
2740
|
+
// timeout で throw する経路でも exit は必ず出す: session は既に close 済みで索引からも
|
|
2741
|
+
// 外れているため、ここで送らないと browser は「生きている stream」を抱えたままになる
|
|
2742
|
+
// (削除自体は main が `delete failed` で止める。exit と失敗応答は独立)。
|
|
2743
|
+
// tombstone からの待ち直しは初回に exit 済みなので出さない (1 セッション 1 回)。
|
|
2744
|
+
for (const s of newVictims) {
|
|
2745
|
+
this.emit("exit", {
|
|
2746
|
+
stream_id: s.stream_id,
|
|
2747
|
+
code: 0,
|
|
2748
|
+
reason: "session-deleted",
|
|
2749
|
+
session_id: s.sessionId,
|
|
2750
|
+
})
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
return newVictims.size
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
/** 削除停止したセッションを key ごとの集合に残し、その run-loop が終わったときだけ外す。 */
|
|
2757
|
+
_rememberDeletionTombstone(key, session) {
|
|
2758
|
+
if (!key) return
|
|
2759
|
+
let set = this._deletionTombstones.get(key)
|
|
2760
|
+
if (!set) {
|
|
2761
|
+
set = new Set()
|
|
2762
|
+
this._deletionTombstones.set(key, set)
|
|
2763
|
+
}
|
|
2764
|
+
set.add(session)
|
|
2765
|
+
session.waitForRunLoopExit().then(() => {
|
|
2766
|
+
const cur = this._deletionTombstones.get(key)
|
|
2767
|
+
if (!cur) return
|
|
2768
|
+
cur.delete(session)
|
|
2769
|
+
if (cur.size === 0) this._deletionTombstones.delete(key)
|
|
2770
|
+
})
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2663
2773
|
/**
|
|
2664
2774
|
* 新しい Claude セッションを開始する。
|
|
2665
2775
|
*
|
package/src/main.mjs
CHANGED
|
@@ -59,6 +59,7 @@ import { UploadManager } from "./claude-upload.mjs"
|
|
|
59
59
|
import { requestSelfUninstall } from "./service-install.mjs"
|
|
60
60
|
import {
|
|
61
61
|
drainTaskNotificationEnvelopeDrops,
|
|
62
|
+
deleteSession,
|
|
62
63
|
fetchSessionHistory,
|
|
63
64
|
listSessions,
|
|
64
65
|
renameSession,
|
|
@@ -5032,6 +5033,42 @@ export async function dispatch(msg, ctx) {
|
|
|
5032
5033
|
})
|
|
5033
5034
|
return
|
|
5034
5035
|
}
|
|
5036
|
+
case "claude.session.delete": {
|
|
5037
|
+
const stream_id = msg.stream_id
|
|
5038
|
+
const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
|
|
5039
|
+
const session_id = typeof msg.session_id === "string" ? msg.session_id : ""
|
|
5040
|
+
const request_id = typeof msg.request_id === "string" ? msg.request_id : undefined
|
|
5041
|
+
let profileId
|
|
5042
|
+
let result
|
|
5043
|
+
let stopped = 0
|
|
5044
|
+
if (!cwd) {
|
|
5045
|
+
result = { ok: false, error: "cwd required" }
|
|
5046
|
+
} else if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(session_id)) {
|
|
5047
|
+
result = { ok: false, error: "invalid session_id" }
|
|
5048
|
+
} else {
|
|
5049
|
+
const resolved = await resolveProjectsRoot(msg.profile_id)
|
|
5050
|
+
profileId = resolved.profileId
|
|
5051
|
+
try {
|
|
5052
|
+
const active = await getActiveProfile()
|
|
5053
|
+
stopped = ctx.claudeBridge && resolved.profileId === active.id
|
|
5054
|
+
? await ctx.claudeBridge.stopSessionForDeletion({ cwd, session_id })
|
|
5055
|
+
: 0
|
|
5056
|
+
result = await deleteSession({ cwd, session_id, projectsRoot: resolved.projectsRoot, logger: ctx.logger })
|
|
5057
|
+
} catch (err) {
|
|
5058
|
+
ctx.logger?.warn?.({ err: err?.message || String(err) }, "claude session delete stop failed")
|
|
5059
|
+
result = { ok: false, error: "delete failed" }
|
|
5060
|
+
}
|
|
5061
|
+
}
|
|
5062
|
+
ctx.client.send({
|
|
5063
|
+
type: "claude.session.delete.response",
|
|
5064
|
+
request_id, stream_id, cwd, session_id,
|
|
5065
|
+
profile_id: profileId,
|
|
5066
|
+
ok: result.ok === true,
|
|
5067
|
+
stopped: result.ok ? stopped : undefined,
|
|
5068
|
+
error: result.ok ? undefined : result.error,
|
|
5069
|
+
})
|
|
5070
|
+
return
|
|
5071
|
+
}
|
|
5035
5072
|
case "profile.list": {
|
|
5036
5073
|
// Claude アカウント・プロファイル一覧と active を返す (Cockpit のアカウント切替UI用)。
|
|
5037
5074
|
// 各プロファイルの「最後にログインしていたアカウント」(<configDir>/.claude.json の
|