@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/bin/hub-agent.mjs +0 -46
- package/package.json +1 -1
- package/src/claude-md-render.mjs +222 -0
- package/src/claude-md-sync.mjs +545 -136
- package/src/claude-md.mjs +272 -250
- package/src/codex-stream-bridge.mjs +6 -2
- package/src/hub-request-deadline.mjs +273 -0
- package/src/main.mjs +0 -108
- package/src/state.mjs +5 -15
- package/src/tmux.mjs +55 -188
- package/src/usage.mjs +1 -1
- package/src/codex-rollout-watch.mjs +0 -148
- package/src/session-mirror.mjs +0 -190
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
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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
|
-
*
|
|
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
|
package/src/session-mirror.mjs
DELETED
|
@@ -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
|
-
}
|