@cocorograph/hub-agent 0.7.46 → 0.7.47

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.7.46",
3
+ "version": "0.7.47",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Codex アカウント・プロファイル管理 (Claude の profiles.mjs と対の Codex 版)。
3
+ *
4
+ * 1 台のマシンで複数の Codex (OpenAI) アカウントを `CODEX_HOME` で切り替えて使うための
5
+ * 仕組み。各プロファイルは独立した Codex ホームディレクトリを持つ。
6
+ *
7
+ * 認証: Codex は OAuth トークン / API キーを `<CODEX_HOME>/auth.json` に **ファイルで**
8
+ * 保存する (Claude の macOS のような Keychain 単一エントリ問題は無い)。したがって
9
+ * `CODEX_HOME` を切り替えるだけでアカウントも分離され、スワップ処理は一切不要。
10
+ *
11
+ * 共有方針 (2026-07, 中村さん決定「アカウントが変わっても振る舞いを変えない」):
12
+ * - **分離するのは認証と純粋なランタイム/ログ/キャッシュだけ**。
13
+ * - 設定 (config.toml) / hooks / skills / scripts / plugins / 記憶 (memories) /
14
+ * 会話履歴 (sessions, history.jsonl) など **振る舞いに影響する資産はすべて共有**する。
15
+ * - 実装は「共有をデフォルトにし、例外 (ISOLATED) だけ分離」する。これにより
16
+ * バージョン番号付き sqlite (memories_1 / goals_1 等) も列挙不要で自動共有される。
17
+ * - 共有は既定 (プライマリ) `~/.codex` への **symlink**。SQLite はシンボリックリンクを
18
+ * たどって実体パスに -wal/-shm を作るため、単一アクティブ運用 (切替時に全 Codex
19
+ * セッションを停止してから起動) では破損しない。
20
+ *
21
+ * 設計方針 (Claude 版と同じ):
22
+ * - 既定プロファイルは常に `~/.codex` (CODEX_DIR override 可)。リネーム・移動しない。
23
+ * - 2 つ目以降だけ `~/.codex-1` `~/.codex-2` … が生える (中立な連番)。
24
+ * - ディレクトリ名にプラン種別を埋め込まない。UI 表示は label で行う。
25
+ * - レジストリは `~/.hub/codex-profiles.json`。
26
+ */
27
+ import { promises as fs } from "node:fs"
28
+ import path from "node:path"
29
+ import os from "node:os"
30
+
31
+ import { paths as hubPaths, ensureConfigDir } from "./config.mjs"
32
+
33
+ /** 既定 (プライマリ) プロファイルの ID。 */
34
+ export const DEFAULT_CODEX_PROFILE_ID = "default"
35
+
36
+ /** `~/.codex` の絶対パス (CODEX_DIR override は hub-bundle.mjs / skills.mjs と揃える)。 */
37
+ export function defaultCodexDir() {
38
+ return process.env.CODEX_DIR || path.join(os.homedir(), ".codex")
39
+ }
40
+
41
+ /**
42
+ * 新規 Codex プロファイル用ディレクトリへ共有しない (= アカウントごとに独立させる) 資産。
43
+ * これ以外はすべて既定 `~/.codex` への symlink として共有する。
44
+ *
45
+ * - 認証: auth.json (これがアカウントの実体。分離の主目的)
46
+ * - 端末/インストール固有: installation_id
47
+ * - 純粋なランタイム/キャッシュ/ログ (振る舞いに影響しない・共有すると破損/肥大の恐れ):
48
+ * cache, .tmp, tmp, shell_snapshots, app-server-control, models_cache.json,
49
+ * version.json, .personality_migration
50
+ */
51
+ const CODEX_ISOLATED_NAMES = new Set([
52
+ "auth.json",
53
+ "installation_id",
54
+ "cache",
55
+ ".tmp",
56
+ "tmp",
57
+ "shell_snapshots",
58
+ "app-server-control",
59
+ "models_cache.json",
60
+ "version.json",
61
+ ".personality_migration",
62
+ ])
63
+
64
+ /**
65
+ * 分離するファイル名パターン (ランタイムログ/セッション状態の sqlite)。
66
+ * 記憶 (memories*.sqlite) / 目標 (goals*.sqlite) は **共有**するため除外する。
67
+ * - logs*.sqlite(-wal/-shm): 実行ログ。アカウント混在を避け分離。
68
+ * - state*.sqlite(-wal/-shm): ランタイム状態。分離。
69
+ */
70
+ function isIsolatedByPattern(name) {
71
+ return /^logs.*\.sqlite(-wal|-shm)?$/.test(name) || /^state.*\.sqlite(-wal|-shm)?$/.test(name)
72
+ }
73
+
74
+ /** その名前を新規プロファイルへ共有する (symlink する) か。 */
75
+ export function isSharedCodexEntry(name) {
76
+ if (CODEX_ISOLATED_NAMES.has(name)) return false
77
+ if (isIsolatedByPattern(name)) return false
78
+ return true
79
+ }
80
+
81
+ /** 既定プロファイルを合成して返す (永続化はしない)。 */
82
+ export function makeDefaultCodexProfile() {
83
+ return {
84
+ id: DEFAULT_CODEX_PROFILE_ID,
85
+ label: "メイン",
86
+ configDir: defaultCodexDir(),
87
+ }
88
+ }
89
+
90
+ /** `~/.hub/codex-profiles.json` の絶対パス。 */
91
+ export function registryPath() {
92
+ return path.join(hubPaths.configDir, "codex-profiles.json")
93
+ }
94
+
95
+ let _cache = null
96
+
97
+ /** チルダ展開。 */
98
+ function resolveDir(dir) {
99
+ if (!dir) return defaultCodexDir()
100
+ if (dir === "~") return os.homedir()
101
+ if (dir.startsWith("~/")) return path.join(os.homedir(), dir.slice(2))
102
+ return path.resolve(dir)
103
+ }
104
+
105
+ /**
106
+ * レジストリを読み込む。存在しなければ既定プロファイルのみの仮想レジストリを返す。
107
+ * @returns {Promise<{activeId: string, profiles: Array<{id:string,label:string,configDir:string}>}>}
108
+ */
109
+ export async function loadRegistry() {
110
+ if (_cache) return _cache
111
+ let parsed = null
112
+ try {
113
+ const raw = await fs.readFile(registryPath(), "utf-8")
114
+ parsed = JSON.parse(raw)
115
+ } catch (err) {
116
+ if (err.code !== "ENOENT") throw err
117
+ }
118
+
119
+ const def = makeDefaultCodexProfile()
120
+ const profiles = [def]
121
+ let activeId = DEFAULT_CODEX_PROFILE_ID
122
+
123
+ if (parsed && Array.isArray(parsed.profiles)) {
124
+ const seen = new Set([DEFAULT_CODEX_PROFILE_ID])
125
+ for (const p of parsed.profiles) {
126
+ if (!p || typeof p.id !== "string" || p.id === DEFAULT_CODEX_PROFILE_ID) continue
127
+ if (seen.has(p.id)) continue
128
+ seen.add(p.id)
129
+ profiles.push({
130
+ id: p.id,
131
+ label: typeof p.label === "string" && p.label ? p.label : p.id,
132
+ configDir: resolveDir(p.configDir),
133
+ })
134
+ }
135
+ if (typeof parsed.defaultLabel === "string" && parsed.defaultLabel) {
136
+ def.label = parsed.defaultLabel
137
+ }
138
+ if (
139
+ typeof parsed.activeId === "string" &&
140
+ profiles.some((p) => p.id === parsed.activeId)
141
+ ) {
142
+ activeId = parsed.activeId
143
+ }
144
+ }
145
+
146
+ _cache = { activeId, profiles }
147
+ return _cache
148
+ }
149
+
150
+ /** レジストリを永続化する。default は profiles[] に含めず defaultLabel / activeId だけ保存。 */
151
+ async function saveRegistry(reg) {
152
+ await ensureConfigDir()
153
+ const def = reg.profiles.find((p) => p.id === DEFAULT_CODEX_PROFILE_ID)
154
+ const payload = {
155
+ activeId: reg.activeId,
156
+ defaultLabel: def ? def.label : undefined,
157
+ profiles: reg.profiles
158
+ .filter((p) => p.id !== DEFAULT_CODEX_PROFILE_ID)
159
+ .map((p) => ({ id: p.id, label: p.label, configDir: p.configDir })),
160
+ }
161
+ await fs.writeFile(registryPath(), JSON.stringify(payload, null, 2), {
162
+ mode: 0o600,
163
+ })
164
+ _cache = null
165
+ }
166
+
167
+ /** プロファイル一覧と active を返す (UI 表示用)。 */
168
+ export async function listProfiles() {
169
+ const reg = await loadRegistry()
170
+ return { activeId: reg.activeId, profiles: reg.profiles }
171
+ }
172
+
173
+ /** active プロファイルオブジェクトを返す。 */
174
+ export async function getActiveProfile() {
175
+ const reg = await loadRegistry()
176
+ return reg.profiles.find((p) => p.id === reg.activeId) || makeDefaultCodexProfile()
177
+ }
178
+
179
+ /** active の Codex ホーム絶対パス。 */
180
+ export async function getActiveConfigDir() {
181
+ return (await getActiveProfile()).configDir
182
+ }
183
+
184
+ /**
185
+ * active プロファイルを切り替える。存在しない id はエラー。
186
+ * env (CODEX_HOME) の反映と全 Codex セッション再起動は呼び出し側 (main.mjs) の責務。
187
+ */
188
+ export async function setActiveProfile(id) {
189
+ const reg = await loadRegistry()
190
+ const target = reg.profiles.find((p) => p.id === id)
191
+ if (!target) throw new Error(`unknown codex profile: ${id}`)
192
+ reg.activeId = id
193
+ await saveRegistry(reg)
194
+ return target
195
+ }
196
+
197
+ /**
198
+ * プロファイルの表示ラベルを変更する。id・configDir・認証には一切触れない。
199
+ * 既定プロファイルのラベルも変更可 (defaultLabel として永続化)。
200
+ */
201
+ export async function renameProfile(id, label) {
202
+ const trimmed = typeof label === "string" ? label.trim() : ""
203
+ if (!trimmed) throw new Error("label must be a non-empty string")
204
+ const reg = await loadRegistry()
205
+ const target = reg.profiles.find((p) => p.id === id)
206
+ if (!target) throw new Error(`unknown codex profile: ${id}`)
207
+ target.label = trimmed
208
+ await saveRegistry(reg)
209
+ return { id: target.id, label: target.label, configDir: target.configDir }
210
+ }
211
+
212
+ /** 次の連番 ID (既存の数値 id の最大 +1、最低 1)。 */
213
+ function nextNumericId(profiles) {
214
+ let max = 0
215
+ for (const p of profiles) {
216
+ const n = Number.parseInt(p.id, 10)
217
+ if (Number.isInteger(n) && n > max) max = n
218
+ }
219
+ return String(max + 1)
220
+ }
221
+
222
+ /**
223
+ * プロファイルを新規追加する (連番 id・中立パス `~/.codex-<n>`)。
224
+ * 共有資産の provisioning は provisionProfile が行う。追加直後はログイン未了
225
+ * (ユーザーが `CODEX_HOME=<dir> codex login` する必要がある)。
226
+ *
227
+ * @param {{label?: string}} [opts]
228
+ * @returns {Promise<{id:string,label:string,configDir:string, needsLogin: boolean}>}
229
+ */
230
+ export async function addProfile(opts = {}) {
231
+ const reg = await loadRegistry()
232
+ const id = nextNumericId(reg.profiles)
233
+ const configDir = path.join(os.homedir(), `.codex-${id}`)
234
+ const profile = {
235
+ id,
236
+ label:
237
+ opts.label && String(opts.label).trim()
238
+ ? String(opts.label).trim()
239
+ : `アカウント${id}`,
240
+ configDir,
241
+ }
242
+ reg.profiles.push(profile)
243
+ await saveRegistry(reg)
244
+ await provisionProfile(configDir)
245
+ return { ...profile, needsLogin: true }
246
+ }
247
+
248
+ /**
249
+ * Codex は auth.json (ファイル) の有無でログイン済みかを判定できる。
250
+ * @param {string} configDir
251
+ * @returns {Promise<boolean>}
252
+ */
253
+ export async function hasCodexCredentials(configDir) {
254
+ try {
255
+ await fs.access(path.join(configDir, "auth.json"))
256
+ return true
257
+ } catch {
258
+ return false
259
+ }
260
+ }
261
+
262
+ /**
263
+ * 新規 Codex プロファイルディレクトリを整備する。
264
+ *
265
+ * 「共有デフォルト・例外分離」: 既定 `~/.codex` の各エントリのうち、ISOLATED でない
266
+ * ものすべてを新ディレクトリへ symlink する (設定・hooks・skills・scripts・plugins・
267
+ * memories・sessions・history 等 = 振る舞いを共有)。ISOLATED (auth.json / ランタイム
268
+ * ログ・キャッシュ) は張らず、Codex 側で各アカウント固有に作らせる。
269
+ *
270
+ * 既存エントリ (symlink/実体) には触らない (冪等)。
271
+ *
272
+ * @param {string} configDir - 対象プロファイルの Codex ホーム絶対パス
273
+ * @returns {Promise<{linked: string[], skipped: string[]}>}
274
+ */
275
+ export async function provisionProfile(configDir) {
276
+ const base = defaultCodexDir()
277
+ await fs.mkdir(configDir, { recursive: true, mode: 0o700 })
278
+ const linked = []
279
+ const skipped = []
280
+
281
+ let entries = []
282
+ try {
283
+ entries = await fs.readdir(base)
284
+ } catch {
285
+ // 既定 ~/.codex がまだ無い (Codex 未初期化)。共有元が無いので何もしない。
286
+ return { linked, skipped: ["<base-missing>"] }
287
+ }
288
+
289
+ for (const name of entries) {
290
+ if (!isSharedCodexEntry(name)) {
291
+ skipped.push(`${name}(分離)`)
292
+ continue
293
+ }
294
+ const src = path.join(base, name)
295
+ const dst = path.join(configDir, name)
296
+ // dst が既に存在 (symlink/実体) なら触らない。
297
+ let exists = false
298
+ try {
299
+ await fs.lstat(dst)
300
+ exists = true
301
+ } catch {
302
+ /* not exists */
303
+ }
304
+ if (exists) {
305
+ skipped.push(`${name}(既存)`)
306
+ continue
307
+ }
308
+ await fs.symlink(src, dst)
309
+ linked.push(name)
310
+ }
311
+
312
+ return { linked, skipped }
313
+ }
314
+
315
+ /** テスト用: モジュールキャッシュを破棄する。 */
316
+ export function _clearCache() {
317
+ _cache = null
318
+ }
package/src/main.mjs CHANGED
@@ -58,6 +58,9 @@ import {
58
58
  addProfile,
59
59
  renameProfile,
60
60
  } from "./profiles.mjs"
