@cocorograph/hub-agent 0.8.20 → 0.8.22

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/src/tmux.mjs CHANGED
@@ -19,10 +19,10 @@ 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"
25
+ import { createSessionBudget } from "./hub-request-deadline.mjs"
26
26
  import { detectSessionState, getSessionCwd } from "./state.mjs"
27
27
  import { shouldLaunchPaneCli } from "./pane-cli-flag.mjs"
28
28
  import { baseSessionName, sessionNameFor } from "./session-names.mjs"
@@ -837,45 +837,65 @@ export async function createSession(name, cwd, opts = {}) {
837
837
  // .bak.conflict に退避)。Hub が一度も保存されていないプロジェクトでは
838
838
  // 何もしない(テナント標準 CLAUDE.md の運用を尊重する)。
839
839
  // どちらも失敗しても session 起動自体は止めない (warn のみ)。
840
+ // ⚠️ 2 つの呼び出しで **1 本の budget を共有**する。1 往復ごとの期限だけでは
841
+ // 往復の本数ぶん足し算されてセッション起動が長時間止まりうるため、Hub 処理に
842
+ // 割り当てた wall-clock 予算(予算の生成時からの経過時間で、通信の待ち時間の
843
+ // 累積ではない)で全体を打ち切る。予算切れなら「何も書かずに次回へ持ち越す」。
840
844
  if (isUnderHubProjectsBase(resolvedCwd)) {
841
845
  const instructionDir = projectInstructionDir(resolvedCwd)
842
846
  const dirName = path.basename(instructionDir)
843
847
  const configFilename = opts.cliKind === "codex" ? "AGENTS.md" : "CLAUDE.md"
848
+ const budget = createSessionBudget()
849
+ // ⚠️ `ensureClaudeMd` の戻り値を捨てない。`source === 'existing'` は
850
+ // 「このセッションより前からローカルにファイルがあった」= **初回登録**
851
+ // (Hub に無くローカルにあるものを 1 度だけ Hub へ取り込む)の唯一の根拠で、
852
+ // ディスクに残さずここから同期へ手渡す。例外時は **undefined のまま渡さない**
853
+ // (=登録しない。何が起きたか分からない回に Hub を触らせない安全側)。
854
+ let localFilePreexisted
844
855
  try {
845
- await ensureClaudeMd({
846
- targetDir: instructionDir,
847
- dirName,
848
- hubUrl: opts.hubUrl,
849
- accessToken: opts.hubAccessToken,
850
- logger: opts.logger,
851
- fetchImpl: opts.fetchImpl,
852
- configFilename,
853
- })
854
- } catch (err) {
855
- opts.logger?.warn?.(
856
- { session: name, cwd: resolvedCwd, err: err?.message || String(err) },
857
- "ensureClaudeMd failed (workspace dir is usable, but no CLAUDE.md)",
858
- )
859
- }
860
- try {
861
- const syncResult = await syncClaudeMdWithHub({
862
- targetDir: instructionDir,
863
- dirName,
864
- hubUrl: opts.hubUrl,
865
- accessToken: opts.hubAccessToken,
866
- logger: opts.logger,
867
- fetchImpl: opts.fetchImpl,
868
- configFilename,
869
- })
870
- opts.logger?.debug?.(
871
- { session: name, dirName, sync: syncResult },
872
- "syncClaudeMdWithHub completed",
873
- )
874
- } catch (err) {
875
- opts.logger?.warn?.(
876
- { session: name, cwd: resolvedCwd, err: err?.message || String(err) },
877
- "syncClaudeMdWithHub failed (session starts anyway)",
878
- )
856
+ try {
857
+ const ensure = await ensureClaudeMd({
858
+ targetDir: instructionDir,
859
+ dirName,
860
+ hubUrl: opts.hubUrl,
861
+ accessToken: opts.hubAccessToken,
862
+ logger: opts.logger,
863
+ fetchImpl: opts.fetchImpl,
864
+ configFilename,
865
+ budget,
866
+ })
867
+ localFilePreexisted = ensure?.source === "existing"
868
+ } catch (err) {
869
+ opts.logger?.warn?.(
870
+ { session: name, cwd: resolvedCwd, err: err?.message || String(err) },
871
+ "ensureClaudeMd failed (workspace dir is usable, but no CLAUDE.md)",
872
+ )
873
+ }
874
+ try {
875
+ const syncResult = await syncClaudeMdWithHub({
876
+ targetDir: instructionDir,
877
+ dirName,
878
+ hubUrl: opts.hubUrl,
879
+ accessToken: opts.hubAccessToken,
880
+ logger: opts.logger,
881
+ fetchImpl: opts.fetchImpl,
882
+ configFilename,
883
+ budget,
884
+ // undefined のときはキーごと渡さない(呼び出し側の意図を明示する)。
885
+ ...(localFilePreexisted === undefined ? {} : { localFilePreexisted }),
886
+ })
887
+ opts.logger?.debug?.(
888
+ { session: name, dirName, sync: syncResult },
889
+ "syncClaudeMdWithHub completed",
890
+ )
891
+ } catch (err) {
892
+ opts.logger?.warn?.(
893
+ { session: name, cwd: resolvedCwd, err: err?.message || String(err) },
894
+ "syncClaudeMdWithHub failed (session starts anyway)",
895
+ )
896
+ }
897
+ } finally {
898
+ budget.clear()
879
899
  }
880
900
  }
881
901
  // 既存チェック
@@ -1233,156 +1253,3 @@ export async function paneClaudeAliveOrUnknown(name, opts = {}) {
1233
1253
  return "unknown"
1234
1254
  }
1235
1255
  }
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
- }