@cocorograph/hub-agent 0.8.20 → 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.20",
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,8 +103,6 @@ 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"
@@ -1753,28 +1751,6 @@ async function readAllSessionEvents() {
1753
1751
  // 共有し、同時要求は single-flight に畳む。create/kill/worktree 変更時は明示 clear する。
1754
1752
  const tmuxSessionsSnapshotCache = new AsyncTtlCache({ ttlMs: 5_000 })
1755
1753
 
1756
- /**
1757
- * 単一 session の最新 session-event をファイル正本から読む。無ければ null。
1758
- * claude.tui.bind 時の `stop` 再配信 (取りこぼし回復) に使う。
1759
- */
1760
- async function readSessionEventFile(sessionName) {
1761
- if (!sessionName || /[/\\]/.test(sessionName)) return null
1762
- try {
1763
- const text = await readFile(
1764
- path.join(SESSION_EVENTS_DIR, `${sessionName}.json`),
1765
- "utf-8",
1766
- )
1767
- const data = JSON.parse(text)
1768
- if (!data || typeof data.event !== "string") return null
1769
- return {
1770
- event: data.event,
1771
- at: typeof data.at === "number" ? data.at : Date.now(),
1772
- }
1773
- } catch {
1774
- return null
1775
- }
1776
- }
1777
-
1778
1754
  /**
1779
1755
  * `/tmp/cockpit_session_events/<tmux_session>.json` を fs.watch して
1780
1756
  * UserPromptSubmit / Stop の event を `session.event` 型で Hub に push する。
@@ -4039,90 +4015,6 @@ async function dispatch(msg, ctx) {
4039
4015
  if (!ctx.claudeBridge) return
4040
4016
  ctx.claudeBridge.interrupt({ stream_id: msg.stream_id })
4041
4017
  return
4042
- case "terminal.mirror.start": {
4043
- // Cockpit Codex 両対応 Stream C (2026-07-03): チャット(SDK/app-server)の会話を
4044
- // 変えずに、ターミナルのペイン (普通のシェルのまま) へ読み取り専用の実況を
4045
- // 追加する。詳細は tmux.mjs の startTerminalMirror / session-mirror.mjs 参照。
4046
- //
4047
- // session_id はブラウザに要求しない。cwd から hub-agent 側でライブなチャット
4048
- // session を解決する (claudeBridge.liveSessionIdForCwd / codexBridge.sessions を
4049
- // cwd で検索)。これにより frontend は「今この cwd で実際に動いているチャット」を
4050
- // 正確に追従でき、古い/取り違えた session_id を渡すリスクが無い。
4051
- const request_id = msg.request_id
4052
- const sessionName = typeof msg.session_name === "string" ? msg.session_name : ""
4053
- const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
4054
- const cliKind = msg.cli_kind === "codex" ? "codex" : "claude"
4055
- const reply = (payload) =>
4056
- ctx.client.send({
4057
- type: "terminal.mirror.start.result",
4058
- request_id,
4059
- session_name: sessionName,
4060
- ...payload,
4061
- })
4062
- if (!sessionName || !cwd) {
4063
- reply({ ok: false, error: "session_name / cwd は必須です" })
4064
- return
4065
- }
4066
- let sessionId = null
4067
- if (cliKind === "codex") {
4068
- for (const s of ctx.codexBridge?.sessions?.values() ?? []) {
4069
- if (s.cwd === cwd && s.threadId) {
4070
- sessionId = s.threadId
4071
- break
4072
- }
4073
- }
4074
- } else {
4075
- sessionId = ctx.claudeBridge?.liveSessionIdForCwd?.(cwd) || null
4076
- }
4077
- if (!sessionId) {
4078
- reply({
4079
- ok: false,
4080
- error: "この cwd で動作中のチャットセッションが見つかりません (先にチャットを開始してください)",
4081
- })
4082
- return
4083
- }
4084
- try {
4085
- const r = await startTerminalMirror(
4086
- sessionName,
4087
- { cwd, sessionId, cliKind },
4088
- { logger },
4089
- )
4090
- reply(r)
4091
- } catch (err) {
4092
- logger?.warn(
4093
- { err: err?.message, sessionName },
4094
- "terminal.mirror.start failed",
4095
- )
4096
- reply({ ok: false, error: err?.message || String(err) })
4097
- }
4098
- return
4099
- }
4100
- case "terminal.mirror.stop": {
4101
- const request_id = msg.request_id
4102
- const sessionName = typeof msg.session_name === "string" ? msg.session_name : ""
4103
- const reply = (payload) =>
4104
- ctx.client.send({
4105
- type: "terminal.mirror.stop.result",
4106
- request_id,
4107
- session_name: sessionName,
4108
- ...payload,
4109
- })
4110
- if (!sessionName) {
4111
- reply({ ok: false, error: "session_name は必須です" })
4112
- return
4113
- }
4114
- try {
4115
- const r = await stopTerminalMirror(sessionName, { logger })
4116
- reply(r)
4117
- } catch (err) {
4118
- logger?.warn(
4119
- { err: err?.message, sessionName },
4120
- "terminal.mirror.stop failed",
4121
- )
4122
- reply({ ok: false, error: err?.message || String(err) })
4123
- }
4124
- return
4125
- }
4126
4018
  case "claude.mcp.status":
4127
4019
  case "claude.mcp.reconnect":
4128
4020
  case "claude.mcp.authenticate":
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
- }