@cocorograph/hub-agent 0.6.53 → 0.6.55

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.6.53",
3
+ "version": "0.6.55",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Claude Code 認証情報 (macOS Keychain) のプロファイル別スワップ。
3
+ *
4
+ * WHY: macOS の Claude Code は OAuth トークンを Keychain の単一エントリ
5
+ * 「Claude Code-credentials」に保存し、サービス名は `CLAUDE_CONFIG_DIR` に
6
+ * 依存しない。そのため config ディレクトリを切り替えても全プロファイルが
7
+ * 同じトークンを読んでしまい、アカウントが切り替わらない (2026-06-07 実測)。
8
+ *
9
+ * 対策: profile.switch のタイミングで
10
+ * 1. 正規エントリ (canonical) を切替元プロファイルのスロットへ退避
11
+ * 2. 切替先プロファイルのスロットから canonical へ復元
12
+ * 3. 切替先スロットが無ければ canonical を削除して needs_login を返す
13
+ * (削除しないと旧アカウントのトークンが残り「切り替わらない」が再発する)
14
+ *
15
+ * スロットは「Claude Code-credentials (hub-agent profile <id>)」という
16
+ * 別サービス名の Keychain エントリ。Claude Code 本体はスロットを読まない。
17
+ *
18
+ * Linux 等 Keychain の無いプラットフォームでは Claude Code が
19
+ * `<configDir>/.credentials.json` を使うため、config ディレクトリの切替だけで
20
+ * 認証も分離される。スワップは darwin 限定で no-op を返す。
21
+ *
22
+ * 書き込みは `security -i` (stdin) 経由で行い、トークンが argv (ps で他
23
+ * プロセスから観測可能) に露出しないようにする。`security -w` の読み出しは
24
+ * データが非 printable のとき hex を返すため decode を挟む。
25
+ */
26
+ import { execFile, spawn } from "node:child_process"
27
+ import { promisify } from "node:util"
28
+
29
+ const execFileP = promisify(execFile)
30
+
31
+ /** Claude Code 本体が読む正規エントリのサービス名 (固定)。 */
32
+ export const CANONICAL_SERVICE = "Claude Code-credentials"
33
+
34
+ /** プロファイル別退避スロットのサービス名。 */
35
+ export function slotService(profileId) {
36
+ return `Claude Code-credentials (hub-agent profile ${profileId})`
37
+ }
38
+
39
+ /** Keychain スワップが必要なプラットフォームか。 */
40
+ export function isKeychainPlatform(platform = process.platform) {
41
+ return platform === "darwin"
42
+ }
43
+
44
+ /**
45
+ * `security find-generic-password -w` の出力を decode する。
46
+ * security はデータに非 printable バイトが含まれると hex で出力する。
47
+ * Claude の credentials は ASCII JSON なので通常 raw のまま返るが、
48
+ * hex らしき文字列で decode 結果が JSON (`{` 始まり) なら decode を採用する。
49
+ */
50
+ export function decodeSecurityOutput(raw) {
51
+ const s = (raw || "").replace(/\r?\n$/, "")
52
+ if (s.startsWith("{")) return s
53
+ if (/^(?:[0-9a-f]{2})+$/.test(s)) {
54
+ try {
55
+ const decoded = Buffer.from(s, "hex").toString("utf-8")
56
+ if (decoded.startsWith("{")) return decoded
57
+ } catch {
58
+ /* hex ではなかった — raw を返す */
59
+ }
60
+ }
61
+ return s
62
+ }
63
+
64
+ /** err が「項目が見つからない」(security exit 44) かを判定する。 */
65
+ function isNotFound(err) {
66
+ const msg = `${err?.message || ""}${err?.stderr || ""}`
67
+ return msg.includes("could not be found") || msg.includes("SecKeychainSearchCopyNext")
68
+ }
69
+
70
+ /**
71
+ * Keychain からシークレットを読む。存在しなければ null。
72
+ * @returns {Promise<string|null>}
73
+ */
74
+ export async function readGenericPassword(service, opts = {}) {
75
+ const exec = opts.execFileP || execFileP
76
+ try {
77
+ const { stdout } = await exec("security", [
78
+ "find-generic-password",
79
+ "-s",
80
+ service,
81
+ "-w",
82
+ ])
83
+ return decodeSecurityOutput(stdout)
84
+ } catch (err) {
85
+ if (isNotFound(err)) return null
86
+ throw err
87
+ }
88
+ }
89
+
90
+ /** Keychain にエントリが存在するか (シークレットは復号しない = ACL プロンプトを誘発しない)。 */
91
+ export async function existsGenericPassword(service, opts = {}) {
92
+ const exec = opts.execFileP || execFileP
93
+ try {
94
+ await exec("security", ["find-generic-password", "-s", service])
95
+ return true
96
+ } catch (err) {
97
+ if (isNotFound(err)) return false
98
+ throw err
99
+ }
100
+ }
101
+
102
+ /** Keychain からエントリを削除する。存在しなければ何もしない。 */
103
+ export async function deleteGenericPassword(service, opts = {}) {
104
+ const exec = opts.execFileP || execFileP
105
+ try {
106
+ await exec("security", ["delete-generic-password", "-s", service])
107
+ return true
108
+ } catch (err) {
109
+ if (isNotFound(err)) return false
110
+ throw err
111
+ }
112
+ }
113
+
114
+ /** `security -i` の対話コマンド用にダブルクォート文字列をエスケープする。 */
115
+ export function escapeSecurityArg(value) {
116
+ return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')
117
+ }
118
+
119
+ /**
120
+ * Keychain へシークレットを書き込む (既存エントリは作り直し)。
121
+ * `-U` での更新は ACL 制約で失敗し得るため delete → add の順で行う。
122
+ * シークレットは stdin (`security -i`) 経由で渡し argv へ露出させない。
123
+ */
124
+ export async function writeGenericPassword(service, secret, opts = {}) {
125
+ await deleteGenericPassword(service, opts).catch(() => {})
126
+ const spawnImpl = opts.spawn || spawn
127
+ const account = opts.account || "hub-agent"
128
+ const line =
129
+ `add-generic-password -a "${escapeSecurityArg(account)}"` +
130
+ ` -s "${escapeSecurityArg(service)}"` +
131
+ ` -w "${escapeSecurityArg(secret)}"\n`
132
+ await new Promise((resolve, reject) => {
133
+ const child = spawnImpl("security", ["-i"], {
134
+ stdio: ["pipe", "ignore", "pipe"],
135
+ })
136
+ let stderr = ""
137
+ child.stderr?.on("data", (d) => {
138
+ stderr += String(d)
139
+ })
140
+ child.on("error", reject)
141
+ child.on("close", (code) => {
142
+ if (code === 0) resolve()
143
+ else reject(new Error(`security add-generic-password failed (exit ${code}): ${stderr.trim()}`))
144
+ })
145
+ child.stdin.write(line)
146
+ child.stdin.end()
147
+ })
148
+ }
149
+
150
+ /**
151
+ * プロファイル切替に伴う認証情報のスワップ本体。
152
+ *
153
+ * @param {{fromId: string, toId: string, logger?: object,
154
+ * execFileP?: Function, spawn?: Function, platform?: string}} opts
155
+ * @returns {Promise<{saved: boolean, restored: boolean, needsLogin: boolean, skipped?: string}>}
156
+ */
157
+ export async function swapCredentials(opts) {
158
+ const { fromId, toId, logger } = opts
159
+ if (!isKeychainPlatform(opts.platform)) {
160
+ // Keychain の無い環境は <configDir>/.credentials.json でもともと分離済み。
161
+ return { saved: false, restored: false, needsLogin: false, skipped: "not-darwin" }
162
+ }
163
+ const io = { execFileP: opts.execFileP, spawn: opts.spawn }
164
+ const result = { saved: false, restored: false, needsLogin: false }
165
+
166
+ // 1. 現在の canonical を切替元スロットへ退避 (リフレッシュ済み最新トークンを保全)。
167
+ const current = await readGenericPassword(CANONICAL_SERVICE, io)
168
+ if (current) {
169
+ await writeGenericPassword(slotService(fromId), current, io)
170
+ result.saved = true
171
+ }
172
+
173
+ // 2. 切替先スロットから canonical を復元。無ければ canonical を削除して要ログイン。
174
+ const target = await readGenericPassword(slotService(toId), io)
175
+ if (target) {
176
+ await writeGenericPassword(CANONICAL_SERVICE, target, io)
177
+ result.restored = true
178
+ } else {
179
+ await deleteGenericPassword(CANONICAL_SERVICE, io).catch(() => {})
180
+ result.needsLogin = true
181
+ }
182
+
183
+ logger?.info?.(
184
+ { from: fromId, to: toId, ...result },
185
+ "claude credentials swapped for profile switch",
186
+ )
187
+ return result
188
+ }
189
+
190
+ /**
191
+ * プロファイルがログイン可能な認証情報を持っているか (UI 表示用)。
192
+ * darwin: active なら canonical、非 active ならスロットの存在を見る。
193
+ * その他: `<configDir>/.credentials.json` の存在を見る (呼び出し側で判定)。
194
+ */
195
+ export async function profileHasKeychainCredentials(profileId, isActive, opts = {}) {
196
+ const service = isActive ? CANONICAL_SERVICE : slotService(profileId)
197
+ try {
198
+ return await existsGenericPassword(service, opts)
199
+ } catch {
200
+ return false
201
+ }
202
+ }
package/src/main.mjs CHANGED
@@ -39,12 +39,19 @@ import {
39
39
  import {
40
40
  DEFAULT_PROFILE_ID,
41
41
  defaultConfigDir,
42
+ hasFileCredentials,
42
43
  listProfiles,
43
44
  getActiveProfile,
44
45
  getActiveProjectsRoot,
46
+ readProfileAccount,
45
47
  setActiveProfile,
46
48
  addProfile,
47
49
  } from "./profiles.mjs"
50
+ import {
51
+ isKeychainPlatform,
52
+ profileHasKeychainCredentials,
53
+ swapCredentials,
54
+ } from "./claude-credentials.mjs"
48
55
  import {
49
56
  buildClaudeCmd,
50
57
  createSession as createTmuxSession,
@@ -59,6 +66,7 @@ import {
59
66
  rebindClaudeSession,
60
67
  removeWorktree as removeWorktreeDir,
61
68
  resumeWithMessage,
69
+ setTmuxGlobalEnv,
62
70
  } from "./tmux.mjs"
63
71
  import { TuiPermissionBridge } from "./tui-permission-bridge.mjs"
64
72
  import { TuiViewerRegistry } from "./tui-viewer-registry.mjs"
@@ -126,6 +134,53 @@ function claudeCmdFromAgentConfig(config) {
126
134
  return cmd
127
135
  }
128
136
 
137
+ /**
138
+ * プロファイル切替用: 全 tmux セッションを kill → 同名・同 cwd で再作成する。
139
+ *
140
+ * 再作成された pane の claude は createSession 内の injectConfigDirEnv 経由で
141
+ * 新しい CLAUDE_CONFIG_DIR を拾うため、TUI セッションもアカウント切替に追従する。
142
+ * initial prompt (leader の /orchestrate 等) までは復元しない — アカウントが
143
+ * 変わる以上、前アカウントの文脈でスキルを自動発火させない方が安全。
144
+ * cwd が取れないセッションは kill のみ行い、再作成はスキップして failed に積む。
145
+ * tmux サーバー未起動なら何もしない。
146
+ */
147
+ async function restartAllTmuxSessionsForProfileSwitch(ctx) {
148
+ const result = { killed: [], recreated: [], failed: [] }
149
+ let sessions = []
150
+ try {
151
+ sessions = await listTmuxSessions({ plugins: ctx.plugins, logger: ctx.logger })
152
+ } catch (err) {
153
+ ctx.logger?.debug?.(
154
+ { err: err?.message || String(err) },
155
+ "tmux list failed during profile switch (server not running?)",
156
+ )
157
+ return result
158
+ }
159
+ if (!sessions.length) return result
160
+ const { killed } = await killManySessions(
161
+ sessions.map((s) => s.name),
162
+ { logger: ctx.logger },
163
+ )
164
+ result.killed = killed
165
+ for (const s of sessions) {
166
+ if (!killed.includes(s.name)) continue
167
+ if (!s.cwd) {
168
+ result.failed.push({ name: s.name, reason: "cwd unknown" })
169
+ continue
170
+ }
171
+ try {
172
+ await createTmuxSession(s.name, s.cwd, {
173
+ claudeCmd: claudeCmdFromAgentConfig(ctx.config),
174
+ logger: ctx.logger,
175
+ })
176
+ result.recreated.push(s.name)
177
+ } catch (err) {
178
+ result.failed.push({ name: s.name, reason: err?.message || String(err) })
179
+ }
180
+ }
181
+ return result
182
+ }
183
+
129
184
  /**
130
185
  * `~/.claude/scripts/manifest.json` から Hub AI bundle の version を読む。
131
186
  * 未セットアップなら null を返す。
@@ -218,6 +273,25 @@ export function applyActiveProfileEnv(profile, log) {
218
273
  )
219
274
  }
220
275
 
276
+ /**
277
+ * active プロファイルの CLAUDE_CONFIG_DIR を tmux サーバーのグローバル環境にも同期する。
278
+ * tmux pane のシェルは hub-agent の process.env を見ないため、手動 respawn や
279
+ * ユーザーが pane 内で直接 `claude` を打つケースの保険として set-environment -g を併用する
280
+ * (新規セッションの主経路は createSession 内の injectConfigDirEnv コマンド焼き込み)。
281
+ * tmux サーバー未起動でも失敗しない。
282
+ */
283
+ export async function syncTmuxProfileEnv(profile, log) {
284
+ const isDefault =
285
+ !profile ||
286
+ profile.id === DEFAULT_PROFILE_ID ||
287
+ profile.configDir === defaultConfigDir()
288
+ await setTmuxGlobalEnv(
289
+ "CLAUDE_CONFIG_DIR",
290
+ isDefault ? null : profile.configDir,
291
+ { logger: log },
292
+ )
293
+ }
294
+
221
295
  export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
222
296
  const config = await readConfig()
223
297
  if (!config) {
@@ -234,6 +308,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
234
308
  try {
235
309
  const activeProfile = await getActiveProfile()
236
310
  applyActiveProfileEnv(activeProfile, logger)
311
+ await syncTmuxProfileEnv(activeProfile, logger)
237
312
  } catch (err) {
238
313
  logger.warn(
239
314
  { err: err.message },
@@ -1523,33 +1598,84 @@ async function dispatch(msg, ctx) {
1523
1598
  }
1524
1599
  case "profile.list": {
1525
1600
  // Claude アカウント・プロファイル一覧と active を返す (Cockpit のアカウント切替UI用)。
1601
+ // 各プロファイルの「最後にログインしていたアカウント」(<configDir>/.claude.json の
1602
+ // oauthAccount) と認証情報の有無も添えて、切替が実際に効いたかを UI で検証可能にする。
1526
1603
  const reg = await listProfiles()
1604
+ const profiles = await Promise.all(
1605
+ reg.profiles.map(async (p) => {
1606
+ const account = await readProfileAccount(p.configDir)
1607
+ const hasCreds = isKeychainPlatform()
1608
+ ? await profileHasKeychainCredentials(p.id, p.id === reg.activeId)
1609
+ : await hasFileCredentials(p.configDir)
1610
+ return {
1611
+ id: p.id,
1612
+ label: p.label,
1613
+ config_dir: p.configDir,
1614
+ account_email: account?.email || null,
1615
+ account_organization: account?.organization || null,
1616
+ has_credentials: hasCreds,
1617
+ }
1618
+ }),
1619
+ )
1527
1620
  ctx.client.send({
1528
1621
  type: "profile.list.response",
1529
1622
  request_id: msg.request_id,
1530
1623
  active_id: reg.activeId,
1531
- profiles: reg.profiles.map((p) => ({
1532
- id: p.id,
1533
- label: p.label,
1534
- config_dir: p.configDir,
1535
- })),
1624
+ profiles,
1536
1625
  })
1537
1626
  return
1538
1627
  }
1539
1628
  case "profile.switch": {
1540
- // アカウント切替。active を永続化 → env 反映 → 全 Claude セッション強制再起動。
1541
- // 走行中ターンも中断する (アカウントが変わる以上、継続は不可)
1629
+ // アカウント切替。認証情報スワップ → active 永続化 → env 反映 (process/tmux)
1630
+ // Claude セッション強制再起動 (SDK チャット + tmux TUI)。走行中ターンも中断する。
1631
+ //
1632
+ // WHY 認証スワップ: macOS では OAuth トークンが Keychain の単一エントリに保存され
1633
+ // CLAUDE_CONFIG_DIR と無関係なため、ディレクトリ切替だけではアカウントが変わらない
1634
+ // (2026-06-07 実測)。claude-credentials.mjs の docstring 参照。
1542
1635
  const id = msg.profile_id || msg.id || ""
1543
1636
  try {
1637
+ const regBefore = await listProfiles()
1638
+ const fromId = regBefore.activeId
1544
1639
  const target = await setActiveProfile(id)
1640
+ let cred = { saved: false, restored: false, needsLogin: false }
1641
+ if (fromId !== target.id) {
1642
+ try {
1643
+ cred = await swapCredentials({
1644
+ fromId,
1645
+ toId: target.id,
1646
+ logger: ctx.logger,
1647
+ })
1648
+ } catch (err) {
1649
+ // スワップ失敗 (Keychain ACL 拒否等) でも切替自体は続行する。
1650
+ // この場合 Keychain には切替前のトークンが残るため、その旨を返す。
1651
+ ctx.logger.warn(
1652
+ { err: err?.message || String(err), from: fromId, to: target.id },
1653
+ "credential swap failed; keychain may still hold previous account",
1654
+ )
1655
+ cred = { saved: false, restored: false, needsLogin: false, error: err?.message }
1656
+ }
1657
+ }
1545
1658
  applyActiveProfileEnv(target, ctx.logger)
1659
+ await syncTmuxProfileEnv(target, ctx.logger)
1546
1660
  // 全セッションを撤去 (frontend は exit 通知でセッション一覧をリロードする)。
1547
1661
  const before = ctx.claudeBridge ? ctx.claudeBridge.list().length : 0
1548
1662
  ctx.claudeBridge?.shutdown()
1663
+ // tmux TUI セッションも旧アカウントのまま残さない: kill → 同名・同 cwd で再作成。
1664
+ const tmuxResult = await restartAllTmuxSessionsForProfileSwitch(ctx)
1549
1665
  ctx.logger.info(
1550
- { profile_id: target.id, restarted: before },
1666
+ {
1667
+ profile_id: target.id,
1668
+ reaped: before,
1669
+ tmux_killed: tmuxResult.killed.length,
1670
+ tmux_recreated: tmuxResult.recreated.length,
1671
+ tmux_failed: tmuxResult.failed.length,
1672
+ cred_saved: cred.saved,
1673
+ cred_restored: cred.restored,
1674
+ needs_login: cred.needsLogin,
1675
+ },
1551
1676
  "claude profile switched; all sessions reaped",
1552
1677
  )
1678
+ const isDefaultTarget = target.id === DEFAULT_PROFILE_ID
1553
1679
  ctx.client.send({
1554
1680
  type: "profile.switch.result",
1555
1681
  request_id: msg.request_id,
@@ -1561,6 +1687,14 @@ async function dispatch(msg, ctx) {
1561
1687
  config_dir: target.configDir,
1562
1688
  },
1563
1689
  reaped: before,
1690
+ tmux_restarted: tmuxResult.recreated.length,
1691
+ needs_login: !!cred.needsLogin,
1692
+ login_command: cred.needsLogin
1693
+ ? isDefaultTarget
1694
+ ? "claude"
1695
+ : `CLAUDE_CONFIG_DIR=${target.configDir} claude`
1696
+ : undefined,
1697
+ credential_error: cred.error || undefined,
1564
1698
  })
1565
1699
  } catch (err) {
1566
1700
  ctx.client.send({
package/src/profiles.mjs CHANGED
@@ -196,6 +196,56 @@ export async function addProfile(opts = {}) {
196
196
  return { ...profile, needsLogin: true }
197
197
  }
198
198
 
199
+ /**
200
+ * プロファイルの `.claude.json` から最後にログインしていたアカウント情報を読む。
201
+ * Claude Code がログイン時に oauthAccount を書き込むため、「このディレクトリで最後に
202
+ * 使われたアカウント」のメタ情報として UI 表示に使える (認証トークンそのものではない)。
203
+ * ファイルが無い・壊れている・未ログインなら null。
204
+ *
205
+ * ⚠️ パスの罠: Claude Code は `.claude.json` を CLAUDE_CONFIG_DIR 指定時のみ
206
+ * `<configDir>/.claude.json` に置き、既定プロファイルでは `~/.claude.json`
207
+ * (ホーム直下、`~/.claude/` の外) に置く。一律 `<configDir>/.claude.json` を読むと
208
+ * 既定プロファイルだけ常に null になる (2026-06-07 実バグ: Cockpit のアカウント
209
+ * 切替 UI で既定プロファイルのみメール・組織が表示されなかった)。
210
+ *
211
+ * @param {string} configDir
212
+ * @returns {Promise<{email: string|null, organization: string|null}|null>}
213
+ */
214
+ export async function readProfileAccount(configDir) {
215
+ const candidates = [path.join(configDir, ".claude.json")]
216
+ if (path.resolve(configDir) === defaultConfigDir()) {
217
+ candidates.push(path.join(os.homedir(), ".claude.json"))
218
+ }
219
+ for (const file of candidates) {
220
+ try {
221
+ const raw = await fs.readFile(file, "utf-8")
222
+ const oa = JSON.parse(raw)?.oauthAccount
223
+ if (!oa || typeof oa !== "object") continue
224
+ return {
225
+ email: typeof oa.emailAddress === "string" ? oa.emailAddress : null,
226
+ organization:
227
+ typeof oa.organizationName === "string" ? oa.organizationName : null,
228
+ }
229
+ } catch {
230
+ /* 次の候補へ */
231
+ }
232
+ }
233
+ return null
234
+ }
235
+
236
+ /**
237
+ * Keychain の無いプラットフォーム向け: `<configDir>/.credentials.json` の有無で
238
+ * ログイン済みかを判定する (darwin では claude-credentials.mjs 側の Keychain 判定を使う)。
239
+ */
240
+ export async function hasFileCredentials(configDir) {
241
+ try {
242
+ await fs.access(path.join(configDir, ".credentials.json"))
243
+ return true
244
+ } catch {
245
+ return false
246
+ }
247
+ }
248
+
199
249
  /** 既定プロファイルから共有 symlink する資産 (アプリが書き込まない読み取り中心)。 */
200
250
  export const SHARED_SYMLINK_ITEMS = [
201
251
  "agents",
package/src/tmux.mjs CHANGED
@@ -424,6 +424,51 @@ export function injectInitialPrompt(claudeCmd, initialPrompt) {
424
424
  .join(" || ")
425
425
  }
426
426
 
427
+ /**
428
+ * claudeCmd の各セグメント先頭に `CLAUDE_CONFIG_DIR=<dir>` の環境変数代入を注入する。
429
+ *
430
+ * WHY: tmux pane のシェルは tmux サーバーの環境を引き継ぐため、hub-agent が
431
+ * process.env.CLAUDE_CONFIG_DIR を切り替えても send-keys で起動する claude には
432
+ * 伝わらない (SDK 経由のチャットセッションにしか効かない)。アカウント・
433
+ * プロファイル切替を TUI セッションにも効かせるため、コマンド文字列自体に
434
+ * 代入プレフィックスを焼き込む。`a || b` の OR 構造では代入が最初のコマンドに
435
+ * しか効かないため、injectInitialPrompt と同様に `||` で split して両側に付ける。
436
+ *
437
+ * configDir が空 (= 既定プロファイル) のときは何もしない。
438
+ */
439
+ export function injectConfigDirEnv(claudeCmd, configDir) {
440
+ if (!configDir || typeof configDir !== "string") return claudeCmd
441
+ if (typeof claudeCmd !== "string" || claudeCmd.length === 0) return claudeCmd
442
+ const assignment = `CLAUDE_CONFIG_DIR=${JSON.stringify(configDir)}`
443
+ return claudeCmd
444
+ .split("||")
445
+ .map((part) => `${assignment} ${part.trim()}`)
446
+ .join(" || ")
447
+ }
448
+
449
+ /**
450
+ * tmux サーバーのグローバル環境変数を設定/解除する。
451
+ * プロファイル切替後に手動で開く pane や respawn にも CLAUDE_CONFIG_DIR が
452
+ * 行き渡るようにするための補助線 (主経路は injectConfigDirEnv のコマンド焼き込み)。
453
+ * value が null/undefined なら unset (-gu)。tmux サーバー未起動などの失敗は握り潰す。
454
+ */
455
+ export async function setTmuxGlobalEnv(name, value, opts = {}) {
456
+ const args =
457
+ value == null
458
+ ? ["set-environment", "-gu", name]
459
+ : ["set-environment", "-g", name, value]
460
+ try {
461
+ await execFileP(tmuxBin(opts), args)
462
+ return true
463
+ } catch (err) {
464
+ opts.logger?.debug?.(
465
+ { name, err: err?.message || String(err) },
466
+ "tmux set-environment failed (server not running?)",
467
+ )
468
+ return false
469
+ }
470
+ }
471
+
427
472
  /**
428
473
  * 汎用 tmux 実行。`tmux.exec` メッセージから呼び出される。
429
474
  *
@@ -674,7 +719,13 @@ export async function createSession(name, cwd, opts = {}) {
674
719
  }
675
720
  }
676
721
  const claudeCmdBase = opts.claudeCmd ?? DEFAULT_CLAUDE_CMD
677
- const claudeCmd = injectInitialPrompt(claudeCmdBase, opts.initialPrompt)
722
+ // アカウント・プロファイル切替 (CLAUDE_CONFIG_DIR) を TUI セッションにも効かせる。
723
+ // applyActiveProfileEnv が process.env を最新に保つため、既定では env から拾う。
724
+ const configDir = opts.claudeConfigDir ?? process.env.CLAUDE_CONFIG_DIR ?? null
725
+ const claudeCmd = injectInitialPrompt(
726
+ injectConfigDirEnv(claudeCmdBase, configDir),
727
+ opts.initialPrompt,
728
+ )
678
729
  if (claudeCmd) {
679
730
  await execFileP(tmuxBin(opts), ["send-keys", "-t", name, claudeCmd, "Enter"])
680
731
  }