@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 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
- if (r.bundle?.error) {
70
- console.error(`hub bundle sync warning: ${r.bundle.error}`)
71
- } else if (r.bundle) {
72
- console.log(
73
- `hub bundle synced: version=${r.bundle.version} written=${r.bundle.written?.length ?? 0} ` +
74
- `skipped_same=${r.bundle.skipped_same?.length ?? 0}`,
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("Hub AI bundle を手動で再同期 (CLAUDE.md / scripts / hooks / projects.json を ~/.claude/ に展開)")
86
- .action(async () => {
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
- const r = await syncBundle({
94
- hubUrl: cfg.hub_url,
95
- agentId: cfg.agent_id,
96
- agentToken: cfg.agent_token,
97
- })
98
- const mcpSummary = r.mcp
99
- ? ` mcp_added=${r.mcp.added.length}${r.mcp.skipped.length > 0 ? ` mcp_skipped=${r.mcp.skipped.length}` : ""}`
100
- : ""
101
- console.log(
102
- `hub bundle synced: version=${r.version} written=${r.written.length} skipped_same=${r.skipped_same.length}${mcpSummary}`,
103
- )
104
- if (r.mcp && r.mcp.added.length > 0) {
105
- console.log(` mcp servers added: ${r.mcp.added.join(", ")}`)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.7.30",
3
+ "version": "0.7.33",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -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
- return { events, total_lines, truncated }
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
- if (at > bestAt) {
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
- this.child = this._spawnFn(this.codexCmd, ["app-server", "--stdio"], {
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
- return { resuming, threadId: session.threadId }
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 が読めない環境は素の linux 扱い */
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
- let bundleResult = null
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
- try {
77
- const { syncBundle } = await import("./hub-bundle.mjs")
78
- bundleResult = await syncBundle({
79
- hubUrl,
80
- agentId: data.agent_id,
81
- agentToken: data.agent_token,
82
- logger: opts.logger,
83
- fetchImpl,
84
- })
85
- } catch (err) {
86
- opts.logger?.warn?.({ err: err.message }, "hub bundle sync failed (enroll still ok)")
87
- bundleResult = { error: err.message }
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
- // bundle 同期で ~/.claude/scripts/setup_hub_ai.py が配置された直後に kick する。
91
- // 設計穴: 初回 enroll では誰も setup_hub_ai.py を実行しないため、`step_claude_md`
92
- // が走らず ~/.claude/CLAUDE.md がいつまでも作成されない事故があった
93
- // (2026-06-17 toma 環境)。bundle 配信は scripts と claude_md_fragment.md は
94
- // 含むが、CLAUDE.md と settings.json は含まないため、誰かが setup_hub_ai を
95
- // 一度叩かないと self-bootstrap しない。enroll 直後に best-effort で 1 回叩く。
96
- // 既存環境では step_claude_md がマーカー範囲を冪等更新するだけで害なし。
97
- try {
98
- const { kickSetupHubAi } = await import("./main.mjs")
99
- kickSetupHubAi({ logger: opts.logger })
100
- } catch (err) {
101
- opts.logger?.warn?.({ err: err.message }, "kickSetupHubAi from enroll failed (ignored)")
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: bundleResult,
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