@cocorograph/hub-agent 0.8.19 → 0.8.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/hub-agent.mjs +0 -46
- package/package.json +1 -1
- package/src/codex-stream-bridge.mjs +6 -2
- package/src/main.mjs +23 -108
- package/src/pointer-context.mjs +196 -0
- package/src/state.mjs +5 -15
- package/src/tmux.mjs +0 -154
- package/src/usage.mjs +1 -1
- package/src/codex-rollout-watch.mjs +0 -148
- package/src/session-mirror.mjs +0 -190
package/bin/hub-agent.mjs
CHANGED
|
@@ -167,52 +167,6 @@ program
|
|
|
167
167
|
}
|
|
168
168
|
})
|
|
169
169
|
|
|
170
|
-
program
|
|
171
|
-
.command("mirror")
|
|
172
|
-
.description(
|
|
173
|
-
"チャット(SDK/app-server)の会話を、このターミナルに読み取り専用で実況表示する " +
|
|
174
|
-
"(Cockpit Codex 両対応 Stream C)。チャットの動作には一切影響しない (書き込まない)。" +
|
|
175
|
-
"Ctrl-C / SIGTERM で停止する",
|
|
176
|
-
)
|
|
177
|
-
.requiredOption("--cwd <path>", "対象セッションの cwd")
|
|
178
|
-
.requiredOption("--session <id>", "Claude session_id または Codex threadId")
|
|
179
|
-
.option("--cli-kind <kind>", "'claude' または 'codex'", "claude")
|
|
180
|
-
.action(async (opts) => {
|
|
181
|
-
if (opts.cliKind !== "claude" && opts.cliKind !== "codex") {
|
|
182
|
-
console.error(`--cli-kind は 'claude' か 'codex' のみ対応: ${opts.cliKind}`)
|
|
183
|
-
process.exit(2)
|
|
184
|
-
}
|
|
185
|
-
const { runClaudeMirror, runCodexMirror } = await import("../src/session-mirror.mjs")
|
|
186
|
-
// watchSessionFile / watchCodexRollout のポーリング timer は常駐 daemon (main.mjs) の
|
|
187
|
-
// graceful shutdown を妨げないよう意図的に unref() されている。この単体 CLI プロセスは
|
|
188
|
-
// 他に event loop を保持する要因が無いため、unref timer だけでは初回同期処理の直後に
|
|
189
|
-
// 即終了してしまう (実機検証で確認)。ref 付きの keep-alive timer で明示的に生かす。
|
|
190
|
-
const keepAlive = setInterval(() => {}, 60_000)
|
|
191
|
-
let watcher
|
|
192
|
-
try {
|
|
193
|
-
watcher =
|
|
194
|
-
opts.cliKind === "codex"
|
|
195
|
-
? await runCodexMirror({ threadId: opts.session })
|
|
196
|
-
: runClaudeMirror({ cwd: opts.cwd, sessionId: opts.session })
|
|
197
|
-
} catch (err) {
|
|
198
|
-
clearInterval(keepAlive)
|
|
199
|
-
console.error(`mirror failed: ${err.message}`)
|
|
200
|
-
process.exit(1)
|
|
201
|
-
}
|
|
202
|
-
const stop = () => {
|
|
203
|
-
clearInterval(keepAlive)
|
|
204
|
-
try {
|
|
205
|
-
watcher?.stop?.()
|
|
206
|
-
} catch {
|
|
207
|
-
/* ignore */
|
|
208
|
-
}
|
|
209
|
-
console.log("[cockpit mirror] 停止しました")
|
|
210
|
-
process.exit(0)
|
|
211
|
-
}
|
|
212
|
-
process.on("SIGINT", stop)
|
|
213
|
-
process.on("SIGTERM", stop)
|
|
214
|
-
})
|
|
215
|
-
|
|
216
170
|
program
|
|
217
171
|
.command("start")
|
|
218
172
|
.description("daemon を起動して Hub と WSS 接続する")
|
package/package.json
CHANGED
|
@@ -1953,8 +1953,12 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1953
1953
|
|
|
1954
1954
|
/**
|
|
1955
1955
|
* 指定 cwd に一致するライブ (attach 済み) codex セッションの threadId を返す。
|
|
1956
|
-
* Claude 版 `liveSessionIdForCwd`
|
|
1957
|
-
*
|
|
1956
|
+
* Claude 版 `liveSessionIdForCwd` の対。
|
|
1957
|
+
*
|
|
1958
|
+
* ⚠️ 2026-08-02 時点で **本番経路からの呼び出し元は無い** (テストのみ)。旧利用元だった
|
|
1959
|
+
* terminal mirror は撤去済み。「main.mjs の state loop から使う」という記述も以前から
|
|
1960
|
+
* 実態と食い違っていた (startStateLoop に codexBridge は渡るが、usage.mjs 側で参照して
|
|
1961
|
+
* いない)。撤去可否は別途調査してから判断すること。
|
|
1958
1962
|
*
|
|
1959
1963
|
* ⚠️ T06407 増分2: 旧実装は「先に見つかったもの」を返していたが、これは 5h/7d(アカウント
|
|
1960
1964
|
* 全体で代表 1 件で十分)前提だった。context% は thread 固有なので liveSessionForCwd の
|
package/src/main.mjs
CHANGED
|
@@ -103,11 +103,10 @@ import {
|
|
|
103
103
|
removeWorktree as removeWorktreeDir,
|
|
104
104
|
setSessionMouse,
|
|
105
105
|
setTmuxGlobalEnv,
|
|
106
|
-
startTerminalMirror,
|
|
107
|
-
stopTerminalMirror,
|
|
108
106
|
} from "./tmux.mjs"
|
|
109
107
|
import { SiblingInjectBridge } from "./sibling-inject.mjs"
|
|
110
108
|
import { isSessionWarm } from "./live-sessions.mjs"
|
|
109
|
+
import { applyPointerContexts, resolvePointerContexts } from "./pointer-context.mjs"
|
|
111
110
|
import {
|
|
112
111
|
boundSessionId,
|
|
113
112
|
codexContextPercentFromTokenUsage,
|
|
@@ -1752,28 +1751,6 @@ async function readAllSessionEvents() {
|
|
|
1752
1751
|
// 共有し、同時要求は single-flight に畳む。create/kill/worktree 変更時は明示 clear する。
|
|
1753
1752
|
const tmuxSessionsSnapshotCache = new AsyncTtlCache({ ttlMs: 5_000 })
|
|
1754
1753
|
|
|
1755
|
-
/**
|
|
1756
|
-
* 単一 session の最新 session-event をファイル正本から読む。無ければ null。
|
|
1757
|
-
* claude.tui.bind 時の `stop` 再配信 (取りこぼし回復) に使う。
|
|
1758
|
-
*/
|
|
1759
|
-
async function readSessionEventFile(sessionName) {
|
|
1760
|
-
if (!sessionName || /[/\\]/.test(sessionName)) return null
|
|
1761
|
-
try {
|
|
1762
|
-
const text = await readFile(
|
|
1763
|
-
path.join(SESSION_EVENTS_DIR, `${sessionName}.json`),
|
|
1764
|
-
"utf-8",
|
|
1765
|
-
)
|
|
1766
|
-
const data = JSON.parse(text)
|
|
1767
|
-
if (!data || typeof data.event !== "string") return null
|
|
1768
|
-
return {
|
|
1769
|
-
event: data.event,
|
|
1770
|
-
at: typeof data.at === "number" ? data.at : Date.now(),
|
|
1771
|
-
}
|
|
1772
|
-
} catch {
|
|
1773
|
-
return null
|
|
1774
|
-
}
|
|
1775
|
-
}
|
|
1776
|
-
|
|
1777
1754
|
/**
|
|
1778
1755
|
* `/tmp/cockpit_session_events/<tmux_session>.json` を fs.watch して
|
|
1779
1756
|
* UserPromptSubmit / Stop の event を `session.event` 型で Hub に push する。
|
|
@@ -4038,90 +4015,6 @@ async function dispatch(msg, ctx) {
|
|
|
4038
4015
|
if (!ctx.claudeBridge) return
|
|
4039
4016
|
ctx.claudeBridge.interrupt({ stream_id: msg.stream_id })
|
|
4040
4017
|
return
|
|
4041
|
-
case "terminal.mirror.start": {
|
|
4042
|
-
// Cockpit Codex 両対応 Stream C (2026-07-03): チャット(SDK/app-server)の会話を
|
|
4043
|
-
// 変えずに、ターミナルのペイン (普通のシェルのまま) へ読み取り専用の実況を
|
|
4044
|
-
// 追加する。詳細は tmux.mjs の startTerminalMirror / session-mirror.mjs 参照。
|
|
4045
|
-
//
|
|
4046
|
-
// session_id はブラウザに要求しない。cwd から hub-agent 側でライブなチャット
|
|
4047
|
-
// session を解決する (claudeBridge.liveSessionIdForCwd / codexBridge.sessions を
|
|
4048
|
-
// cwd で検索)。これにより frontend は「今この cwd で実際に動いているチャット」を
|
|
4049
|
-
// 正確に追従でき、古い/取り違えた session_id を渡すリスクが無い。
|
|
4050
|
-
const request_id = msg.request_id
|
|
4051
|
-
const sessionName = typeof msg.session_name === "string" ? msg.session_name : ""
|
|
4052
|
-
const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
|
|
4053
|
-
const cliKind = msg.cli_kind === "codex" ? "codex" : "claude"
|
|
4054
|
-
const reply = (payload) =>
|
|
4055
|
-
ctx.client.send({
|
|
4056
|
-
type: "terminal.mirror.start.result",
|
|
4057
|
-
request_id,
|
|
4058
|
-
session_name: sessionName,
|
|
4059
|
-
...payload,
|
|
4060
|
-
})
|
|
4061
|
-
if (!sessionName || !cwd) {
|
|
4062
|
-
reply({ ok: false, error: "session_name / cwd は必須です" })
|
|
4063
|
-
return
|
|
4064
|
-
}
|
|
4065
|
-
let sessionId = null
|
|
4066
|
-
if (cliKind === "codex") {
|
|
4067
|
-
for (const s of ctx.codexBridge?.sessions?.values() ?? []) {
|
|
4068
|
-
if (s.cwd === cwd && s.threadId) {
|
|
4069
|
-
sessionId = s.threadId
|
|
4070
|
-
break
|
|
4071
|
-
}
|
|
4072
|
-
}
|
|
4073
|
-
} else {
|
|
4074
|
-
sessionId = ctx.claudeBridge?.liveSessionIdForCwd?.(cwd) || null
|
|
4075
|
-
}
|
|
4076
|
-
if (!sessionId) {
|
|
4077
|
-
reply({
|
|
4078
|
-
ok: false,
|
|
4079
|
-
error: "この cwd で動作中のチャットセッションが見つかりません (先にチャットを開始してください)",
|
|
4080
|
-
})
|
|
4081
|
-
return
|
|
4082
|
-
}
|
|
4083
|
-
try {
|
|
4084
|
-
const r = await startTerminalMirror(
|
|
4085
|
-
sessionName,
|
|
4086
|
-
{ cwd, sessionId, cliKind },
|
|
4087
|
-
{ logger },
|
|
4088
|
-
)
|
|
4089
|
-
reply(r)
|
|
4090
|
-
} catch (err) {
|
|
4091
|
-
logger?.warn(
|
|
4092
|
-
{ err: err?.message, sessionName },
|
|
4093
|
-
"terminal.mirror.start failed",
|
|
4094
|
-
)
|
|
4095
|
-
reply({ ok: false, error: err?.message || String(err) })
|
|
4096
|
-
}
|
|
4097
|
-
return
|
|
4098
|
-
}
|
|
4099
|
-
case "terminal.mirror.stop": {
|
|
4100
|
-
const request_id = msg.request_id
|
|
4101
|
-
const sessionName = typeof msg.session_name === "string" ? msg.session_name : ""
|
|
4102
|
-
const reply = (payload) =>
|
|
4103
|
-
ctx.client.send({
|
|
4104
|
-
type: "terminal.mirror.stop.result",
|
|
4105
|
-
request_id,
|
|
4106
|
-
session_name: sessionName,
|
|
4107
|
-
...payload,
|
|
4108
|
-
})
|
|
4109
|
-
if (!sessionName) {
|
|
4110
|
-
reply({ ok: false, error: "session_name は必須です" })
|
|
4111
|
-
return
|
|
4112
|
-
}
|
|
4113
|
-
try {
|
|
4114
|
-
const r = await stopTerminalMirror(sessionName, { logger })
|
|
4115
|
-
reply(r)
|
|
4116
|
-
} catch (err) {
|
|
4117
|
-
logger?.warn(
|
|
4118
|
-
{ err: err?.message, sessionName },
|
|
4119
|
-
"terminal.mirror.stop failed",
|
|
4120
|
-
)
|
|
4121
|
-
reply({ ok: false, error: err?.message || String(err) })
|
|
4122
|
-
}
|
|
4123
|
-
return
|
|
4124
|
-
}
|
|
4125
4018
|
case "claude.mcp.status":
|
|
4126
4019
|
case "claude.mcp.reconnect":
|
|
4127
4020
|
case "claude.mcp.authenticate":
|
|
@@ -5067,10 +4960,32 @@ async function dispatch(msg, ctx) {
|
|
|
5067
4960
|
}
|
|
5068
4961
|
return { sessions: enriched, worktree_stubs: worktreeStubs }
|
|
5069
4962
|
})
|
|
4963
|
+
// 行ごとの「次に開く会話」の context% (任意)。宛先 (context_targets) は要求ごとに
|
|
4964
|
+
// 違うため、共有される snapshot キャッシュの**外**で解決し、触る行だけ複製して
|
|
4965
|
+
// 載せる (他クライアントの応答を汚さない)。context_targets が無ければ従来どおりの
|
|
4966
|
+
// 応答 (後方互換)。
|
|
4967
|
+
let sessions = snapshot.sessions
|
|
4968
|
+
if (Array.isArray(msg.context_targets) && msg.context_targets.length > 0) {
|
|
4969
|
+
try {
|
|
4970
|
+
const pointerByName = await resolvePointerContexts(
|
|
4971
|
+
msg.context_targets,
|
|
4972
|
+
sessions,
|
|
4973
|
+
{ logger: ctx.logger },
|
|
4974
|
+
)
|
|
4975
|
+
sessions = applyPointerContexts(sessions, pointerByName)
|
|
4976
|
+
} catch (err) {
|
|
4977
|
+
// pointer context はあくまで付加情報。失敗してもセッション一覧は返す。
|
|
4978
|
+
ctx.logger?.warn?.(
|
|
4979
|
+
{ err: err?.message },
|
|
4980
|
+
"resolvePointerContexts failed",
|
|
4981
|
+
)
|
|
4982
|
+
}
|
|
4983
|
+
}
|
|
5070
4984
|
ctx.client.send({
|
|
5071
4985
|
type: "tmux.sessions",
|
|
5072
4986
|
request_id: msg.request_id,
|
|
5073
4987
|
...snapshot,
|
|
4988
|
+
sessions,
|
|
5074
4989
|
})
|
|
5075
4990
|
} catch (err) {
|
|
5076
4991
|
ctx.client.send({
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「その行が次に開く会話」の context% (= pointer context) を解決する。
|
|
3
|
+
*
|
|
4
|
+
* ## 背景 (WHY)
|
|
5
|
+
* Cockpit のサイドバーは、コンテキスト使用率を **その行が次に開く会話に帰属させて**表示する
|
|
6
|
+
* ように直した (Hub 側 PR #4534)。しかし供給側 (agent) は「いま動いている会話」の値しか
|
|
7
|
+
* 出しておらず、一度も開いていない行・閉じた行では使用率が出ない。
|
|
8
|
+
*
|
|
9
|
+
* 従来は「その cwd で mtime が一番新しい jsonl」に落として埋めていたが、それが
|
|
10
|
+
* **サブエージェント / headless `claude -p` / 過去セッションの数字が混ざる**原因だったので
|
|
11
|
+
* 撤去した (cross-activity contamination)。よってここでは **frontend が行ごとに指定した
|
|
12
|
+
* 会話 ID (`session_id`) でしか値を引かない**。ID が無い行は推測せず null を返す。
|
|
13
|
+
*
|
|
14
|
+
* ## 不変条件 (ここを崩すと汚染が戻る)
|
|
15
|
+
* 1. `session_id` が渡されていない行では `contextForCwd` を **呼ばない** (mtime 最新への
|
|
16
|
+
* フォールバックが走るため)。
|
|
17
|
+
* 2. `session_id` が渡された行でも、戻り値の `uuid` が要求 ID と**一致するときだけ**採用する。
|
|
18
|
+
* `jsonlContextForCwd` は「その ID の jsonl が存在しない」とき mtime 最新へフォールバック
|
|
19
|
+
* する仕様のため、件数ではなく**実体 (uuid) の同一性**で確認する。
|
|
20
|
+
* 3. 鮮度 (mtime) による足切りをしない。止まっている会話の使用率は古くならない
|
|
21
|
+
* (次に開けばその値から再開する) ので、statusLine 経路の `SESSION_STALE_MS` は適用しない。
|
|
22
|
+
*
|
|
23
|
+
* ## N+1 対策
|
|
24
|
+
* サイドバーは 8 秒周期でポーリングし、複数タブが位相をずらして同時に叩く。行数分の jsonl
|
|
25
|
+
* 読み取りをそのまま毎回走らせないよう、`(cwd, session_id)` をキーにした短期 TTL +
|
|
26
|
+
* single-flight キャッシュを噛ませる (同時要求は 1 本の I/O に畳む)。
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { cliKindForSessionName } from "./session-names.mjs"
|
|
30
|
+
import { jsonlContextForCwd } from "./usage.mjs"
|
|
31
|
+
|
|
32
|
+
/** pointer context のキャッシュ TTL (ms)。tmux スナップショットキャッシュと同じ 5 秒。 */
|
|
33
|
+
const POINTER_CONTEXT_TTL_MS = Number(
|
|
34
|
+
process.env.HUB_AGENT_POINTER_CONTEXT_TTL_MS ?? 5_000,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
/** キャッシュ保持数の上限 (会話 ID は増え続けるため LRU で頭打ちにする)。 */
|
|
38
|
+
const POINTER_CONTEXT_MAX_ENTRIES = 512
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* キー付きの短期 TTL + single-flight キャッシュ。
|
|
42
|
+
*
|
|
43
|
+
* `AsyncTtlCache` (単一値版) と役割は同じだが、こちらは `(cwd, session_id)` ごとに独立した
|
|
44
|
+
* エントリを持つ。build 失敗はキャッシュしない (次の要求で再試行できる)。
|
|
45
|
+
*/
|
|
46
|
+
export class KeyedAsyncTtlCache {
|
|
47
|
+
constructor({ ttlMs, now = () => Date.now(), maxEntries = POINTER_CONTEXT_MAX_ENTRIES } = {}) {
|
|
48
|
+
this.ttlMs = Math.max(0, Number(ttlMs) || 0)
|
|
49
|
+
this.now = now
|
|
50
|
+
this.maxEntries = Math.max(1, Number(maxEntries) || 1)
|
|
51
|
+
this.entries = new Map() // key → {value, expiresAt, inFlight}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async get(key, build) {
|
|
55
|
+
const hit = this.entries.get(key)
|
|
56
|
+
if (hit) {
|
|
57
|
+
if (hit.inFlight) return hit.inFlight // 同時要求は 1 本の I/O に畳む
|
|
58
|
+
if (this.now() < hit.expiresAt) return hit.value
|
|
59
|
+
}
|
|
60
|
+
const pending = Promise.resolve().then(build)
|
|
61
|
+
const entry = { value: undefined, expiresAt: 0, inFlight: pending }
|
|
62
|
+
// delete → set で挿入順 = 最近使った順にし、prune を LRU として使えるようにする。
|
|
63
|
+
this.entries.delete(key)
|
|
64
|
+
this.entries.set(key, entry)
|
|
65
|
+
try {
|
|
66
|
+
const value = await pending
|
|
67
|
+
entry.value = value
|
|
68
|
+
entry.expiresAt = this.now() + this.ttlMs
|
|
69
|
+
entry.inFlight = null
|
|
70
|
+
this.prune()
|
|
71
|
+
return value
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (this.entries.get(key) === entry) this.entries.delete(key)
|
|
74
|
+
throw err
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 期限切れエントリを落とし、上限を超えていれば古い順に捨てる。 */
|
|
79
|
+
prune() {
|
|
80
|
+
const now = this.now()
|
|
81
|
+
for (const [key, e] of this.entries) {
|
|
82
|
+
if (!e.inFlight && now >= e.expiresAt) this.entries.delete(key)
|
|
83
|
+
}
|
|
84
|
+
while (this.entries.size > this.maxEntries) {
|
|
85
|
+
const oldest = this.entries.keys().next()
|
|
86
|
+
if (oldest.done) break
|
|
87
|
+
this.entries.delete(oldest.value)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
clear() {
|
|
92
|
+
this.entries.clear()
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 既定のプロセス共有キャッシュ (tmux.list_sessions ハンドラ用)。 */
|
|
97
|
+
const defaultPointerContextCache = new KeyedAsyncTtlCache({ ttlMs: POINTER_CONTEXT_TTL_MS })
|
|
98
|
+
|
|
99
|
+
/** テスト用: 既定キャッシュを空にする。 */
|
|
100
|
+
export function _resetPointerContextCache() {
|
|
101
|
+
defaultPointerContextCache.clear()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** cwd と会話 ID の連結。区切りは両者に現れ得ない制御文字 (エスケープ表記で書く)。 */
|
|
105
|
+
function cacheKey(cwd, sessionId) {
|
|
106
|
+
return `${cwd}\u0000${sessionId}`
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* `context_targets` (行ごとの「次に開く会話」) を解決し、`session_name → context% | null` の
|
|
111
|
+
* Map を返す。
|
|
112
|
+
*
|
|
113
|
+
* 値が確定しなかった行にも **null を明示的に入れる** (「見に行った上で不明」を表す)。
|
|
114
|
+
* `context_targets` に含まれない行は Map に載らない = 応答にフィールドを付けない。
|
|
115
|
+
*
|
|
116
|
+
* @param {Array<{session_name?: string, session_id?: string|null}>|undefined} targets
|
|
117
|
+
* @param {Array<{name?: string, cwd?: string|null}>} rows tmux.list_sessions の行 (cwd 解決用)
|
|
118
|
+
* @param {{contextForCwd?: Function, cache?: KeyedAsyncTtlCache, logger?: object}} [opts]
|
|
119
|
+
* @returns {Promise<Map<string, number|null>>}
|
|
120
|
+
*/
|
|
121
|
+
export async function resolvePointerContexts(targets, rows, opts = {}) {
|
|
122
|
+
const {
|
|
123
|
+
contextForCwd = jsonlContextForCwd,
|
|
124
|
+
cache = defaultPointerContextCache,
|
|
125
|
+
logger,
|
|
126
|
+
} = opts
|
|
127
|
+
const out = new Map()
|
|
128
|
+
if (!Array.isArray(targets) || targets.length === 0) return out
|
|
129
|
+
|
|
130
|
+
const cwdByName = new Map()
|
|
131
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
132
|
+
if (row && typeof row.name === "string") cwdByName.set(row.name, row.cwd || null)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const jobs = []
|
|
136
|
+
for (const target of targets) {
|
|
137
|
+
const name = typeof target?.session_name === "string" ? target.session_name : ""
|
|
138
|
+
if (!name || out.has(name)) continue // 同名の重複指定は先勝ち
|
|
139
|
+
out.set(name, null)
|
|
140
|
+
|
|
141
|
+
const sessionId = typeof target?.session_id === "string" ? target.session_id.trim() : ""
|
|
142
|
+
// 不変条件 1: ID が無ければ読みに行かない (mtime 最新フォールバックを踏まない)。
|
|
143
|
+
if (!sessionId) continue
|
|
144
|
+
const cwd = cwdByName.get(name)
|
|
145
|
+
if (!cwd) continue
|
|
146
|
+
// codex 行は claude の jsonl を持たない。読んでも uuid 不一致で null になるだけなので、
|
|
147
|
+
// 無駄な readdir/stat を避けるためここで落とす (結果は同じ)。
|
|
148
|
+
if (cliKindForSessionName(name) !== "claude") continue
|
|
149
|
+
jobs.push({ name, cwd, sessionId })
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
await Promise.all(
|
|
153
|
+
jobs.map(async ({ name, cwd, sessionId }) => {
|
|
154
|
+
try {
|
|
155
|
+
const pct = await cache.get(cacheKey(cwd, sessionId), async () => {
|
|
156
|
+
const res = await contextForCwd(cwd, sessionId)
|
|
157
|
+
// 不変条件 2: 実体 (uuid) が一致したときだけ採用する。
|
|
158
|
+
if (!res || res.uuid !== sessionId) return null
|
|
159
|
+
return typeof res.percent === "number" ? res.percent : null
|
|
160
|
+
})
|
|
161
|
+
if (typeof pct === "number") out.set(name, pct)
|
|
162
|
+
} catch (err) {
|
|
163
|
+
logger?.warn?.(
|
|
164
|
+
{ err: err?.message, session: name },
|
|
165
|
+
"pointer context resolve failed",
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
}),
|
|
169
|
+
)
|
|
170
|
+
return out
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 解決済みの pointer context を session 行へ載せる (元配列・元オブジェクトは変更しない)。
|
|
175
|
+
*
|
|
176
|
+
* `tmux.list_sessions` の応答行は TTL スナップショットキャッシュで**複数の要求に共有される**
|
|
177
|
+
* ため、要求ごとに違う値を直接書き込むと他クライアントの応答を汚す。触る行だけ複製する。
|
|
178
|
+
*
|
|
179
|
+
* 既存の `context_pct` (いま動いている会話の値) とは**別フィールド**にする。混ぜると
|
|
180
|
+
* 「どちらの会話の値か」が曖昧になるため。
|
|
181
|
+
*
|
|
182
|
+
* @param {Array<object>} sessions
|
|
183
|
+
* @param {Map<string, number|null>} pointerByName
|
|
184
|
+
* @returns {Array<object>}
|
|
185
|
+
*/
|
|
186
|
+
export function applyPointerContexts(sessions, pointerByName) {
|
|
187
|
+
if (!Array.isArray(sessions)) return []
|
|
188
|
+
if (!(pointerByName instanceof Map) || pointerByName.size === 0) return sessions
|
|
189
|
+
return sessions.map((s) =>
|
|
190
|
+
s && pointerByName.has(s.name)
|
|
191
|
+
? { ...s, pointer_context_pct: pointerByName.get(s.name) }
|
|
192
|
+
: s,
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export default { KeyedAsyncTtlCache, resolvePointerContexts, applyPointerContexts }
|
package/src/state.mjs
CHANGED
|
@@ -131,17 +131,6 @@ function stripAnsi(s) {
|
|
|
131
131
|
.replace(/\x1b[()][AB012]/g, "")
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
/**
|
|
135
|
-
* capturePane 結果の末尾フッター領域 (既定 8 行) を返す。claude TUI の作業スピナー /
|
|
136
|
-
* 中断フッターはペイン最下部に固定描画されるため、本文中に同じ語が出ても誤検出しない
|
|
137
|
-
* よう、作業スピナーの補助判定はこの領域に限定する。
|
|
138
|
-
*/
|
|
139
|
-
function footerRegion(text, lines = 8) {
|
|
140
|
-
if (!text) return ""
|
|
141
|
-
const rows = text.split("\n")
|
|
142
|
-
return rows.length <= lines ? text : rows.slice(-lines).join("\n")
|
|
143
|
-
}
|
|
144
|
-
|
|
145
134
|
/**
|
|
146
135
|
* 作業スピナー行 (ライブステータス行) の構造パターン。実機採取 (2026-06-15〜):
|
|
147
136
|
* "· Blanching… (2m 4s · ↓ 7.5k tokens)" (出力中)
|
|
@@ -158,9 +147,10 @@ function footerRegion(text, lines = 8) {
|
|
|
158
147
|
* - 省略記号 (…/...) **または** 括弧タイマーのどちらか一方で可 (両方必須をやめる)
|
|
159
148
|
* - タイマーは「分のみ」「秒のみ」「分秒」いずれも可 (秒必須をやめる)
|
|
160
149
|
* - 行頭インデント制限 (\s{0,4}) を撤廃 (背の高いペイン/折返しで字下げが深い版に備える)
|
|
161
|
-
* 緩めても安全な理由: 検出は必ず
|
|
162
|
-
*
|
|
163
|
-
*
|
|
150
|
+
* 緩めても安全な理由: 検出は必ず capture 末尾 12 行への限定 (workingSpinnerLine) と
|
|
151
|
+
* _belowIsOnlyChrome (下が入力欄チロムのみ) を通すため、本文プローズ・表セル・箇条書きへの
|
|
152
|
+
* 誤点灯は下チロム判定が弾く。行頭は英数字・空白・本文記号 (-*>#|) を除く 1 文字に限定し、
|
|
153
|
+
* 語は ASCII 英字に限る (日本語プローズは非マッチ)。
|
|
164
154
|
* 完了サマリー "✻ Brewed for 2m 52s" は省略記号も**括弧**タイマーも持たない (タイマーが括弧外) ため
|
|
165
155
|
* 引き続き一致しない。
|
|
166
156
|
*/
|
|
@@ -338,7 +328,7 @@ export async function capturePane(sessionName, opts = {}) {
|
|
|
338
328
|
sessionName,
|
|
339
329
|
// 末尾 50 行を取得 (旧 30 行)。copy-mode スクロールや背の高いペインで作業/中断
|
|
340
330
|
// フッターが取得窓外に出て生成中を取りこぼす偽陰性を減らす。status 判定の作業
|
|
341
|
-
//
|
|
331
|
+
// スピナー検出はこのうち末尾 12 行 (workingSpinnerLine) に限定する。
|
|
342
332
|
"-S",
|
|
343
333
|
"-50",
|
|
344
334
|
"-E",
|
package/src/tmux.mjs
CHANGED
|
@@ -19,7 +19,6 @@ import fs from "node:fs/promises"
|
|
|
19
19
|
import os from "node:os"
|
|
20
20
|
import path from "node:path"
|
|
21
21
|
import { promisify } from "node:util"
|
|
22
|
-
import { fileURLToPath } from "node:url"
|
|
23
22
|
|
|
24
23
|
import { ensureClaudeMd } from "./claude-md.mjs"
|
|
25
24
|
import { syncClaudeMdWithHub } from "./claude-md-sync.mjs"
|
|
@@ -1233,156 +1232,3 @@ export async function paneClaudeAliveOrUnknown(name, opts = {}) {
|
|
|
1233
1232
|
return "unknown"
|
|
1234
1233
|
}
|
|
1235
1234
|
}
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
// ---------------------------------------------------------------------------
|
|
1239
|
-
// ターミナルミラー (Cockpit Codex 両対応 Stream C, 2026-07-03)
|
|
1240
|
-
//
|
|
1241
|
-
// 「チャットの動作は変えず、ターミナルのペインは普通に使えるシェルのまま、そこに
|
|
1242
|
-
// チャットの会話を実況表示する読み取り専用の子プロセス」をバックグラウンドで起動/
|
|
1243
|
-
// 停止する。session-mirror.mjs / bin/hub-agent.mjs の `mirror` サブコマンド参照。
|
|
1244
|
-
//
|
|
1245
|
-
// 安全設計:
|
|
1246
|
-
// - ペインの前景が plain shell のときだけ起動を許可する(`claude`/`codex` 対話 TUI が
|
|
1247
|
-
// 前景にいる状態で send-keys すると、実況コマンドがその対話プロンプトへの入力として
|
|
1248
|
-
// 誤解釈されてしまうため)。
|
|
1249
|
-
// - 起動コマンドは `& echo $! > <pidfile>` で直後に PID を記録し、停止はその PID を
|
|
1250
|
-
// kill するだけ(プロセス名 grep 等の曖昧一致はしない)。
|
|
1251
|
-
// ---------------------------------------------------------------------------
|
|
1252
|
-
|
|
1253
|
-
/** 現在の hub-agent パッケージの `bin/hub-agent.mjs` 絶対パス。node 実行 (`process.execPath`)
|
|
1254
|
-
* と組み合わせることで、対象ペインの shell の PATH に依存せず確実に起動できる。 */
|
|
1255
|
-
function hubAgentBinPath() {
|
|
1256
|
-
return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "bin", "hub-agent.mjs")
|
|
1257
|
-
}
|
|
1258
|
-
|
|
1259
|
-
/** POSIX シェルの単一引数として安全な形にクォートする (単純な `'...'` ラップ + `'` エスケープ)。 */
|
|
1260
|
-
function shQuote(s) {
|
|
1261
|
-
return `'${String(s).replace(/'/g, "'\\''")}'`
|
|
1262
|
-
}
|
|
1263
|
-
|
|
1264
|
-
/** ミラーの pidfile パス (セッション名ごとに 1 つ)。 */
|
|
1265
|
-
function mirrorPidFile(sessionName) {
|
|
1266
|
-
return path.join(os.tmpdir(), `hub-agent-mirror-${sanitizeTmuxName(sessionName)}.pid`)
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
/**
|
|
1270
|
-
* ミラー起動用の send-keys コマンド文字列を組み立てる (純粋関数)。
|
|
1271
|
-
*
|
|
1272
|
-
* @param {{cwd: string, sessionId: string, cliKind: 'claude'|'codex', sessionName: string}} args
|
|
1273
|
-
* @returns {string}
|
|
1274
|
-
* @throws sessionId が安全形でなければ throw (コマンド注入防止、buildResumeCmd と同方針)
|
|
1275
|
-
*/
|
|
1276
|
-
export function buildMirrorStartCmd({ cwd, sessionId, cliKind, sessionName }) {
|
|
1277
|
-
if (!isSafeSessionId(sessionId)) {
|
|
1278
|
-
throw new Error(`unsafe session_id for mirror: ${String(sessionId)}`)
|
|
1279
|
-
}
|
|
1280
|
-
const kind = cliKind === "codex" ? "codex" : "claude"
|
|
1281
|
-
const bin = hubAgentBinPath()
|
|
1282
|
-
const pidFile = mirrorPidFile(sessionName)
|
|
1283
|
-
const args = [
|
|
1284
|
-
shQuote(process.execPath),
|
|
1285
|
-
shQuote(bin),
|
|
1286
|
-
"mirror",
|
|
1287
|
-
"--cwd",
|
|
1288
|
-
shQuote(cwd),
|
|
1289
|
-
"--session",
|
|
1290
|
-
shQuote(sessionId),
|
|
1291
|
-
"--cli-kind",
|
|
1292
|
-
kind,
|
|
1293
|
-
].join(" ")
|
|
1294
|
-
return `${args} & echo $! > ${shQuote(pidFile)}; disown`
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
/** ミラー停止用の send-keys コマンド文字列を組み立てる (純粋関数)。 */
|
|
1298
|
-
export function buildMirrorStopCmd({ sessionName }) {
|
|
1299
|
-
const pidFile = mirrorPidFile(sessionName)
|
|
1300
|
-
return `kill $(cat ${shQuote(pidFile)} 2>/dev/null) 2>/dev/null; rm -f ${shQuote(pidFile)}`
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
/**
|
|
1304
|
-
* 対象ペインの前景が plain shell か判定する (`paneClaudeAliveOrUnknown` の逆方向・2 値版)。
|
|
1305
|
-
* 判定不能 (tmux 失敗等) は false を返す = 起動を許可しない安全側。
|
|
1306
|
-
*
|
|
1307
|
-
* @param {string} name
|
|
1308
|
-
* @param {{ tmux?: string }} [opts]
|
|
1309
|
-
* @returns {Promise<boolean>}
|
|
1310
|
-
*/
|
|
1311
|
-
export async function isPaneAtShellPrompt(name, opts = {}) {
|
|
1312
|
-
if (!name) return false
|
|
1313
|
-
try {
|
|
1314
|
-
const { stdout } = await execFileP(tmuxBin(opts), [
|
|
1315
|
-
"display-message",
|
|
1316
|
-
"-p",
|
|
1317
|
-
"-t",
|
|
1318
|
-
`${name}:`,
|
|
1319
|
-
"-F",
|
|
1320
|
-
"#{pane_current_command}",
|
|
1321
|
-
])
|
|
1322
|
-
const cmd = (stdout || "").trim().toLowerCase()
|
|
1323
|
-
const SHELLS = new Set(["fish", "bash", "zsh", "sh", "dash"])
|
|
1324
|
-
return SHELLS.has(cmd)
|
|
1325
|
-
} catch {
|
|
1326
|
-
return false
|
|
1327
|
-
}
|
|
1328
|
-
}
|
|
1329
|
-
|
|
1330
|
-
/**
|
|
1331
|
-
* ターミナルミラーを対象ペインで起動する。ペイン前景が plain shell のときのみ許可する
|
|
1332
|
-
* (対話 TUI が前景にいる状態での誤爆入力を防ぐ)。
|
|
1333
|
-
*
|
|
1334
|
-
* @param {string} name tmux セッション名
|
|
1335
|
-
* @param {{cwd: string, sessionId: string, cliKind: 'claude'|'codex'}} args
|
|
1336
|
-
* @param {{ tmux?: string, logger?: import('pino').Logger }} [opts]
|
|
1337
|
-
* @returns {Promise<{ ok: boolean, reason?: string, error?: string }>}
|
|
1338
|
-
*/
|
|
1339
|
-
export async function startTerminalMirror(name, { cwd, sessionId, cliKind }, opts = {}) {
|
|
1340
|
-
if (!isSafeSessionName(name)) {
|
|
1341
|
-
return { ok: false, error: "unsafe session name" }
|
|
1342
|
-
}
|
|
1343
|
-
const atShell = await isPaneAtShellPrompt(name, opts)
|
|
1344
|
-
if (!atShell) {
|
|
1345
|
-
return {
|
|
1346
|
-
ok: false,
|
|
1347
|
-
reason: "pane-not-at-shell",
|
|
1348
|
-
error: "ペインが対話 TUI 実行中のためミラーを起動できません (シェルに戻ってから再試行してください)",
|
|
1349
|
-
}
|
|
1350
|
-
}
|
|
1351
|
-
let cmd
|
|
1352
|
-
try {
|
|
1353
|
-
cmd = buildMirrorStartCmd({ cwd, sessionId, cliKind, sessionName: name })
|
|
1354
|
-
} catch (err) {
|
|
1355
|
-
return { ok: false, error: err?.message || String(err) }
|
|
1356
|
-
}
|
|
1357
|
-
try {
|
|
1358
|
-
await execFileP(tmuxBin(opts), ["send-keys", "-t", name, cmd, "Enter"])
|
|
1359
|
-
opts.logger?.info({ session: name, cliKind, sessionId }, "terminal mirror started")
|
|
1360
|
-
return { ok: true }
|
|
1361
|
-
} catch (err) {
|
|
1362
|
-
opts.logger?.warn({ session: name, err: err?.message }, "terminal mirror start failed")
|
|
1363
|
-
return { ok: false, error: err?.message || String(err) }
|
|
1364
|
-
}
|
|
1365
|
-
}
|
|
1366
|
-
|
|
1367
|
-
/**
|
|
1368
|
-
* ターミナルミラーを対象ペインで停止する。pidfile に記録された PID を kill するだけの
|
|
1369
|
-
* ベストエフォート (既に停止済み/pidfile 無しでもエラーにしない)。
|
|
1370
|
-
*
|
|
1371
|
-
* @param {string} name tmux セッション名
|
|
1372
|
-
* @param {{ tmux?: string, logger?: import('pino').Logger }} [opts]
|
|
1373
|
-
* @returns {Promise<{ ok: boolean, error?: string }>}
|
|
1374
|
-
*/
|
|
1375
|
-
export async function stopTerminalMirror(name, opts = {}) {
|
|
1376
|
-
if (!isSafeSessionName(name)) {
|
|
1377
|
-
return { ok: false, error: "unsafe session name" }
|
|
1378
|
-
}
|
|
1379
|
-
const cmd = buildMirrorStopCmd({ sessionName: name })
|
|
1380
|
-
try {
|
|
1381
|
-
await execFileP(tmuxBin(opts), ["send-keys", "-t", name, cmd, "Enter"])
|
|
1382
|
-
opts.logger?.info({ session: name }, "terminal mirror stop requested")
|
|
1383
|
-
return { ok: true }
|
|
1384
|
-
} catch (err) {
|
|
1385
|
-
opts.logger?.warn({ session: name, err: err?.message }, "terminal mirror stop failed")
|
|
1386
|
-
return { ok: false, error: err?.message || String(err) }
|
|
1387
|
-
}
|
|
1388
|
-
}
|
package/src/usage.mjs
CHANGED
|
@@ -1361,7 +1361,7 @@ async function scanAllDatesForCodexRollout(base, threadId) {
|
|
|
1361
1361
|
* 表示できなくなる(2026-07-15 の resume 修正で古いスレッド復帰が正しく動くようになり
|
|
1362
1362
|
* 露出)。高速パスで見つからなければ全日付フォールバック走査に落とし、結果を threadId
|
|
1363
1363
|
* でメモ化する。
|
|
1364
|
-
*
|
|
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
|
-
}
|