@cocorograph/hub-agent 0.8.19 → 0.8.21

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/bin/hub-agent.mjs CHANGED
@@ -167,52 +167,6 @@ program
167
167
  }
168
168
  })
169
169
 
170
- program
171
- .command("mirror")
172
- .description(
173
- "チャット(SDK/app-server)の会話を、このターミナルに読み取り専用で実況表示する " +
174
- "(Cockpit Codex 両対応 Stream C)。チャットの動作には一切影響しない (書き込まない)。" +
175
- "Ctrl-C / SIGTERM で停止する",
176
- )
177
- .requiredOption("--cwd <path>", "対象セッションの cwd")
178
- .requiredOption("--session <id>", "Claude session_id または Codex threadId")
179
- .option("--cli-kind <kind>", "'claude' または 'codex'", "claude")
180
- .action(async (opts) => {
181
- if (opts.cliKind !== "claude" && opts.cliKind !== "codex") {
182
- console.error(`--cli-kind は 'claude' か 'codex' のみ対応: ${opts.cliKind}`)
183
- process.exit(2)
184
- }
185
- const { runClaudeMirror, runCodexMirror } = await import("../src/session-mirror.mjs")
186
- // watchSessionFile / watchCodexRollout のポーリング timer は常駐 daemon (main.mjs) の
187
- // graceful shutdown を妨げないよう意図的に unref() されている。この単体 CLI プロセスは
188
- // 他に event loop を保持する要因が無いため、unref timer だけでは初回同期処理の直後に
189
- // 即終了してしまう (実機検証で確認)。ref 付きの keep-alive timer で明示的に生かす。
190
- const keepAlive = setInterval(() => {}, 60_000)
191
- let watcher
192
- try {
193
- watcher =
194
- opts.cliKind === "codex"
195
- ? await runCodexMirror({ threadId: opts.session })
196
- : runClaudeMirror({ cwd: opts.cwd, sessionId: opts.session })
197
- } catch (err) {
198
- clearInterval(keepAlive)
199
- console.error(`mirror failed: ${err.message}`)
200
- process.exit(1)
201
- }
202
- const stop = () => {
203
- clearInterval(keepAlive)
204
- try {
205
- watcher?.stop?.()
206
- } catch {
207
- /* ignore */
208
- }
209
- console.log("[cockpit mirror] 停止しました")
210
- process.exit(0)
211
- }
212
- process.on("SIGINT", stop)
213
- process.on("SIGTERM", stop)
214
- })
215
-
216
170
  program
217
171
  .command("start")
218
172
  .description("daemon を起動して Hub と WSS 接続する")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.8.19",
