@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 +1 -1
- package/src/claude-credentials.mjs +202 -0
- package/src/claude-history.mjs +31 -3
- package/src/main.mjs +153 -10
- package/src/profiles.mjs +37 -0
- package/src/tmux.mjs +52 -1
package/package.json
CHANGED
|
@@ -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/claude-history.mjs
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
}
|