@cocorograph/hub-agent 0.6.52 → 0.6.54

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.52",
3
+ "version": "0.6.54",
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
+ }
@@ -256,22 +256,50 @@ export async function listSessions({ cwd, projectsRoot, limit = 30, logger }) {
256
256
  * - `viewingSessionId`: フロントが今表示している (閲覧ハートビートが運ぶ) session_id。
257
257
  * - `newestSessionId`: cwd 配下で実際に最新 (mtime 降順先頭) の session_id。
258
258
  * - `lastNotifiedNewId`: 同一 new への多重通知を防ぐため、最後に通知した new_session_id。
259
+ * - `lastNotifiedAt` / `now`: 再通知スロットリング用のタイムスタンプ (ms)。両方与えられた
260
+ * ときだけ時間判定が効く (無ければ従来どおり「同一 new は一度きり」)。
261
+ * - `reNotifyMs`: 同一 new でも再通知を許す最小間隔 (既定 4000ms)。
262
+ *
263
+ * 回転とみなす条件:
264
+ * 最新が存在し、閲覧中 id と異なり、かつ次のいずれか:
265
+ * (a) 最後に通知した new と異なる (= さらに別 new へ回転した) → 即通知。
266
+ * (b) 最後に通知した new と同じだが、`reNotifyMs` 以上経過している → **再通知**。
267
+ *
268
+ * (b) が肝。回転通知は one-shot broadcast で、多タブ・再接続・hidden タブ等で取りこぼすと
269
+ * クライアントが旧 session に**恒久固着**する (ゾンビ閲覧者バグ)。viewer が依然 stale な
270
+ * session_id を報告し続けている (= newest と不一致) という事実は「まだ切り替わっていない」
271
+ * 証拠なので、スロットリングしつつ繰り返し通知して救済する。切り替わった viewer は次の
272
+ * ハートビートで viewingSessionId === newest を報告し、条件 (最新≠閲覧中) を満たさなくなる
273
+ * ため自然に再通知が止まる。`reNotifyMs` をハートビート間隔 (5s) よりやや短くしておくと、
274
+ * 固着している間は毎ハートビートで 1 回ずつ通知が飛ぶ。
259
275
  *
260
- * 回転とみなす条件: 最新が存在し、閲覧中 id と異なり、かつ直近で同じ new を通知済みでない。
261
276
  * 注意: 過去セッションを意図的に開いている (= newest 非追従) ビューでは呼び出し側が
262
277
  * `follow_newest=false` でこの判定自体をスキップすること (ピン留め閲覧を勝手に最新へ
263
278
  * 引きずらないため)。本関数は「追従中ビュー」前提で newest とのズレだけを見る。
264
279
  *
265
- * @param {{viewingSessionId?: string|null, newestSessionId?: string|null, lastNotifiedNewId?: string|null}} args
280
+ * @param {{viewingSessionId?: string|null, newestSessionId?: string|null, lastNotifiedNewId?: string|null, lastNotifiedAt?: number|null, now?: number|null, reNotifyMs?: number}} args
266
281
  * @returns {{rotated: boolean, newSessionId?: string}}
267
282
  */
268
283
  export function decideSessionRotation({
269
284
  viewingSessionId,
270
285
  newestSessionId,
271
286
  lastNotifiedNewId,
287
+ lastNotifiedAt,
288
+ now,
289
+ reNotifyMs = 4000,
272
290
  } = {}) {
273
291
  if (!newestSessionId || !viewingSessionId) return { rotated: false }
274
292
  if (newestSessionId === viewingSessionId) return { rotated: false }
275
- if (newestSessionId === lastNotifiedNewId) return { rotated: false }
293
+ if (newestSessionId === lastNotifiedNewId) {
294
+ // 同一 new。時刻情報が揃っていて再通知間隔を超えていれば、固着救済のため再通知する。
295
+ if (
296
+ typeof now === "number" &&
297
+ typeof lastNotifiedAt === "number" &&
298
+ now - lastNotifiedAt >= reNotifyMs
299
+ ) {
300
+ return { rotated: true, newSessionId: newestSessionId }
301
+ }
302
+ return { rotated: false }
303
+ }
276
304
  return { rotated: true, newSessionId: newestSessionId }
277
305
  }
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 },
@@ -1148,13 +1223,22 @@ async function dispatch(msg, ctx) {
1148
1223
  })
1149
1224
  const newestId = sessions?.[0]?.session_id || null
1150
1225
  const key = viewName || viewCwd
1226
+ const prev = ctx.tuiRotationNotified.get(key)
1227
+ const now = Date.now()
1151
1228
  const { rotated, newSessionId } = decideSessionRotation({
1152
1229
  viewingSessionId: viewSid,
1153
1230
  newestSessionId: newestId,
1154
- lastNotifiedNewId: ctx.tuiRotationNotified.get(key),
1231
+ // 旧形式 (文字列) と新形式 ({newId, ts}) の両対応。
1232
+ lastNotifiedNewId:
1233
+ prev && typeof prev === "object" ? prev.newId : prev,
1234
+ lastNotifiedAt:
1235
+ prev && typeof prev === "object" ? prev.ts : null,
1236
+ now,
1155
1237
  })
1156
1238
  if (!rotated) return