61
+ // Codex アカウント・プロファイル (独立切替)。profiles.mjs と同名 export が多いため
62
+ // 名前衝突を避けて namespace import する。
63
+ import * as codexProfiles from "./codex-profiles.mjs"
61
64
  import {
62
65
  isKeychainPlatform,
63
66
  profileHasKeychainCredentials,
@@ -360,6 +363,45 @@ export async function syncTmuxProfileEnv(profile, log) {
360
363
  )
361
364
  }
362
365
 
366
+ /**
367
+ * active Codex プロファイルの configDir を process.env.CODEX_HOME へ反映する。
368
+ * 既定 (`~/.codex`) のときは変数を消して Codex 既定パスに戻す。codex app-server は
369
+ * この env を継承して起動するため、以後の Codex セッションはこのアカウントで動く。
370
+ * Claude 版 applyActiveProfileEnv の Codex 対応 (独立切替)。
371
+ */
372
+ export function applyActiveCodexEnv(profile, log) {
373
+ const isDefault =
374
+ !profile ||
375
+ profile.id === codexProfiles.DEFAULT_CODEX_PROFILE_ID ||
376
+ profile.configDir === codexProfiles.defaultCodexDir()
377
+ if (isDefault) {
378
+ delete process.env.CODEX_HOME
379
+ } else {
380
+ process.env.CODEX_HOME = profile.configDir
381
+ }
382
+ log?.info?.(
383
+ {
384
+ profile_id: profile?.id || codexProfiles.DEFAULT_CODEX_PROFILE_ID,
385
+ codex_home: process.env.CODEX_HOME || codexProfiles.defaultCodexDir(),
386
+ },
387
+ "active codex profile applied",
388
+ )
389
+ }
390
+
391
+ /**
392
+ * active Codex プロファイルの CODEX_HOME を tmux サーバーのグローバル環境へ同期する。
393
+ * tmux pane で直接 `codex` を打つケースや respawn の保険。tmux 未起動でも失敗しない。
394
+ */
395
+ export async function syncTmuxCodexEnv(profile, log) {
396
+ const isDefault =
397
+ !profile ||
398
+ profile.id === codexProfiles.DEFAULT_CODEX_PROFILE_ID ||
399
+ profile.configDir === codexProfiles.defaultCodexDir()
400
+ await setTmuxGlobalEnv("CODEX_HOME", isDefault ? null : profile.configDir, {
401
+ logger: log,
402
+ })
403
+ }
404
+
363
405
  /**
364
406
  * `setup_hub_ai.py --silent` を best-effort でキックし、全プロファイルの settings.json へ
365
407
  * bundle hooks (cockpit_permission_bridge 等) を反映させる。
@@ -440,6 +482,18 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
440
482
  )
441
483
  }
442
484
 
485
+ // 同様に active Codex プロファイルの CODEX_HOME を反映する (独立切替)。
486
+ try {
487
+ const activeCodex = await codexProfiles.getActiveProfile()
488
+ applyActiveCodexEnv(activeCodex, logger)
489
+ await syncTmuxCodexEnv(activeCodex, logger)
490
+ } catch (err) {
491
+ logger.warn(
492
+ { err: err.message },
493
+ "failed to apply active codex profile; falling back to default ~/.codex",
494
+ )
495
+ }
496
+
443
497
  // launchd / systemd 起動では .zshrc 等の対話シェル設定が読まれず、~/.claude.json の
444
498
  // MCP 定義が参照する環境変数 (例: Authorization: Bearer ${WRI_MCP_TOKEN}) が解決
445
499
  // できない。ユーザーのログインシェル env + dotenv 層を 1 回解決して process.env を
@@ -4018,6 +4072,129 @@ async function dispatch(msg, ctx) {
4018
4072
  }
4019
4073
  return
4020
4074
  }
4075
+ case "codex.profile.list": {
4076
+ // Codex アカウント・プロファイル一覧と active を返す (Cockpit の Codex 切替UI用)。
4077
+ // 認証はファイル (<CODEX_HOME>/auth.json) の有無で判定 (Keychain 不要)。
4078
+ const reg = await codexProfiles.listProfiles()
4079
+ const profiles = await Promise.all(
4080
+ reg.profiles.map(async (p) => ({
4081
+ id: p.id,
4082
+ label: p.label,
4083
+ config_dir: p.configDir,
4084
+ has_credentials: await codexProfiles.hasCodexCredentials(p.configDir),
4085
+ })),
4086
+ )
4087
+ ctx.client.send({
4088
+ type: "codex.profile.list.response",
4089
+ request_id: msg.request_id,
4090
+ active_id: reg.activeId,
4091
+ profiles,
4092
+ })
4093
+ return
4094
+ }
4095
+ case "codex.profile.switch": {
4096
+ // Codex アカウント切替。active 永続化 → CODEX_HOME を env/tmux に反映 → Codex
4097
+ // app-server セッションのみ再起動 (Claude 側は独立なので触らない)。認証はファイル
4098
+ // 分離のため CODEX_HOME 切替だけでアカウントが変わる (スワップ不要)。
4099
+ const id = msg.profile_id || msg.id || ""
4100
+ try {
4101
+ const target = await codexProfiles.setActiveProfile(id)
4102
+ applyActiveCodexEnv(target, ctx.logger)
4103
+ await syncTmuxCodexEnv(target, ctx.logger)
4104
+ // Codex セッションだけ撤去 (Claude セッションは残す = 独立切替)。
4105
+ const before = ctx.codexBridge?.sessions?.size ?? 0
4106
+ ctx.codexBridge?.shutdown()
4107
+ const hasCreds = await codexProfiles.hasCodexCredentials(target.configDir)
4108
+ const needsLogin = !hasCreds
4109
+ const isDefaultTarget = target.id === codexProfiles.DEFAULT_CODEX_PROFILE_ID
4110
+ ctx.logger.info(
4111
+ { profile_id: target.id, reaped: before, needs_login: needsLogin },
4112
+ "codex profile switched; codex sessions reaped",
4113
+ )
4114
+ ctx.client.send({
4115
+ type: "codex.profile.switch.result",
4116
+ request_id: msg.request_id,
4117
+ ok: true,
4118
+ active_id: target.id,
4119
+ profile: {
4120
+ id: target.id,
4121
+ label: target.label,
4122
+ config_dir: target.configDir,
4123
+ },
4124
+ reaped: before,
4125
+ needs_login: needsLogin,
4126
+ login_command: needsLogin
4127
+ ? isDefaultTarget
4128
+ ? "codex login"
4129
+ : `CODEX_HOME=${target.configDir} codex login`
4130
+ : undefined,
4131
+ })
4132
+ } catch (err) {
4133
+ ctx.client.send({
4134
+ type: "codex.profile.switch.result",
4135
+ request_id: msg.request_id,
4136
+ ok: false,
4137
+ profile_id: id,
4138
+ error: err?.message || String(err),
4139
+ })
4140
+ }
4141
+ return
4142
+ }
4143
+ case "codex.profile.add": {
4144
+ // 新規 Codex プロファイル追加 (連番 id・中立パス `~/.codex-<n>`)。共有資産を
4145
+ // provisioning 済み。追加直後はログイン未了 (CODEX_HOME=<dir> codex login)。
4146
+ try {
4147
+ const created = await codexProfiles.addProfile({ label: msg.label })
4148
+ ctx.client.send({
4149
+ type: "codex.profile.add.result",
4150
+ request_id: msg.request_id,
4151
+ ok: true,
4152
+ profile: {
4153
+ id: created.id,
4154
+ label: created.label,
4155
+ config_dir: created.configDir,
4156
+ },
4157
+ needs_login: created.needsLogin,
4158
+ login_command: `CODEX_HOME=${created.configDir} codex login`,
4159
+ })
4160
+ // 新プロファイルへ bundle hooks / AGENTS.md を反映 (codex 向け setup)。
4161
+ kickSetupHubAi({ logger: ctx.logger, cliKind: "codex" })
4162
+ } catch (err) {
4163
+ ctx.client.send({
4164
+ type: "codex.profile.add.result",
4165
+ request_id: msg.request_id,
4166
+ ok: false,
4167
+ error: err?.message || String(err),
4168
+ })
4169
+ }
4170
+ return
4171
+ }
4172
+ case "codex.profile.rename": {
4173
+ // Codex プロファイルの表示ラベルのみ変更 (切替・認証に触れない)。
4174
+ const id = msg.profile_id || msg.id || ""
4175
+ try {
4176
+ const updated = await codexProfiles.renameProfile(id, msg.label)
4177
+ ctx.client.send({
4178
+ type: "codex.profile.rename.result",
4179
+ request_id: msg.request_id,
4180
+ ok: true,
4181
+ profile: {
4182
+ id: updated.id,
4183
+ label: updated.label,
4184
+ config_dir: updated.configDir,
4185
+ },
4186
+ })
4187
+ } catch (err) {
4188
+ ctx.client.send({
4189
+ type: "codex.profile.rename.result",
4190
+ request_id: msg.request_id,
4191
+ ok: false,
4192
+ profile_id: id,
4193
+ error: err?.message || String(err),
4194
+ })
4195
+ }
4196
+ return
4197
+ }
4021
4198
  case "tmux.exec": {
4022
4199
  const args = Array.isArray(msg.args) ? msg.args : []
4023
4200
  const hookResult = await runHookChain(ctx.plugins, "interceptTmuxExec", {