@cocorograph/hub-agent 0.8.60 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.8.60",
3
+ "version": "0.8.63",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -15,7 +15,9 @@
15
15
  * - 上限 MAX_HISTORY_LINES (デフォルト 500) で末尾から切る
16
16
  * - ファイル不在 (新規セッション) なら空配列を返す (エラーにしない)
17
17
  */
18
- import { open, readFile, readdir, stat } from "node:fs/promises"
18
+ import { randomUUID } from "node:crypto"
19
+ import { constants as fsConstants } from "node:fs"
20
+ import { open, readFile, readdir, rm, stat, unlink } from "node:fs/promises"
19
21
  import os from "node:os"
20
22
  import path from "node:path"
21
23
 
@@ -576,6 +578,116 @@ export function jsonlPath({ cwd, session_id, projectsRoot }) {
576
578
  return path.join(root, encodeCwdToDirName(cwd), `${session_id}.jsonl`)
577
579
  }
578
580
 
581
+ /**
582
+ * Claude CLI の `/rename` と同じ custom-title レコードを追記する。
583
+ * ターミナルでの改名と整合させ、表示名の抽出経路を 1 本に保つため同じ形式を使う。
584
+ *
585
+ * @param {{cwd: string, session_id: string, title: unknown, projectsRoot?: string, logger?: import('pino').Logger, openImpl?: typeof open}} args
586
+ * `openImpl` はテスト専用(stat と write の間に別 writer を割り込ませる競合再現に使う)。本番では渡さない。
587
+ * @returns {Promise<{ok: true, custom_title: string}|{ok: false, error: string}>}
588
+ */
589
+ export async function renameSession({ cwd, session_id, title, projectsRoot, logger, openImpl = open }) {
590
+ if (typeof cwd !== "string" || cwd.length === 0) return { ok: false, error: "cwd required" }
591
+ 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)) {
592
+ return { ok: false, error: "invalid session_id" }
593
+ }
594
+ if (typeof title !== "string") return { ok: false, error: "title must be a string" }
595
+
596
+ const customTitle = title.trim().replace(/\s+/g, " ")
597
+ if (Array.from(customTitle).length > 200) return { ok: false, error: "title too long" }
598
+
599
+ const filePath = jsonlPath({ cwd, session_id, projectsRoot })
600
+ let handle
601
+ try {
602
+ // ⚠️ O_CREAT を含めない。`stat` → `appendFile(path)` の 2 段にすると、その間に履歴掃除や
603
+ // 別プロセスが jsonl を消したとき appendFile が「custom-title だけの jsonl」を新規作成して
604
+ // 削除済みセッションを復活させる(cross-review 1 巡目)。存在確認・末尾読取・追記を同じ
605
+ // handle で行い、作成経路をそもそも持たない。
606
+ // ⚠️ O_APPEND を付ける。"r+" の明示 offset 書込みは「stat 時点の末尾」への上書きなので、
607
+ // stat と write の間に Claude CLI が会話レコードを追記するとそれを潰す(cross-review 2 巡目)。
608
+ // O_APPEND なら kernel が write 時点の末尾へ原子的に置く。
609
+ handle = await openImpl(filePath, fsConstants.O_RDWR | fsConstants.O_APPEND)
610
+ const fileStat = await handle.stat()
611
+ if (!fileStat.isFile()) return { ok: false, error: "session not found" }
612
+
613
+ let prefix = ""
614
+ if (fileStat.size > 0) {
615
+ const tail = Buffer.allocUnsafe(1)
616
+ // 読取は O_APPEND の影響を受けないので明示 offset で末尾 1 バイトを見る。
617
+ const { bytesRead } = await handle.read(tail, 0, 1, fileStat.size - 1)
618
+ if (bytesRead === 1 && tail[0] !== 0x0a) prefix = "\n"
619
+ }
620
+ const record = {
621
+ type: "custom-title",
622
+ customTitle,
623
+ sessionId: session_id,
624
+ uuid: randomUUID(),
625
+ timestamp: new Date().toISOString(),
626
+ }
627
+ // 1 レコードを単一 write に保つ(CLI 側の追記と行が交錯しても改行境界は壊れない)。
628
+ // 末尾改行の確認後に別 writer が追記した場合は空行が 1 つ増えうるが、reader は空行を無視する。
629
+ const line = Buffer.from(`${prefix}${JSON.stringify(record)}\n`)
630
+ // position は null(O_APPEND により write 時点の末尾)。short write は再送しない(重複・
631
+ // 破損の方が害が大きい)。
632
+ const { bytesWritten } = await handle.write(line, 0, line.length, null)
633
+ if (bytesWritten !== line.length) {
634
+ logger?.warn({ bytesWritten, expected: line.length, filePath }, "claude session rename short write")
635
+ return { ok: false, error: "rename failed" }
636
+ }
637
+ return { ok: true, custom_title: customTitle }
638
+ } catch (err) {
639
+ // クライアントへ返す文言は固定語に限定する。Node の生メッセージ(絶対パス・ユーザー名を
640
+ // 含みうる)は logger だけに残す。
641
+ const notFound = err?.code === "ENOENT" || err?.code === "EISDIR"
642
+ logger?.warn({ err: err?.message || String(err), code: err?.code, filePath }, "claude session rename failed")
643
+ return { ok: false, error: notFound ? "session not found" : "rename failed" }
644
+ } finally {
645
+ await handle?.close().catch(() => {})
646
+ }
647
+ }
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
+
579
691
  /**
580
692
  * jsonl 1 ファイルを読んで UI 表示用イベント配列を返す。
581
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
  *
@@ -159,6 +159,10 @@ export class DispatchScheduler {
159
159
  const mutationMode = globalMutationMode(type)
160
160
  if (mutationMode) return `${mutationMode}:global`
161
161
 
162
+ // usage build は最大 25 秒かかり、同じ control lane の tmux.list_sessions が
163
+ // frontend の 10 秒打ち切りで捨てられていたため分離する (2026-09-04 本番計測)。
164
+ if (type === "usage.request") return "usage"
165
+
162
166
  const mode = chatMode(type)
163
167
  if (!mode) return "control"
164
168
 
package/src/main.mjs CHANGED
@@ -59,8 +59,10 @@ 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,
65
+ renameSession,
64
66
  } from "./claude-history.mjs"
65
67
  import { listAgents } from "./agents.mjs"
66
68
  import { listSkills } from "./skills.mjs"
@@ -3787,9 +3789,11 @@ export async function buildUsagePayload(ctx) {
3787
3789
  const accountUsageStartedAt = Date.now()
3788
3790
  try {
3789
3791
  // ⚠️ getAccountUsage は SDK のサブプロセスを spawn する。**時間制限が無いと
3790
- // 返ってこないときに control レーン全体が止まる** (usage.request は 60 秒周期で
3791
- // control レーンに乗り、そこにセッション停止も並ぶ)。同種の head-of-line
3792
- // blocking で cold p95 40〜54 秒を計測した記録が isReadOnlyRpc の注記にある。
3792
+ // 返ってこないときに usage レーンが止まり、後続の usage.request (60 秒周期) が
3793
+ // 無期限に詰まる**。2026-09-04 までは control レーン (tmux.list_sessions や
3794
+ // セッション停止が並ぶ) に乗っていて head-of-line blocking を起こしていたため
3795
+ // dispatch-scheduler の _laneFor で専用 "usage" レーンへ分離した。同種の詰まりで
3796
+ // cold p95 40〜54 秒を計測した記録が isReadOnlyRpc の注記にある。
3793
3797
  // 時間切れは握りつぶさず warn で残す (これ自体が「詰まりの実測値」になる)。
3794
3798
  const acct = await withDeadline(
3795
3799
  ctx.claudeBridge.getAccountUsage(),
@@ -5008,6 +5012,63 @@ export async function dispatch(msg, ctx) {
5008
5012
  })
5009
5013
  return
5010
5014
  }
5015
+ case "claude.session.rename": {
5016
+ // Cockpit ピッカーからの改名。jsonl へ custom-title を追記する書き込み RPC なので
5017
+ // isReadOnlyRpc には載せない(直列 dispatchChain を通す)。
5018
+ const stream_id = msg.stream_id
5019
+ const cwd = msg.cwd || ""
5020
+ const session_id = typeof msg.session_id === "string" ? msg.session_id : ""
5021
+ // request_id は要求ごとに一意(ブラウザが採番)。同一 session への再試行と遅延応答を
5022
+ // ブラウザ側が相関できるよう、そのまま echo する(欠けていれば undefined のまま)。
5023
+ const request_id = typeof msg.request_id === "string" ? msg.request_id : undefined
5024
+ const { projectsRoot, profileId } = await resolveProjectsRoot(msg.profile_id)
5025
+ const result = await renameSession({ cwd, session_id, title: msg.title, projectsRoot, logger: ctx.logger })
5026
+ ctx.client.send({
5027
+ type: "claude.session.rename.response",
5028
+ request_id, stream_id, cwd, session_id,
5029
+ profile_id: profileId,
5030
+ ok: result.ok === true,
5031
+ custom_title: result.ok ? result.custom_title : undefined,
5032
+ error: result.ok ? undefined : result.error,
5033
+ })
5034
+ return
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
+ }
5011
5072
  case "profile.list": {
5012
5073
  // Claude アカウント・プロファイル一覧と active を返す (Cockpit のアカウント切替UI用)。
5013
5074
  // 各プロファイルの「最後にログインしていたアカウント」(<configDir>/.claude.json の