@cocorograph/hub-agent 0.7.32 → 0.7.34

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.32",
3
+ "version": "0.7.34",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -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
@@ -125,6 +125,47 @@ export class CodexStreamBridge extends EventEmitter {
125
125
  }
126
126
  }
127
127
 
128
+ /**
129
+ * cwd 配下の Codex thread 一覧を返す (Cockpit Codex 両対応 セッションピッカー/最新
130
+ * セッション自動選択用。claude 側 listSessions と対称)。
131
+ *
132
+ * `thread/list` は特定のスレッドに紐付かない app-server のグローバル RPC (公式スキーマ
133
+ * `ThreadListParams`/`ThreadListResponse` で確認済み: cwd フィルタ・sortKey='recency_at'・
134
+ * sortDirection='desc' が使える) なので、attach() のような常駐セッションは作らず、
135
+ * 呼び出しごとに使い捨てクライアントで initialize → thread/list → stop する
136
+ * (list のためだけに長寿命プロセスを維持しない = リーク防止)。
137
+ *
138
+ * @returns {Promise<Array<{thread_id: string, cwd: string, preview: string, updated_at_ms: number}>>}
139
+ */
140
+ async listThreads({ cwd, limit } = {}) {
141
+ const client = this._clientFactory({ cwd, codexCmd: this.codexCmd, logger: this.logger })
142
+ client.start()
143
+ try {
144
+ await client.request("initialize", { clientInfo: CLIENT_INFO })
145
+ const result = await client.request("thread/list", {
146
+ cwd: cwd || undefined,
147
+ limit: limit || undefined,
148
+ // recency_at は「読み込みも含めた実際の使用感」の並び順 (公式スキーマの説明:
149
+ // "Unix timestamp used for thread recency ordering")。updated_at だと最終書き込み
150
+ // のみで並ぶため、Claude 側の mtime (listSessions) と体感を揃えるならこちらが近い。
151
+ sortKey: "recency_at",
152
+ sortDirection: "desc",
153
+ })
154
+ const threads = Array.isArray(result?.data) ? result.data : []
155
+ return threads.map((t) => ({
156
+ thread_id: t.id,
157
+ cwd: t.cwd || "",
158
+ preview: typeof t.preview === "string" ? t.preview.slice(0, 80) : "",
159
+ // recencyAt 優先 (無ければ updatedAt) を ms へ変換。claude 側 ClaudeSessionSummary.mtime
160
+ // (ms epoch) と同じ単位に揃え、frontend が同じ並び替えロジックを使い回せるようにする。
161
+ updated_at_ms:
162
+ (typeof t.recencyAt === "number" ? t.recencyAt : t.updatedAt || 0) * 1000,
163
+ }))
164
+ } finally {
165
+ client.stop()
166
+ }
167
+ }
168
+
128
169
  _wireClient(session) {
129
170
  const { client, stream_id } = session
130
171
  client.on("notification", (msg) => this._onNotification(session, msg))
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