@cocorograph/hub-agent 0.7.44 → 0.7.46
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 +62 -166
- package/src/claude-stream-bridge.mjs +45 -7
- package/src/main.mjs +54 -33
- package/src/profiles.mjs +20 -0
package/package.json
CHANGED
|
@@ -1,202 +1,98 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Claude Code 認証情報 (macOS Keychain)
|
|
2
|
+
* Claude Code の OAuth 認証情報 (macOS Keychain) の在否判定。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* 2026-07 更新 (B案 / swap 撤去): Claude Code は OAuth トークンを Keychain の
|
|
5
|
+
* `Claude Code-credentials-<sha256(CLAUDE_CONFIG_DIR)先頭8桁>` に保存し、config
|
|
6
|
+
* ディレクトリごとにネイティブに分離するようになった (Claude Code 2.1.x で実測確認)。
|
|
7
|
+
* そのため hub-agent が旧来行っていた「素の `Claude Code-credentials` を profile 切替
|
|
8
|
+
* のたびに手動退避/復元 (swap) する」処理は不要かつ有害 (Claude Code が認証に使わない
|
|
9
|
+
* 別エントリ = acct "hub-agent" を触るだけで空振りし、既定プロファイル等で再ログインを
|
|
10
|
+
* 誘発していた) になったため撤去した。ここは UI の「要ログイン」表示用に「その config
|
|
11
|
+
* ディレクトリで Claude Code がログイン済みか」を在否チェックするだけに縮小する。
|
|
12
|
+
* シークレットは復号しない (`-w` を付けない) ため Keychain の ACL プロンプトを誘発しない。
|
|
8
13
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* (削除しないと旧アカウントのトークンが残り「切り替わらない」が再発する)
|
|
14
|
+
* 旧 swap の経緯 (2026-06-07): 当時の Claude Code は全 config ディレクトリで単一の素の
|
|
15
|
+
* `Claude Code-credentials` を共有していたため、ディレクトリ切替だけではアカウントが
|
|
16
|
+
* 変わらず、Keychain を手動退避/復元する必要があった。実装は git 履歴を参照
|
|
17
|
+
* (CANONICAL_SERVICE / slotService / swapCredentials 等)。
|
|
14
18
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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 を挟む。
|
|
19
|
+
* Linux 等 Keychain の無い環境は Claude Code が `<configDir>/.credentials.json` を使い
|
|
20
|
+
* もともと config ディレクトリ単位で分離済み (profiles.mjs の hasFileCredentials を使う)。
|
|
25
21
|
*/
|
|
26
|
-
import {
|
|
22
|
+
import { promises as fs } from "node:fs"
|
|
23
|
+
import { createHash } from "node:crypto"
|
|
24
|
+
import { execFile } from "node:child_process"
|
|
27
25
|
import { promisify } from "node:util"
|
|
28
26
|
|
|
29
27
|
const execFileP = promisify(execFile)
|
|
30
28
|
|
|
31
|
-
/**
|
|
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 スワップが必要なプラットフォームか。 */
|
|
29
|
+
/** Keychain を使うプラットフォームか (認証分離が Keychain 依存なのは darwin のみ)。 */
|
|
40
30
|
export function isKeychainPlatform(platform = process.platform) {
|
|
41
31
|
return platform === "darwin"
|
|
42
32
|
}
|
|
43
33
|
|
|
44
34
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
35
|
+
* ある config ディレクトリの認証情報を Claude Code が保存する Keychain サービス名。
|
|
36
|
+
* `Claude Code-credentials-<sha256(configDir)先頭8桁>` (Claude Code 2.1.x の実装)。
|
|
37
|
+
* ⚠️ このハッシュ式が本体とズレると在否チェックが全部空振りするため、
|
|
38
|
+
* test/claude-credentials.test.mjs が実機既知値で固定している。
|
|
39
|
+
*
|
|
40
|
+
* @param {string} configDir CLAUDE_CONFIG_DIR (既定プロファイルは `~/.claude`)
|
|
41
|
+
* @returns {string}
|
|
49
42
|
*/
|
|
50
|
-
export function
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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")
|
|
43
|
+
export function credentialService(configDir) {
|
|
44
|
+
const hash = createHash("sha256")
|
|
45
|
+
.update(String(configDir))
|
|
46
|
+
.digest("hex")
|
|
47
|
+
.slice(0, 8)
|
|
48
|
+
return `Claude Code-credentials-${hash}`
|
|
68
49
|
}
|
|
69
50
|
|
|
70
51
|
/**
|
|
71
|
-
* Keychain
|
|
72
|
-
* @returns {Promise<
|
|
52
|
+
* Keychain にエントリが存在するか (シークレットは復号しない = ACL プロンプト無し)。
|
|
53
|
+
* @returns {Promise<boolean>}
|
|
73
54
|
*/
|
|
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
55
|
export async function existsGenericPassword(service, opts = {}) {
|
|
92
56
|
const exec = opts.execFileP || execFileP
|
|
93
57
|
try {
|
|
94
58
|
await exec("security", ["find-generic-password", "-s", service])
|
|
95
59
|
return true
|
|
96
60
|
} catch (err) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
61
|
+
const msg = `${err?.message || ""}${err?.stderr || ""}`
|
|
62
|
+
if (
|
|
63
|
+
msg.includes("could not be found") ||
|
|
64
|
+
msg.includes("SecKeychainSearchCopyNext")
|
|
65
|
+
) {
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
110
68
|
throw err
|
|
111
69
|
}
|
|
112
70
|
}
|
|
113
71
|
|
|
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
72
|
/**
|
|
151
|
-
*
|
|
73
|
+
* その config ディレクトリで Claude Code がログイン済みか (UI の「要ログイン」表示用)。
|
|
152
74
|
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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` の存在を見る (呼び出し側で判定)。
|
|
75
|
+
* `~/.claude` が symlink (例: iCloud Drive への集約) のとき、Claude Code が
|
|
76
|
+
* symlink パスと realpath のどちらでハッシュを計算するかに依存しないよう、両方の
|
|
77
|
+
* ハッシュ候補を在否チェックし、いずれか存在すればログイン済みとみなす。
|
|
78
|
+
*
|
|
79
|
+
* @param {string} configDir
|
|
80
|
+
* @param {{execFileP?: Function}} [opts]
|
|
81
|
+
* @returns {Promise<boolean>}
|
|
194
82
|
*/
|
|
195
|
-
export async function profileHasKeychainCredentials(
|
|
196
|
-
const
|
|
83
|
+
export async function profileHasKeychainCredentials(configDir, opts = {}) {
|
|
84
|
+
const candidates = new Set([configDir])
|
|
197
85
|
try {
|
|
198
|
-
|
|
86
|
+
candidates.add(await fs.realpath(configDir))
|
|
199
87
|
} catch {
|
|
200
|
-
|
|
88
|
+
/* realpath 不能 (ディレクトリ未作成等) は無視して literal パスのみ評価 */
|
|
89
|
+
}
|
|
90
|
+
for (const dir of candidates) {
|
|
91
|
+
try {
|
|
92
|
+
if (await existsGenericPassword(credentialService(dir), opts)) return true
|
|
93
|
+
} catch {
|
|
94
|
+
/* security 実行エラーは「不明」→ 存在しない寄せ */
|
|
95
|
+
}
|
|
201
96
|
}
|
|
97
|
+
return false
|
|
202
98
|
}
|
|
@@ -306,8 +306,15 @@ class ClaudeStreamSession {
|
|
|
306
306
|
* キャンセル機能 (0.6.26): 各項目に安定 id を振り、browser から id 指定で
|
|
307
307
|
* 個別削除できるようにする。要素は { id: string, text: string }。 */
|
|
308
308
|
this._pendingMessages = []
|
|
309
|
-
/** キャンセル機能: pending 項目へ安定 id
|
|
309
|
+
/** キャンセル機能: pending 項目へ安定 id を振るための連番カウンタ (browser が
|
|
310
|
+
* input_id を渡さない旧経路のフォールバック用)。 */
|
|
310
311
|
this._queueSeq = 0
|
|
312
|
+
/** id 空間統一 (2026-07-08): キャンセルが投入より先着したときの取りこぼし防止。
|
|
313
|
+
* cancelQueued(id) された input_id をここに控え、後から届いた同 input_id の
|
|
314
|
+
* sendMessage を投入せず破棄する (楽観チップの×を押した直後に in-flight だった
|
|
315
|
+
* メッセージが後着して実行される「ゴースト実行」を防ぐ)。到着で消費 (delete)。
|
|
316
|
+
* 無限増加を避けるため上限で全クリアする (キャンセルは低頻度)。 */
|
|
317
|
+
this._canceledInputIds = new Set()
|
|
311
318
|
/** 改修3: 直近 browser へ通知した queue 署名 (件数 + id 列)。変化時のみ emit する。
|
|
312
319
|
* 空キューの署名 ("0:") で初期化し、空→空の冗長 emit を抑止する。 */
|
|
313
320
|
this._lastEmittedQueueSig = "0:"
|
|
@@ -667,6 +674,22 @@ class ClaudeStreamSession {
|
|
|
667
674
|
const prompt = extractPromptText(message)
|
|
668
675
|
if (!prompt) return { accepted: false, reason: "empty_message" }
|
|
669
676
|
|
|
677
|
+
// id 空間統一 (2026-07-08): browser が渡す input_id をキュー id として採用する
|
|
678
|
+
// (旧経路は input_id 無し → qN フォールバック)。これにより楽観チップ (input_id) と
|
|
679
|
+
// 権威キュー項目の id が一致し、確認前のキャンセルも効くようになる。
|
|
680
|
+
const inputId =
|
|
681
|
+
typeof opts.input_id === "string" && opts.input_id ? opts.input_id : null
|
|
682
|
+
// キャンセル先着レース: この input_id が既にキャンセル指示済みなら投入せず破棄する。
|
|
683
|
+
// accepted:true で返して browser の ackWatchdog 再送を止める (二重投入/ゴースト実行防止)。
|
|
684
|
+
if (inputId && this._canceledInputIds.has(inputId)) {
|
|
685
|
+
this._canceledInputIds.delete(inputId)
|
|
686
|
+
this.logger?.info(
|
|
687
|
+
{ stream_id: this.stream_id, input_id: inputId },
|
|
688
|
+
"dropped pre-canceled input (cancel arrived before input)",
|
|
689
|
+
)
|
|
690
|
+
return { accepted: true }
|
|
691
|
+
}
|
|
692
|
+
|
|
670
693
|
// ultracode ワンショット (0.6.28): このターンのみ xhigh effort + 常時 dynamic-workflow
|
|
671
694
|
// オーケストレーションを有効化する。セッション既定としては持たず (トークン消費が
|
|
672
695
|
// 桁違いになるため)、ターン単位でトグルし、完了後は通常状態へ戻す。
|
|
@@ -690,7 +713,7 @@ class ClaudeStreamSession {
|
|
|
690
713
|
// 次を push すると SDK streaming-input で割り込み扱いになり得るため (公式 interrupt 警告)。
|
|
691
714
|
// pending は queue_state で UI (送信待ちチップ) に出す (per-message と同じ体験)。
|
|
692
715
|
if (this._busy) {
|
|
693
|
-
this._enqueuePending(prompt, ultracode)
|
|
716
|
+
this._enqueuePending(prompt, ultracode, inputId)
|
|
694
717
|
this.logger?.info(
|
|
695
718
|
{ stream_id: this.stream_id, queued: this._pendingMessages.length },
|
|
696
719
|
"resident busy, message queued",
|
|
@@ -735,7 +758,7 @@ class ClaudeStreamSession {
|
|
|
735
758
|
// バイパスさせず、必ず pending 経由の FIFO へ合流させる (resident 側の FIFO 修正
|
|
736
759
|
// (PR #212) と同じ「pending を追い越させない」という設計思想)。
|
|
737
760
|
if (this._busy || this._pendingMessages.length > 0) {
|
|
738
|
-
this._enqueuePending(prompt, ultracode)
|
|
761
|
+
this._enqueuePending(prompt, ultracode, inputId)
|
|
739
762
|
this.logger?.info(
|
|
740
763
|
{ stream_id: this.stream_id, queued: this._pendingMessages.length },
|
|
741
764
|
"claude busy, message queued",
|
|
@@ -938,8 +961,11 @@ class ClaudeStreamSession {
|
|
|
938
961
|
|
|
939
962
|
/** キャンセル機能 (0.6.26): pending キューへ安定 id 付きで 1 件積む。
|
|
940
963
|
* ultracode (0.6.28): ワンショット ultracode フラグもエントリに保持し、drain 時に伝播する。 */
|
|
941
|
-
_enqueuePending(prompt, ultracode = false) {
|
|
942
|
-
|
|
964
|
+
_enqueuePending(prompt, ultracode = false, inputId = null) {
|
|
965
|
+
// id 空間統一 (2026-07-08): browser 由来の input_id があればそれをキュー id に使う
|
|
966
|
+
// (楽観チップの id と一致させ、確認前キャンセルを可能にする)。無ければ従来の qN。
|
|
967
|
+
const id =
|
|
968
|
+
typeof inputId === "string" && inputId ? inputId : `q${++this._queueSeq}`
|
|
943
969
|
this._pendingMessages.push({ id, text: prompt, ultracode: ultracode === true })
|
|
944
970
|
return id
|
|
945
971
|
}
|
|
@@ -948,6 +974,14 @@ class ClaudeStreamSession {
|
|
|
948
974
|
* 実行開始済み (既に drain された) メッセージは pending に残っていないので no-op になる。
|
|
949
975
|
* 削除に成功したら queue_state を再 emit して送信待ちチップを更新する。 */
|
|
950
976
|
cancelQueued(id) {
|
|
977
|
+
// id 空間統一 (2026-07-08): キャンセル先着レース対策。まだ投入されていない (in-flight の)
|
|
978
|
+
// input_id をキャンセルした場合に備え、id を控えて後着 sendMessage を破棄できるようにする
|
|
979
|
+
// (楽観チップの×を押した直後にメッセージが後着して実行される「ゴースト実行」を防ぐ)。
|
|
980
|
+
// 上限で全クリア (キャンセルは低頻度なので粗いバウンドで十分)。
|
|
981
|
+
if (typeof id === "string" && id) {
|
|
982
|
+
if (this._canceledInputIds.size >= 1024) this._canceledInputIds.clear()
|
|
983
|
+
this._canceledInputIds.add(id)
|
|
984
|
+
}
|
|
951
985
|
const before = this._pendingMessages.length
|
|
952
986
|
this._pendingMessages = this._pendingMessages.filter((m) => m.id !== id)
|
|
953
987
|
const removed = before !== this._pendingMessages.length
|
|
@@ -1571,7 +1605,7 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1571
1605
|
* 「キュー/常駐 query への投入が完了した」ことを意味する (ターン完走まで待つと
|
|
1572
1606
|
* ack が大幅に遅延するため)。ターン実行自体の結果は従来どおり onEvent 経由で届く。
|
|
1573
1607
|
*/
|
|
1574
|
-
async input({ stream_id, message, ultracode }) {
|
|
1608
|
+
async input({ stream_id, message, ultracode, input_id }) {
|
|
1575
1609
|
const s = this.sessions.get(stream_id)
|
|
1576
1610
|
if (!s) {
|
|
1577
1611
|
this.logger?.warn({ stream_id }, "claude.input but stream missing")
|
|
@@ -1579,8 +1613,12 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1579
1613
|
}
|
|
1580
1614
|
s.touch(stream_id) // B11: この端末の最終アクティビティを更新 (死端末 GC 用)
|
|
1581
1615
|
// ultracode (0.6.28): このメッセージのみ ultracode ワンショットを適用するフラグ。
|
|
1616
|
+
// id 空間統一 (2026-07-08): input_id をキュー id として下流へ渡す。
|
|
1582
1617
|
try {
|
|
1583
|
-
const result = await s.sendMessage(message, {
|
|
1618
|
+
const result = await s.sendMessage(message, {
|
|
1619
|
+
ultracode: ultracode === true,
|
|
1620
|
+
input_id: typeof input_id === "string" && input_id ? input_id : null,
|
|
1621
|
+
})
|
|
1584
1622
|
if (result?.accepted) return { ok: true }
|
|
1585
1623
|
return { ok: false, reason: result?.reason || "queue_dead" }
|
|
1586
1624
|
} catch (err) {
|
package/src/main.mjs
CHANGED
|
@@ -56,11 +56,11 @@ import {
|
|
|
56
56
|
readProfileAccount,
|
|
57
57
|
setActiveProfile,
|
|
58
58
|
addProfile,
|
|
59
|
+
renameProfile,
|
|
59
60
|
} from "./profiles.mjs"
|
|
60
61
|
import {
|
|
61
62
|
isKeychainPlatform,
|
|
62
63
|
profileHasKeychainCredentials,
|
|
63
|
-
swapCredentials,
|
|
64
64
|
} from "./claude-credentials.mjs"
|
|
65
65
|
import {
|
|
66
66
|
answerAskUserQuestion,
|
|
@@ -2138,6 +2138,9 @@ export function handleClaudeChatInput(msg, ctx) {
|
|
|
2138
2138
|
// ultracode (0.6.28): browser がこのメッセージ単位で送るワンショット指定。
|
|
2139
2139
|
// true のときだけそのターンを xhigh effort + dynamic-workflow で実行する。
|
|
2140
2140
|
ultracode: msg.ultracode === true,
|
|
2141
|
+
// id 空間統一 (2026-07-08): input_id をキュー id として下流へ渡す (楽観チップの
|
|
2142
|
+
// 確認前キャンセルを可能にする)。
|
|
2143
|
+
input_id,
|
|
2141
2144
|
})
|
|
2142
2145
|
.then((result) => {
|
|
2143
2146
|
if (!input_id) return
|
|
@@ -2588,6 +2591,11 @@ async function dispatch(msg, ctx) {
|
|
|
2588
2591
|
type: "claude.ready",
|
|
2589
2592
|
stream_id,
|
|
2590
2593
|
resuming: info.resuming,
|
|
2594
|
+
// id 空間統一 (2026-07-08): この agent は browser の input_id をキュー id として
|
|
2595
|
+
// 採用し、楽観送信待ちアイテムの確認前キャンセルに対応することを通知する capability。
|
|
2596
|
+
// 旧 agent にはこのフラグが無く、browser は楽観アイテムの×を出さない (ゴースト
|
|
2597
|
+
// 実行防止のフォールバック)。
|
|
2598
|
+
supports_queue_input_id: true,
|
|
2591
2599
|
})
|
|
2592
2600
|
// S1 (2026-07): セッション状態の identity 正本化。TUI の claude.tui.bind
|
|
2593
2601
|
// (main.mjs 内 clearChatSignal 呼び出し箇所参照) と対称に、SDK チャットの
|
|
@@ -3863,7 +3871,7 @@ async function dispatch(msg, ctx) {
|
|
|
3863
3871
|
reg.profiles.map(async (p) => {
|
|
3864
3872
|
const account = await readProfileAccount(p.configDir)
|
|
3865
3873
|
const hasCreds = isKeychainPlatform()
|
|
3866
|
-
? await profileHasKeychainCredentials(p.
|
|
3874
|
+
? await profileHasKeychainCredentials(p.configDir)
|
|
3867
3875
|
: await hasFileCredentials(p.configDir)
|
|
3868
3876
|
return {
|
|
3869
3877
|
id: p.id,
|
|
@@ -3884,35 +3892,18 @@ async function dispatch(msg, ctx) {
|
|
|
3884
3892
|
return
|
|
3885
3893
|
}
|
|
3886
3894
|
case "profile.switch": {
|
|
3887
|
-
//
|
|
3888
|
-
//
|
|
3895
|
+
// アカウント切替。active 永続化 → env 反映 (process/tmux) → 全 Claude セッション
|
|
3896
|
+
// 強制再起動 (SDK チャット + tmux TUI)。走行中ターンも中断する。
|
|
3889
3897
|
//
|
|
3890
|
-
// WHY
|
|
3891
|
-
// CLAUDE_CONFIG_DIR
|
|
3892
|
-
//
|
|
3898
|
+
// WHY 認証スワップ廃止 (B案, 2026-07): Claude Code 2.1.x は OAuth トークンを
|
|
3899
|
+
// `Claude Code-credentials-<sha256(CLAUDE_CONFIG_DIR)>` に config ディレクトリ別
|
|
3900
|
+
// で保存するようになり、CLAUDE_CONFIG_DIR を切り替えるだけでアカウントも切り替わる。
|
|
3901
|
+
// 旧来の Keychain 手動スワップは Claude Code が読まない素エントリを触るだけの空振り
|
|
3902
|
+
// となり、既定プロファイル等で不要な再ログインを誘発していたため撤去した。
|
|
3903
|
+
// claude-credentials.mjs の docstring 参照。
|
|
3893
3904
|
const id = msg.profile_id || msg.id || ""
|
|
3894
3905
|
try {
|
|
3895
|
-
const regBefore = await listProfiles()
|
|
3896
|
-
const fromId = regBefore.activeId
|
|
3897
3906
|
const target = await setActiveProfile(id)
|
|
3898
|
-
let cred = { saved: false, restored: false, needsLogin: false }
|
|
3899
|
-
if (fromId !== target.id) {
|
|
3900
|
-
try {
|
|
3901
|
-
cred = await swapCredentials({
|
|
3902
|
-
fromId,
|
|
3903
|
-
toId: target.id,
|
|
3904
|
-
logger: ctx.logger,
|
|
3905
|
-
})
|
|
3906
|
-
} catch (err) {
|
|
3907
|
-
// スワップ失敗 (Keychain ACL 拒否等) でも切替自体は続行する。
|
|
3908
|
-
// この場合 Keychain には切替前のトークンが残るため、その旨を返す。
|
|
3909
|
-
ctx.logger.warn(
|
|
3910
|
-
{ err: err?.message || String(err), from: fromId, to: target.id },
|
|
3911
|
-
"credential swap failed; keychain may still hold previous account",
|
|
3912
|
-
)
|
|
3913
|
-
cred = { saved: false, restored: false, needsLogin: false, error: err?.message }
|
|
3914
|
-
}
|
|
3915
|
-
}
|
|
3916
3907
|
applyActiveProfileEnv(target, ctx.logger)
|
|
3917
3908
|
await syncTmuxProfileEnv(target, ctx.logger)
|
|
3918
3909
|
// 全セッションを撤去 (frontend は exit 通知でセッション一覧をリロードする)。
|
|
@@ -3920,6 +3911,11 @@ async function dispatch(msg, ctx) {
|
|
|
3920
3911
|
ctx.claudeBridge?.shutdown()
|
|
3921
3912
|
// tmux TUI セッションも旧アカウントのまま残さない: kill → 同名・同 cwd で再作成。
|
|
3922
3913
|
const tmuxResult = await restartAllTmuxSessionsForProfileSwitch(ctx)
|
|
3914
|
+
// 切替先の config ディレクトリに認証情報が無ければ要ログイン (バッジ/案内用)。
|
|
3915
|
+
const hasCreds = isKeychainPlatform()
|
|
3916
|
+
? await profileHasKeychainCredentials(target.configDir)
|
|
3917
|
+
: await hasFileCredentials(target.configDir)
|
|
3918
|
+
const needsLogin = !hasCreds
|
|
3923
3919
|
ctx.logger.info(
|
|
3924
3920
|
{
|
|
3925
3921
|
profile_id: target.id,
|
|
@@ -3927,9 +3923,7 @@ async function dispatch(msg, ctx) {
|
|
|
3927
3923
|
tmux_killed: tmuxResult.killed.length,
|
|
3928
3924
|
tmux_recreated: tmuxResult.recreated.length,
|
|
3929
3925
|
tmux_failed: tmuxResult.failed.length,
|
|
3930
|
-
|
|
3931
|
-
cred_restored: cred.restored,
|
|
3932
|
-
needs_login: cred.needsLogin,
|
|
3926
|
+
needs_login: needsLogin,
|
|
3933
3927
|
},
|
|
3934
3928
|
"claude profile switched; all sessions reaped",
|
|
3935
3929
|
)
|
|
@@ -3946,13 +3940,12 @@ async function dispatch(msg, ctx) {
|
|
|
3946
3940
|
},
|
|
3947
3941
|
reaped: before,
|
|
3948
3942
|
tmux_restarted: tmuxResult.recreated.length,
|
|
3949
|
-
needs_login:
|
|
3950
|
-
login_command:
|
|
3943
|
+
needs_login: needsLogin,
|
|
3944
|
+
login_command: needsLogin
|
|
3951
3945
|
? isDefaultTarget
|
|
3952
3946
|
? "claude"
|
|
3953
3947
|
: `CLAUDE_CONFIG_DIR=${target.configDir} claude`
|
|
3954
3948
|
: undefined,
|
|
3955
|
-
credential_error: cred.error || undefined,
|
|
3956
3949
|
})
|
|
3957
3950
|
} catch (err) {
|
|
3958
3951
|
ctx.client.send({
|
|
@@ -3997,6 +3990,34 @@ async function dispatch(msg, ctx) {
|
|
|
3997
3990
|
}
|
|
3998
3991
|
return
|
|
3999
3992
|
}
|
|
3993
|
+
case "profile.rename": {
|
|
3994
|
+
// プロファイルの表示ラベルのみ変更する。id・configDir・認証情報 (Keychain
|
|
3995
|
+
// スロット / .credentials.json) には一切触れないため、切替や再ログインは不要。
|
|
3996
|
+
// 既定プロファイルのラベルも変更可 (configDir は常に ~/.claude のまま)。
|
|
3997
|
+
const id = msg.profile_id || msg.id || ""
|
|
3998
|
+
try {
|
|
3999
|
+
const updated = await renameProfile(id, msg.label)
|
|
4000
|
+
ctx.client.send({
|
|
4001
|
+
type: "profile.rename.result",
|
|
4002
|
+
request_id: msg.request_id,
|
|
4003
|
+
ok: true,
|
|
4004
|
+
profile: {
|
|
4005
|
+
id: updated.id,
|
|
4006
|
+
label: updated.label,
|
|
4007
|
+
config_dir: updated.configDir,
|
|
4008
|
+
},
|
|
4009
|
+
})
|
|
4010
|
+
} catch (err) {
|
|
4011
|
+
ctx.client.send({
|
|
4012
|
+
type: "profile.rename.result",
|
|
4013
|
+
request_id: msg.request_id,
|
|
4014
|
+
ok: false,
|
|
4015
|
+
profile_id: id,
|
|
4016
|
+
error: err?.message || String(err),
|
|
4017
|
+
})
|
|
4018
|
+
}
|
|
4019
|
+
return
|
|
4020
|
+
}
|
|
4000
4021
|
case "tmux.exec": {
|
|
4001
4022
|
const args = Array.isArray(msg.args) ? msg.args : []
|
|
4002
4023
|
const hookResult = await runHookChain(ctx.plugins, "interceptTmuxExec", {
|
package/src/profiles.mjs
CHANGED
|
@@ -169,6 +169,26 @@ export async function setActiveProfile(id) {
|
|
|
169
169
|
return target
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
/**
|
|
173
|
+
* プロファイルの表示ラベルを変更する。id・configDir・認証情報には一切触れない。
|
|
174
|
+
* 既定 (プライマリ) プロファイルのラベルも変更可能で、saveRegistry が defaultLabel
|
|
175
|
+
* として永続化する (configDir は常に `~/.claude` のまま = ディレクトリは動かさない)。
|
|
176
|
+
*
|
|
177
|
+
* @param {string} id 対象プロファイルの id (既定は "default")
|
|
178
|
+
* @param {string} label 新しいラベル (前後空白を除去。空文字列は不可)
|
|
179
|
+
* @returns {Promise<{id:string,label:string,configDir:string}>} 更新後のプロファイル
|
|
180
|
+
*/
|
|
181
|
+
export async function renameProfile(id, label) {
|
|
182
|
+
const trimmed = typeof label === "string" ? label.trim() : ""
|
|
183
|
+
if (!trimmed) throw new Error("label must be a non-empty string")
|
|
184
|
+
const reg = await loadRegistry()
|
|
185
|
+
const target = reg.profiles.find((p) => p.id === id)
|
|
186
|
+
if (!target) throw new Error(`unknown profile: ${id}`)
|
|
187
|
+
target.label = trimmed
|
|
188
|
+
await saveRegistry(reg)
|
|
189
|
+
return { id: target.id, label: target.label, configDir: target.configDir }
|
|
190
|
+
}
|
|
191
|
+
|
|
172
192
|
/** 次の連番 ID (既存の数値 id の最大 +1、最低 1)。 */
|
|
173
193
|
function nextNumericId(profiles) {
|
|
174
194
|
let max = 0
|