3
+ "version": "0.8.21",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -1953,8 +1953,12 @@ export class CodexStreamBridge extends EventEmitter {
1953
1953
 
1954
1954
  /**
1955
1955
  * 指定 cwd に一致するライブ (attach 済み) codex セッションの threadId を返す。
1956
- * Claude 版 `liveSessionIdForCwd` の対 (main.mjs の state loop / terminal mirror から
1957
- * cwd → threadId 解決に使う)。
1956
+ * Claude 版 `liveSessionIdForCwd` の対。
1957
+ *
1958
+ * ⚠️ 2026-08-02 時点で **本番経路からの呼び出し元は無い** (テストのみ)。旧利用元だった
1959
+ * terminal mirror は撤去済み。「main.mjs の state loop から使う」という記述も以前から
1960
+ * 実態と食い違っていた (startStateLoop に codexBridge は渡るが、usage.mjs 側で参照して
1961
+ * いない)。撤去可否は別途調査してから判断すること。
1958
1962
  *
1959
1963
  * ⚠️ T06407 増分2: 旧実装は「先に見つかったもの」を返していたが、これは 5h/7d(アカウント
1960
1964
  * 全体で代表 1 件で十分)前提だった。context% は thread 固有なので liveSessionForCwd の
package/src/main.mjs CHANGED
@@ -103,11 +103,10 @@ import {
103
103
  removeWorktree as removeWorktreeDir,
104
104
  setSessionMouse,
105
105
  setTmuxGlobalEnv,
106
- startTerminalMirror,
107
- stopTerminalMirror,
108
106
  } from "./tmux.mjs"
109
107
  import { SiblingInjectBridge } from "./sibling-inject.mjs"
110
108
  import { isSessionWarm } from "./live-sessions.mjs"
109
+ import { applyPointerContexts, resolvePointerContexts } from "./pointer-context.mjs"
111
110
  import {
112
111
  boundSessionId,
113
112
  codexContextPercentFromTokenUsage,
@@ -1752,28 +1751,6 @@ async function readAllSessionEvents() {
1752
1751
  // 共有し、同時要求は single-flight に畳む。create/kill/worktree 変更時は明示 clear する。
1753
1752
  const tmuxSessionsSnapshotCache = new AsyncTtlCache({ ttlMs: 5_000 })
1754
1753
 
1755
- /**
1756
- * 単一 session の最新 session-event をファイル正本から読む。無ければ null。
1757
- * claude.tui.bind 時の `stop` 再配信 (取りこぼし回復) に使う。
1758
- */
1759
- async function readSessionEventFile(sessionName) {
1760
- if (!sessionName || /[/\\]/.test(sessionName)) return null
1761
- try {
1762
- const text = await readFile(
1763
- path.join(SESSION_EVENTS_DIR, `${sessionName}.json`),
1764
- "utf-8",
1765
- )
1766
- const data = JSON.parse(text)
1767
- if (!data || typeof data.event !== "string") return null
1768
- return {
1769
- event: data.event,
1770
- at: typeof data.at === "number" ? data.at : Date.now(),
1771
- }
1772
- } catch {
1773
- return null
1774
- }
1775
- }
1776
-
1777
1754
  /**
1778
1755
  * `/tmp/cockpit_session_events/<tmux_session>.json` を fs.watch して
1779
1756
  * UserPromptSubmit / Stop の event を `session.event` 型で Hub に push する。
@@ -4038,90 +4015,6 @@ async function dispatch(msg, ctx) {
4038
4015
  if (!ctx.claudeBridge) return
4039
4016
  ctx.claudeBridge.interrupt({ stream_id: msg.stream_id })
4040
4017
  return
4041
- case "terminal.mirror.start": {
4042
- // Cockpit Codex 両対応 Stream C (2026-07-03): チャット(SDK/app-server)の会話を
4043
- // 変えずに、ターミナルのペイン (普通のシェルのまま) へ読み取り専用の実況を
4044
- // 追加する。詳細は tmux.mjs の startTerminalMirror / session-mirror.mjs 参照。
4045
- //
4046
- // session_id はブラウザに要求しない。cwd から hub-agent 側でライブなチャット
4047
- // session を解決する (claudeBridge.liveSessionIdForCwd / codexBridge.sessions を
4048
- // cwd で検索)。これにより frontend は「今この cwd で実際に動いているチャット」を
4049
- // 正確に追従でき、古い/取り違えた session_id を渡すリスクが無い。
4050
- const request_id = msg.request_id
4051
- const sessionName = typeof msg.session_name === "string" ? msg.session_name : ""
4052
- const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
4053
- const cliKind = msg.cli_kind === "codex" ? "codex" : "claude"
4054
- const reply = (payload) =>
4055
- ctx.client.send({
4056
- type: "terminal.mirror.start.result",
4057
- request_id,
4058
- session_name: sessionName,
4059
- ...payload,
4060
- })
4061
- if (!sessionName || !cwd) {
4062
- reply({ ok: false, error: "session_name / cwd は必須です" })
4063
- return
4064
- }
4065
- let sessionId = null
4066
- if (cliKind === "codex") {
4067
- for (const s of ctx.codexBridge?.sessions?.values() ?? []) {
4068
- if (s.cwd === cwd && s.threadId) {
4069
- sessionId = s.threadId
4070
- break
4071
- }
4072
- }
4073
- } else {
4074
- sessionId = ctx.claudeBridge?.liveSessionIdForCwd?.(cwd) || null
4075
- }
4076
- if (!sessionId) {
4077
- reply({
4078
- ok: false,
4079
- error: "この cwd で動作中のチャットセッションが見つかりません (先にチャットを開始してください)",
4080
- })
4081
- return
4082
- }
4083
- try {
4084
- const r = await startTerminalMirror(
4085
- sessionName,
4086
- { cwd, sessionId, cliKind },
4087
- { logger },
4088
- )
4089
- reply(r)
4090
- } catch (err) {
4091
- logger?.warn(
4092
- { err: err?.message, sessionName },
4093
- "terminal.mirror.start failed",
4094
- )
4095
- reply({ ok: false, error: err?.message || String(err) })
4096
- }
4097
- return
4098
- }
4099
- case "terminal.mirror.stop": {
4100
- const request_id = msg.request_id
4101
- const sessionName = typeof msg.session_name === "string" ? msg.session_name : ""
4102
- const reply = (payload) =>
4103
- ctx.client.send({
4104
- type: "terminal.mirror.stop.result",
4105
- request_id,
4106
- session_name: sessionName,
4107
- ...payload,
4108
- })
4109
- if (!sessionName) {
4110
- reply({ ok: false, error: "session_name は必須です" })
4111
- return
4112
- }
4113
- try {
4114
- const r = await stopTerminalMirror(sessionName, { logger })
4115
- reply(r)
4116
- } catch (err) {
4117
- logger?.warn(
4118
- { err: err?.message, sessionName },
4119
- "terminal.mirror.stop failed",
4120
- )
4121
- reply({ ok: false, error: err?.message || String(err) })
4122
- }
4123
- return
4124
- }
4125
4018
  case "claude.mcp.status":
4126
4019
  case "claude.mcp.reconnect":
4127
4020
  case "claude.mcp.authenticate":
@@ -5067,10 +4960,32 @@ async function dispatch(msg, ctx) {
5067
4960
  }
5068
4961
  return { sessions: enriched, worktree_stubs: worktreeStubs }
5069
4962
  })
4963
+ // 行ごとの「次に開く会話」の context% (任意)。宛先 (context_targets) は要求ごとに
4964
+ // 違うため、共有される snapshot キャッシュの**外**で解決し、触る行だけ複製して
4965
+ // 載せる (他クライアントの応答を汚さない)。context_targets が無ければ従来どおりの
4966
+ // 応答 (後方互換)。
4967
+ let sessions = snapshot.sessions
4968
+ if (Array.isArray(msg.context_targets) && msg.context_targets.length > 0) {
4969
+ try {
4970
+ const pointerByName = await resolvePointerContexts(
4971
+ msg.context_targets,
4972
+ sessions,
4973
+ { logger: ctx.logger },
4974
+ )
4975
+ sessions = applyPointerContexts(sessions, pointerByName)
4976
+ } catch (err) {
4977
+ // pointer context はあくまで付加情報。失敗してもセッション一覧は返す。
4978
+ ctx.logger?.warn?.(
4979
+ { err: err?.message },
4980
+ "resolvePointerContexts failed",
4981
+ )
4982
+ }
4983
+ }
5070
4984
  ctx.client.send({
5071
4985
  type: "tmux.sessions",
5072
4986
  request_id: msg.request_id,
5073
4987
  ...snapshot,
4988
+ sessions,
5074
4989
  })
5075
4990
  } catch (err) {
5076
4991
  ctx.client.send({
@@ -0,0 +1,196 @@
1
+ /**
2
+ * 「その行が次に開く会話」の context% (= pointer context) を解決する。
3
+ *
4
+ * ## 背景 (WHY)
5
+ * Cockpit のサイドバーは、コンテキスト使用率を **その行が次に開く会話に帰属させて**表示する
6
+ * ように直した (Hub 側 PR #4534)。しかし供給側 (agent) は「いま動いている会話」の値しか
7
+ * 出しておらず、一度も開いていない行・閉じた行では使用率が出ない。
8
+ *
9
+ * 従来は「その cwd で mtime が一番新しい jsonl」に落として埋めていたが、それが
10
+ * **サブエージェント / headless `claude -p` / 過去セッションの数字が混ざる**原因だったので
11
+ * 撤去した (cross-activity contamination)。よってここでは **frontend が行ごとに指定した
12
+ * 会話 ID (`session_id`) でしか値を引かない**。ID が無い行は推測せず null を返す。
13
+ *
14
+ * ## 不変条件 (ここを崩すと汚染が戻る)
15
+ * 1. `session_id` が渡されていない行では `contextForCwd` を **呼ばない** (mtime 最新への
16
+ * フォールバックが走るため)。
17
+ * 2. `session_id` が渡された行でも、戻り値の `uuid` が要求 ID と**一致するときだけ**採用する。
18
+ * `jsonlContextForCwd` は「その ID の jsonl が存在しない」とき mtime 最新へフォールバック
19
+ * する仕様のため、件数ではなく**実体 (uuid) の同一性**で確認する。
20
+ * 3. 鮮度 (mtime) による足切りをしない。止まっている会話の使用率は古くならない
21
+ * (次に開けばその値から再開する) ので、statusLine 経路の `SESSION_STALE_MS` は適用しない。
22
+ *
23
+ * ## N+1 対策
24
+ * サイドバーは 8 秒周期でポーリングし、複数タブが位相をずらして同時に叩く。行数分の jsonl
25
+ * 読み取りをそのまま毎回走らせないよう、`(cwd, session_id)` をキーにした短期 TTL +
26
+ * single-flight キャッシュを噛ませる (同時要求は 1 本の I/O に畳む)。
27
+ */
28
+
29
+ import { cliKindForSessionName } from "./session-names.mjs"
30
+ import { jsonlContextForCwd } from "./usage.mjs"
31
+
32
+ /** pointer context のキャッシュ TTL (ms)。tmux スナップショットキャッシュと同じ 5 秒。 */
33
+ const POINTER_CONTEXT_TTL_MS = Number(
34
+ process.env.HUB_AGENT_POINTER_CONTEXT_TTL_MS ?? 5_000,
35
+ )
36
+
37
+ /** キャッシュ保持数の上限 (会話 ID は増え続けるため LRU で頭打ちにする)。 */
38
+ const POINTER_CONTEXT_MAX_ENTRIES = 512
39
+
40
+ /**
41
+ * キー付きの短期 TTL + single-flight キャッシュ。
42
+ *
43
+ * `AsyncTtlCache` (単一値版) と役割は同じだが、こちらは `(cwd, session_id)` ごとに独立した
44
+ * エントリを持つ。build 失敗はキャッシュしない (次の要求で再試行できる)。
45
+ */
46
+ export class KeyedAsyncTtlCache {
47
+ constructor({ ttlMs, now = () => Date.now(), maxEntries = POINTER_CONTEXT_MAX_ENTRIES } = {}) {
48
+ this.ttlMs = Math.max(0, Number(ttlMs) || 0)
49
+ this.now = now
50
+ this.maxEntries = Math.max(1, Number(maxEntries) || 1)
51
+ this.entries = new Map() // key → {value, expiresAt, inFlight}
52
+ }
53
+
54
+ async get(key, build) {
55
+ const hit = this.entries.get(key)
56
+ if (hit) {
57
+ if (hit.inFlight) return hit.inFlight // 同時要求は 1 本の I/O に畳む
58
+ if (this.now() < hit.expiresAt) return hit.value
59
+ }
60
+ const pending = Promise.resolve().then(build)
61
+ const entry = { value: undefined, expiresAt: 0, inFlight: pending }
62
+ // delete → set で挿入順 = 最近使った順にし、prune を LRU として使えるようにする。
63
+ this.entries.delete(key)
64
+ this.entries.set(key, entry)
65
+ try {
66
+ const value = await pending
67
+ entry.value = value
68
+ entry.expiresAt = this.now() + this.ttlMs
69
+ entry.inFlight = null
70
+ this.prune()
71
+ return value
72
+ } catch (err) {
73
+ if (this.entries.get(key) === entry) this.entries.delete(key)
74
+ throw err
75
+ }
76
+ }
77
+
78
+ /** 期限切れエントリを落とし、上限を超えていれば古い順に捨てる。 */
79
+ prune() {
80
+ const now = this.now()
81
+ for (const [key, e] of this.entries) {
82
+ if (!e.inFlight && now >= e.expiresAt) this.entries.delete(key)
83
+ }
84
+ while (this.entries.size > this.maxEntries) {
85
+ const oldest = this.entries.keys().next()
86
+ if (oldest.done) break
87
+ this.entries.delete(oldest.value)
88
+ }
89
+ }
90
+
91
+ clear() {
92
+ this.entries.clear()
93
+ }
94
+ }
95
+
96
+ /** 既定のプロセス共有キャッシュ (tmux.list_sessions ハンドラ用)。 */
97
+ const defaultPointerContextCache = new KeyedAsyncTtlCache({ ttlMs: POINTER_CONTEXT_TTL_MS })
98
+
99
+ /** テスト用: 既定キャッシュを空にする。 */
100
+ export function _resetPointerContextCache() {
101
+ defaultPointerContextCache.clear()
102
+ }
103
+
104
+ /** cwd と会話 ID の連結。区切りは両者に現れ得ない制御文字 (エスケープ表記で書く)。 */
105
+ function cacheKey(cwd, sessionId) {
106
+ return `${cwd}\u0000${sessionId}`
107
+ }
108
+
109
+ /**
110
+ * `context_targets` (行ごとの「次に開く会話」) を解決し、`session_name → context% | null` の
111
+ * Map を返す。
112
+ *
113
+ * 値が確定しなかった行にも **null を明示的に入れる** (「見に行った上で不明」を表す)。
114
+ * `context_targets` に含まれない行は Map に載らない = 応答にフィールドを付けない。
115
+ *
116
+ * @param {Array<{session_name?: string, session_id?: string|null}>|undefined} targets
117
+ * @param {Array<{name?: string, cwd?: string|null}>} rows tmux.list_sessions の行 (cwd 解決用)
118
+ * @param {{contextForCwd?: Function, cache?: KeyedAsyncTtlCache, logger?: object}} [opts]
119
+ * @returns {Promise<Map<string, number|null>>}
120
+ */
121
+ export async function resolvePointerContexts(targets, rows, opts = {}) {
122
+ const {
123
+ contextForCwd = jsonlContextForCwd,
124
+ cache = defaultPointerContextCache,
125
+ logger,
126
+ } = opts
127
+ const out = new Map()
128
+ if (!Array.isArray(targets) || targets.length === 0) return out
129
+
130
+ const cwdByName = new Map()
131
+ for (const row of Array.isArray(rows) ? rows : []) {
132
+ if (row && typeof row.name === "string") cwdByName.set(row.name, row.cwd || null)
133
+ }
134
+
135
+ const jobs = []
136
+ for (const target of targets) {
137
+ const name = typeof target?.session_name === "string" ? target.session_name : ""
138
+ if (!name || out.has(name)) continue // 同名の重複指定は先勝ち
139
+ out.set(name, null)
140
+
141
+ const sessionId = typeof target?.session_id === "string" ? target.session_id.trim() : ""
142
+ // 不変条件 1: ID が無ければ読みに行かない (mtime 最新フォールバックを踏まない)。
143
+ if (!sessionId) continue
144
+ const cwd = cwdByName.get(name)
145
+ if (!cwd) continue
146
+ // codex 行は claude の jsonl を持たない。読んでも uuid 不一致で null になるだけなので、
147
+ // 無駄な readdir/stat を避けるためここで落とす (結果は同じ)。
148
+ if (cliKindForSessionName(name) !== "claude") continue
149
+ jobs.push({ name, cwd, sessionId })
150
+ }
151
+
152
+ await Promise.all(
153
+ jobs.map(async ({ name, cwd, sessionId }) => {
154
+ try {
155
+ const pct = await cache.get(cacheKey(cwd, sessionId), async () => {
156
+ const res = await contextForCwd(cwd, sessionId)
157
+ // 不変条件 2: 実体 (uuid) が一致したときだけ採用する。
158
+ if (!res || res.uuid !== sessionId) return null
159
+ return typeof res.percent === "number" ? res.percent : null
160
+ })
161
+ if (typeof pct === "number") out.set(name, pct)
162
+ } catch (err) {
163
+ logger?.warn?.(
164
+ { err: err?.message, session: name },
165
+ "pointer context resolve failed",
166
+ )
167
+ }
168
+ }),
169
+ )
170
+ return out
171
+ }
172
+
173
+ /**
174
+ * 解決済みの pointer context を session 行へ載せる (元配列・元オブジェクトは変更しない)。
175
+ *
176
+ * `tmux.list_sessions` の応答行は TTL スナップショットキャッシュで**複数の要求に共有される**
177
+ * ため、要求ごとに違う値を直接書き込むと他クライアントの応答を汚す。触る行だけ複製する。
178
+ *
179
+ * 既存の `context_pct` (いま動いている会話の値) とは**別フィールド**にする。混ぜると
180
+ * 「どちらの会話の値か」が曖昧になるため。
181
+ *
182
+ * @param {Array<object>} sessions
183
+ * @param {Map<string, number|null>} pointerByName
184
+ * @returns {Array<object>}
185
+ */
186
+ export function applyPointerContexts(sessions, pointerByName) {
187
+ if (!Array.isArray(sessions)) return []
188
+ if (!(pointerByName instanceof Map) || pointerByName.size === 0) return sessions
189
+ return sessions.map((s) =>
190
+ s && pointerByName.has(s.name)
191
+ ? { ...s, pointer_context_pct: pointerByName.get(s.name) }
192
+ : s,
193
+ )
194
+ }
195
+
196
+ export default { KeyedAsyncTtlCache, resolvePointerContexts, applyPointerContexts }
package/src/state.mjs CHANGED
@@ -131,17 +131,6 @@ function stripAnsi(s) {
131
131
  .replace(/\x1b[()][AB012]/g, "")
132
132
  }
133
133
 
134
- /**
135
- * capturePane 結果の末尾フッター領域 (既定 8 行) を返す。claude TUI の作業スピナー /
136
- * 中断フッターはペイン最下部に固定描画されるため、本文中に同じ語が出ても誤検出しない
137
- * よう、作業スピナーの補助判定はこの領域に限定する。
138
- */
139
- function footerRegion(text, lines = 8) {
140
- if (!text) return ""
141
- const rows = text.split("\n")
142
- return rows.length <= lines ? text : rows.slice(-lines).join("\n")
143
- }
144
-
145
134
  /**
146
135
  * 作業スピナー行 (ライブステータス行) の構造パターン。実機採取 (2026-06-15〜):
147
136
  * "· Blanching… (2m 4s · ↓ 7.5k tokens)" (出力中)
@@ -158,9 +147,10 @@ function footerRegion(text, lines = 8) {
158
147
  * - 省略記号 (…/...) **または** 括弧タイマーのどちらか一方で可 (両方必須をやめる)
159
148
  * - タイマーは「分のみ」「秒のみ」「分秒」いずれも可 (秒必須をやめる)
160
149
  * - 行頭インデント制限 (\s{0,4}) を撤廃 (背の高いペイン/折返しで字下げが深い版に備える)
161
- * 緩めても安全な理由: 検出は必ず footerRegion + _belowIsOnlyChrome (下が入力欄チロムのみ) を
162
- * 通すため、本文プローズ・表セル・箇条書きへの誤点灯は下チロム判定が弾く。行頭は英数字・空白・
163
- * 本文記号 (-*>#|) を除く 1 文字に限定し、語は ASCII 英字に限る (日本語プローズは非マッチ)。
150
+ * 緩めても安全な理由: 検出は必ず capture 末尾 12 行への限定 (workingSpinnerLine) と
151
+ * _belowIsOnlyChrome (下が入力欄チロムのみ) を通すため、本文プローズ・表セル・箇条書きへの
152
+ * 誤点灯は下チロム判定が弾く。行頭は英数字・空白・本文記号 (-*>#|) を除く 1 文字に限定し、
153
+ * 語は ASCII 英字に限る (日本語プローズは非マッチ)。
164
154
  * 完了サマリー "✻ Brewed for 2m 52s" は省略記号も**括弧**タイマーも持たない (タイマーが括弧外) ため
165
155
  * 引き続き一致しない。
166
156
  */
@@ -338,7 +328,7 @@ export async function capturePane(sessionName, opts = {}) {
338
328
  sessionName,
339
329
  // 末尾 50 行を取得 (旧 30 行)。copy-mode スクロールや背の高いペインで作業/中断
340
330
  // フッターが取得窓外に出て生成中を取りこぼす偽陰性を減らす。status 判定の作業
341
- // スピナー検出はこのうち末尾フッター領域 (footerRegion) に限定する。
331
+ // スピナー検出はこのうち末尾 12 行 (workingSpinnerLine) に限定する。
342
332
  "-S",
343
333
  "-50",
344
334
  "-E",
package/src/tmux.mjs CHANGED
@@ -19,7 +19,6 @@ import fs from "node:fs/promises"
19
19
  import os from "node:os"
20
20
  import path from "node:path"
21
21
  import { promisify } from "node:util"
22
- import { fileURLToPath } from "node:url"
23
22
 
24
23
  import { ensureClaudeMd } from "./claude-md.mjs"
25
24
  import { syncClaudeMdWithHub } from "./claude-md-sync.mjs"
@@ -1233,156 +1232,3 @@ export async function paneClaudeAliveOrUnknown(name, opts = {}) {
1233
1232
  return "unknown"
1234
1233
  }
1235
1234
  }
1236
-
1237
-
1238
- // ---------------------------------------------------------------------------
1239
- // ターミナルミラー (Cockpit Codex 両対応 Stream C, 2026-07-03)
1240
- //
1241
- // 「チャットの動作は変えず、ターミナルのペインは普通に使えるシェルのまま、そこに
1242
- // チャットの会話を実況表示する読み取り専用の子プロセス」をバックグラウンドで起動/
1243
- // 停止する。session-mirror.mjs / bin/hub-agent.mjs の `mirror` サブコマンド参照。
1244
- //
1245
- // 安全設計:
1246
- // - ペインの前景が plain shell のときだけ起動を許可する(`claude`/`codex` 対話 TUI が
1247
- // 前景にいる状態で send-keys すると、実況コマンドがその対話プロンプトへの入力として
1248
- // 誤解釈されてしまうため)。
1249
- // - 起動コマンドは `& echo $! > <pidfile>` で直後に PID を記録し、停止はその PID を
1250
- // kill するだけ(プロセス名 grep 等の曖昧一致はしない)。
1251
- // ---------------------------------------------------------------------------
1252
-
1253
- /** 現在の hub-agent パッケージの `bin/hub-agent.mjs` 絶対パス。node 実行 (`process.execPath`)
1254
- * と組み合わせることで、対象ペインの shell の PATH に依存せず確実に起動できる。 */
1255
- function hubAgentBinPath() {
1256
- return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "bin", "hub-agent.mjs")
1257
- }
1258
-
1259
- /** POSIX シェルの単一引数として安全な形にクォートする (単純な `'...'` ラップ + `'` エスケープ)。 */
1260
- function shQuote(s) {
1261
- return `'${String(s).replace(/'/g, "'\\''")}'`
1262
- }
1263
-
1264
- /** ミラーの pidfile パス (セッション名ごとに 1 つ)。 */
1265
- function mirrorPidFile(sessionName) {
1266
- return path.join(os.tmpdir(), `hub-agent-mirror-${sanitizeTmuxName(sessionName)}.pid`)
1267
- }
1268
-
1269
- /**
1270
- * ミラー起動用の send-keys コマンド文字列を組み立てる (純粋関数)。
1271
- *
1272
- * @param {{cwd: string, sessionId: string, cliKind: 'claude'|'codex', sessionName: string}} args
1273
- * @returns {string}
1274
- * @throws sessionId が安全形でなければ throw (コマンド注入防止、buildResumeCmd と同方針)
1275
- */
1276
- export function buildMirrorStartCmd({ cwd, sessionId, cliKind, sessionName }) {
1277
- if (!isSafeSessionId(sessionId)) {
1278
- throw new Error(`unsafe session_id for mirror: ${String(sessionId)}`)
1279
- }
1280
- const kind = cliKind === "codex" ? "codex" : "claude"
1281
- const bin = hubAgentBinPath()
1282
- const pidFile = mirrorPidFile(sessionName)
1283
- const args = [
1284
- shQuote(process.execPath),
1285
- shQuote(bin),
1286
- "mirror",
1287
- "--cwd",
1288
- shQuote(cwd),
1289
- "--session",
1290
- shQuote(sessionId),
1291
- "--cli-kind",
1292
- kind,
1293
- ].join(" ")
1294
- return `${args} & echo $! > ${shQuote(pidFile)}; disown`
1295
- }
1296
-
1297
- /** ミラー停止用の send-keys コマンド文字列を組み立てる (純粋関数)。 */
1298
- export function buildMirrorStopCmd({ sessionName }) {
1299
- const pidFile = mirrorPidFile(sessionName)
1300
- return `kill $(cat ${shQuote(pidFile)} 2>/dev/null) 2>/dev/null; rm -f ${shQuote(pidFile)}`
1301
- }
1302
-
1303
- /**
1304
- * 対象ペインの前景が plain shell か判定する (`paneClaudeAliveOrUnknown` の逆方向・2 値版)。
1305
- * 判定不能 (tmux 失敗等) は false を返す = 起動を許可しない安全側。
1306
- *
1307
- * @param {string} name
1308
- * @param {{ tmux?: string }} [opts]
1309
- * @returns {Promise<boolean>}
1310
- */
1311
- export async function isPaneAtShellPrompt(name, opts = {}) {
1312
- if (!name) return false
1313
- try {
1314
- const { stdout } = await execFileP(tmuxBin(opts), [
1315
- "display-message",
1316
- "-p",
1317
- "-t",
1318
- `${name}:`,
1319
- "-F",
1320
- "#{pane_current_command}",
1321
- ])
1322
- const cmd = (stdout || "").trim().toLowerCase()
1323
- const SHELLS = new Set(["fish", "bash", "zsh", "sh", "dash"])
1324
- return SHELLS.has(cmd)
1325
- } catch {
1326
- return false
1327
- }
1328
- }
1329
-
1330
- /**
1331
- * ターミナルミラーを対象ペインで起動する。ペイン前景が plain shell のときのみ許可する
1332
- * (対話 TUI が前景にいる状態での誤爆入力を防ぐ)。
1333
- *
1334
- * @param {string} name tmux セッション名
1335
- * @param {{cwd: string, sessionId: string, cliKind: 'claude'|'codex'}} args
1336
- * @param {{ tmux?: string, logger?: import('pino').Logger }} [opts]
1337
- * @returns {Promise<{ ok: boolean, reason?: string, error?: string }>}
1338
- */
1339
- export async function startTerminalMirror(name, { cwd, sessionId, cliKind }, opts = {}) {
1340
- if (!isSafeSessionName(name)) {
1341
- return { ok: false, error: "unsafe session name" }
1342
- }
1343
- const atShell = await isPaneAtShellPrompt(name, opts)
1344
- if (!atShell) {
1345
- return {
1346
- ok: false,
1347
- reason: "pane-not-at-shell",
1348
- error: "ペインが対話 TUI 実行中のためミラーを起動できません (シェルに戻ってから再試行してください)",
1349
- }
1350
- }
1351
- let cmd
1352
- try {
1353
- cmd = buildMirrorStartCmd({ cwd, sessionId, cliKind, sessionName: name })
1354
- } catch (err) {
1355
- return { ok: false, error: err?.message || String(err) }
1356
- }
1357
- try {
1358
- await execFileP(tmuxBin(opts), ["send-keys", "-t", name, cmd, "Enter"])
1359
- opts.logger?.info({ session: name, cliKind, sessionId }, "terminal mirror started")
1360
- return { ok: true }
1361
- } catch (err) {
1362
- opts.logger?.warn({ session: name, err: err?.message }, "terminal mirror start failed")
1363
- return { ok: false, error: err?.message || String(err) }
1364
- }
1365
- }
1366
-
1367
- /**
1368
- * ターミナルミラーを対象ペインで停止する。pidfile に記録された PID を kill するだけの
1369
- * ベストエフォート (既に停止済み/pidfile 無しでもエラーにしない)。
1370
- *
1371
- * @param {string} name tmux セッション名
1372
- * @param {{ tmux?: string, logger?: import('pino').Logger }} [opts]
1373
- * @returns {Promise<{ ok: boolean, error?: string }>}
1374
- */
1375
- export async function stopTerminalMirror(name, opts = {}) {
1376
- if (!isSafeSessionName(name)) {
1377
- return { ok: false, error: "unsafe session name" }
1378
- }
1379
- const cmd = buildMirrorStopCmd({ sessionName: name })
1380
- try {
1381
- await execFileP(tmuxBin(opts), ["send-keys", "-t", name, cmd, "Enter"])
1382
- opts.logger?.info({ session: name }, "terminal mirror stop requested")
1383
- return { ok: true }
1384
- } catch (err) {
1385
- opts.logger?.warn({ session: name, err: err?.message }, "terminal mirror stop failed")
1386
- return { ok: false, error: err?.message || String(err) }
1387
- }
1388
- }
package/src/usage.mjs CHANGED
@@ -1361,7 +1361,7 @@ async function scanAllDatesForCodexRollout(base, threadId) {
1361
1361
  * 表示できなくなる(2026-07-15 の resume 修正で古いスレッド復帰が正しく動くようになり
1362
1362
  * 露出)。高速パスで見つからなければ全日付フォールバック走査に落とし、結果を threadId
1363
1363
  * でメモ化する。
1364
- * `session-mirror.mjs`(Stream C ターミナルミラー)からも再利用するため export する。
1364
+ * 同モジュール内の usage 集計に加え、`codex-delivery-audit.mjs` からも使うため export する。
1365
1365
  * @returns {Promise<string|null>}
1366
1366
  */
1367
1367
  export async function findCodexRolloutFile(threadId, { lookbackDays = 9, now = Date.now() } = {}) {
@@ -1,148 +0,0 @@
1
- /**
2
- * Codex rollout jsonl のライブ追従 watcher (Cockpit Codex 両対応 Stream C: ターミナル
3
- * ミラー、2026-07-03)。
4
- *
5
- * `claude-history-watch.mjs`(Claude 版 jsonl tail)と対の役割。Codex の
6
- * `~/.codex/sessions/Y/M/D/rollout-<ts>-<threadId>.jsonl` を byte offset で tail し、
7
- * 新規行を 1 行ずつ onEvent に渡す。フィルタは行わない(呼び出し側の formatter が
8
- * 表示対象の `event_msg.payload.type` を選別する)。
9
- *
10
- * 設計はミラーリング用途に絞って `claude-history-watch.mjs` と同じ tail 機構
11
- * (byte offset + fs.watch + アダプティブポーリング + truncate 検知)を複製する。
12
- * Claude 側の汎用化リファクタは既存の chat 表示経路への回帰リスクがあるため見送り、
13
- * 新規ファイルとして独立させた(意図的な重複、コメントで明示)。
14
- */
15
- import { watch as fsWatch } from "node:fs"
16
- import { open, stat } from "node:fs/promises"
17
-
18
- const POLL_FAST_MS = Number(process.env.HUB_AGENT_JSONL_POLL_FAST_MS ?? 400)
19
- const POLL_IDLE_MS = Number(process.env.HUB_AGENT_JSONL_POLL_IDLE_MS ?? 1500)
20
- const FAST_WINDOW_MS = Number(process.env.HUB_AGENT_JSONL_FAST_WINDOW_MS ?? 5000)
21
-
22
- /**
23
- * 1 つの rollout jsonl ファイルを tail する watcher を生成する。
24
- *
25
- * @param {object} args
26
- * @param {string} args.filePath - 監視対象の rollout jsonl 絶対パス
27
- * @param {(rawLineObj: object) => void} args.onEvent - 新規行ごとに呼ばれる (パース済み JSON)
28
- * @param {boolean} [args.fromEnd=true] - 既存内容は飛ばし、監視開始後の追記のみ拾う
29
- * @param {import('pino').Logger} [args.logger]
30
- * @returns {{ stop: () => void }}
31
- */
32
- export function watchCodexRollout({ filePath, onEvent, fromEnd = true, logger }) {
33
- let offset = 0
34
- let reading = false
35
- let stopped = false
36
- let leftover = ""
37
- let fsWatcher = null
38
- let pollTimer = null
39
- let initialized = false
40
- let lastChangeAt = 0
41
-
42
- async function initOffset() {
43
- try {
44
- const st = await stat(filePath)
45
- offset = fromEnd ? st.size : 0
46
- initialized = true
47
- } catch {
48
- offset = 0
49
- initialized = false
50
- }
51
- }
52
-
53
- async function readIncrement() {
54
- if (stopped || reading) return
55
- reading = true
56
- try {
57
- let st
58
- try {
59
- st = await stat(filePath)
60
- } catch {
61
- return // まだ無い (rollout はターン開始直後に作られる)
62
- }
63
- if (!initialized) initialized = true
64
- if (st.size < offset) {
65
- // truncate / rotate された → 先頭から読み直す (通常 rollout は追記のみだが念のため)
66
- offset = 0
67
- leftover = ""
68
- }
69
- if (st.size <= offset) return
70
- let fh
71
- try {
72
- fh = await open(filePath, "r")
73
- const len = st.size - offset
74
- const buf = Buffer.allocUnsafe(len)
75
- await fh.read(buf, 0, len, offset)
76
- offset = st.size
77
- lastChangeAt = Date.now()
78
- const text = leftover + buf.toString("utf-8")
79
- const lines = text.split("\n")
80
- leftover = lines.pop() ?? ""
81
- for (const line of lines) {
82
- if (!line) continue
83
- let d
84
- try {
85
- d = JSON.parse(line)
86
- } catch {
87
- continue // 途中で読んだ不完全な行 (稀) はスキップ
88
- }
89
- try {
90
- onEvent(d)
91
- } catch (err) {
92
- logger?.warn?.({ err: err?.message }, "codex rollout watch onEvent failed")
93
- }
94
- }
95
- } finally {
96
- try {
97
- await fh?.close()
98
- } catch {
99
- /* ignore */
100
- }
101
- }
102
- } catch (err) {
103
- logger?.debug?.({ err: err?.message, filePath }, "codex rollout watch read failed")
104
- } finally {
105
- reading = false
106
- }
107
- }
108
-
109
- function schedulePoll() {
110
- if (stopped) return
111
- const idle = Date.now() - lastChangeAt > FAST_WINDOW_MS
112
- const delay = idle ? POLL_IDLE_MS : POLL_FAST_MS
113
- pollTimer = setTimeout(async () => {
114
- await readIncrement()
115
- schedulePoll()
116
- }, delay)
117
- pollTimer?.unref?.()
118
- }
119
-
120
- async function start() {
121
- await initOffset()
122
- await readIncrement()
123
- try {
124
- fsWatcher = fsWatch(filePath, { persistent: false }, () => {
125
- readIncrement()
126
- })
127
- } catch {
128
- // ファイル未存在等で fs.watch が張れない場合はポーリングのみに頼る
129
- }
130
- schedulePoll()
131
- }
132
-
133
- start()
134
-
135
- return {
136
- stop() {
137
- stopped = true
138
- if (pollTimer) clearTimeout(pollTimer)
139
- try {
140
- fsWatcher?.close()
141
- } catch {
142
- /* ignore */
143
- }
144
- },
145
- }
146
- }
147
-
148
- export default watchCodexRollout
@@ -1,190 +0,0 @@
1
- /**
2
- * ターミナルへの「チャット追従ミラー」(Cockpit Codex 両対応 Stream C, 2026-07-03)。
3
- *
4
- * 背景: 2026-06-06 の `claude.tui.bind`(会話継続バインディング)以降、チャット
5
- * (SDK/app-server) と TUI ペインは同一 session_id を同時ホストしない排他設計になった
6
- * (DESIGN.md C7「二重ホスト防止」)。それ以前は両者が独立 session として並走していたが、
7
- * 「別々の会話が同じ cwd で表示を取り合いパタパタする」実害があり意図的に廃止された。
8
- *
9
- * 本機能は「チャットの動作は一切変えず(今まで通り SDK/app-server が単独で会話を進める)、
10
- * ターミナルのペインはユーザーが普通に使えるシェルのまま、そこにチャットの会話ログを
11
- * 実況表示するだけの読み取り専用プロセス」を **バックグラウンドジョブとして** 追加する。
12
- * ターミナル側は一切書き込まないため C7 のリスクには当たらない(同一ファイルへの
13
- * 二重書込は発生しない。読むだけ)。
14
- *
15
- * 実行方式: `tmux.mjs` の `startTerminalMirror` が対象ペインへ `send-keys` で
16
- * `hub-agent mirror ... & disown` を打ち込み、ペインの現在のシェルの子プロセスとして
17
- * バックグラウンドで起動する(実機検証済み: 子プロセスの stdout はペインに自然に
18
- * 割り込み表示され、ユーザーの入力行は破壊されない — 通常の Unix ターミナルで
19
- * バックグラウンドジョブの出力を眺めながら作業するのと同じ挙動)。
20
- */
21
- import { watchSessionFile } from "./claude-history-watch.mjs"
22
- import { jsonlPath } from "./claude-history.mjs"
23
- import { watchCodexRollout } from "./codex-rollout-watch.mjs"
24
- import { findCodexRolloutFile } from "./usage.mjs"
25
-
26
- // 依存追加を避けるため、chalk 相当の最小 ANSI ヘルパーを自前で持つ (SGR コードのみ)。
27
- const noColor = process.env.NO_COLOR || !process.stdout.isTTY
28
- const ANSI = { cyan: "36", green: "32", yellow: "33", dim: "2", red: "31" }
29
-
30
- function c(color, text) {
31
- if (noColor) return text
32
- return `\x1b[${ANSI[color]}m${text}\x1b[0m`
33
- }
34
-
35
- /** 長い本文をターミナル向けに省略する。 */
36
- function truncate(text, max = 400) {
37
- const s = String(text ?? "")
38
- if (s.length <= max) return s
39
- return `${s.slice(0, max)}… (${s.length - max} 文字省略)`
40
- }
41
-
42
- function indentBlock(text) {
43
- return String(text ?? "")
44
- .split("\n")
45
- .map((l) => ` ${l}`)
46
- .join("\n")
47
- }
48
-
49
- /**
50
- * Claude jsonl の 1 イベント (`normalizeHistoryEvent` 済み) をミラー表示用テキストに
51
- * 変換する。表示すべきものが無ければ null を返す (呼び出し側は print しない)。
52
- *
53
- * @param {object} event
54
- * @returns {string|null}
55
- */
56
- export function formatClaudeEvent(event) {
57
- if (!event || typeof event !== "object") return null
58
- const content = event.message?.content
59
- if (event.type === "user") {
60
- if (!Array.isArray(content)) {
61
- const text = typeof content === "string" ? content : ""
62
- if (!text.trim()) return null
63
- // コマンド echo (/clear /compact /skill 等) はノイズなのでスキップ。
64
- if (/<command-name>/.test(text)) return null
65
- return `${c('cyan', "›")} ${indentBlock(truncate(text)).trimStart()}`
66
- }
67
- const isToolResult = content.some((b) => b?.type === "tool_result")
68
- if (isToolResult) return null // ツール結果の自動 user ラッパーは表示しない (ノイズ)
69
- const text = content
70
- .filter((b) => b?.type === "text")
71
- .map((b) => b.text)
72
- .join("")
73
- if (!text.trim() || /<command-name>/.test(text)) return null
74
- return `${c('cyan', "›")} ${indentBlock(truncate(text)).trimStart()}`
75
- }
76
- if (event.type === "assistant") {
77
- if (!Array.isArray(content)) return null
78
- const lines = []
79
- for (const b of content) {
80
- if (!b) continue
81
- if (b.type === "text" && b.text?.trim()) {
82
- lines.push(`${c('green', "●")} ${indentBlock(truncate(b.text)).trimStart()}`)
83
- } else if (b.type === "tool_use") {
84
- const brief = summarizeToolInput(b.name, b.input)
85
- lines.push(`${c('yellow', "⚙")} ${b.name}${brief ? ` ${brief}` : ""}`)
86
- }
87
- }
88
- return lines.length > 0 ? lines.join("\n") : null
89
- }
90
- return null
91
- }
92
-
93
- /** tool_use の input から簡潔な 1 行要約を作る (Bash はコマンド、それ以外は主要パス等)。 */
94
- function summarizeToolInput(name, input) {
95
- if (!input || typeof input !== "object") return ""
96
- if (name === "Bash" && typeof input.command === "string") {
97
- return c('dim', truncate(input.command, 120))
98
- }
99
- const path = input.file_path || input.path || input.notebook_path
100
- if (typeof path === "string") return c('dim', path)
101
- return ""
102
- }
103
-
104
- /**
105
- * Codex rollout jsonl の 1 行 (`{timestamp, type, payload}`) をミラー表示用テキストに
106
- * 変換する。表示対象は実機確認済みの `event_msg` サブタイプ(user_message / agent_message /
107
- * task_started)に絞る(未確認スキーマの exec 系は誤表示を避けるため今回は対象外)。
108
- *
109
- * @param {object} line
110
- * @returns {string|null}
111
- */
112
- export function formatCodexEvent(line) {
113
- if (!line || typeof line !== "object" || line.type !== "event_msg") return null
114
- const payload = line.payload
115
- if (!payload || typeof payload !== "object") return null
116
- switch (payload.type) {
117
- case "user_message": {
118
- const text = typeof payload.message === "string" ? payload.message : ""
119
- if (!text.trim()) return null
120
- return `${c('cyan', "›")} ${indentBlock(truncate(text)).trimStart()}`
121
- }
122
- case "agent_message": {
123
- const text = typeof payload.message === "string" ? payload.message : ""
124
- if (!text.trim()) return null
125
- return `${c('green', "●")} ${indentBlock(truncate(text)).trimStart()}`
126
- }
127
- case "task_started":
128
- return c('dim', "… thinking")
129
- default:
130
- return null
131
- }
132
- }
133
-
134
- /**
135
- * Claude セッションのミラーを実行し続ける (SIGINT/SIGTERM まで)。
136
- *
137
- * @param {object} args
138
- * @param {string} args.cwd
139
- * @param {string} args.sessionId
140
- * @param {string} [args.projectsRoot]
141
- * @param {(line: string) => void} [args.write] - テスト用に stdout 以外へ差し替え可能
142
- * @param {import('pino').Logger} [args.logger]
143
- * @returns {{ stop: () => void }}
144
- */
145
- export function runClaudeMirror({ cwd, sessionId, projectsRoot, write = defaultWrite, logger }) {
146
- const filePath = jsonlPath({ cwd, session_id: sessionId, projectsRoot })
147
- write(c('dim', `[cockpit mirror] Claude session ${sessionId} を追従中… (${filePath})`))
148
- const watcher = watchSessionFile({
149
- filePath,
150
- fromEnd: true,
151
- logger,
152
- onEvent: (event) => {
153
- const text = formatClaudeEvent(event)
154
- if (text) write(text)
155
- },
156
- })
157
- return watcher
158
- }
159
-
160
- /**
161
- * Codex セッションのミラーを実行し続ける (SIGINT/SIGTERM まで)。
162
- *
163
- * @param {object} args
164
- * @param {string} args.threadId
165
- * @param {(line: string) => void} [args.write]
166
- * @param {import('pino').Logger} [args.logger]
167
- * @returns {Promise<{ stop: () => void }>}
168
- */
169
- export async function runCodexMirror({ threadId, write = defaultWrite, logger }) {
170
- const filePath = await findCodexRolloutFile(threadId)
171
- if (!filePath) {
172
- write(c('red', `[cockpit mirror] Codex thread ${threadId} の rollout が見つかりません`))
173
- return { stop() {} }
174
- }
175
- write(c('dim', `[cockpit mirror] Codex thread ${threadId} を追従中… (${filePath})`))
176
- const watcher = watchCodexRollout({
177
- filePath,
178
- fromEnd: true,
179
- logger,
180
- onEvent: (line) => {
181
- const text = formatCodexEvent(line)
182
- if (text) write(text)
183
- },
184
- })
185
- return watcher
186
- }
187
-
188
- function defaultWrite(line) {
189
- process.stdout.write(`${line}\n`)
190
- }