@cocorograph/hub-agent 0.8.62 → 0.8.64

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.8.62",
3
+ "version": "0.8.64",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -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 チェーンは維持したまま、現ターン
@@ -554,6 +563,9 @@ export class ClaudeStreamSession {
554
563
  * メッセージが後着して実行される「ゴースト実行」を防ぐ)。到着で消費 (delete)。
555
564
  * 無限増加を避けるため上限で全クリアする (キャンセルは低頻度)。 */
556
565
  this._canceledInputIds = new Set()
566
+ /** 取消成功した input_id の直近リング。accepted は配達確認と二重実行防止の根拠なので外さず、
567
+ * browser が「accepted + キュー不在」のバブル昇格から取消済み id を除外する別軸にする。 */
568
+ this._cancelledInputIds = []
557
569
  /** 改修3: 直近 browser へ通知した queue 署名 (件数 + id 列)。変化時のみ emit する。
558
570
  * 空キューの署名 ("0:") で初期化し、空→空の冗長 emit を抑止する。 */
559
571
  this._lastEmittedQueueSig = "0:"
@@ -849,6 +861,7 @@ export class ClaudeStreamSession {
849
861
  * ファイルを掴んでいない「終了後」にのみ呼ぶこと (ライブ追記中の書き換えは会話本体を壊す)。
850
862
  * display は _emit で常時マスク済みのため、これは at-rest ファイルの掃除専用。 */
851
863
  _maskJsonlAtRest() {
864
+ if (this._suppressAtRestMask) return
852
865
  if (this._jsonlMasked || !MASK_ENABLED) return
853
866
  if (!this.sessionId || !this.cwd) return
854
867
  this._jsonlMasked = true
@@ -1303,7 +1316,10 @@ export class ClaudeStreamSession {
1303
1316
  // チップを固着させる。ack を返す条件 (accepted) と同じ場所で 1 回だけ記録する。
1304
1317
  const inputId =
1305
1318
  typeof opts?.input_id === "string" && opts.input_id ? opts.input_id : null
1306
- if (inputId && result?.accepted) this._recordAcceptedInput(inputId)
1319
+ if (inputId && result?.accepted) {
1320
+ this._recordAcceptedInput(inputId)
1321
+ if (result.preCancelled) this._recordCancelledInput(inputId)
1322
+ }
1307
1323
  return result
1308
1324
  }
1309
1325
 
@@ -1326,7 +1342,7 @@ export class ClaudeStreamSession {
1326
1342
  { stream_id: this.stream_id, input_id: inputId },
1327
1343
  "dropped pre-canceled input (cancel arrived before input)",
1328
1344
  )
1329
- return { accepted: true }
1345
+ return { accepted: true, preCancelled: true }
1330
1346
  }
1331
1347
 
1332
1348
  // ultracode ワンショット (0.6.28): このターンのみ xhigh effort + 常時 dynamic-workflow
@@ -1421,6 +1437,7 @@ export class ClaudeStreamSession {
1421
1437
  /** per-message 1 ターンを実行する (resume チェーン)。busy 中に届いた送信は sendMessage
1422
1438
  * が pending キューへ退避し、本メソッドの finally で drain する。 */
1423
1439
  async _runPerMessage(prompt, opts = {}) {
1440
+ const finishRunLoop = this._markRunLoopStarted()
1424
1441
  this._busy = true
1425
1442
  this._abortController = new AbortController()
1426
1443
  let aborted = false
@@ -1612,6 +1629,7 @@ export class ClaudeStreamSession {
1612
1629
  // jsonl を掴んでいない) のこの地点で、closed 済みならオンディスク jsonl をマスクする。
1613
1630
  // reap 経路は上の close() 内で既にマスク済み (冪等ガードで二重実行されない)。
1614
1631
  if (this._closed) this._maskJsonlAtRest()
1632
+ finishRunLoop()
1615
1633
  }
1616
1634
  }
1617
1635
 
@@ -1701,6 +1719,8 @@ export class ClaudeStreamSession {
1701
1719
  * snapshot の `accepted` として browser へ載せ、ack 喪失時の自己回復に使う。 */
1702
1720
  _recordAcceptedInput(inputId) {
1703
1721
  if (typeof inputId !== "string" || !inputId) return
1722
+ const cancelledAt = this._cancelledInputIds.indexOf(inputId)
1723
+ if (cancelledAt !== -1) this._cancelledInputIds.splice(cancelledAt, 1)
1704
1724
  const at = this._acceptedInputIds.indexOf(inputId)
1705
1725
  if (at !== -1) this._acceptedInputIds.splice(at, 1)
1706
1726
  this._acceptedInputIds.push(inputId)
@@ -1712,6 +1732,19 @@ export class ClaudeStreamSession {
1712
1732
  }
1713
1733
  }
1714
1734
 
1735
+ _recordCancelledInput(inputId) {
1736
+ if (typeof inputId !== "string" || !inputId) return
1737
+ const at = this._cancelledInputIds.indexOf(inputId)
1738
+ if (at !== -1) this._cancelledInputIds.splice(at, 1)
1739
+ this._cancelledInputIds.push(inputId)
1740
+ if (this._cancelledInputIds.length > ACCEPTED_INPUT_ID_RING_MAX) {
1741
+ this._cancelledInputIds.splice(
1742
+ 0,
1743
+ this._cancelledInputIds.length - ACCEPTED_INPUT_ID_RING_MAX,
1744
+ )
1745
+ }
1746
+ }
1747
+
1715
1748
  /** キャンセル機能 (0.6.26): browser から id 指定で送信待ちメッセージを 1 件取り消す。
1716
1749
  * 実行開始済み (既に drain された) メッセージは pending に残っていないので no-op になる。
1717
1750
  * 削除に成功したら queue_state を再 emit して送信待ちチップを更新する。 */
@@ -1728,6 +1761,8 @@ export class ClaudeStreamSession {
1728
1761
  this._pendingMessages = this._pendingMessages.filter((m) => m.id !== id)
1729
1762
  const removed = before !== this._pendingMessages.length
1730
1763
  if (removed) {
1764
+ this._canceledInputIds.delete(id)
1765
+ this._recordCancelledInput(id)
1731
1766
  this.logger?.info(
1732
1767
  { stream_id: this.stream_id, id, queued: this._pendingMessages.length },
1733
1768
  "queued message canceled",
@@ -1843,6 +1878,7 @@ export class ClaudeStreamSession {
1843
1878
  // と判定して楽観チップを消し、「キューに無い + ここにも無い = 未配達」なら
1844
1879
  // 消さずに再送導線を出す (無言削除の回帰を作らないための 2 軸判定)。
1845
1880
  accepted: [...this._acceptedInputIds],
1881
+ cancelled: [...this._cancelledInputIds],
1846
1882
  }
1847
1883
  }
1848
1884
 
@@ -2083,6 +2119,7 @@ export class ClaudeStreamSession {
2083
2119
  * 常駐では query stream が唯一のイベント源のため watcher は張らない (過去確定分は browser が
2084
2120
  * history.request で hydrate 済み。resume で query stream が過去行を replay しない前提)。 */
2085
2121
  async _runResidentQuery() {
2122
+ const finishRunLoop = this._markRunLoopStarted()
2086
2123
  // SDK-P0-4 (2026-07-13): 常駐にも AbortController を渡す (per-message には元からある)。
2087
2124
  // 正規の中断は interrupt() だが、control request が失敗/無応答のときに generator を畳む
2088
2125
  // 最終手段が無いと、close()/shutdown() で CLI 子プロセスが残留する。
@@ -2298,6 +2335,7 @@ export class ClaudeStreamSession {
2298
2335
  // マスク (2026-06-23): 常駐 query 終了後 (= CLI が jsonl を掴んでいない) のこの地点で、
2299
2336
  // closed 済みならオンディスク jsonl をマスクする。reap 経路は close() 内で実行済み。
2300
2337
  if (this._closed) this._maskJsonlAtRest()
2338
+ finishRunLoop()
2301
2339
  }
2302
2340
  }
2303
2341
 
@@ -2473,6 +2511,34 @@ export class ClaudeStreamSession {
2473
2511
  return true
2474
2512
  }
2475
2513
 
2514
+ /** run-loop 開始を登録し、**その run-loop 自身だけ**を完了させる関数を返す (冪等)。 */
2515
+ _markRunLoopStarted() {
2516
+ let resolve
2517
+ const promise = new Promise((r) => {
2518
+ resolve = r
2519
+ })
2520
+ this._activeRunLoops.add(promise)
2521
+ return () => {
2522
+ if (!this._activeRunLoops.delete(promise)) return
2523
+ resolve()
2524
+ }
2525
+ }
2526
+
2527
+ /** 削除専用 close。通常 close の at-rest mask だけを抑止する。 */
2528
+ closeForDeletion() {
2529
+ this._suppressAtRestMask = true
2530
+ this.close()
2531
+ }
2532
+
2533
+ /** 呼び出し時点で活動中の run-loop が**すべて**終わるまで待つ。待っている間に新しい run-loop が
2534
+ * 同期起動された (pending drain / resident 再起動) 場合はそれも待つ。close 済みなら
2535
+ * `_drainPending()` / 再起動は `_closed` ガードで始まらないため、連鎖は有限で終わる。 */
2536
+ async waitForRunLoopExit() {
2537
+ while (this._activeRunLoops.size > 0) {
2538
+ await Promise.all([...this._activeRunLoops])
2539
+ }
2540
+ }
2541
+
2476
2542
  /** セッション終了。新規ターンを止め、実行中なら中断し、watcher も停止する。 */
2477
2543
  close() {
2478
2544
  this._closed = true
@@ -2554,6 +2620,12 @@ export class ClaudeStreamBridge extends EventEmitter {
2554
2620
  /** session_id → 生存セッション。再接続 (再アタッチ) で同一セッションを引く索引。
2555
2621
  * browser が切れてもここに残し、走行中ターンの継続 + 再接続でのライブ追従を可能にする。 */
2556
2622
  this._liveBySession = new Map()
2623
+ /** key(cwd+session_id) → 削除停止を開始済みで run-loop 終了待ちのセッション集合。
2624
+ * 索引から外した後も再試行が同じ CLI 群を待てるように保持し、各 CLI の終了時に外す。
2625
+ * 同じ key で新しい CLI が起動→再削除→timeout した場合も全件残す (1 件だけだと 2 件目が追跡漏れ)。 */
2626
+ this._deletionTombstones = new Map()
2627
+ /** テストでは短縮可能。実運用では CLI reap を最大 5 秒待つ。 */
2628
+ this._sessionDeletionWaitMs = 5_000
2557
2629
 
2558
2630
  /** MCP control 専用 query (agent-global)。遅延起動・アイドル破棄。session 非依存。 */
2559
2631
  this._mcpControlQuery = null
@@ -2660,6 +2732,68 @@ export class ClaudeStreamBridge extends EventEmitter {
2660
2732
  return stopped
2661
2733
  }
2662
2734
 
2735
+ /** cwd と session_id が完全一致する live セッションだけを停止し、CLI 終了まで待つ。
2736
+ * 戻り値は今回新たに停止した数 (tombstone の待ち直しは数えない)。timeout は throw。 */
2737
+ async stopSessionForDeletion({ cwd, session_id }) {
2738
+ const key = this._liveKeyFor(cwd, session_id)
2739
+ const newVictims = new Set()
2740
+ for (const s of [...this._liveBySession.values(), ...this.sessions.values()]) {
2741
+ if (s.cwd === cwd && s.sessionId === session_id && !s._closed) newVictims.add(s)
2742
+ }
2743
+ const waitTargets = new Set(newVictims)
2744
+ for (const s of this._deletionTombstones.get(key) ?? []) waitTargets.add(s)
2745
+
2746
+ for (const s of newVictims) {
2747
+ this._dropSessionMappings(s)
2748
+ s.closeForDeletion()
2749
+ this._rememberDeletionTombstone(key, s)
2750
+ }
2751
+
2752
+ let timer
2753
+ try {
2754
+ // 全対象を 1 本の Promise にまとめて待つ (逐次だと先頭の timeout で後続を確認しないまま抜ける)。
2755
+ await Promise.race([
2756
+ Promise.all([...waitTargets].map((s) => s.waitForRunLoopExit())),
2757
+ new Promise((_, reject) => {
2758
+ timer = setTimeout(() => reject(new Error("session deletion stop timeout")), this._sessionDeletionWaitMs)
2759
+ timer.unref?.()
2760
+ }),
2761
+ ])
2762
+ } finally {
2763
+ if (timer) clearTimeout(timer)
2764
+ // timeout で throw する経路でも exit は必ず出す: session は既に close 済みで索引からも
2765
+ // 外れているため、ここで送らないと browser は「生きている stream」を抱えたままになる
2766
+ // (削除自体は main が `delete failed` で止める。exit と失敗応答は独立)。
2767
+ // tombstone からの待ち直しは初回に exit 済みなので出さない (1 セッション 1 回)。
2768
+ for (const s of newVictims) {
2769
+ this.emit("exit", {
2770
+ stream_id: s.stream_id,
2771
+ code: 0,
2772
+ reason: "session-deleted",
2773
+ session_id: s.sessionId,
2774
+ })
2775
+ }
2776
+ }
2777
+ return newVictims.size
2778
+ }
2779
+
2780
+ /** 削除停止したセッションを key ごとの集合に残し、その run-loop が終わったときだけ外す。 */
2781
+ _rememberDeletionTombstone(key, session) {
2782
+ if (!key) return
2783
+ let set = this._deletionTombstones.get(key)
2784
+ if (!set) {
2785
+ set = new Set()
2786
+ this._deletionTombstones.set(key, set)
2787
+ }
2788
+ set.add(session)
2789
+ session.waitForRunLoopExit().then(() => {
2790
+ const cur = this._deletionTombstones.get(key)
2791
+ if (!cur) return
2792
+ cur.delete(session)
2793
+ if (cur.size === 0) this._deletionTombstones.delete(key)
2794
+ })
2795
+ }
2796
+
2663
2797
  /**
2664
2798
  * 新しい Claude セッションを開始する。
2665
2799
  *
@@ -973,6 +973,9 @@ export class CodexStreamBridge extends EventEmitter {
973
973
  queueStateSeq: 0,
974
974
  /** ack 喪失からの自己回復用、受理済み browser input_id の bounded ring。 */
975
975
  acceptedInputIds: [],
976
+ /** 取消成功 id は accepted(二重実行ガード)から外さず、browser が「accepted +キュー不在」
977
+ * のバブル昇格から除外するための別軸として保持する。 */
978
+ cancelledInputIds: [],
976
979
  /** @type {{resolve: Function, timer: any} | null} round3 major#2: turn/start 後に turn/started
977
980
  * (activeTurnId 確定) を待つ barrier の待機者。timeout 付きでハングを防ぐ。 */
978
981
  _turnActiveWaiter: null,
@@ -2062,6 +2065,9 @@ export class CodexStreamBridge extends EventEmitter {
2062
2065
 
2063
2066
  _recordAcceptedInput(session, inputId) {
2064
2067
  if (!session || typeof inputId !== "string" || !inputId) return
2068
+ const cancelledIds = session.cancelledInputIds || (session.cancelledInputIds = [])
2069
+ const cancelledAt = cancelledIds.indexOf(inputId)
2070
+ if (cancelledAt !== -1) cancelledIds.splice(cancelledAt, 1)
2065
2071
  const ids = session.acceptedInputIds || (session.acceptedInputIds = [])
2066
2072
  const at = ids.indexOf(inputId)
2067
2073
  if (at !== -1) ids.splice(at, 1)
@@ -2071,6 +2077,17 @@ export class CodexStreamBridge extends EventEmitter {
2071
2077
  }
2072
2078
  }
2073
2079
 
2080
+ _recordCancelledInput(session, inputId) {
2081
+ if (!session || typeof inputId !== "string" || !inputId) return
2082
+ const ids = session.cancelledInputIds || (session.cancelledInputIds = [])
2083
+ const at = ids.indexOf(inputId)
2084
+ if (at !== -1) ids.splice(at, 1)
2085
+ ids.push(inputId)
2086
+ if (ids.length > ACCEPTED_INPUT_ID_RING_MAX) {
2087
+ ids.splice(0, ids.length - ACCEPTED_INPUT_ID_RING_MAX)
2088
+ }
2089
+ }
2090
+
2074
2091
  /**
2075
2092
  * 重複ガードの台帳から id を取り消す (2026-08-05)。
2076
2093
  *
@@ -2103,6 +2120,7 @@ export class CodexStreamBridge extends EventEmitter {
2103
2120
  seq: session.queueStateSeq || 0,
2104
2121
  generation: session.seq,
2105
2122
  accepted: [...(session.acceptedInputIds || [])],
2123
+ cancelled: [...(session.cancelledInputIds || [])],
2106
2124
  }
2107
2125
  }
2108
2126
 
@@ -2121,6 +2139,7 @@ export class CodexStreamBridge extends EventEmitter {
2121
2139
  return false
2122
2140
  }
2123
2141
  const [item] = session.inputQueue.splice(index, 1)
2142
+ this._recordCancelledInput(session, item.inputId || item.deliveryId)
2124
2143
  if (!item.acceptedAsQueued) {
2125
2144
  const err = Object.assign(new Error("codex queued input cancelled"), {
2126
2145
  reason: "cancelled",
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 の