1157
- ctx.tuiRotationNotified.set(key, newSessionId)
1239
+ // {newId, ts} で保存し、同一 new への再通知をスロットリングする
1240
+ // (固着した viewer は throttle 間隔ごとに 1 回ずつ救済通知を受ける)。
1241
+ ctx.tuiRotationNotified.set(key, { newId: newSessionId, ts: now })
1158
1242
  // 冪等マップも新 id へ更新し、将来の remount bind が旧 id へ
1159
1243
  // respawn (claude 再起動) してしまうのを防ぐ。
1160
1244
  if (viewName) ctx.tuiReboundSessions.set(viewName, newSessionId)
@@ -1514,33 +1598,84 @@ async function dispatch(msg, ctx) {
1514
1598
  }
1515
1599
  case "profile.list": {
1516
1600
  // Claude アカウント・プロファイル一覧と active を返す (Cockpit のアカウント切替UI用)。
1601
+ // 各プロファイルの「最後にログインしていたアカウント」(<configDir>/.claude.json の
1602
+ // oauthAccount) と認証情報の有無も添えて、切替が実際に効いたかを UI で検証可能にする。
1517
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
+ )
1518
1620
  ctx.client.send({
1519
1621
  type: "profile.list.response",
1520
1622
  request_id: msg.request_id,
1521
1623
  active_id: reg.activeId,
1522
- profiles: reg.profiles.map((p) => ({
1523
- id: p.id,
1524
- label: p.label,
1525
- config_dir: p.configDir,
1526
- })),
1624
+ profiles,
1527
1625
  })
1528
1626
  return
1529
1627
  }
1530
1628
  case "profile.switch": {
1531
- // アカウント切替。active を永続化 → env 反映 → 全 Claude セッション強制再起動。
1532
- // 走行中ターンも中断する (アカウントが変わる以上、継続は不可)
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 参照。
1533
1635
  const id = msg.profile_id || msg.id || ""
1534
1636
  try {
1637
+ const regBefore = await listProfiles()
1638
+ const fromId = regBefore.activeId
1535
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
+ }
1536
1658
  applyActiveProfileEnv(target, ctx.logger)
1659
+ await syncTmuxProfileEnv(target, ctx.logger)
1537
1660
  // 全セッションを撤去 (frontend は exit 通知でセッション一覧をリロードする)。
1538
1661
  const before = ctx.claudeBridge ? ctx.claudeBridge.list().length : 0
1539
1662
  ctx.claudeBridge?.shutdown()
1663
+ // tmux TUI セッションも旧アカウントのまま残さない: kill → 同名・同 cwd で再作成。
1664
+ const tmuxResult = await restartAllTmuxSessionsForProfileSwitch(ctx)
1540
1665
  ctx.logger.info(
1541
- { 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
+ },
1542
1676
  "claude profile switched; all sessions reaped",
1543
1677
  )
1678
+ const isDefaultTarget = target.id === DEFAULT_PROFILE_ID
1544
1679
  ctx.client.send({
1545
1680
  type: "profile.switch.result",
1546
1681
  request_id: msg.request_id,
@@ -1552,6 +1687,14 @@ async function dispatch(msg, ctx) {
1552
1687
  config_dir: target.configDir,
1553
1688
  },
1554
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,
1555
1698
  })
1556
1699
  } catch (err) {
1557
1700
  ctx.client.send({
package/src/profiles.mjs CHANGED
@@ -196,6 +196,43 @@ export async function addProfile(opts = {}) {
196
196
  return { ...profile, needsLogin: true }
197
197
  }
198
198
 
199
+ /**
200
+ * プロファイルの `<configDir>/.claude.json` から最後にログインしていたアカウント情報を読む。
201
+ * Claude Code がログイン時に oauthAccount を書き込むため、「このディレクトリで最後に
202
+ * 使われたアカウント」のメタ情報として UI 表示に使える (認証トークンそのものではない)。
203
+ * ファイルが無い・壊れている・未ログインなら null。
204
+ *
205
+ * @param {string} configDir
206
+ * @returns {Promise<{email: string|null, organization: string|null}|null>}
207
+ */
208
+ export async function readProfileAccount(configDir) {
209
+ try {
210
+ const raw = await fs.readFile(path.join(configDir, ".claude.json"), "utf-8")
211
+ const oa = JSON.parse(raw)?.oauthAccount
212
+ if (!oa || typeof oa !== "object") return null
213
+ return {
214
+ email: typeof oa.emailAddress === "string" ? oa.emailAddress : null,
215
+ organization:
216
+ typeof oa.organizationName === "string" ? oa.organizationName : null,
217
+ }
218
+ } catch {
219
+ return null
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Keychain の無いプラットフォーム向け: `<configDir>/.credentials.json` の有無で
225
+ * ログイン済みかを判定する (darwin では claude-credentials.mjs 側の Keychain 判定を使う)。
226
+ */
227
+ export async function hasFileCredentials(configDir) {
228
+ try {
229
+ await fs.access(path.join(configDir, ".credentials.json"))
230
+ return true
231
+ } catch {
232
+ return false
233
+ }
234
+ }
235
+
199
236
  /** 既定プロファイルから共有 symlink する資産 (アプリが書き込まない読み取り中心)。 */
200
237
  export const SHARED_SYMLINK_ITEMS = [
201
238
  "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
  }