@cocorograph/hub-agent 0.8.56 → 0.8.58
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 +20 -0
- package/src/claude-stream-bridge.mjs +4 -0
- package/src/codex-stream-bridge.mjs +6 -0
- package/src/main.mjs +177 -56
- package/src/profiles.mjs +36 -36
package/package.json
CHANGED
package/src/chat-signals.mjs
CHANGED
|
@@ -70,6 +70,11 @@ const STREAM_TICK_THROTTLE_MS = 3 * 1000
|
|
|
70
70
|
// key → 最後に stream tick を記録した時刻 (スロットル判定用)。
|
|
71
71
|
const _lastStreamTickAt = new Map()
|
|
72
72
|
|
|
73
|
+
// `${cliKind}\u0000${cwd}` → チャット入力を最後に受け付けた時刻。handler が dispatch 前に同期で記録する
|
|
74
|
+
// (bridge の応答を待つ間に turn/started 等で state loop が processing を観測しても、観測時刻ではなく
|
|
75
|
+
// この受付時刻で prompt_submit を書けるように)。tmux 走査より先に記録するので走査失敗にも影響されない。
|
|
76
|
+
const _promptAcceptedAtByKey = new Map()
|
|
77
|
+
|
|
73
78
|
const VALID_STATUS = new Set(["processing", "waiting", "idle"])
|
|
74
79
|
|
|
75
80
|
function _now() {
|
|
@@ -88,6 +93,19 @@ function _key(cwd, cliKind) {
|
|
|
88
93
|
return `${normalizeCliKind(cliKind)}\u0000${cwd}`
|
|
89
94
|
}
|
|
90
95
|
|
|
96
|
+
/** (cwd, cliKind) の最新 prompt 受付時刻を単調に記録する。 */
|
|
97
|
+
export function recordPromptAccepted(cwd, cliKind, at) {
|
|
98
|
+
const key = _key(cwd, cliKind)
|
|
99
|
+
if (!key) return
|
|
100
|
+
_promptAcceptedAtByKey.set(key, Math.max(_promptAcceptedAtByKey.get(key) ?? 0, at))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** (cwd, cliKind) の最新 prompt 受付時刻を返す (無ければ undefined)。 */
|
|
104
|
+
export function getPromptAcceptedAt(cwd, cliKind) {
|
|
105
|
+
const key = _key(cwd, cliKind)
|
|
106
|
+
return key ? _promptAcceptedAtByKey.get(key) : undefined
|
|
107
|
+
}
|
|
108
|
+
|
|
91
109
|
/**
|
|
92
110
|
* チャットのアクティビティを記録する。status / context_pct は与えられた分だけ更新し、
|
|
93
111
|
* turnAt (= 最終アクティビティ時刻 / ソート用) は呼ばれるたびに前進させる。
|
|
@@ -239,12 +257,14 @@ export function clearChatSignal(cwd, cliKind) {
|
|
|
239
257
|
if (!key) return
|
|
240
258
|
_byKey.delete(key)
|
|
241
259
|
_lastStreamTickAt.delete(key)
|
|
260
|
+
_promptAcceptedAtByKey.delete(key)
|
|
242
261
|
}
|
|
243
262
|
|
|
244
263
|
/** テスト用: ストアを空にする。 */
|
|
245
264
|
export function _resetChatSignals() {
|
|
246
265
|
_byKey.clear()
|
|
247
266
|
_lastStreamTickAt.clear()
|
|
267
|
+
_promptAcceptedAtByKey.clear()
|
|
248
268
|
}
|
|
249
269
|
|
|
250
270
|
export { CHAT_SIGNAL_STALE_MS, STREAM_TICK_THROTTLE_MS }
|
|
@@ -4760,4 +4760,10 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
4760
4760
|
}
|
|
4761
4761
|
this.sessions.clear()
|
|
4762
4762
|
}
|
|
4763
|
+
|
|
4764
|
+
getCwd(stream_id) {
|
|
4765
|
+
const session = this.sessions.get(stream_id)
|
|
4766
|
+
if (!session || session.dead) return null
|
|
4767
|
+
return session.cwd ?? null
|
|
4768
|
+
}
|
|
4763
4769
|
}
|
package/src/main.mjs
CHANGED
|
@@ -139,8 +139,10 @@ import {
|
|
|
139
139
|
import {
|
|
140
140
|
clearChatSignal,
|
|
141
141
|
getChatSignal,
|
|
142
|
+
getPromptAcceptedAt,
|
|
142
143
|
recordChatActivity,
|
|
143
144
|
recordChatStreamTick,
|
|
145
|
+
recordPromptAccepted,
|
|
144
146
|
} from "./chat-signals.mjs"
|
|
145
147
|
import { cliKindForSessionName, sessionNameFor } from "./session-names.mjs"
|
|
146
148
|
import {
|
|
@@ -518,9 +520,11 @@ export async function syncTmuxCodexEnv(profile, log) {
|
|
|
518
520
|
* bundle hooks (cockpit_permission_bridge 等) を反映させる。
|
|
519
521
|
*
|
|
520
522
|
* 新規プロファイル追加 (profile.add) 直後に呼ぶ。provisionProfile は
|
|
521
|
-
* `~/.claude/settings.json`
|
|
522
|
-
*
|
|
523
|
-
*
|
|
523
|
+
* `~/.claude/settings.json` への symlink を新プロファイルへ張る (コピーではない) ので、
|
|
524
|
+
* 正本が最新ならフックは自動的に揃う。ただし正本自体が古い / setup 未走のタイミングだと
|
|
525
|
+
* 新プロファイルの初回セッションでもフックが欠けうるため、1 回叩いて正本を最新化する。
|
|
526
|
+
* 旧版がコピーで置いた実体 settings.json が残るプロファイルにも、setup_hub_ai は
|
|
527
|
+
* 全プロファイル対応 (案1) なので同じ 1 回で反映される。
|
|
524
528
|
*
|
|
525
529
|
* 失敗・未 setup 環境 (スクリプト不在 / python3 無し) でも **例外を投げない**。detached +
|
|
526
530
|
* stdio ignore で daemon をブロックしない。session_start.py の auto-update と同じ
|
|
@@ -1686,18 +1690,31 @@ export function updateSawUserTurn(name, event) {
|
|
|
1686
1690
|
* @param {number} at epoch ms
|
|
1687
1691
|
* @returns {{ sortAffecting: boolean, sortAffectingAt: number|undefined, stale: boolean }}
|
|
1688
1692
|
*/
|
|
1689
|
-
export function ingestSessionEventForSort(name, event, at) {
|
|
1693
|
+
export function ingestSessionEventForSort(name, event, at, { fresh = false } = {}) {
|
|
1690
1694
|
const prevAt = lastEventAtByName.get(name) ?? 0
|
|
1691
1695
|
// stale (この session で処理済みの at より古い) read は状態を汚さない。arm や status は
|
|
1692
1696
|
// 呼び出し側で従来通り処理してよいが、sort へは一切効かせない。
|
|
1693
|
-
|
|
1697
|
+
// `fresh`: in-process の直接 publish (チャット入力の受付) は out-of-order な file read ではなく
|
|
1698
|
+
// 「いま起きた」event なので、この stale 判定を通さない (受付〜tmux 走査の ms 窓に state loop の
|
|
1699
|
+
// stop が先に取り込まれても、人間の送信を落とさない)。sort 時刻の単調性は下の比較で保つ。
|
|
1700
|
+
if (!fresh && typeof at === "number" && at < prevAt) {
|
|
1694
1701
|
return {
|
|
1695
1702
|
sortAffecting: false,
|
|
1696
1703
|
sortAffectingAt: lastSortAffectingAtByName.get(name),
|
|
1697
1704
|
stale: true,
|
|
1698
1705
|
}
|
|
1699
1706
|
}
|
|
1700
|
-
|
|
1707
|
+
// fresh な prompt_submit でも sort 時刻を前進させないもの (同一行への 2 送信の tmux 走査が逆順に
|
|
1708
|
+
// 完了した後着の古い方、同値の再通知) は、frontend へ sort_affecting=true で流さない。frontend は
|
|
1709
|
+
// 単調マージで弾くが、契約として「sort_affecting=true は sort 時刻が前進した event」に揃える。
|
|
1710
|
+
if (fresh && typeof at === "number" && at <= (lastSortAffectingAtByName.get(name) ?? 0)) {
|
|
1711
|
+
return {
|
|
1712
|
+
sortAffecting: false,
|
|
1713
|
+
sortAffectingAt: lastSortAffectingAtByName.get(name),
|
|
1714
|
+
stale: true,
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
if (typeof at === "number") lastEventAtByName.set(name, Math.max(prevAt, at))
|
|
1701
1718
|
updateSawUserTurn(name, event)
|
|
1702
1719
|
const sortAffecting = computeSortAffecting(event, sawUserTurnByName.get(name) === true)
|
|
1703
1720
|
// sort_affecting=true で前進したときだけ sort 時刻を更新し、sidecar に永続化する (major#1)。
|
|
@@ -1937,50 +1954,8 @@ async function startSessionEventWatcher({ client, logger, readinessTracker }) {
|
|
|
1937
1954
|
const text = await readFile(full, "utf-8")
|
|
1938
1955
|
const data = JSON.parse(text)
|
|
1939
1956
|
if (!data || typeof data.event !== "string") return
|
|
1940
|
-
// ターン境界 (prompt_submit / stop) で readiness tracker を arm し、子プロセスが
|
|
1941
|
-
// baseline へ復帰したら 'ready' を出す土台にする。'ready' 自身では arm しない
|
|
1942
|
-
// (readiness loop の書込み → 再 arm の無限ループを避ける)。prompt_submit は次ターンの
|
|
1943
|
-
// ready を解禁するため emitted をリセットする。
|
|
1944
|
-
if (readinessTracker) {
|
|
1945
|
-
if (data.event === "prompt_submit") {
|
|
1946
|
-
readinessTracker.arm(sessionName, { newTurn: true })
|
|
1947
|
-
} else if (data.event === "stop" || data.event === "idle_hint") {
|
|
1948
|
-
// stop: ターン論理終了。idle_hint: Notification(idle_prompt) フックの裏取り。
|
|
1949
|
-
// どちらも arm するだけで、実際の 'ready' は proc 内省 (子プロセス baseline 復帰)
|
|
1950
|
-
// が確定させる。これにより Notification が Stop フック完了前に発火しても、
|
|
1951
|
-
// proc が clear するまで誤フラッシュしない (idle_hint→即 ready にはしない)。
|
|
1952
|
-
readinessTracker.arm(sessionName)
|
|
1953
|
-
}
|
|
1954
|
-
}
|
|
1955
|
-
// sort_affecting 判定を状態機械へ委譲する: at 単調ガードで out-of-order な stale read を
|
|
1956
|
-
// 弾き (major#2)、prompt_submit/session_start で状態遷移し、sort_affecting=true なら
|
|
1957
|
-
// sort 時刻を更新する (major#1)。arm や status push は従来通り (stale でも status は最新化)。
|
|
1958
1957
|
const at = typeof data.at === "number" ? data.at : Date.now()
|
|
1959
|
-
|
|
1960
|
-
// 計装 (2026-06-19, 既定 OFF=2026-06-20): ターン境界イベントと arm を記録する。常時 info は
|
|
1961
|
-
// ログ肥大の主因のため HUB_AGENT_STATE_TRACE=1 のときだけ出す(障害解析時に有効化)。
|
|
1962
|
-
if (STATE_TRACE) {
|
|
1963
|
-
logger?.info(
|
|
1964
|
-
{
|
|
1965
|
-
session_name: sessionName,
|
|
1966
|
-
event: data.event,
|
|
1967
|
-
armed: readinessTracker ? readinessTracker.isArmed(sessionName) : undefined,
|
|
1968
|
-
sort_affecting: sortAffecting,
|
|
1969
|
-
},
|
|
1970
|
-
"session.event push",
|
|
1971
|
-
)
|
|
1972
|
-
}
|
|
1973
|
-
// 直前に作った一覧 snapshot がこの event より古い場合、次の poll で last_event を
|
|
1974
|
-
// 逆戻りさせない。build 中なら AsyncTtlCache が新世代へ合流し直す。
|
|
1975
|
-
tmuxSessionsSnapshotCache.clear()
|
|
1976
|
-
client.send({
|
|
1977
|
-
type: "session.event",
|
|
1978
|
-
session_name: sessionName,
|
|
1979
|
-
event: data.event,
|
|
1980
|
-
at,
|
|
1981
|
-
// false=ライフサイクル/resume 落ち着き由来 → frontend は status のみ更新し並び順は動かさない。
|
|
1982
|
-
sort_affecting: sortAffecting,
|
|
1983
|
-
})
|
|
1958
|
+
await publishSessionEvent({ client, readinessTracker, logger }, sessionName, data.event, at)
|
|
1984
1959
|
} catch {
|
|
1985
1960
|
// 一時欠如 / parse 失敗等は無視 (次の変更で読み直す)
|
|
1986
1961
|
}
|
|
@@ -2021,6 +1996,46 @@ async function startSessionEventWatcher({ client, logger, readinessTracker }) {
|
|
|
2021
1996
|
}
|
|
2022
1997
|
}
|
|
2023
1998
|
|
|
1999
|
+
/** session-event をプロセス内で取り込み、並び順を更新して WS push する。 */
|
|
2000
|
+
export async function publishSessionEvent(
|
|
2001
|
+
{ client, readinessTracker, logger },
|
|
2002
|
+
sessionName,
|
|
2003
|
+
event,
|
|
2004
|
+
at,
|
|
2005
|
+
{ fresh = false, arm = !fresh } = {},
|
|
2006
|
+
) {
|
|
2007
|
+
// readiness (proc 内省の ready 判定) を動かすのはファイル監視経路だけ。直接 publish (fresh) は
|
|
2008
|
+
// 「人間が送った」事実を並び順へ流すだけで、ターンの開始を意味しない (bridge が拒否すれば
|
|
2009
|
+
// ターンは始まらないし、走査が逆順に完了した後着の古い方は既に終わったターン)。ここで arm すると
|
|
2010
|
+
// 次の stop が来ない限り busy が残る。ターン境界の arm は従来どおり state loop / bundle hook が
|
|
2011
|
+
// 書く event ファイル経由で行う。
|
|
2012
|
+
const { sortAffecting } = ingestSessionEventForSort(sessionName, event, at, { fresh })
|
|
2013
|
+
if (arm) {
|
|
2014
|
+
if (event === "prompt_submit") {
|
|
2015
|
+
readinessTracker?.arm(sessionName, { newTurn: true })
|
|
2016
|
+
} else if (event === "stop" || event === "idle_hint") {
|
|
2017
|
+
readinessTracker?.arm(sessionName)
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
if (STATE_TRACE) {
|
|
2021
|
+
logger?.info?.({
|
|
2022
|
+
session_name: sessionName,
|
|
2023
|
+
event,
|
|
2024
|
+
armed: readinessTracker ? readinessTracker.isArmed(sessionName) : undefined,
|
|
2025
|
+
sort_affecting: sortAffecting,
|
|
2026
|
+
}, "session.event push")
|
|
2027
|
+
}
|
|
2028
|
+
tmuxSessionsSnapshotCache.clear()
|
|
2029
|
+
client.send({
|
|
2030
|
+
type: "session.event",
|
|
2031
|
+
session_name: sessionName,
|
|
2032
|
+
event,
|
|
2033
|
+
at,
|
|
2034
|
+
sort_affecting: sortAffecting,
|
|
2035
|
+
})
|
|
2036
|
+
return { sortAffecting }
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2024
2039
|
|
|
2025
2040
|
/**
|
|
2026
2041
|
* arm 済みセッション (prompt_submit / stop を受けた or busy 観測中) の claude プロセスを
|
|
@@ -2414,6 +2429,71 @@ export async function pushImmediateChatState({
|
|
|
2414
2429
|
}
|
|
2415
2430
|
}
|
|
2416
2431
|
|
|
2432
|
+
/**
|
|
2433
|
+
* チャット入力の受付時刻を、対応する tmux 行の prompt_submit として **in-process で** publish する。
|
|
2434
|
+
*
|
|
2435
|
+
* state loop の 5 秒 tick だけでは、5 秒未満で完了するターンや、終了と次の開始の間に tick が
|
|
2436
|
+
* 入らない連続ターンを取りこぼし、観測できても送信時刻ではなく assistant event の時刻になる。
|
|
2437
|
+
* そこで handler が入力を受け付けた瞬間 (dispatch 前) に、cwd と CLI 種別が一致する tmux 行へ
|
|
2438
|
+
* publishSessionEvent(prompt_submit, 受付時刻, fresh) を流す。
|
|
2439
|
+
*
|
|
2440
|
+
* ファイルは書かない。並び順の正本は ingestSessionEventForSort の sidecar (poll の
|
|
2441
|
+
* sort_affecting_at で復元される) で、event ファイルは status 復元用に state loop が従来どおり
|
|
2442
|
+
* 書く (その prompt_submit の at は chatSessionEventForTick が受付時刻へ揃える)。直接経路が
|
|
2443
|
+
* ファイルを書かないので、fs.watch の読み落とし・並行 rename・kill 中の保留書き込みといった
|
|
2444
|
+
* ファイル由来の競合そのものが無い。
|
|
2445
|
+
*
|
|
2446
|
+
* bridge が入力を拒否した場合 (empty / queue dead 等) も publish 済みのまま取り消さない:
|
|
2447
|
+
* 並び順の定義は「最後に人間が送った順」で、送信操作自体は起きているため (仕様)。
|
|
2448
|
+
*/
|
|
2449
|
+
export async function notifyChatPromptSubmit({
|
|
2450
|
+
cwd,
|
|
2451
|
+
cliKind,
|
|
2452
|
+
at,
|
|
2453
|
+
client,
|
|
2454
|
+
readinessTracker,
|
|
2455
|
+
plugins,
|
|
2456
|
+
logger,
|
|
2457
|
+
listSessionStatesFn = listSessionStates,
|
|
2458
|
+
publishFn = publishSessionEvent,
|
|
2459
|
+
}) {
|
|
2460
|
+
if (!cwd || !client) return
|
|
2461
|
+
try {
|
|
2462
|
+
const states = await listSessionStatesFn({ plugins, logger })
|
|
2463
|
+
for (const s of states) {
|
|
2464
|
+
if (s.cwd !== cwd || cliKindForSessionName(s.session_name) !== cliKind) continue
|
|
2465
|
+
try {
|
|
2466
|
+
await publishFn({ client, readinessTracker, logger }, s.session_name, "prompt_submit", at, { fresh: true })
|
|
2467
|
+
} catch (err) {
|
|
2468
|
+
logger?.warn?.({ err: err?.message, cwd, cliKind, session_name: s.session_name }, "notifyChatPromptSubmit publish failed")
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
} catch (err) {
|
|
2472
|
+
logger?.warn?.({ err: err?.message, cwd, cliKind }, "notifyChatPromptSubmit failed")
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
/**
|
|
2477
|
+
* chat signal から、この tick で書く session-event を導出する。
|
|
2478
|
+
* FIFO で前ターンの stop 後に次ターンの processing を観測しても、prompt_submit の at は
|
|
2479
|
+
* 最後に人間が送った時刻を超えない。直接 publish が失敗 (tmux 走査不能等) しても edge を抑止しないため、
|
|
2480
|
+
* processing 観測時に同じ at で回復できる。同値 at の重複は ingest の単調ガードで無害。
|
|
2481
|
+
*
|
|
2482
|
+
* @returns {{ev: string, at: number}|null}
|
|
2483
|
+
*/
|
|
2484
|
+
export function chatSessionEventForTick({ chat, prevEv, prevTurnAt, directAt }) {
|
|
2485
|
+
const ev = chat.inputPending
|
|
2486
|
+
? "input_required"
|
|
2487
|
+
: chat.status === "processing"
|
|
2488
|
+
? "prompt_submit"
|
|
2489
|
+
: "stop"
|
|
2490
|
+
if (!(chat.turnAt > prevTurnAt && ev !== prevEv)) return null
|
|
2491
|
+
const at = ev === "prompt_submit" && typeof directAt === "number" && directAt > 0
|
|
2492
|
+
? directAt
|
|
2493
|
+
: chat.turnAt
|
|
2494
|
+
return { ev, at }
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2417
2497
|
/**
|
|
2418
2498
|
* turnActiveForCwd / jsonlContextForCwd に渡す bound session_id を解決する純関数。
|
|
2419
2499
|
*
|
|
@@ -2588,17 +2668,19 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
2588
2668
|
//
|
|
2589
2669
|
// 確認待ち (inputPending) は専用 event "input_required"、生成中は "prompt_submit"、
|
|
2590
2670
|
// それ以外は "stop"。並び順に効くのは computeSortAffecting により prompt_submit だけ。
|
|
2591
|
-
const ev = chat.inputPending
|
|
2592
|
-
? "input_required"
|
|
2593
|
-
: chat.status === "processing"
|
|
2594
|
-
? "prompt_submit"
|
|
2595
|
-
: "stop"
|
|
2596
2671
|
const prevTurnAt = lastTurnAtByName.get(s.session_name) || 0
|
|
2597
2672
|
const prevEv = lastChatEventByName.get(s.session_name)
|
|
2598
|
-
|
|
2673
|
+
const eventForTick = chatSessionEventForTick({
|
|
2674
|
+
chat,
|
|
2675
|
+
prevEv,
|
|
2676
|
+
prevTurnAt,
|
|
2677
|
+
directAt: s.cwd ? getPromptAcceptedAt(s.cwd, sessionCliKind) : undefined,
|
|
2678
|
+
})
|
|
2679
|
+
if (eventForTick) {
|
|
2680
|
+
const { ev, at } = eventForTick
|
|
2599
2681
|
lastTurnAtByName.set(s.session_name, chat.turnAt)
|
|
2600
2682
|
lastChatEventByName.set(s.session_name, ev)
|
|
2601
|
-
writeSessionEventFile(s.session_name, ev,
|
|
2683
|
+
writeSessionEventFile(s.session_name, ev, at).catch(() => {})
|
|
2602
2684
|
}
|
|
2603
2685
|
}
|
|
2604
2686
|
// 内容安定で「確実に停止」と判定できたか (frontend が 2 サンプル消灯確認を省いて
|
|
@@ -3126,6 +3208,7 @@ function inputLifecycleRejection(ctx) {
|
|
|
3126
3208
|
* テストから直接呼べるよう dispatch から切り出して export する。
|
|
3127
3209
|
*/
|
|
3128
3210
|
export function handleClaudeChatInput(msg, ctx) {
|
|
3211
|
+
const acceptedAt = Date.now()
|
|
3129
3212
|
const stream_id = msg.stream_id
|
|
3130
3213
|
const input_id = typeof msg.input_id === "string" && msg.input_id ? msg.input_id : null
|
|
3131
3214
|
if (!ctx.claudeBridge) {
|
|
@@ -3174,6 +3257,26 @@ export function handleClaudeChatInput(msg, ctx) {
|
|
|
3174
3257
|
}
|
|
3175
3258
|
return undefined
|
|
3176
3259
|
}
|
|
3260
|
+
// cwd は dispatch 前に確定する (Promise 解決後に引くと、その間に stream が別 cwd へ再 attach
|
|
3261
|
+
// された場合に別行へ記録する)。受付時刻の記録と並び順の publish も dispatch **前** に行う:
|
|
3262
|
+
// Claude の resident reconcile / Codex の turn/start RPC が未解決の間に assistant / turn/started
|
|
3263
|
+
// が先に届いて state loop が processing を観測しても、記録済みなら観測時刻ではなく受付時刻で
|
|
3264
|
+
// 書かれる。bridge が拒否しても取り消さない (仕様。notifyChatPromptSubmit の JSDoc)。
|
|
3265
|
+
const cwdSnapshot = ctx.claudeBridge.getCwd?.(stream_id) ?? null
|
|
3266
|
+
if (cwdSnapshot) {
|
|
3267
|
+
recordPromptAccepted(cwdSnapshot, "claude", acceptedAt)
|
|
3268
|
+
notifyChatPromptSubmit({
|
|
3269
|
+
cwd: cwdSnapshot,
|
|
3270
|
+
cliKind: "claude",
|
|
3271
|
+
at: acceptedAt,
|
|
3272
|
+
plugins: ctx.plugins,
|
|
3273
|
+
logger: ctx.logger,
|
|
3274
|
+
client: ctx.client,
|
|
3275
|
+
readinessTracker: ctx.readinessTracker,
|
|
3276
|
+
listSessionStatesFn: ctx._listSessionStates || listSessionStates,
|
|
3277
|
+
publishFn: ctx._publishSessionEvent || publishSessionEvent,
|
|
3278
|
+
}).catch(() => {})
|
|
3279
|
+
}
|
|
3177
3280
|
const p = ctx.claudeBridge
|
|
3178
3281
|
.input({
|
|
3179
3282
|
stream_id,
|
|
@@ -3398,6 +3501,7 @@ export function buildAttachHistoryPromise({
|
|
|
3398
3501
|
* テストから直接呼べるよう dispatch から切り出して export する。
|
|
3399
3502
|
*/
|
|
3400
3503
|
export function handleCodexChatInput(msg, ctx) {
|
|
3504
|
+
const acceptedAt = Date.now()
|
|
3401
3505
|
const stream_id = msg.stream_id
|
|
3402
3506
|
const input_id = typeof msg.input_id === "string" && msg.input_id ? msg.input_id : null
|
|
3403
3507
|
if (!ctx.codexBridge) {
|
|
@@ -3438,6 +3542,23 @@ export function handleCodexChatInput(msg, ctx) {
|
|
|
3438
3542
|
})
|
|
3439
3543
|
return undefined
|
|
3440
3544
|
}
|
|
3545
|
+
// claude 側と同じ理由で dispatch 前に cwd を確定し、受付時刻の記録と publish を行う
|
|
3546
|
+
// (handleClaudeChatInput のコメント参照)。
|
|
3547
|
+
const cwdSnapshot = ctx.codexBridge.getCwd?.(stream_id) ?? null
|
|
3548
|
+
if (cwdSnapshot) {
|
|
3549
|
+
recordPromptAccepted(cwdSnapshot, "codex", acceptedAt)
|
|
3550
|
+
notifyChatPromptSubmit({
|
|
3551
|
+
cwd: cwdSnapshot,
|
|
3552
|
+
cliKind: "codex",
|
|
3553
|
+
at: acceptedAt,
|
|
3554
|
+
plugins: ctx.plugins,
|
|
3555
|
+
logger: ctx.logger,
|
|
3556
|
+
client: ctx.client,
|
|
3557
|
+
readinessTracker: ctx.readinessTracker,
|
|
3558
|
+
listSessionStatesFn: ctx._listSessionStates || listSessionStates,
|
|
3559
|
+
publishFn: ctx._publishSessionEvent || publishSessionEvent,
|
|
3560
|
+
}).catch(() => {})
|
|
3561
|
+
}
|
|
3441
3562
|
const p = ctx.codexBridge
|
|
3442
3563
|
.input({
|
|
3443
3564
|
stream_id,
|
package/src/profiles.mjs
CHANGED
|
@@ -4,8 +4,12 @@
|
|
|
4
4
|
* 1 台のマシンで複数の Claude アカウント (例: 個人 Max / チーム) を `CLAUDE_CONFIG_DIR`
|
|
5
5
|
* で切り替えて使うための仕組み。各プロファイルは独立した config ディレクトリを持ち、
|
|
6
6
|
* 認証 (`.credentials.json` / Keychain) と `.claude.json` をアカウント別に保持する。一方で
|
|
7
|
-
* skills / agents / commands / scripts / hooks / CLAUDE.md は symlink
|
|
8
|
-
*
|
|
7
|
+
* skills / agents / commands / scripts / hooks / CLAUDE.md / settings.json は symlink で
|
|
8
|
+
* 共有する (provisionProfile が面倒を見る)。settings.json はプロファイル間で共有扱いとし、
|
|
9
|
+
* アカウント別の上書きはサポートしない — つまり forceLoginOrgUUID / apiKeyHelper /
|
|
10
|
+
* env.ANTHROPIC_* のようなアカウント固有の値を settings.json に置くと全プロファイルへ効く
|
|
11
|
+
* (経緯と却下案は Hub ナレッジ
|
|
12
|
+
* `ナレッジ/Cockpit/自動コンパクションとPreCompactフック` の「プロファイル間の設定ドリフト」節)。
|
|
9
13
|
*
|
|
10
14
|
* 会話履歴 (`projects/`) は **既定 `~/.claude/projects` に集約して全プロファイルで共有する**
|
|
11
15
|
* (ensureSharedProjects が `<configDir>/projects` を既定への symlink にする)。同じ作業
|
|
@@ -312,7 +316,7 @@ function nextNumericId(profiles) {
|
|
|
312
316
|
|
|
313
317
|
/**
|
|
314
318
|
* プロファイルを新規追加する (連番 id・中立パス `~/.claude-<n>`)。
|
|
315
|
-
* ディレクトリ整備 (symlink
|
|
319
|
+
* ディレクトリ整備 (共有資産の symlink) は provisionProfile が行う。
|
|
316
320
|
* 追加後もログインは未了 (ユーザーが端末で `/login` する必要がある)。
|
|
317
321
|
*
|
|
318
322
|
* @param {{label?: string}} [opts]
|
|
@@ -383,7 +387,8 @@ export async function hasFileCredentials(configDir) {
|
|
|
383
387
|
}
|
|
384
388
|
}
|
|
385
389
|
|
|
386
|
-
/** 既定プロファイルから共有 symlink
|
|
390
|
+
/** 既定プロファイルから共有 symlink する資産。settings.json の書き手 (Claude Code 本体 /
|
|
391
|
+
* setup_hub_ai.py の hooks auto-merge) は symlink 越しに正本 `~/.claude/settings.json` を更新する。 */
|
|
387
392
|
export const SHARED_SYMLINK_ITEMS = [
|
|
388
393
|
"agents",
|
|
389
394
|
"skills",
|
|
@@ -391,11 +396,9 @@ export const SHARED_SYMLINK_ITEMS = [
|
|
|
391
396
|
"scripts",
|
|
392
397
|
"hooks",
|
|
393
398
|
"CLAUDE.md",
|
|
399
|
+
"settings.json",
|
|
394
400
|
]
|
|
395
401
|
|
|
396
|
-
/** コピーで共有する資産 (アプリが書き換えるため symlink 不可)。 */
|
|
397
|
-
export const SHARED_COPY_ITEMS = ["settings.json"]
|
|
398
|
-
|
|
399
402
|
/**
|
|
400
403
|
* 会話履歴 (`projects/`) を既定 `~/.claude/projects` に集約・共有する。
|
|
401
404
|
*
|
|
@@ -459,18 +462,17 @@ export async function ensureSharedProjects(configDir) {
|
|
|
459
462
|
|
|
460
463
|
/**
|
|
461
464
|
* プロファイルディレクトリを整備する。
|
|
462
|
-
* - 共有 symlink 資産: 既定 `~/.claude/<item>` への絶対 symlink
|
|
463
|
-
*
|
|
465
|
+
* - 共有 symlink 資産: 既定 `~/.claude/<item>` への絶対 symlink を張る。既存 (symlink でも
|
|
466
|
+
* 実体でも) があれば触らない — 旧版がコピーで置いた settings.json も同じ規則で残る。
|
|
464
467
|
* 認証・履歴・`.claude.json` には一切触れない (アカウント固有のため)。
|
|
465
468
|
*
|
|
466
469
|
* @param {string} configDir - 対象プロファイルの config ディレクトリ絶対パス
|
|
467
|
-
* @returns {Promise<{linked: string[],
|
|
470
|
+
* @returns {Promise<{linked: string[], skipped: string[]}>}
|
|
468
471
|
*/
|
|
469
472
|
export async function provisionProfile(configDir) {
|
|
470
473
|
const base = defaultConfigDir()
|
|
471
474
|
await fs.mkdir(configDir, { recursive: true, mode: 0o700 })
|
|
472
475
|
const linked = []
|
|
473
|
-
const copied = []
|
|
474
476
|
const skipped = []
|
|
475
477
|
|
|
476
478
|
for (const item of SHARED_SYMLINK_ITEMS) {
|
|
@@ -479,8 +481,19 @@ export async function provisionProfile(configDir) {
|
|
|
479
481
|
try {
|
|
480
482
|
await fs.access(src)
|
|
481
483
|
} catch {
|
|
482
|
-
|
|
483
|
-
|
|
484
|
+
// settings.json だけは正本を空 JSON で作ってから張る。ここで skip すると、直後の
|
|
485
|
+
// setup_hub_ai (hooks auto-merge) がプロファイル側に実体を作り、以後 provisionProfile が
|
|
486
|
+
// 「既存」として触らないため、コピー時代と同じドリフトが復活する。
|
|
487
|
+
// 既定側が壊れた symlink のときは書き込み先が分からないので触らない。
|
|
488
|
+
if (item !== "settings.json" || (await isBrokenSymlink(src))) {
|
|
489
|
+
skipped.push(`${item}(元無し)`)
|
|
490
|
+
continue
|
|
491
|
+
}
|
|
492
|
+
// 既定ディレクトリ自体が無い端末 (Claude Code 未起動) でも正本を作れるようにする。
|
|
493
|
+
await fs.mkdir(base, { recursive: true, mode: 0o700 })
|
|
494
|
+
await fs.writeFile(src, "{}\n", { flag: "wx" }).catch((err) => {
|
|
495
|
+
if (err?.code !== "EEXIST") throw err
|
|
496
|
+
})
|
|
484
497
|
}
|
|
485
498
|
// dst が既に存在 (symlink/実体) なら触らない。
|
|
486
499
|
let exists = false
|
|
@@ -498,28 +511,6 @@ export async function provisionProfile(configDir) {
|
|
|
498
511
|
linked.push(item)
|
|
499
512
|
}
|
|
500
513
|
|
|
501
|
-
for (const item of SHARED_COPY_ITEMS) {
|
|
502
|
-
const src = path.join(base, item)
|
|
503
|
-
const dst = path.join(configDir, item)
|
|
504
|
-
let dstExists = false
|
|
505
|
-
try {
|
|
506
|
-
await fs.lstat(dst)
|
|
507
|
-
dstExists = true
|
|
508
|
-
} catch {
|
|
509
|
-
/* not exists */
|
|
510
|
-
}
|
|
511
|
-
if (dstExists) {
|
|
512
|
-
skipped.push(`${item}(既存)`)
|
|
513
|
-
continue
|
|
514
|
-
}
|
|
515
|
-
try {
|
|
516
|
-
await fs.copyFile(src, dst)
|
|
517
|
-
copied.push(item)
|
|
518
|
-
} catch {
|
|
519
|
-
skipped.push(`${item}(元無し)`)
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
|
|
523
514
|
// 会話履歴は既定 ~/.claude/projects へ集約して共有する (アカウント間で会話
|
|
524
515
|
// コンテキストを引き継げるようにするため)。新規は素直に symlink、既存実体は
|
|
525
516
|
// 無損失マージしてから symlink 化する。
|
|
@@ -531,7 +522,16 @@ export async function provisionProfile(configDir) {
|
|
|
531
522
|
skipped.push(`projects(失敗:${err?.code || err?.message || "error"})`)
|
|
532
523
|
}
|
|
533
524
|
|
|
534
|
-
return { linked,
|
|
525
|
+
return { linked, skipped }
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** `p` が lstat できるのに access できない (= リンク先を失った symlink) なら true。 */
|
|
529
|
+
async function isBrokenSymlink(p) {
|
|
530
|
+
try {
|
|
531
|
+
return (await fs.lstat(p)).isSymbolicLink()
|
|
532
|
+
} catch {
|
|
533
|
+
return false
|
|
534
|
+
}
|
|
535
535
|
}
|
|
536
536
|
|
|
537
537
|
/** テスト用: モジュールキャッシュを破棄する。 */
|