@cocorograph/hub-agent 0.7.30 → 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.
- package/bin/hub-agent.mjs +96 -22
- package/package.json +1 -1
- package/src/claude-history.mjs +62 -1
- package/src/claude-stream-bridge.mjs +6 -1
- package/src/codex-appserver-client.mjs +14 -1
- package/src/codex-rollout-watch.mjs +148 -0
- package/src/codex-stream-bridge.mjs +23 -2
- package/src/config.mjs +21 -3
- package/src/enroll.mjs +48 -27
- package/src/hub-bundle.mjs +115 -38
- package/src/main.mjs +133 -2
- package/src/session-mirror.mjs +190 -0
- package/src/skills.mjs +33 -6
- package/src/tmux.mjs +195 -5
- package/src/usage.mjs +202 -2
package/src/hub-bundle.mjs
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ===
|
|
68
|
-
entry.path ===
|
|
69
|
-
entry.path ===
|
|
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
|
|
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/`
|
|
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 (
|
|
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
|
|
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
|
-
* `
|
|
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
|
|
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, [
|
|
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 =
|
|
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
|
-
|
|
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":
|
|
@@ -3093,6 +3186,10 @@ async function dispatch(msg, ctx) {
|
|
|
3093
3186
|
stream_id,
|
|
3094
3187
|
resuming: info.resuming,
|
|
3095
3188
|
thread_id: info.threadId,
|
|
3189
|
+
// Slice 6: 履歴 hydrate。resume 時のみ非空 (thread/start は常に [])。
|
|
3190
|
+
// consumers.py の per-stream relay は content-blind なので backend 側の
|
|
3191
|
+
// 変更は不要 (_AGENT_PER_STREAM_TYPES の codex.ready がそのまま運ぶ)。
|
|
3192
|
+
history: info.history || [],
|
|
3096
3193
|
})
|
|
3097
3194
|
} catch (err) {
|
|
3098
3195
|
ctx.client.send({
|
|
@@ -3607,7 +3704,13 @@ async function dispatch(msg, ctx) {
|
|
|
3607
3704
|
}
|
|
3608
3705
|
case "skills.request": {
|
|
3609
3706
|
try {
|
|
3610
|
-
|
|
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
|
+
})
|
|
3611
3714
|
ctx.client.send({
|
|
3612
3715
|
type: "skills.response",
|
|
3613
3716
|
request_id: msg.request_id,
|
|
@@ -3663,11 +3766,37 @@ async function dispatch(msg, ctx) {
|
|
|
3663
3766
|
getUsage(),
|
|
3664
3767
|
getSessionUsages(),
|
|
3665
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
|
+
|
|
3666
3793
|
ctx.client.send({
|
|
3667
3794
|
type: "usage.response",
|
|
3668
3795
|
request_id: msg.request_id,
|
|
3669
3796
|
usage,
|
|
3670
3797
|
sessions,
|
|
3798
|
+
codexUsage,
|
|
3799
|
+
codexSessions,
|
|
3671
3800
|
})
|
|
3672
3801
|
} catch (err) {
|
|
3673
3802
|
ctx.client.send({
|
|
@@ -3675,6 +3804,8 @@ async function dispatch(msg, ctx) {
|
|
|
3675
3804
|
request_id: msg.request_id,
|
|
3676
3805
|
usage: null,
|
|
3677
3806
|
sessions: [],
|
|
3807
|
+
codexUsage: null,
|
|
3808
|
+
codexSessions: [],
|
|
3678
3809
|
error: err.message,
|
|
3679
3810
|
})
|
|
3680
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
|
+
}
|