@cocorograph/hub-agent 0.6.33 → 0.6.35
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/chat-signals.mjs +12 -2
- package/src/claude-stream-bridge.mjs +47 -10
- package/src/main.mjs +167 -5
- package/src/profiles.mjs +281 -0
package/package.json
CHANGED
package/src/chat-signals.mjs
CHANGED
|
@@ -40,8 +40,14 @@ function _now() {
|
|
|
40
40
|
* チャットのアクティビティを記録する。status / context_pct は与えられた分だけ更新し、
|
|
41
41
|
* turnAt (= 最終アクティビティ時刻 / ソート用) は呼ばれるたびに前進させる。
|
|
42
42
|
*
|
|
43
|
+
* inputPending: チャットセッションが permission / AskUserQuestion の応答待ちで
|
|
44
|
+
* ターンをブロックしているか。サイドバーの「確認待ち」ドット & 通知の発火源にする
|
|
45
|
+
* (status とは独立に持つ。SessionStatus enum を汚さず、status は processing のまま
|
|
46
|
+
* "確認待ち" を別軸で表現するため)。permission 要求で true、次の assistant / result
|
|
47
|
+
* イベント (= ユーザーが応答してターンが再開/完了) で false に落とす。
|
|
48
|
+
*
|
|
43
49
|
* @param {string} cwd セッションの作業ディレクトリ
|
|
44
|
-
* @param {{ status?: string, contextPct?: number|null }} patch
|
|
50
|
+
* @param {{ status?: string, contextPct?: number|null, inputPending?: boolean }} patch
|
|
45
51
|
*/
|
|
46
52
|
export function recordChatActivity(cwd, patch = {}) {
|
|
47
53
|
if (!cwd || typeof cwd !== "string") return
|
|
@@ -49,6 +55,7 @@ export function recordChatActivity(cwd, patch = {}) {
|
|
|
49
55
|
const prev = _byCwd.get(cwd) || {
|
|
50
56
|
status: null,
|
|
51
57
|
context_pct: null,
|
|
58
|
+
inputPending: false,
|
|
52
59
|
turnAt: 0,
|
|
53
60
|
updatedAtMs: 0,
|
|
54
61
|
}
|
|
@@ -59,6 +66,9 @@ export function recordChatActivity(cwd, patch = {}) {
|
|
|
59
66
|
if (typeof patch.contextPct === "number" && Number.isFinite(patch.contextPct)) {
|
|
60
67
|
next.context_pct = Math.max(0, Math.min(100, patch.contextPct))
|
|
61
68
|
}
|
|
69
|
+
if (typeof patch.inputPending === "boolean") {
|
|
70
|
+
next.inputPending = patch.inputPending
|
|
71
|
+
}
|
|
62
72
|
_byCwd.set(cwd, next)
|
|
63
73
|
}
|
|
64
74
|
|
|
@@ -67,7 +77,7 @@ export function recordChatActivity(cwd, patch = {}) {
|
|
|
67
77
|
*
|
|
68
78
|
* @param {string} cwd
|
|
69
79
|
* @param {number} [now]
|
|
70
|
-
* @returns {{ status: string|null, context_pct: number|null, turnAt: number, updatedAtMs: number } | null}
|
|
80
|
+
* @returns {{ status: string|null, context_pct: number|null, inputPending?: boolean, turnAt: number, updatedAtMs: number } | null}
|
|
71
81
|
*/
|
|
72
82
|
export function getChatSignal(cwd, now = _now()) {
|
|
73
83
|
if (!cwd) return null
|
|
@@ -37,15 +37,23 @@ const CHAT_RESIDENT_ENABLED = process.env.HUB_AGENT_CHAT_RESIDENT !== "0"
|
|
|
37
37
|
/** 改修4 (2026-05-29): resume セッションも常駐query化する (init を毎ターン送らず、
|
|
38
38
|
* 1 セッション=1 query で system/init を初回 1 回のみにする。VS Code 拡張 / Claude Web
|
|
39
39
|
* と同じ挙動)。query 起動時に options.resume で過去文脈を引き継ぐ。
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
40
|
+
*
|
|
41
|
+
* ⚠️ デフォルト無効化 (2026-06-02): 「眠っていたセッションを起こすと
|
|
42
|
+
* 『Continue from where you left off.』→『No response requested.』や /clear の
|
|
43
|
+
* local-command-caveat が合成ユーザーバブルとして表示され、処理中になり、実メッセージが
|
|
44
|
+
* キュー待ちになる」回帰が確認されたため既定 OFF に戻す。
|
|
45
|
+
*
|
|
46
|
+
* 根本原因: resident 経路 (sendMessage) は **空の InputQueue のまま query を起動してから
|
|
47
|
+
* push** する設計のため、「resume + 入力がまだ空」の窓で SDK が中断/未完了セッションの
|
|
48
|
+
* 自動継続ターン (Continue from where you left off) を差し込む。per-message 経路は
|
|
49
|
+
* InputQueue に push→即 close してから起動するためこの窓が無く、自動継続は起きない
|
|
50
|
+
* (0.6.4 で暴走を潰した設計)。よって resumed セッションは per-message 経路に戻す。
|
|
51
|
+
*
|
|
52
|
+
* env HUB_AGENT_CHAT_RESIDENT_RESUME="1" で個別に再有効化できる (空キュー窓を塞ぐ
|
|
53
|
+
* 根治を入れたら既定 ON へ戻す候補)。新規セッションの常駐化は CHAT_RESIDENT_ENABLED 側で
|
|
54
|
+
* 独立制御 (新規は resume しないため自動継続の窓が無く、既定 ON のまま安全)。 */
|
|
47
55
|
const CHAT_RESIDENT_RESUME_ENABLED =
|
|
48
|
-
process.env.HUB_AGENT_CHAT_RESIDENT_RESUME
|
|
56
|
+
process.env.HUB_AGENT_CHAT_RESIDENT_RESUME === "1"
|
|
49
57
|
|
|
50
58
|
/** 多端末共有セッション (2026-05-30)。同じ Claude セッション (session_id) を複数端末で
|
|
51
59
|
* 同時に開いて、どの端末でもライブ表示・入力・許可応答できるようにする。env
|
|
@@ -229,7 +237,10 @@ class ClaudeStreamSession {
|
|
|
229
237
|
* false に戻す。 */
|
|
230
238
|
this._ultracodeCurrent = false
|
|
231
239
|
|
|
232
|
-
/** @type {Map<string, {resolve: (decision: object) => void}>}
|
|
240
|
+
/** @type {Map<string, {resolve: (decision: object) => void, tool_name: string, input: object}>}
|
|
241
|
+
* permission 応答待ち。tool_name / input も保持し、reattach 時に未応答分を
|
|
242
|
+
* onPermission で再送 (replay) して「裏で動いていたセッションを開いたら確認待ち
|
|
243
|
+
* カードが出ずに固まる」不具合を解消する (固まりの直接原因の修正)。 */
|
|
233
244
|
this._permissionResolvers = new Map()
|
|
234
245
|
/** 現在ターン実行中か (多重 query 防止) */
|
|
235
246
|
this._busy = false
|
|
@@ -290,7 +301,11 @@ class ClaudeStreamSession {
|
|
|
290
301
|
if (!this.onPermission) return { behavior: "allow", updatedInput: input }
|
|
291
302
|
const request_id = randomUUID()
|
|
292
303
|
return await new Promise((resolve) => {
|
|
293
|
-
this._permissionResolvers.set(request_id, {
|
|
304
|
+
this._permissionResolvers.set(request_id, {
|
|
305
|
+
resolve,
|
|
306
|
+
tool_name: toolName,
|
|
307
|
+
input,
|
|
308
|
+
})
|
|
294
309
|
try {
|
|
295
310
|
this.onPermission({ tool_name: toolName, input, request_id })
|
|
296
311
|
} catch (err) {
|
|
@@ -344,6 +359,27 @@ class ClaudeStreamSession {
|
|
|
344
359
|
// started=[] なのでバブル昇格は起きずチップ更新のみ (冪等)。onEvent は新しい
|
|
345
360
|
// stream_id 宛の stream_group relay で確実に届き、session_group fanout で他端末にも届く。
|
|
346
361
|
this._emitQueueState([], { force: true })
|
|
362
|
+
// 固まり解消 (2026-06-02): 未応答 permission を再送する。permission は in-flight な
|
|
363
|
+
// callback で jsonl hydrate では復元されないため、別セッションを表示中に裏で発生した
|
|
364
|
+
// 確認待ちは取りこぼされ、サーバー側 Promise が永久 pending になりセッションが固まる。
|
|
365
|
+
// 再アタッチした端末へ現在の未応答分を再 emit し、確認待ちカードを復元させる
|
|
366
|
+
// (browser 側は request_id で重複排除するので、既に表示済みの端末でも二重化しない)。
|
|
367
|
+
this._replayPendingPermissions()
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** 未応答 permission を onPermission 経由で再送する (reattach から呼ぶ)。 */
|
|
371
|
+
_replayPendingPermissions() {
|
|
372
|
+
if (!this.onPermission || this._permissionResolvers.size === 0) return
|
|
373
|
+
for (const [request_id, r] of this._permissionResolvers) {
|
|
374
|
+
try {
|
|
375
|
+
this.onPermission({ tool_name: r.tool_name, input: r.input, request_id })
|
|
376
|
+
} catch (err) {
|
|
377
|
+
this.logger?.warn(
|
|
378
|
+
{ err: err.message, stream_id: this.stream_id, request_id },
|
|
379
|
+
"replay permission failed",
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
347
383
|
}
|
|
348
384
|
|
|
349
385
|
/** B11: 端末の最終アクティビティ時刻を更新する (死端末 GC の生存判定に使う)。 */
|
|
@@ -1135,6 +1171,7 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1135
1171
|
this.emit("permission", {
|
|
1136
1172
|
stream_id: session.stream_id,
|
|
1137
1173
|
session_id: session.sessionId,
|
|
1174
|
+
cwd: session.cwd,
|
|
1138
1175
|
request_id,
|
|
1139
1176
|
tool_name,
|
|
1140
1177
|
input,
|
package/src/main.mjs
CHANGED
|
@@ -28,6 +28,15 @@ import { fetchSessionHistory, listSessions } from "./claude-history.mjs"
|
|
|
28
28
|
import { listAgents } from "./agents.mjs"
|
|
29
29
|
import { listSkills } from "./skills.mjs"
|
|
30
30
|
import { listSessionStates } from "./state.mjs"
|
|
31
|
+
import {
|
|
32
|
+
DEFAULT_PROFILE_ID,
|
|
33
|
+
defaultConfigDir,
|
|
34
|
+
listProfiles,
|
|
35
|
+
getActiveProfile,
|
|
36
|
+
getActiveProjectsRoot,
|
|
37
|
+
setActiveProfile,
|
|
38
|
+
addProfile,
|
|
39
|
+
} from "./profiles.mjs"
|
|
31
40
|
import {
|
|
32
41
|
buildClaudeCmd,
|
|
33
42
|
createSession as createTmuxSession,
|
|
@@ -144,6 +153,34 @@ export function isFastPathMessage(type) {
|
|
|
144
153
|
return type === "pty.data" || type === "pty.resize"
|
|
145
154
|
}
|
|
146
155
|
|
|
156
|
+
/**
|
|
157
|
+
* active プロファイルの configDir を process.env.CLAUDE_CONFIG_DIR へ反映する。
|
|
158
|
+
* 既定 (`~/.claude`) のときは変数を消して従来挙動 (Claude Code の既定パス) に戻す。
|
|
159
|
+
* SDK が起動する Claude Code サブプロセスはこの env を継承するため、以後の query() は
|
|
160
|
+
* このプロファイルの認証・履歴・設定ディレクトリで動く。全セッションの再起動とセットで使う。
|
|
161
|
+
*
|
|
162
|
+
* @param {{id:string,configDir:string}} profile
|
|
163
|
+
* @param {import('pino').Logger} [log]
|
|
164
|
+
*/
|
|
165
|
+
export function applyActiveProfileEnv(profile, log) {
|
|
166
|
+
const isDefault =
|
|
167
|
+
!profile ||
|
|
168
|
+
profile.id === DEFAULT_PROFILE_ID ||
|
|
169
|
+
profile.configDir === defaultConfigDir()
|
|
170
|
+
if (isDefault) {
|
|
171
|
+
delete process.env.CLAUDE_CONFIG_DIR
|
|
172
|
+
} else {
|
|
173
|
+
process.env.CLAUDE_CONFIG_DIR = profile.configDir
|
|
174
|
+
}
|
|
175
|
+
log?.info?.(
|
|
176
|
+
{
|
|
177
|
+
profile_id: profile?.id || DEFAULT_PROFILE_ID,
|
|
178
|
+
config_dir: process.env.CLAUDE_CONFIG_DIR || defaultConfigDir(),
|
|
179
|
+
},
|
|
180
|
+
"active claude profile applied",
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
147
184
|
export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
148
185
|
const config = await readConfig()
|
|
149
186
|
if (!config) {
|
|
@@ -155,6 +192,18 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
155
192
|
const plugins = await loadPlugins(logger)
|
|
156
193
|
const ctx = { logger, config, plugins }
|
|
157
194
|
|
|
195
|
+
// 起動時: 保存済み active プロファイルの CLAUDE_CONFIG_DIR を反映してから
|
|
196
|
+
// Claude SDK を初期化する。profiles.json が無ければ既定 (~/.claude) のまま。
|
|
197
|
+
try {
|
|
198
|
+
const activeProfile = await getActiveProfile()
|
|
199
|
+
applyActiveProfileEnv(activeProfile, logger)
|
|
200
|
+
} catch (err) {
|
|
201
|
+
logger.warn(
|
|
202
|
+
{ err: err.message },
|
|
203
|
+
"failed to apply active claude profile; falling back to default ~/.claude",
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
158
207
|
await runHookBroadcast(plugins, "onAgentStart", ctx)
|
|
159
208
|
|
|
160
209
|
const resolvedPty = ptyModule || (await import("node-pty"))
|
|
@@ -218,23 +267,39 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
218
267
|
// TUI を出さず、capture-pane スクレイプも bundle hook も発火しないための補完。
|
|
219
268
|
// - assistant (生成中) → processing + usage から context%
|
|
220
269
|
// - result (ターン完了/入力待ち) → waiting
|
|
270
|
+
// assistant / result はターンの進行/完了 = permission 応答後に必ず流れるため、
|
|
271
|
+
// ここで inputPending を落として「確認待ち」ドット/通知を自己解消させる。
|
|
221
272
|
if (event?.type === "assistant") {
|
|
222
273
|
const pct = event.message?.usage ? contextPctFromUsage(event.message.usage) : null
|
|
223
274
|
try {
|
|
224
|
-
recordChatActivity(cwd, {
|
|
275
|
+
recordChatActivity(cwd, {
|
|
276
|
+
status: "processing",
|
|
277
|
+
contextPct: pct,
|
|
278
|
+
inputPending: false,
|
|
279
|
+
})
|
|
225
280
|
} catch {
|
|
226
281
|
/* ignore */
|
|
227
282
|
}
|
|
228
283
|
} else if (event?.type === "result") {
|
|
229
284
|
try {
|
|
230
|
-
recordChatActivity(cwd, { status: "waiting" })
|
|
285
|
+
recordChatActivity(cwd, { status: "waiting", inputPending: false })
|
|
231
286
|
} catch {
|
|
232
287
|
/* ignore */
|
|
233
288
|
}
|
|
234
289
|
}
|
|
235
290
|
client.send({ type: "claude.event", stream_id, session_id, event })
|
|
236
291
|
})
|
|
237
|
-
claudeBridge.on("permission", ({ stream_id, session_id, request_id, tool_name, input }) => {
|
|
292
|
+
claudeBridge.on("permission", ({ stream_id, session_id, cwd, request_id, tool_name, input }) => {
|
|
293
|
+
// 確認待ち信号 (2026-06-02): cwd キーで inputPending を立て、サイドバーの
|
|
294
|
+
// 「確認待ち」ドット & 通知の発火源にする。次の assistant/result で落ちる。
|
|
295
|
+
// これにより「裏で動いているセッションがどれか分からない」発見性の問題を解消する。
|
|
296
|
+
if (cwd) {
|
|
297
|
+
try {
|
|
298
|
+
recordChatActivity(cwd, { inputPending: true })
|
|
299
|
+
} catch {
|
|
300
|
+
/* ignore */
|
|
301
|
+
}
|
|
302
|
+
}
|
|
238
303
|
// 多端末共有: session_id を載せて backend が session group へ broadcast できるように
|
|
239
304
|
// する (全端末に許可ダイアログ → 先着 1 件採用)。
|
|
240
305
|
client.send({
|
|
@@ -579,7 +644,14 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge }) {
|
|
|
579
644
|
const prevTurnAt = lastTurnAtByName.get(s.session_name) || 0
|
|
580
645
|
if (chat.turnAt > prevTurnAt) {
|
|
581
646
|
lastTurnAtByName.set(s.session_name, chat.turnAt)
|
|
582
|
-
|
|
647
|
+
// 確認待ち (inputPending) は専用 event "input_required" を書き、サイドバーの
|
|
648
|
+
// 「確認待ち」ドット & 通知に橋渡しする。それ以外は従来通り processing →
|
|
649
|
+
// prompt_submit / それ以外 → stop。
|
|
650
|
+
const ev = chat.inputPending
|
|
651
|
+
? "input_required"
|
|
652
|
+
: chat.status === "processing"
|
|
653
|
+
? "prompt_submit"
|
|
654
|
+
: "stop"
|
|
583
655
|
writeSessionEventFile(s.session_name, ev, chat.turnAt).catch(() => {})
|
|
584
656
|
}
|
|
585
657
|
}
|
|
@@ -910,10 +982,13 @@ async function dispatch(msg, ctx) {
|
|
|
910
982
|
const cwd = msg.cwd || ""
|
|
911
983
|
const session_id = msg.session_id || ""
|
|
912
984
|
const maxLines = typeof msg.max_lines === "number" ? msg.max_lines : undefined
|
|
985
|
+
// active プロファイルの projects/ から読む (アカウント別に履歴がサイロ化されているため)。
|
|
986
|
+
const projectsRoot = await getActiveProjectsRoot()
|
|
913
987
|
const result = await fetchSessionHistory({
|
|
914
988
|
cwd,
|
|
915
989
|
session_id,
|
|
916
990
|
maxLines,
|
|
991
|
+
projectsRoot,
|
|
917
992
|
logger: ctx.logger,
|
|
918
993
|
})
|
|
919
994
|
ctx.client.send({
|
|
@@ -934,7 +1009,14 @@ async function dispatch(msg, ctx) {
|
|
|
934
1009
|
const stream_id = msg.stream_id
|
|
935
1010
|
const cwd = msg.cwd || ""
|
|
936
1011
|
const limit = typeof msg.limit === "number" ? msg.limit : undefined
|
|
937
|
-
|
|
1012
|
+
// active プロファイルの projects/ からセッション一覧を読む。
|
|
1013
|
+
const projectsRoot = await getActiveProjectsRoot()
|
|
1014
|
+
const result = await listSessions({
|
|
1015
|
+
cwd,
|
|
1016
|
+
limit,
|
|
1017
|
+
projectsRoot,
|
|
1018
|
+
logger: ctx.logger,
|
|
1019
|
+
})
|
|
938
1020
|
ctx.client.send({
|
|
939
1021
|
type: "claude.sessions.response",
|
|
940
1022
|
stream_id,
|
|
@@ -944,6 +1026,86 @@ async function dispatch(msg, ctx) {
|
|
|
944
1026
|
})
|
|
945
1027
|
return
|
|
946
1028
|
}
|
|
1029
|
+
case "profile.list": {
|
|
1030
|
+
// Claude アカウント・プロファイル一覧と active を返す (Cockpit のアカウント切替UI用)。
|
|
1031
|
+
const reg = await listProfiles()
|
|
1032
|
+
ctx.client.send({
|
|
1033
|
+
type: "profile.list.response",
|
|
1034
|
+
request_id: msg.request_id,
|
|
1035
|
+
active_id: reg.activeId,
|
|
1036
|
+
profiles: reg.profiles.map((p) => ({
|
|
1037
|
+
id: p.id,
|
|
1038
|
+
label: p.label,
|
|
1039
|
+
config_dir: p.configDir,
|
|
1040
|
+
})),
|
|
1041
|
+
})
|
|
1042
|
+
return
|
|
1043
|
+
}
|
|
1044
|
+
case "profile.switch": {
|
|
1045
|
+
// アカウント切替。active を永続化 → env 反映 → 全 Claude セッション強制再起動。
|
|
1046
|
+
// 走行中ターンも中断する (アカウントが変わる以上、継続は不可)。
|
|
1047
|
+
const id = msg.profile_id || msg.id || ""
|
|
1048
|
+
try {
|
|
1049
|
+
const target = await setActiveProfile(id)
|
|
1050
|
+
applyActiveProfileEnv(target, ctx.logger)
|
|
1051
|
+
// 全セッションを撤去 (frontend は exit 通知でセッション一覧をリロードする)。
|
|
1052
|
+
const before = ctx.claudeBridge ? ctx.claudeBridge.list().length : 0
|
|
1053
|
+
ctx.claudeBridge?.shutdown()
|
|
1054
|
+
ctx.logger.info(
|
|
1055
|
+
{ profile_id: target.id, restarted: before },
|
|
1056
|
+
"claude profile switched; all sessions reaped",
|
|
1057
|
+
)
|
|
1058
|
+
ctx.client.send({
|
|
1059
|
+
type: "profile.switch.result",
|
|
1060
|
+
request_id: msg.request_id,
|
|
1061
|
+
ok: true,
|
|
1062
|
+
active_id: target.id,
|
|
1063
|
+
profile: {
|
|
1064
|
+
id: target.id,
|
|
1065
|
+
label: target.label,
|
|
1066
|
+
config_dir: target.configDir,
|
|
1067
|
+
},
|
|
1068
|
+
reaped: before,
|
|
1069
|
+
})
|
|
1070
|
+
} catch (err) {
|
|
1071
|
+
ctx.client.send({
|
|
1072
|
+
type: "profile.switch.result",
|
|
1073
|
+
request_id: msg.request_id,
|
|
1074
|
+
ok: false,
|
|
1075
|
+
profile_id: id,
|
|
1076
|
+
error: err?.message || String(err),
|
|
1077
|
+
})
|
|
1078
|
+
}
|
|
1079
|
+
return
|
|
1080
|
+
}
|
|
1081
|
+
case "profile.add": {
|
|
1082
|
+
// 新規プロファイルを追加 (連番 id・中立パス `~/.claude-<n>`) し、共有資産を整備する。
|
|
1083
|
+
// 追加直後はログイン未了 (needs_login)。ユーザーが端末で
|
|
1084
|
+
// `CLAUDE_CONFIG_DIR=<config_dir> claude` → /login する必要がある。
|
|
1085
|
+
try {
|
|
1086
|
+
const created = await addProfile({ label: msg.label })
|
|
1087
|
+
ctx.client.send({
|
|
1088
|
+
type: "profile.add.result",
|
|
1089
|
+
request_id: msg.request_id,
|
|
1090
|
+
ok: true,
|
|
1091
|
+
profile: {
|
|
1092
|
+
id: created.id,
|
|
1093
|
+
label: created.label,
|
|
1094
|
+
config_dir: created.configDir,
|
|
1095
|
+
},
|
|
1096
|
+
needs_login: created.needsLogin,
|
|
1097
|
+
login_command: `CLAUDE_CONFIG_DIR=${created.configDir} claude`,
|
|
1098
|
+
})
|
|
1099
|
+
} catch (err) {
|
|
1100
|
+
ctx.client.send({
|
|
1101
|
+
type: "profile.add.result",
|
|
1102
|
+
request_id: msg.request_id,
|
|
1103
|
+
ok: false,
|
|
1104
|
+
error: err?.message || String(err),
|
|
1105
|
+
})
|
|
1106
|
+
}
|
|
1107
|
+
return
|
|
1108
|
+
}
|
|
947
1109
|
case "tmux.exec": {
|
|
948
1110
|
const args = Array.isArray(msg.args) ? msg.args : []
|
|
949
1111
|
const hookResult = await runHookChain(ctx.plugins, "interceptTmuxExec", {
|
package/src/profiles.mjs
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude アカウント・プロファイル管理。
|
|
3
|
+
*
|
|
4
|
+
* 1 台のマシンで複数の Claude アカウント (例: 個人 Max / チーム) を `CLAUDE_CONFIG_DIR`
|
|
5
|
+
* で切り替えて使うための仕組み。各プロファイルは独立した config ディレクトリを持ち、
|
|
6
|
+
* 認証・会話履歴 (`projects/`)・`.claude.json` をアカウント別に保持する。一方で
|
|
7
|
+
* skills / agents / commands / scripts / hooks / CLAUDE.md は symlink で共有し、
|
|
8
|
+
* settings.json はコピーで共有する (provisionProfile が面倒を見る)。
|
|
9
|
+
*
|
|
10
|
+
* 設計方針 (重要):
|
|
11
|
+
* - 既定 (プライマリ) プロファイルは **常に `~/.claude`**。リネーム・移動しない。
|
|
12
|
+
* profiles.json が存在しない単一アカウント利用者は、この仕組みに一切触れずに
|
|
13
|
+
* 従来どおり `~/.claude` で動く (完全に追加機能・ゼロ設定)。
|
|
14
|
+
* - 2 つ目以降を足したときだけ `~/.claude-1` `~/.claude-2` … が生える (中立な連番)。
|
|
15
|
+
* `~/.claude-0` は作らない。
|
|
16
|
+
* - ディレクトリ名にプラン種別を埋め込まない。UI 表示は label で行う。
|
|
17
|
+
*
|
|
18
|
+
* レジストリは `~/.hub/profiles.json` に保存する (config.mjs と同じ ~/.hub 配下)。
|
|
19
|
+
*/
|
|
20
|
+
import { promises as fs } from "node:fs"
|
|
21
|
+
import path from "node:path"
|
|
22
|
+
import os from "node:os"
|
|
23
|
+
|
|
24
|
+
import { paths as hubPaths, ensureConfigDir } from "./config.mjs"
|
|
25
|
+
|
|
26
|
+
/** 既定 (プライマリ) プロファイルの ID。 */
|
|
27
|
+
export const DEFAULT_PROFILE_ID = "default"
|
|
28
|
+
|
|
29
|
+
/** `~/.claude` の絶対パス。 */
|
|
30
|
+
export function defaultConfigDir() {
|
|
31
|
+
return path.join(os.homedir(), ".claude")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 既定プロファイルを合成して返す (永続化はしない)。 */
|
|
35
|
+
export function makeDefaultProfile() {
|
|
36
|
+
return {
|
|
37
|
+
id: DEFAULT_PROFILE_ID,
|
|
38
|
+
label: "メイン",
|
|
39
|
+
configDir: defaultConfigDir(),
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** `~/.hub/profiles.json` の絶対パス。 */
|
|
44
|
+
export function registryPath() {
|
|
45
|
+
return path.join(hubPaths.configDir, "profiles.json")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** モジュール内キャッシュ (頻繁に呼ばれる getActiveConfigDir のため)。
|
|
49
|
+
* setActiveProfile / 書き込み系で必ず無効化する。 */
|
|
50
|
+
let _cache = null
|
|
51
|
+
|
|
52
|
+
/** チルダ展開 (`~` / `~/...` を home 起点に解決)。それ以外は path.resolve。 */
|
|
53
|
+
function resolveDir(dir) {
|
|
54
|
+
if (!dir) return defaultConfigDir()
|
|
55
|
+
if (dir === "~") return os.homedir()
|
|
56
|
+
if (dir.startsWith("~/")) return path.join(os.homedir(), dir.slice(2))
|
|
57
|
+
return path.resolve(dir)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* レジストリを読み込む。存在しなければ既定プロファイルのみの仮想レジストリを返す。
|
|
62
|
+
* 返り値の profiles は必ず先頭に既定プロファイルを含み、configDir は絶対パスに正規化済み。
|
|
63
|
+
*
|
|
64
|
+
* @returns {Promise<{activeId: string, profiles: Array<{id:string,label:string,configDir:string}>}>}
|
|
65
|
+
*/
|
|
66
|
+
export async function loadRegistry() {
|
|
67
|
+
if (_cache) return _cache
|
|
68
|
+
let parsed = null
|
|
69
|
+
try {
|
|
70
|
+
const raw = await fs.readFile(registryPath(), "utf-8")
|
|
71
|
+
parsed = JSON.parse(raw)
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (err.code !== "ENOENT") throw err
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const def = makeDefaultProfile()
|
|
77
|
+
let profiles = [def]
|
|
78
|
+
let activeId = DEFAULT_PROFILE_ID
|
|
79
|
+
|
|
80
|
+
if (parsed && Array.isArray(parsed.profiles)) {
|
|
81
|
+
// 既定以外を取り込み (configDir 正規化)。id 重複・default の二重定義は弾く。
|
|
82
|
+
const seen = new Set([DEFAULT_PROFILE_ID])
|
|
83
|
+
for (const p of parsed.profiles) {
|
|
84
|
+
if (!p || typeof p.id !== "string" || p.id === DEFAULT_PROFILE_ID) continue
|
|
85
|
+
if (seen.has(p.id)) continue
|
|
86
|
+
seen.add(p.id)
|
|
87
|
+
profiles.push({
|
|
88
|
+
id: p.id,
|
|
89
|
+
label: typeof p.label === "string" && p.label ? p.label : p.id,
|
|
90
|
+
configDir: resolveDir(p.configDir),
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
// 既定プロファイルのラベルだけはレジストリで上書きできる。
|
|
94
|
+
if (typeof parsed.defaultLabel === "string" && parsed.defaultLabel) {
|
|
95
|
+
def.label = parsed.defaultLabel
|
|
96
|
+
}
|
|
97
|
+
if (
|
|
98
|
+
typeof parsed.activeId === "string" &&
|
|
99
|
+
profiles.some((p) => p.id === parsed.activeId)
|
|
100
|
+
) {
|
|
101
|
+
activeId = parsed.activeId
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_cache = { activeId, profiles }
|
|
106
|
+
return _cache
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** レジストリを永続化する。default は profiles[] に含めず、追加分と activeId / defaultLabel だけ保存。 */
|
|
110
|
+
async function saveRegistry(reg) {
|
|
111
|
+
await ensureConfigDir()
|
|
112
|
+
const def = reg.profiles.find((p) => p.id === DEFAULT_PROFILE_ID)
|
|
113
|
+
const payload = {
|
|
114
|
+
activeId: reg.activeId,
|
|
115
|
+
defaultLabel: def ? def.label : undefined,
|
|
116
|
+
profiles: reg.profiles
|
|
117
|
+
.filter((p) => p.id !== DEFAULT_PROFILE_ID)
|
|
118
|
+
.map((p) => ({ id: p.id, label: p.label, configDir: p.configDir })),
|
|
119
|
+
}
|
|
120
|
+
await fs.writeFile(registryPath(), JSON.stringify(payload, null, 2), {
|
|
121
|
+
mode: 0o600,
|
|
122
|
+
})
|
|
123
|
+
_cache = null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** プロファイル一覧と active を返す (UI 表示用)。 */
|
|
127
|
+
export async function listProfiles() {
|
|
128
|
+
const reg = await loadRegistry()
|
|
129
|
+
return { activeId: reg.activeId, profiles: reg.profiles }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** active プロファイルオブジェクトを返す。 */
|
|
133
|
+
export async function getActiveProfile() {
|
|
134
|
+
const reg = await loadRegistry()
|
|
135
|
+
return (
|
|
136
|
+
reg.profiles.find((p) => p.id === reg.activeId) || makeDefaultProfile()
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** active の config ディレクトリ絶対パス。 */
|
|
141
|
+
export async function getActiveConfigDir() {
|
|
142
|
+
return (await getActiveProfile()).configDir
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** active の会話履歴ルート (`<configDir>/projects`)。 */
|
|
146
|
+
export async function getActiveProjectsRoot() {
|
|
147
|
+
return path.join(await getActiveConfigDir(), "projects")
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* active プロファイルを切り替える。存在しない id はエラー。
|
|
152
|
+
* env (CLAUDE_CONFIG_DIR) の反映と全セッション再起動は呼び出し側 (main.mjs) の責務。
|
|
153
|
+
*
|
|
154
|
+
* @param {string} id
|
|
155
|
+
* @returns {Promise<{id:string,label:string,configDir:string}>} 切替後の active プロファイル
|
|
156
|
+
*/
|
|
157
|
+
export async function setActiveProfile(id) {
|
|
158
|
+
const reg = await loadRegistry()
|
|
159
|
+
const target = reg.profiles.find((p) => p.id === id)
|
|
160
|
+
if (!target) throw new Error(`unknown profile: ${id}`)
|
|
161
|
+
reg.activeId = id
|
|
162
|
+
await saveRegistry(reg)
|
|
163
|
+
return target
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 次の連番 ID (既存の数値 id の最大 +1、最低 1)。 */
|
|
167
|
+
function nextNumericId(profiles) {
|
|
168
|
+
let max = 0
|
|
169
|
+
for (const p of profiles) {
|
|
170
|
+
const n = Number.parseInt(p.id, 10)
|
|
171
|
+
if (Number.isInteger(n) && n > max) max = n
|
|
172
|
+
}
|
|
173
|
+
return String(max + 1)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* プロファイルを新規追加する (連番 id・中立パス `~/.claude-<n>`)。
|
|
178
|
+
* ディレクトリ整備 (symlink / settings コピー) は provisionProfile が行う。
|
|
179
|
+
* 追加後もログインは未了 (ユーザーが端末で `/login` する必要がある)。
|
|
180
|
+
*
|
|
181
|
+
* @param {{label?: string}} [opts]
|
|
182
|
+
* @returns {Promise<{id:string,label:string,configDir:string, needsLogin: boolean}>}
|
|
183
|
+
*/
|
|
184
|
+
export async function addProfile(opts = {}) {
|
|
185
|
+
const reg = await loadRegistry()
|
|
186
|
+
const id = nextNumericId(reg.profiles)
|
|
187
|
+
const configDir = path.join(os.homedir(), `.claude-${id}`)
|
|
188
|
+
const profile = {
|
|
189
|
+
id,
|
|
190
|
+
label: opts.label && String(opts.label).trim() ? String(opts.label).trim() : `アカウント${id}`,
|
|
191
|
+
configDir,
|
|
192
|
+
}
|
|
193
|
+
reg.profiles.push(profile)
|
|
194
|
+
await saveRegistry(reg)
|
|
195
|
+
await provisionProfile(configDir)
|
|
196
|
+
return { ...profile, needsLogin: true }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** 既定プロファイルから共有 symlink する資産 (アプリが書き込まない読み取り中心)。 */
|
|
200
|
+
export const SHARED_SYMLINK_ITEMS = [
|
|
201
|
+
"agents",
|
|
202
|
+
"skills",
|
|
203
|
+
"commands",
|
|
204
|
+
"scripts",
|
|
205
|
+
"hooks",
|
|
206
|
+
"CLAUDE.md",
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
/** コピーで共有する資産 (アプリが書き換えるため symlink 不可)。 */
|
|
210
|
+
export const SHARED_COPY_ITEMS = ["settings.json"]
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* プロファイルディレクトリを整備する。
|
|
214
|
+
* - 共有 symlink 資産: 既定 `~/.claude/<item>` への絶対 symlink を張る (既存・実体があれば触らない)。
|
|
215
|
+
* - 共有コピー資産: 既定からコピー (プロファイル側に未存在のときだけ)。
|
|
216
|
+
* 認証・履歴・`.claude.json` には一切触れない (アカウント固有のため)。
|
|
217
|
+
*
|
|
218
|
+
* @param {string} configDir - 対象プロファイルの config ディレクトリ絶対パス
|
|
219
|
+
* @returns {Promise<{linked: string[], copied: string[], skipped: string[]}>}
|
|
220
|
+
*/
|
|
221
|
+
export async function provisionProfile(configDir) {
|
|
222
|
+
const base = defaultConfigDir()
|
|
223
|
+
await fs.mkdir(configDir, { recursive: true, mode: 0o700 })
|
|
224
|
+
const linked = []
|
|
225
|
+
const copied = []
|
|
226
|
+
const skipped = []
|
|
227
|
+
|
|
228
|
+
for (const item of SHARED_SYMLINK_ITEMS) {
|
|
229
|
+
const src = path.join(base, item)
|
|
230
|
+
const dst = path.join(configDir, item)
|
|
231
|
+
try {
|
|
232
|
+
await fs.access(src)
|
|
233
|
+
} catch {
|
|
234
|
+
skipped.push(`${item}(元無し)`)
|
|
235
|
+
continue
|
|
236
|
+
}
|
|
237
|
+
// dst が既に存在 (symlink/実体) なら触らない。
|
|
238
|
+
let exists = false
|
|
239
|
+
try {
|
|
240
|
+
await fs.lstat(dst)
|
|
241
|
+
exists = true
|
|
242
|
+
} catch {
|
|
243
|
+
/* not exists */
|
|
244
|
+
}
|
|
245
|
+
if (exists) {
|
|
246
|
+
skipped.push(`${item}(既存)`)
|
|
247
|
+
continue
|
|
248
|
+
}
|
|
249
|
+
await fs.symlink(src, dst)
|
|
250
|
+
linked.push(item)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
for (const item of SHARED_COPY_ITEMS) {
|
|
254
|
+
const src = path.join(base, item)
|
|
255
|
+
const dst = path.join(configDir, item)
|
|
256
|
+
let dstExists = false
|
|
257
|
+
try {
|
|
258
|
+
await fs.lstat(dst)
|
|
259
|
+
dstExists = true
|
|
260
|
+
} catch {
|
|
261
|
+
/* not exists */
|
|
262
|
+
}
|
|
263
|
+
if (dstExists) {
|
|
264
|
+
skipped.push(`${item}(既存)`)
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
await fs.copyFile(src, dst)
|
|
269
|
+
copied.push(item)
|
|
270
|
+
} catch {
|
|
271
|
+
skipped.push(`${item}(元無し)`)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return { linked, copied, skipped }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** テスト用: モジュールキャッシュを破棄する。 */
|
|
279
|
+
export function _clearCache() {
|
|
280
|
+
_cache = null
|
|
281
|
+
}
|