@cocorograph/hub-agent 0.7.32 → 0.7.33

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.
@@ -24,27 +24,41 @@ import { spawn } from "node:child_process"
24
24
  const HUB_AI_RULES_BEGIN = "<!-- BEGIN HUB-AI-RULES -->"
25
25
  const HUB_AI_RULES_END = "<!-- END HUB-AI-RULES -->"
26
26
 
27
- function resolveClaudeDir() {
28
- return process.env.CLAUDE_DIR || path.join(os.homedir(), ".claude")
27
+ // cli_kind ('claude' / 'codex') → 配置先ディレクトリ名 (Cockpit Codex 両対応
28
+ // Stream A、2026-07-02)。Hub backend の hub_ai_views._CLI_DIR_NAME と対の
29
+ // テーブル。既定 'claude' の挙動は完全に従来どおり (後方互換)。
30
+ const _CLI_DIR_NAME = { claude: ".claude", codex: ".codex" }
31
+
32
+ function resolveClaudeDir(cliKind = "claude") {
33
+ // CLAUDE_DIR env override は既存の claude 専用テスト/運用で使われているため
34
+ // cli_kind='claude' のときのみ効かせる (codex は CODEX_DIR で別途上書き可能)。
35
+ if (cliKind === "codex") {
36
+ return process.env.CODEX_DIR || path.join(os.homedir(), _CLI_DIR_NAME.codex)
37
+ }
38
+ return process.env.CLAUDE_DIR || path.join(os.homedir(), _CLI_DIR_NAME.claude)
29
39
  }
30
40
 
31
- function tokenPath() {
32
- return path.join(resolveClaudeDir(), "scripts", ".hub_jwt.json")
41
+ function tokenPath(cliKind = "claude") {
42
+ return path.join(resolveClaudeDir(cliKind), "scripts", ".hub_jwt.json")
33
43
  }
34
44
 
35
- function bundleUrl(hubUrl) {
36
- return `${hubUrl.replace(/\/+$/, "")}/api/admin/hub-ai/setup-bundle/`
45
+ function bundleUrl(hubUrl, cliKind = "claude") {
46
+ const base = `${hubUrl.replace(/\/+$/, "")}/api/admin/hub-ai/setup-bundle/`
47
+ return cliKind === "codex" ? `${base}?cli_kind=codex` : base
37
48
  }
38
49
 
39
50
  /**
40
51
  * Hub から bundle を fetch する。
52
+ * @param {{hubUrl: string, agentId: string, agentToken: string, fetchImpl?: Function,
53
+ * cliKind?: 'claude'|'codex'}} args cliKind 省略時は 'claude'(既存呼び出し元は無改修で
54
+ * 従来どおり ~/.claude 向けバンドルを取得する)。
41
55
  * @returns {Promise<{ version: string, files: any[], post_install_commands?: any[], token?: any }>}
42
56
  */
43
- export async function fetchBundle({ hubUrl, agentId, agentToken, fetchImpl } = {}) {
57
+ export async function fetchBundle({ hubUrl, agentId, agentToken, fetchImpl, cliKind = "claude" } = {}) {
44
58
  if (!hubUrl) throw new Error("fetchBundle requires hubUrl")
45
59
  if (!agentId || !agentToken) throw new Error("fetchBundle requires agentId + agentToken")
46
60
  const f = fetchImpl || globalThis.fetch
47
- const res = await f(bundleUrl(hubUrl), {
61
+ const res = await f(bundleUrl(hubUrl, cliKind), {
48
62
  headers: {
49
63
  Authorization: `Bearer ${agentId}:${agentToken}`,
50
64
  },
@@ -62,40 +76,48 @@ export async function fetchBundle({ hubUrl, agentId, agentToken, fetchImpl } = {
62
76
  return res.json()
63
77
  }
64
78
 
65
- function isClaudeMdEntry(entry) {
79
+ // cli_kind → 指示ファイル名 (Hub backend hub_ai_views._CONFIG_MD_FILENAME と対)。
80
+ const _CONFIG_MD_FILENAME = { claude: "CLAUDE.md", codex: "AGENTS.md" }
81
+
82
+ function isConfigMdEntry(entry, cliKind = "claude") {
83
+ const filename = _CONFIG_MD_FILENAME[cliKind] || _CONFIG_MD_FILENAME.claude
84
+ const dirName = _CLI_DIR_NAME[cliKind] || _CLI_DIR_NAME.claude
66
85
  return (
67
- entry.path === "CLAUDE.md" ||
68
- entry.path === "./CLAUDE.md" ||
69
- entry.path === "~/.claude/CLAUDE.md"
86
+ entry.path === filename ||
87
+ entry.path === `./${filename}` ||
88
+ entry.path === `~/${dirName}/${filename}`
70
89
  )
71
90
  }
72
91
 
73
92
  /**
74
- * backend が返した bundle entry の path 文字列を `~/.claude/` 配下の絶対パスに
75
- * 展開する。
93
+ * backend が返した bundle entry の path 文字列を `~/.claude/` (または cliKind='codex'
94
+ * のとき `~/.codex/`) 配下の絶対パスに展開する。
76
95
  *
77
96
  * 受け付ける入力形式:
78
97
  * - `./scripts/hub_helper.py` — relative (legacy)
79
98
  * - `scripts/hub_helper.py` — relative (legacy bare)
80
- * - `~/.claude/scripts/hub_helper.py` — `~/.claude/` プレフィックス付き (現行)
99
+ * - `~/.claude/scripts/hub_helper.py` — `~/.claude/` プレフィックス付き (現行、
100
+ * cliKind='codex' なら backend が `~/.codex/...` で返す)
81
101
  *
82
- * いずれの形式でも `<HOME>/.claude/scripts/hub_helper.py` に解決する。
102
+ * いずれの形式でも `<HOME>/<cliDir>/scripts/hub_helper.py` に解決する。
83
103
  *
84
104
  * 2026-05-27 までの実装は `~/.claude/` を strip せず `path.join(base, rel)` を
85
105
  * 呼んでいたため、`~` がリテラルディレクトリとして作成され
86
106
  * `<HOME>/.claude/~/.claude/scripts/...` という二重展開された壊れたパスに
87
107
  * ファイルが配置される事故が発生 (新規端末で hub-agent CLI 経由でセットアップ
88
108
  * すると、`readBundleVersion()` が正規パスを読めず bundle_version が null に
89
- * なる症状で顕在化)。
109
+ * なる症状で顕在化)。cli_kind 対応 (Stream A, 2026-07-02) でも同じ罠を踏まない
110
+ * よう、strip 対象を `~/.claude/` と `~/.codex/` の両方にする(cliKind に関わらず
111
+ * どちらの prefix が来ても正規化できる。通常は cliKind と一致するはず)。
90
112
  */
91
- function intoClaudeAbsolute(rel) {
92
- const base = resolveClaudeDir()
93
- // `./` / `~/.claude/` の二系統を strip して、純粋な `~/.claude/` 配下の
113
+ function intoClaudeAbsolute(rel, cliKind = "claude") {
114
+ const base = resolveClaudeDir(cliKind)
115
+ // `./` / `~/.claude/` / `~/.codex/` の3系統を strip して、純粋な cliDir 配下の
94
116
  // サブパスに正規化してから join する。順序に依存しないよう両方を試す。
95
117
  let cleaned = rel.replace(/^\.\//, "")
96
- cleaned = cleaned.replace(/^~\/\.claude\//, "")
97
- // 末端で `~/.claude` (末尾スラッシュ無し) を渡された場合のために最終チェック。
98
- if (cleaned === "~/.claude" || cleaned === "~") {
118
+ cleaned = cleaned.replace(/^~\/\.claude\//, "").replace(/^~\/\.codex\//, "")
119
+ // 末端で `~/.claude` / `~/.codex` (末尾スラッシュ無し) を渡された場合のために最終チェック。
120
+ if (cleaned === "~/.claude" || cleaned === "~/.codex" || cleaned === "~") {
99
121
  cleaned = ""
100
122
  }
101
123
  return path.join(base, cleaned)
@@ -111,25 +133,31 @@ function parseMode(mode) {
111
133
  }
112
134
 
113
135
  /**
114
- * Bundle の files[] を `~/.claude/<path>` に展開する。
136
+ * Bundle の files[] を `~/.claude/<path>`(cliKind='codex' なら `~/.codex/<path>`)に展開する。
137
+ *
138
+ * cliKind は明示指定しなければ `bundle.cli_kind`(backend が `_apply_role_templating`
139
+ * でレンダリングに使った値、fetchBundle のレスポンスに `cli_kind` として同梱される)から
140
+ * 自動判定し、それも無ければ 'claude' にフォールバックする(既存呼び出し元は無改修で
141
+ * 従来どおり動く)。
115
142
  * @returns {Promise<{ written: string[], skipped_same: string[], merged_claude_md: boolean }>}
116
143
  */
117
- export async function applyBundle({ bundle, logger } = {}) {
144
+ export async function applyBundle({ bundle, logger, cliKind } = {}) {
118
145
  if (!bundle || !Array.isArray(bundle.files)) {
119
146
  throw new Error("applyBundle requires bundle.files[]")
120
147
  }
148
+ const kind = cliKind || bundle.cli_kind || "claude"
121
149
 
122
150
  const written = []
123
151
  const skippedSame = []
124
152
  let mergedClaudeMd = false
125
153
 
126
154
  for (const entry of bundle.files) {
127
- const dest = intoClaudeAbsolute(entry.path)
155
+ const dest = intoClaudeAbsolute(entry.path, kind)
128
156
  const mode = parseMode(entry.mode)
129
157
  const content = entry.content ?? ""
130
158
 
131
- if (isClaudeMdEntry(entry)) {
132
- // CLAUDE.md は marker 範囲だけ置換、無ければ末尾追記
159
+ if (isConfigMdEntry(entry, kind)) {
160
+ // CLAUDE.md / AGENTS.md は marker 範囲だけ置換、無ければ末尾追記
133
161
  const result = await mergeClaudeMdMarkerBlock(dest, content, { logger })
134
162
  if (result.changed) {
135
163
  written.push(dest)
@@ -152,9 +180,9 @@ export async function applyBundle({ bundle, logger } = {}) {
152
180
  logger?.info({ dest, mode: mode.toString(8) }, "hub-bundle wrote file")
153
181
  }
154
182
 
155
- // JWT を保存 (~/.claude/scripts/.hub_jwt.json)
183
+ // JWT を保存 (~/.claude/scripts/.hub_jwt.json、または ~/.codex/scripts/.hub_jwt.json)
156
184
  if (bundle.token) {
157
- const tp = tokenPath()
185
+ const tp = tokenPath(kind)
158
186
  await fs.mkdir(path.dirname(tp), { recursive: true })
159
187
  await fs.writeFile(
160
188
  tp,
@@ -277,7 +305,7 @@ export async function applyMcpServers(bundle, { logger } = {}) {
277
305
  if (servers.length === 0) return { added: [], skipped: [] }
278
306
 
279
307
  // `claude` CLI の存在確認 (which / where 相当)。なければ no-op。
280
- const claudeBin = await resolveClaudeBin()
308
+ const claudeBin = await resolveCliBin("claude")
281
309
  if (!claudeBin) {
282
310
  logger?.info("claude CLI が PATH に無いため MCP server 自動登録をスキップ")
283
311
  return { added: [], skipped: servers.map((s) => s.name) }
@@ -316,15 +344,60 @@ export async function applyMcpServers(bundle, { logger } = {}) {
316
344
  }
317
345
 
318
346
  /**
319
- * `claude` バイナリの絶対パスを返す。なければ null。
347
+ * Bundle response の `mcp_servers` を Codex CLI に登録する(Cockpit Codex 両対応
348
+ * Stream A2、2026-07-02)。`applyMcpServers`(Claude 版)と対称の役割。
349
+ *
350
+ * 実機確認済みコマンド(codex-cli 0.142.5): `codex mcp add <name> --url <url>`。
351
+ * Claude の `--transport`/`--scope` に相当するオプションは無い(streamable HTTP
352
+ * 固定・scope 概念自体が無い、`codex mcp add --help` で確認済み)。
353
+ *
354
+ * ⚠️ Hub MCP は Bearer 静的トークンではなく OAuth 2.0 + PKCE(`claude mcp add
355
+ * --transport http` と同方式)。`codex mcp add` は登録のみで、認証は別途
356
+ * `codex mcp login <name>`(対話 OAuth)が必要と見込まれるが、headless agent
357
+ * から起動した codex app-server プロセス内でブラウザ OAuth ポップアップが
358
+ * どう解決されるかは未検証(A5 のライブ検証で確認。`codex login` 後に
359
+ * ユーザー自身が `codex mcp login hub`/`hub_staff` を一度実行する運用に
360
+ * なる可能性が高い)。
361
+ */
362
+ export async function applyCodexMcpServers(bundle, { logger } = {}) {
363
+ const servers = Array.isArray(bundle?.mcp_servers) ? bundle.mcp_servers : []
364
+ if (servers.length === 0) return { added: [], skipped: [] }
365
+
366
+ const codexBin = await resolveCliBin("codex")
367
+ if (!codexBin) {
368
+ logger?.info("codex CLI が PATH に無いため MCP server 自動登録をスキップ")
369
+ return { added: [], skipped: servers.map((s) => s.name) }
370
+ }
371
+
372
+ const added = []
373
+ const skipped = []
374
+ for (const s of servers) {
375
+ if (!s?.name || !s?.url) {
376
+ skipped.push(s?.name || "<unnamed>")
377
+ continue
378
+ }
379
+ const argv = [codexBin, "mcp", "add", s.name, "--url", s.url]
380
+ try {
381
+ await runOnce(argv, { logger })
382
+ added.push(s.name)
383
+ } catch (err) {
384
+ logger?.warn({ name: s.name, err: err.message }, "codex mcp add failed")
385
+ skipped.push(s.name)
386
+ }
387
+ }
388
+ return { added, skipped }
389
+ }
390
+
391
+ /**
392
+ * `claude` / `codex` バイナリの絶対パスを返す。なければ null。
320
393
  * PATH を辿る (`which` / `where`) ことで fnm / nvm / homebrew / volta 等の
321
394
  * バージョンマネージャ配下にあるものも拾う。
322
395
  */
323
- async function resolveClaudeBin() {
396
+ async function resolveCliBin(binName) {
324
397
  const isWindows = process.platform === "win32"
325
398
  const cmd = isWindows ? "where" : "which"
326
399
  return new Promise((resolve) => {
327
- const child = spawn(cmd, ["claude"], { stdio: ["ignore", "pipe", "ignore"] })
400
+ const child = spawn(cmd, [binName], { stdio: ["ignore", "pipe", "ignore"] })
328
401
  let out = ""
329
402
  child.stdout?.on("data", (chunk) => {
330
403
  out += chunk.toString("utf-8")
@@ -340,10 +413,12 @@ async function resolveClaudeBin() {
340
413
 
341
414
  /**
342
415
  * fetch + apply + post_install + MCP server 自動登録をまとめて実行。
416
+ * cliKind='codex' で Codex 向け(`~/.codex/`、`codex mcp add`)に切り替わる
417
+ * (Stream A2、2026-07-02。既定 'claude' は完全に従来どおり)。
343
418
  */
344
- export async function syncBundle({ hubUrl, agentId, agentToken, logger, fetchImpl } = {}) {
345
- const bundle = await fetchBundle({ hubUrl, agentId, agentToken, fetchImpl })
346
- const result = await applyBundle({ bundle, logger })
419
+ export async function syncBundle({ hubUrl, agentId, agentToken, logger, fetchImpl, cliKind = "claude" } = {}) {
420
+ const bundle = await fetchBundle({ hubUrl, agentId, agentToken, fetchImpl, cliKind })
421
+ const result = await applyBundle({ bundle, logger, cliKind })
347
422
  try {
348
423
  await runPostInstall(bundle, { logger })
349
424
  } catch (err) {
@@ -351,7 +426,9 @@ export async function syncBundle({ hubUrl, agentId, agentToken, logger, fetchImp
351
426
  }
352
427
  let mcp = { added: [], skipped: [] }
353
428
  try {
354
- mcp = await applyMcpServers(bundle, { logger })
429
+ mcp = cliKind === "codex"
430
+ ? await applyCodexMcpServers(bundle, { logger })
431
+ : await applyMcpServers(bundle, { logger })
355
432
  } catch (err) {
356
433
  logger?.warn({ err: err.message }, "applyMcpServers failed (continuing)")
357
434
  }
package/src/main.mjs CHANGED
@@ -87,12 +87,16 @@ import {
87
87
  setTmuxGlobalEnv,
88
88
  setTuiEffort,
89
89
  setTuiModel,
90
+ startTerminalMirror,
91
+ stopTerminalMirror,
90
92
  } from "./tmux.mjs"
91
93
  import { TuiPermissionBridge } from "./tui-permission-bridge.mjs"
92
94
  import { TuiViewerRegistry } from "./tui-viewer-registry.mjs"
93
95
  import { JsonlLiveWatchers } from "./jsonl-live-watchers.mjs"
94
96
  import {
95
97
  boundSessionId,
98
+ codexSessionUsages,
99
+ codexUsageForThread,
96
100
  contextWindowSize,
97
101
  getSessionUsages,
98
102
  getUsage,
@@ -358,10 +362,15 @@ export function kickSetupHubAi({
358
362
  spawnImpl,
359
363
  existsImpl,
360
364
  homedir,
365
+ cliKind = "claude",
361
366
  } = {}) {
362
367
  try {
363
368
  const home = homedir || os.homedir()
364
- const setupScript = path.join(home, ".claude", "scripts", "setup_hub_ai.py")
369
+ // Cockpit Codex 両対応 (Stream A2, 2026-07-02): cliKind='codex' なら
370
+ // ~/.codex/scripts/setup_hub_ai.py(AGENTS.md/hooks.json 向けにテンプレート
371
+ // 済みの版)を kick する。既定 'claude' は完全に従来どおり。
372
+ const dirName = cliKind === "codex" ? ".codex" : ".claude"
373
+ const setupScript = path.join(home, dirName, "scripts", "setup_hub_ai.py")
365
374
  const exists = existsImpl || existsSync
366
375
  if (!exists(setupScript)) {
367
376
  logger?.info?.(
@@ -2964,6 +2973,90 @@ async function dispatch(msg, ctx) {
2964
2973
  }
2965
2974
  return
2966
2975
  }
2976
+ case "terminal.mirror.start": {
2977
+ // Cockpit Codex 両対応 Stream C (2026-07-03): チャット(SDK/app-server)の会話を
2978
+ // 変えずに、ターミナルのペイン (普通のシェルのまま) へ読み取り専用の実況を
2979
+ // 追加する。詳細は tmux.mjs の startTerminalMirror / session-mirror.mjs 参照。
2980
+ //
2981
+ // session_id はブラウザに要求しない。cwd から hub-agent 側でライブなチャット
2982
+ // session を解決する (claudeBridge.liveSessionIdForCwd / codexBridge.sessions を
2983
+ // cwd で検索)。これにより frontend は「今この cwd で実際に動いているチャット」を
2984
+ // 正確に追従でき、古い/取り違えた session_id を渡すリスクが無い。
2985
+ const request_id = msg.request_id
2986
+ const sessionName = typeof msg.session_name === "string" ? msg.session_name : ""
2987
+ const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
2988
+ const cliKind = msg.cli_kind === "codex" ? "codex" : "claude"
2989
+ const reply = (payload) =>
2990
+ ctx.client.send({
2991
+ type: "terminal.mirror.start.result",
2992
+ request_id,
2993
+ session_name: sessionName,
2994
+ ...payload,
2995
+ })
2996
+ if (!sessionName || !cwd) {
2997
+ reply({ ok: false, error: "session_name / cwd は必須です" })
2998
+ return
2999
+ }
3000
+ let sessionId = null
3001
+ if (cliKind === "codex") {
3002
+ for (const s of ctx.codexBridge?.sessions?.values() ?? []) {
3003
+ if (s.cwd === cwd && s.threadId) {
3004
+ sessionId = s.threadId
3005
+ break
3006
+ }
3007
+ }
3008
+ } else {
3009
+ sessionId = ctx.claudeBridge?.liveSessionIdForCwd?.(cwd) || null
3010
+ }
3011
+ if (!sessionId) {
3012
+ reply({
3013
+ ok: false,
3014
+ error: "この cwd で動作中のチャットセッションが見つかりません (先にチャットを開始してください)",
3015
+ })
3016
+ return
3017
+ }
3018
+ try {
3019
+ const r = await startTerminalMirror(
3020
+ sessionName,
3021
+ { cwd, sessionId, cliKind },
3022
+ { logger },
3023
+ )
3024
+ reply(r)
3025
+ } catch (err) {
3026
+ logger?.warn(
3027
+ { err: err?.message, sessionName },
3028
+ "terminal.mirror.start failed",
3029
+ )
3030
+ reply({ ok: false, error: err?.message || String(err) })
3031
+ }
3032
+ return
3033
+ }
3034
+ case "terminal.mirror.stop": {
3035
+ const request_id = msg.request_id
3036
+ const sessionName = typeof msg.session_name === "string" ? msg.session_name : ""
3037
+ const reply = (payload) =>
3038
+ ctx.client.send({
3039
+ type: "terminal.mirror.stop.result",
3040
+ request_id,
3041
+ session_name: sessionName,
3042
+ ...payload,
3043
+ })
3044
+ if (!sessionName) {
3045
+ reply({ ok: false, error: "session_name は必須です" })
3046
+ return
3047
+ }
3048
+ try {
3049
+ const r = await stopTerminalMirror(sessionName, { logger })
3050
+ reply(r)
3051
+ } catch (err) {
3052
+ logger?.warn(
3053
+ { err: err?.message, sessionName },
3054
+ "terminal.mirror.stop failed",
3055
+ )
3056
+ reply({ ok: false, error: err?.message || String(err) })
3057
+ }
3058
+ return
3059
+ }
2967
3060
  case "claude.mcp.status":
2968
3061
  case "claude.mcp.reconnect":
2969
3062
  case "claude.mcp.authenticate":
@@ -3611,7 +3704,13 @@ async function dispatch(msg, ctx) {
3611
3704
  }
3612
3705
  case "skills.request": {
3613
3706
  try {
3614
- const skills = await listSkills({ sessionName: msg.session_name })
3707
+ // Cockpit Codex 両対応 (Stream A3, 2026-07-02): browser が cli_kind を
3708
+ // 添えれば Codex 向け skills (~/.codex/skills/) を返す。未指定は
3709
+ // 従来どおり claude 向け。
3710
+ const skills = await listSkills({
3711
+ sessionName: msg.session_name,
3712
+ cliKind: msg.cli_kind === "codex" ? "codex" : "claude",
3713
+ })
3615
3714
  ctx.client.send({
3616
3715
  type: "skills.response",
3617
3716
  request_id: msg.request_id,
@@ -3667,11 +3766,37 @@ async function dispatch(msg, ctx) {
3667
3766
  getUsage(),
3668
3767
  getSessionUsages(),
3669
3768
  ])
3769
+
3770
+ // Codex 版 (Stream B3, 2026-07-03): rollout jsonl の token_count イベントから
3771
+ // 5h/7d + context% を取る。⚠️ Codex の 5h/7d は ChatGPT/OpenAI サブスクリプション
3772
+ // の残量であり、上の `usage` (Claude/Anthropic サブスクリプション) とは別契約の
3773
+ // 別数値のため、`usage` にマージせず独立フィールド `codexUsage`/`codexSessions`
3774
+ // として返す (frontend 側でどちらのアカウントの残量か混同しないため)。
3775
+ let codexUsage = null
3776
+ let codexSessions = []
3777
+ if (ctx.codexBridge && ctx.codexBridge.sessions.size > 0) {
3778
+ try {
3779
+ const liveCodex = Array.from(ctx.codexBridge.sessions.values())
3780
+ .filter((s) => s.threadId && s.cwd)
3781
+ .map((s) => ({ sessionId: s.threadId, cwd: s.cwd, threadId: s.threadId }))
3782
+ if (liveCodex.length > 0) {
3783
+ // 5h/7d はアカウント全体の値 (どの live session から読んでも同じ契約の残量)
3784
+ // なので、代表として最初の 1 件から取る。
3785
+ codexUsage = await codexUsageForThread(liveCodex[0].threadId)
3786
+ codexSessions = await codexSessionUsages(liveCodex)
3787
+ }
3788
+ } catch (err) {
3789
+ ctx.logger?.debug?.({ err: err?.message }, "codex usage snapshot skipped")
3790
+ }
3791
+ }
3792
+
3670
3793
  ctx.client.send({
3671
3794
  type: "usage.response",
3672
3795
  request_id: msg.request_id,
3673
3796
  usage,
3674
3797
  sessions,
3798
+ codexUsage,
3799
+ codexSessions,
3675
3800
  })
3676
3801
  } catch (err) {
3677
3802
  ctx.client.send({
@@ -3679,6 +3804,8 @@ async function dispatch(msg, ctx) {
3679
3804
  request_id: msg.request_id,
3680
3805
  usage: null,
3681
3806
  sessions: [],
3807
+ codexUsage: null,
3808
+ codexSessions: [],
3682
3809
  error: err.message,
3683
3810
  })
3684
3811
  }
@@ -0,0 +1,190 @@
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
+ }