@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/bin/hub-agent.mjs
CHANGED
|
@@ -66,13 +66,18 @@ program
|
|
|
66
66
|
skipBundle: opts.bundle === false,
|
|
67
67
|
})
|
|
68
68
|
console.log(`enroll succeeded: agent_id=${r.agent_id} primary=${r.is_primary} hub=${r.hub_url}`)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
`
|
|
75
|
-
)
|
|
69
|
+
// Cockpit Codex 両対応 (Stream A2): claude/codex 両方が検出された環境では
|
|
70
|
+
// r.bundles に両方の結果が入る。無ければ従来どおり r.bundle 単体を見る。
|
|
71
|
+
const bundleEntries = r.bundles ? Object.entries(r.bundles) : r.bundle ? [["claude", r.bundle]] : []
|
|
72
|
+
for (const [cliKind, b] of bundleEntries) {
|
|
73
|
+
if (b?.error) {
|
|
74
|
+
console.error(`hub bundle sync warning (${cliKind}): ${b.error}`)
|
|
75
|
+
} else if (b) {
|
|
76
|
+
console.log(
|
|
77
|
+
`hub bundle synced (${cliKind}): version=${b.version} written=${b.written?.length ?? 0} ` +
|
|
78
|
+
`skipped_same=${b.skipped_same?.length ?? 0}`,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
76
81
|
}
|
|
77
82
|
} catch (err) {
|
|
78
83
|
console.error(`enroll failed: ${err.message}`)
|
|
@@ -82,27 +87,50 @@ program
|
|
|
82
87
|
|
|
83
88
|
program
|
|
84
89
|
.command("sync-bundle")
|
|
85
|
-
.description(
|
|
86
|
-
|
|
90
|
+
.description(
|
|
91
|
+
"Hub AI bundle を手動で再同期 (CLAUDE.md / scripts / hooks / projects.json を ~/.claude/ に展開)。" +
|
|
92
|
+
"ローカルで検出された CLI 全て (claude / codex) を既定で同期する",
|
|
93
|
+
)
|
|
94
|
+
.option("--cli-kind <kind>", "'claude' または 'codex' のみ同期する (省略時は検出された全 CLI)")
|
|
95
|
+
.action(async (opts) => {
|
|
87
96
|
try {
|
|
88
97
|
const cfg = await readConfig()
|
|
89
98
|
if (!cfg) {
|
|
90
99
|
console.error("not enrolled (run `hub-agent enroll <token>` first)")
|
|
91
100
|
process.exit(2)
|
|
92
101
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
102
|
+
// Cockpit Codex 両対応 (Stream A2, 2026-07-02): --cli-kind 未指定なら
|
|
103
|
+
// detectAvailableClis() で検出された全 CLI を同期する(1 台に claude/codex
|
|
104
|
+
// 両方入っていれば両方ブートストラップされる)。
|
|
105
|
+
let targets
|
|
106
|
+
if (opts.cliKind) {
|
|
107
|
+
if (opts.cliKind !== "claude" && opts.cliKind !== "codex") {
|
|
108
|
+
console.error(`--cli-kind は 'claude' か 'codex' のみ対応: ${opts.cliKind}`)
|
|
109
|
+
process.exit(2)
|
|
110
|
+
}
|
|
111
|
+
targets = [opts.cliKind]
|
|
112
|
+
} else {
|
|
113
|
+
const { detectAvailableClis } = await import("../src/config.mjs")
|
|
114
|
+
const detected = (await detectAvailableClis()).filter((c) => c === "claude" || c === "codex")
|
|
115
|
+
targets = detected.length > 0 ? detected : ["claude"]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const cliKind of targets) {
|
|
119
|
+
const r = await syncBundle({
|
|
120
|
+
hubUrl: cfg.hub_url,
|
|
121
|
+
agentId: cfg.agent_id,
|
|
122
|
+
agentToken: cfg.agent_token,
|
|
123
|
+
cliKind,
|
|
124
|
+
})
|
|
125
|
+
const mcpSummary = r.mcp
|
|
126
|
+
? ` mcp_added=${r.mcp.added.length}${r.mcp.skipped.length > 0 ? ` mcp_skipped=${r.mcp.skipped.length}` : ""}`
|
|
127
|
+
: ""
|
|
128
|
+
console.log(
|
|
129
|
+
`hub bundle synced (${cliKind}): version=${r.version} written=${r.written.length} skipped_same=${r.skipped_same.length}${mcpSummary}`,
|
|
130
|
+
)
|
|
131
|
+
if (r.mcp && r.mcp.added.length > 0) {
|
|
132
|
+
console.log(` mcp servers added: ${r.mcp.added.join(", ")}`)
|
|
133
|
+
}
|
|
106
134
|
}
|
|
107
135
|
} catch (err) {
|
|
108
136
|
console.error(`sync-bundle failed: ${err.message}`)
|
|
@@ -110,6 +138,52 @@ program
|
|
|
110
138
|
}
|
|
111
139
|
})
|
|
112
140
|
|
|
141
|
+
program
|
|
142
|
+
.command("mirror")
|
|
143
|
+
.description(
|
|
144
|
+
"チャット(SDK/app-server)の会話を、このターミナルに読み取り専用で実況表示する " +
|
|
145
|
+
"(Cockpit Codex 両対応 Stream C)。チャットの動作には一切影響しない (書き込まない)。" +
|
|
146
|
+
"Ctrl-C / SIGTERM で停止する",
|
|
147
|
+
)
|
|
148
|
+
.requiredOption("--cwd <path>", "対象セッションの cwd")
|
|
149
|
+
.requiredOption("--session <id>", "Claude session_id または Codex threadId")
|
|
150
|
+
.option("--cli-kind <kind>", "'claude' または 'codex'", "claude")
|
|
151
|
+
.action(async (opts) => {
|
|
152
|
+
if (opts.cliKind !== "claude" && opts.cliKind !== "codex") {
|
|
153
|
+
console.error(`--cli-kind は 'claude' か 'codex' のみ対応: ${opts.cliKind}`)
|
|
154
|
+
process.exit(2)
|
|
155
|
+
}
|
|
156
|
+
const { runClaudeMirror, runCodexMirror } = await import("../src/session-mirror.mjs")
|
|
157
|
+
// watchSessionFile / watchCodexRollout のポーリング timer は常駐 daemon (main.mjs) の
|
|
158
|
+
// graceful shutdown を妨げないよう意図的に unref() されている。この単体 CLI プロセスは
|
|
159
|
+
// 他に event loop を保持する要因が無いため、unref timer だけでは初回同期処理の直後に
|
|
160
|
+
// 即終了してしまう (実機検証で確認)。ref 付きの keep-alive timer で明示的に生かす。
|
|
161
|
+
const keepAlive = setInterval(() => {}, 60_000)
|
|
162
|
+
let watcher
|
|
163
|
+
try {
|
|
164
|
+
watcher =
|
|
165
|
+
opts.cliKind === "codex"
|
|
166
|
+
? await runCodexMirror({ threadId: opts.session })
|
|
167
|
+
: runClaudeMirror({ cwd: opts.cwd, sessionId: opts.session })
|
|
168
|
+
} catch (err) {
|
|
169
|
+
clearInterval(keepAlive)
|
|
170
|
+
console.error(`mirror failed: ${err.message}`)
|
|
171
|
+
process.exit(1)
|
|
172
|
+
}
|
|
173
|
+
const stop = () => {
|
|
174
|
+
clearInterval(keepAlive)
|
|
175
|
+
try {
|
|
176
|
+
watcher?.stop?.()
|
|
177
|
+
} catch {
|
|
178
|
+
/* ignore */
|
|
179
|
+
}
|
|
180
|
+
console.log("[cockpit mirror] 停止しました")
|
|
181
|
+
process.exit(0)
|
|
182
|
+
}
|
|
183
|
+
process.on("SIGINT", stop)
|
|
184
|
+
process.on("SIGTERM", stop)
|
|
185
|
+
})
|
|
186
|
+
|
|
113
187
|
program
|
|
114
188
|
.command("start")
|
|
115
189
|
.description("daemon を起動して Hub と WSS 接続する")
|
package/package.json
CHANGED
package/src/claude-history.mjs
CHANGED
|
@@ -29,6 +29,50 @@ export const MAX_HISTORY_LINES = 500
|
|
|
29
29
|
/** UI 表示対象の SDK message type (それ以外は jsonl 内部メタなので除外)。 */
|
|
30
30
|
export const DISPLAY_TYPES = new Set(["user", "assistant", "system", "result"])
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* hydrate 結果のパース済みキャッシュ (2026-07-03: Cockpit セッション切替パフォーマンス改善)。
|
|
34
|
+
*
|
|
35
|
+
* 背景: CockpitChatView は resume セッション切替のたびに毎回 readSessionHistory を呼ぶが
|
|
36
|
+
* (window/page.tsx の `key={selected}` 再マウント設計)、同一ファイルが未変更のまま
|
|
37
|
+
* 数秒〜数分内に再 hydrate されるケースが大半 (行ったり来たりのセッション切替、複数タブ)。
|
|
38
|
+
* readFile → split → 全行 JSON.parse (+ MASK_ENABLED 時は tool_use 走査) は jsonl が
|
|
39
|
+
* 大きいほど無視できないコストになるため、`mtimeMs + size` が前回と一致する間は
|
|
40
|
+
* パース結果をそのまま再利用し、ディスク読み直し・再パースを丸ごとスキップする。
|
|
41
|
+
* ファイルが変わっていれば (ライブ会話が進行 / 別ターミナルから追記等) 通常どおり再読み込み
|
|
42
|
+
* して上書きするので、常に最新のファイル内容を正しく反映する (stale 表示のリスクは無い)。
|
|
43
|
+
*
|
|
44
|
+
* key: filePath, value: { mtimeMs, size, maxLines, result }。maxLines もキーの一部として
|
|
45
|
+
* 比較する (異なる maxLines でのリクエストはキャッシュを再利用しない)。
|
|
46
|
+
*/
|
|
47
|
+
const HYDRATE_CACHE_MAX_ENTRIES = 200
|
|
48
|
+
const hydrateCache = new Map()
|
|
49
|
+
|
|
50
|
+
function getHydrateCache(filePath, mtimeMs, size, maxLines) {
|
|
51
|
+
const entry = hydrateCache.get(filePath)
|
|
52
|
+
if (!entry) return null
|
|
53
|
+
if (entry.mtimeMs !== mtimeMs || entry.size !== size || entry.maxLines !== maxLines) return null
|
|
54
|
+
// LRU: 再利用したエントリを Map の末尾へ移し直す (再挿入で挿入順を更新)。
|
|
55
|
+
hydrateCache.delete(filePath)
|
|
56
|
+
hydrateCache.set(filePath, entry)
|
|
57
|
+
return entry.result
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function setHydrateCache(filePath, mtimeMs, size, maxLines, result) {
|
|
61
|
+
hydrateCache.delete(filePath)
|
|
62
|
+
hydrateCache.set(filePath, { mtimeMs, size, maxLines, result })
|
|
63
|
+
// 上限超過分は挿入順が古いものから GC (長時間稼働の hub-agent プロセスでメモリが
|
|
64
|
+
// 際限なく増えないようにする)。
|
|
65
|
+
while (hydrateCache.size > HYDRATE_CACHE_MAX_ENTRIES) {
|
|
66
|
+
const oldestKey = hydrateCache.keys().next().value
|
|
67
|
+
hydrateCache.delete(oldestKey)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** テスト用: hydrate キャッシュを全消去する。 */
|
|
72
|
+
export function clearHydrateCacheForTest() {
|
|
73
|
+
hydrateCache.clear()
|
|
74
|
+
}
|
|
75
|
+
|
|
32
76
|
/**
|
|
33
77
|
* jsonl の生 object を、Browser が SDK message として受け取れる表示用 shape に正規化する。
|
|
34
78
|
* 余分な内部メタは落とし、表示・集計に必要なフィールドだけを superset で拾う。
|
|
@@ -97,6 +141,21 @@ export function jsonlPath({ cwd, session_id, projectsRoot }) {
|
|
|
97
141
|
* @returns {Promise<{events: object[], total_lines: number, truncated: boolean}>}
|
|
98
142
|
*/
|
|
99
143
|
export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINES, logger } = {}) {
|
|
144
|
+
// stat は readFile よりずっと軽い (メタデータのみ)。mtimeMs+size が前回と同じなら
|
|
145
|
+
// ファイル内容も同じとみなし、以降の readFile + 全行 parse をまるごとスキップする。
|
|
146
|
+
let st
|
|
147
|
+
try {
|
|
148
|
+
st = await stat(filePath)
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if (err.code === "ENOENT") {
|
|
151
|
+
return { events: [], total_lines: 0, truncated: false }
|
|
152
|
+
}
|
|
153
|
+
logger?.warn({ err: err.message, filePath }, "claude history stat failed")
|
|
154
|
+
throw err
|
|
155
|
+
}
|
|
156
|
+
const cached = getHydrateCache(filePath, st.mtimeMs, st.size, maxLines)
|
|
157
|
+
if (cached) return cached
|
|
158
|
+
|
|
100
159
|
let text
|
|
101
160
|
try {
|
|
102
161
|
text = await readFile(filePath, "utf-8")
|
|
@@ -143,7 +202,9 @@ export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINE
|
|
|
143
202
|
if (MASK_ENABLED) event = maskMessageObject(event, toolNames)
|
|
144
203
|
events.push(event)
|
|
145
204
|
}
|
|
146
|
-
|
|
205
|
+
const result = { events, total_lines, truncated }
|
|
206
|
+
setHydrateCache(filePath, st.mtimeMs, st.size, maxLines, result)
|
|
207
|
+
return result
|
|
147
208
|
}
|
|
148
209
|
|
|
149
210
|
/**
|
|
@@ -1647,7 +1647,12 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1647
1647
|
for (const t of session.lastActivityByStream.values()) {
|
|
1648
1648
|
if (t > at) at = t
|
|
1649
1649
|
}
|
|
1650
|
-
|
|
1650
|
+
// `>=` (2026-07-03): Date.now() はミリ秒精度のため、短時間に複数セッションへ
|
|
1651
|
+
// attach+input すると同一ミリ秒でタイが起き得る (テストや高速な連続操作で実測)。
|
|
1652
|
+
// `this.sessions` は Map で挿入順を保持するため、タイ時は後から attach された
|
|
1653
|
+
// (= イテレーション順で後に来る) セッションを採用するのが「最終アクティビティが
|
|
1654
|
+
// 最も新しい」の意図に合う。厳密な `>` だと先勝ちになり意図と逆転する。
|
|
1655
|
+
if (at >= bestAt) {
|
|
1651
1656
|
bestAt = at
|
|
1652
1657
|
best = session.sessionId
|
|
1653
1658
|
}
|
|
@@ -40,7 +40,20 @@ export class CodexAppServerClient extends EventEmitter {
|
|
|
40
40
|
/** codex app-server プロセスを起動する。二重呼び出しは無害 (no-op)。 */
|
|
41
41
|
start() {
|
|
42
42
|
if (this.child || this.closed) return
|
|
43
|
-
|
|
43
|
+
// Cockpit Codex 両対応 Stream A5(2026-07-02 実機検証で確定、中村さん明示承認済み):
|
|
44
|
+
// app-server は stdio 駆動で TTY が無いため、Codex 標準の対話的 hook 信頼確認
|
|
45
|
+
// プロンプトを出す余地が無い。--dangerously-bypass-hook-trust を付けないと
|
|
46
|
+
// Hub 配信の hooks.json が永久に発火しない(tmux.mjs の composeCodexFlags と同じ
|
|
47
|
+
// 理由・同じ既定、HUB_CODEX_BYPASS_HOOK_TRUST=0 で無効化可)。ルートオプションは
|
|
48
|
+
// サブコマンド (`app-server`) より前に置く必要がある(`codex app-server --help`
|
|
49
|
+
// にはこのフラグが出ない = サブコマンド固有オプションではないため)。
|
|
50
|
+
const bypassHookTrust = process.env.HUB_CODEX_BYPASS_HOOK_TRUST !== "0"
|
|
51
|
+
const args = [
|
|
52
|
+
...(bypassHookTrust ? ["--dangerously-bypass-hook-trust"] : []),
|
|
53
|
+
"app-server",
|
|
54
|
+
"--stdio",
|
|
55
|
+
]
|
|
56
|
+
this.child = this._spawnFn(this.codexCmd, args, {
|
|
44
57
|
cwd: this.cwd,
|
|
45
58
|
env: this.env || process.env,
|
|
46
59
|
stdio: ["pipe", "pipe", "pipe"],
|
|
@@ -0,0 +1,148 @@
|
|
|
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
|
|
@@ -23,6 +23,13 @@
|
|
|
23
23
|
* - 画像/添付入力 (テキストのみ)。
|
|
24
24
|
* - acceptForSession や execpolicy/network amendment 等の高度な承認 decision
|
|
25
25
|
* (permissionReply は decision をそのまま素通しするので呼び出し側が組み立てる)。
|
|
26
|
+
*
|
|
27
|
+
* Slice 6 (履歴 hydrate) で追加: `thread/resume` のレスポンスは既定で
|
|
28
|
+
* `thread.turns[].items[]` (= ThreadItem の時系列) を含む (公式スキーマ
|
|
29
|
+
* `ThreadReadResponse.json` の Thread.turns 説明: "Only populated on thread/resume,
|
|
30
|
+
* thread/rollback, thread/fork, and thread/read (when includeTurns is true)")。
|
|
31
|
+
* 追加の `thread/read` 往復は不要で、attach() の戻り値に `history` として平坦化して
|
|
32
|
+
* 返す (browser 側での re-mount 白紙状態を解消する)。
|
|
26
33
|
*/
|
|
27
34
|
import { EventEmitter } from "node:events"
|
|
28
35
|
|
|
@@ -39,6 +46,17 @@ const IDLE_DETACH_TTL_MS = 5 * 60 * 1000
|
|
|
39
46
|
|
|
40
47
|
const CLIENT_INFO = { name: "cockpit-hub-agent", title: "Cockpit", version: "1.0.0" }
|
|
41
48
|
|
|
49
|
+
// Slice 6: thread/resume が返す Thread.turns[].items[] を単一の時系列配列へ平坦化する。
|
|
50
|
+
// turns は開始ターン順の配列、各 turn.items も時系列順のため単純結合で会話順を保てる。
|
|
51
|
+
function flattenThreadItems(thread) {
|
|
52
|
+
const turns = Array.isArray(thread?.turns) ? thread.turns : []
|
|
53
|
+
const items = []
|
|
54
|
+
for (const turn of turns) {
|
|
55
|
+
if (Array.isArray(turn?.items)) items.push(...turn.items)
|
|
56
|
+
}
|
|
57
|
+
return items
|
|
58
|
+
}
|
|
59
|
+
|
|
42
60
|
function userInputFromMessage(message) {
|
|
43
61
|
const content = message?.content
|
|
44
62
|
if (typeof content === "string") {
|
|
@@ -65,7 +83,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
65
83
|
}
|
|
66
84
|
|
|
67
85
|
/**
|
|
68
|
-
* @returns {Promise<{resuming: boolean, threadId: string}>}
|
|
86
|
+
* @returns {Promise<{resuming: boolean, threadId: string, history: object[]}>}
|
|
69
87
|
*/
|
|
70
88
|
async attach({ stream_id, cwd, model, effort, approvalPolicy, sandbox, resumeThreadId }) {
|
|
71
89
|
if (this.sessions.has(stream_id)) {
|
|
@@ -96,7 +114,10 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
96
114
|
? await client.request("thread/resume", { ...startParams, threadId: resumeThreadId })
|
|
97
115
|
: await client.request("thread/start", startParams)
|
|
98
116
|
session.threadId = result?.thread?.id
|
|
99
|
-
|
|
117
|
+
// 新規スレッド (thread/start) は履歴が無いので空配列。resume 時のみ平坦化する
|
|
118
|
+
// (thread/start のレスポンスは turns を持たない仕様のため呼んでも常に空)。
|
|
119
|
+
const history = resuming ? flattenThreadItems(result?.thread) : []
|
|
120
|
+
return { resuming, threadId: session.threadId, history }
|
|
100
121
|
} catch (err) {
|
|
101
122
|
this.sessions.delete(stream_id)
|
|
102
123
|
client.stop()
|
package/src/config.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { promises as fs, constants as fsConsts, readFileSync } from "node:fs"
|
|
11
11
|
import path from "node:path"
|
|
12
12
|
import os from "node:os"
|
|
13
|
-
import { execFile } from "node:child_process"
|
|
13
|
+
import { execFile, execFileSync } from "node:child_process"
|
|
14
14
|
import { promisify } from "node:util"
|
|
15
15
|
|
|
16
16
|
const _execFileP = promisify(execFile)
|
|
@@ -21,9 +21,20 @@ const _execFileP = promisify(execFile)
|
|
|
21
21
|
* 重要: Windows のエージェントは WSL2 内で動くため `process.platform` は "linux" を
|
|
22
22
|
* 返す。素の Linux と区別するため、WSL を検出して "wsl" を返す。
|
|
23
23
|
*
|
|
24
|
+
* 判定は 3 段階のフォールバック:
|
|
25
|
+
* 1. `WSL_DISTRO_NAME` / `WSL_INTEROP` 環境変数 (対話シェルなら通常付く)
|
|
26
|
+
* 2. `/proc/version` の内容 (microsoft/wsl 文字列)
|
|
27
|
+
* 3. (2026-07-03 追加) `uname -r` のカーネルリリース文字列。systemd サービス等
|
|
28
|
+
* 非対話起動では 1 の環境変数が unit に伝播しないケースがある (WSL2 + systemd の
|
|
29
|
+
* 典型的な罠。対話シェルにしか WSL_DISTRO_NAME が無く、hub-agent をサービス化
|
|
30
|
+
* すると引き継がれない)。`uname -r` は環境変数に依存せずカーネル自体から
|
|
31
|
+
* "5.15.x-microsoft-standard-WSL2" のような値を返すため、より確実な最終フォールバック
|
|
32
|
+
* として使う。
|
|
33
|
+
*
|
|
34
|
+
* @param {{execImpl?: (cmd: string, args: string[]) => string}} [opts] テスト用の exec 差し替え
|
|
24
35
|
* @returns {"darwin"|"wsl"|"linux"|"win32"|string}
|
|
25
36
|
*/
|
|
26
|
-
export function detectPlatform() {
|
|
37
|
+
export function detectPlatform({ execImpl } = {}) {
|
|
27
38
|
if (process.platform === "darwin") return "darwin"
|
|
28
39
|
if (process.platform === "win32") return "win32"
|
|
29
40
|
if (process.platform === "linux") {
|
|
@@ -32,7 +43,14 @@ export function detectPlatform() {
|
|
|
32
43
|
const v = readFileSync("/proc/version", "utf-8").toLowerCase()
|
|
33
44
|
if (v.includes("microsoft") || v.includes("wsl")) return "wsl"
|
|
34
45
|
} catch {
|
|
35
|
-
/* /proc/version
|
|
46
|
+
/* /proc/version が読めない環境は次のフォールバックへ */
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const run = execImpl || ((cmd, args) => execFileSync(cmd, args, { encoding: "utf-8" }))
|
|
50
|
+
const release = run("uname", ["-r"]).toLowerCase()
|
|
51
|
+
if (release.includes("microsoft") || release.includes("wsl")) return "wsl"
|
|
52
|
+
} catch {
|
|
53
|
+
/* uname が無い/失敗する環境は素の linux 扱い */
|
|
36
54
|
}
|
|
37
55
|
return "linux"
|
|
38
56
|
}
|
package/src/enroll.mjs
CHANGED
|
@@ -71,34 +71,51 @@ export async function enroll(enrollmentToken, opts = {}) {
|
|
|
71
71
|
// Hub AI bundle を続けて同期する (CLAUDE.md / scripts / hooks / projects.json
|
|
72
72
|
// を ~/.claude/ に展開)。失敗しても enroll 自体は成功扱いにする (--no-bundle
|
|
73
73
|
// で skip 可能)。
|
|
74
|
-
|
|
74
|
+
//
|
|
75
|
+
// Cockpit Codex 両対応 (Stream A2, 2026-07-02): 1 台のマシンに claude/codex
|
|
76
|
+
// 両方入っている場合 (availableClis に両方含まれる)、両方の bundle を同期する
|
|
77
|
+
// (~/.claude/ と ~/.codex/ の両方を自己完結的にブートストラップ)。claude が
|
|
78
|
+
// 未検出の環境は無い想定だが、fail-soft のため availableClis に claude が
|
|
79
|
+
// 無ければ codex のみ同期する。
|
|
80
|
+
const bundleResults = {}
|
|
75
81
|
if (!opts.skipBundle) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
82
|
+
const { syncBundle } = await import("./hub-bundle.mjs")
|
|
83
|
+
const { kickSetupHubAi } = await import("./main.mjs")
|
|
84
|
+
// Hub backend が cli_kind='claude'/'codex' のみ対応するため他候補は無視する。
|
|
85
|
+
// detectAvailableClis() が空を返す (PATH 検出失敗等) 場合も fail-soft で
|
|
86
|
+
// 従来どおり claude 向け同期だけは必ず試みる。
|
|
87
|
+
const detected = availableClis.filter((c) => c === "claude" || c === "codex")
|
|
88
|
+
const targets = detected.length > 0 ? detected : ["claude"]
|
|
89
|
+
for (const cliKind of targets) {
|
|
90
|
+
try {
|
|
91
|
+
bundleResults[cliKind] = await syncBundle({
|
|
92
|
+
hubUrl,
|
|
93
|
+
agentId: data.agent_id,
|
|
94
|
+
agentToken: data.agent_token,
|
|
95
|
+
logger: opts.logger,
|
|
96
|
+
fetchImpl,
|
|
97
|
+
cliKind,
|
|
98
|
+
})
|
|
99
|
+
} catch (err) {
|
|
100
|
+
opts.logger?.warn?.({ cliKind, err: err.message }, "hub bundle sync failed (enroll still ok)")
|
|
101
|
+
bundleResults[cliKind] = { error: err.message }
|
|
102
|
+
}
|
|
89
103
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
104
|
+
// bundle 同期で ~/.claude/scripts/setup_hub_ai.py (or ~/.codex/...) が
|
|
105
|
+
// 配置された直後に kick する。設計穴: 初回 enroll では誰も
|
|
106
|
+
// setup_hub_ai.py を実行しないため、`step_claude_md` が走らず
|
|
107
|
+
// CLAUDE.md/AGENTS.md がいつまでも作成されない事故があった
|
|
108
|
+
// (2026-06-17 toma 環境)。bundle 配信は scripts と
|
|
109
|
+
// claude_md_fragment.md は含むが、CLAUDE.md/AGENTS.md と
|
|
110
|
+
// settings.json/hooks.json は含まないため、誰かが setup_hub_ai を
|
|
111
|
+
// 一度叩かないと self-bootstrap しない。enroll 直後に best-effort で
|
|
112
|
+
// 1 回叩く。既存環境では step_claude_md がマーカー範囲を冪等更新
|
|
113
|
+
// するだけで害なし。
|
|
114
|
+
try {
|
|
115
|
+
kickSetupHubAi({ logger: opts.logger, cliKind })
|
|
116
|
+
} catch (err) {
|
|
117
|
+
opts.logger?.warn?.({ cliKind, err: err.message }, "kickSetupHubAi from enroll failed (ignored)")
|
|
118
|
+
}
|
|
102
119
|
}
|
|
103
120
|
}
|
|
104
121
|
|
|
@@ -106,7 +123,11 @@ export async function enroll(enrollmentToken, opts = {}) {
|
|
|
106
123
|
agent_id: data.agent_id,
|
|
107
124
|
is_primary: !!data.is_primary,
|
|
108
125
|
hub_url: hubUrl,
|
|
109
|
-
bundle
|
|
126
|
+
// 後方互換: 呼び出し元 (bin/hub-agent.mjs 等) は従来 `bundle`(claude 単体の
|
|
127
|
+
// 結果オブジェクト)を見ていたため、claude の結果をそのまま残しつつ、
|
|
128
|
+
// 複数 CLI 分の内訳を `bundles` に別途持たせる。
|
|
129
|
+
bundle: bundleResults.claude ?? Object.values(bundleResults)[0] ?? null,
|
|
130
|
+
bundles: bundleResults,
|
|
110
131
|
}
|
|
111
132
|
}
|
|
112
133
|
|