@cocorograph/hub-agent 0.8.55 → 0.8.57
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-credentials.mjs +39 -0
- package/src/claude-stream-bridge.mjs +4 -0
- package/src/codex-profiles.mjs +60 -12
- package/src/codex-stream-bridge.mjs +6 -0
- package/src/dispatch-scheduler.mjs +3 -2
- package/src/main.mjs +212 -53
- package/src/profiles.mjs +81 -12
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 }
|
|
@@ -131,6 +131,45 @@ export async function existsGenericPassword(service, opts = {}) {
|
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
/**
|
|
135
|
+
* 非既定プロファイルのハッシュ付き Keychain 認証を削除する。
|
|
136
|
+
*
|
|
137
|
+
* プロファイル削除 (profiles.mjs deleteProfile) の beforeRemove で呼ぶ。候補は literal / realpath の
|
|
138
|
+
* `credentialService()` だけで、素の `PLAIN_CREDENTIAL_SERVICE` (既定プロファイル用) は
|
|
139
|
+
* **絶対に対象へ含めない**。未存在は無視し、それ以外の失敗は削除全体を止める。
|
|
140
|
+
*
|
|
141
|
+
* @param {string} configDir
|
|
142
|
+
* @param {{execFileP?: Function, platform?: string, realpath?: string|null}} [opts]
|
|
143
|
+
* realpath は呼び出し側が rm 前に取った値 (削除後は fs.realpath が失敗するため)
|
|
144
|
+
* @returns {Promise<number>} 削除できた件数 (非 darwin は 0)
|
|
145
|
+
*/
|
|
146
|
+
export async function deleteKeychainCredentials(configDir, opts = {}) {
|
|
147
|
+
if (!isKeychainPlatform(opts.platform)) return 0
|
|
148
|
+
const exec = opts.execFileP || execFileP
|
|
149
|
+
const dirs = new Set([configDir])
|
|
150
|
+
if (opts.realpath) dirs.add(opts.realpath)
|
|
151
|
+
else {
|
|
152
|
+
try { dirs.add(await fs.realpath(configDir)) } catch { /* 削除済み等 */ }
|
|
153
|
+
}
|
|
154
|
+
const services = new Set([...dirs].map(credentialService))
|
|
155
|
+
services.delete(PLAIN_CREDENTIAL_SERVICE)
|
|
156
|
+
let deleted = 0
|
|
157
|
+
for (const service of services) {
|
|
158
|
+
try {
|
|
159
|
+
await exec("security", ["delete-generic-password", "-s", service])
|
|
160
|
+
deleted += 1
|
|
161
|
+
} catch (err) {
|
|
162
|
+
const msg = `${err?.message || ""}${err?.stderr || ""}`
|
|
163
|
+
if (msg.includes("could not be found") || msg.includes("SecKeychainSearchCopyNext")) continue
|
|
164
|
+
const detail = String(err?.stderr || err?.message || "unknown error").trim().replace(/\s+/g, " ")
|
|
165
|
+
const failure = new Error(`Keychain service ${service} の削除に失敗しました: ${detail}`)
|
|
166
|
+
failure.code = "io"
|
|
167
|
+
throw failure
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return deleted
|
|
171
|
+
}
|
|
172
|
+
|
|
134
173
|
/**
|
|
135
174
|
* その config ディレクトリで Claude Code がログイン済みか (UI の「要ログイン」表示用)。
|
|
136
175
|
*
|
package/src/codex-profiles.mjs
CHANGED
|
@@ -149,19 +149,22 @@ export async function loadRegistry() {
|
|
|
149
149
|
|
|
150
150
|
/** レジストリを永続化する。default は profiles[] に含めず defaultLabel / activeId だけ保存。 */
|
|
151
151
|
async function saveRegistry(reg) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
152
|
+
try {
|
|
153
|
+
await ensureConfigDir()
|
|
154
|
+
const def = reg.profiles.find((p) => p.id === DEFAULT_CODEX_PROFILE_ID)
|
|
155
|
+
const payload = {
|
|
156
|
+
activeId: reg.activeId,
|
|
157
|
+
defaultLabel: def ? def.label : undefined,
|
|
158
|
+
profiles: reg.profiles
|
|
159
|
+
.filter((p) => p.id !== DEFAULT_CODEX_PROFILE_ID)
|
|
160
|
+
.map((p) => ({ id: p.id, label: p.label, configDir: p.configDir })),
|
|
161
|
+
}
|
|
162
|
+
await fs.writeFile(registryPath(), JSON.stringify(payload, null, 2), {
|
|
163
|
+
mode: 0o600,
|
|
164
|
+
})
|
|
165
|
+
} finally {
|
|
166
|
+
_cache = null
|
|
160
167
|
}
|
|
161
|
-
await fs.writeFile(registryPath(), JSON.stringify(payload, null, 2), {
|
|
162
|
-
mode: 0o600,
|
|
163
|
-
})
|
|
164
|
-
_cache = null
|
|
165
168
|
}
|
|
166
169
|
|
|
167
170
|
/** プロファイル一覧と active を返す (UI 表示用)。 */
|
|
@@ -252,8 +255,53 @@ export async function renameProfile(id, label) {
|
|
|
252
255
|
return { id: target.id, label: target.label, configDir: target.configDir }
|
|
253
256
|
}
|
|
254
257
|
|
|
258
|
+
function deletionError(code, message, cause) {
|
|
259
|
+
const err = new Error(message, cause ? { cause } : undefined)
|
|
260
|
+
err.code = code
|
|
261
|
+
return err
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export async function deleteProfile(id) {
|
|
265
|
+
if (id === DEFAULT_CODEX_PROFILE_ID) throw deletionError("default", "既定プロファイルは削除できません")
|
|
266
|
+
const reg = await loadRegistry()
|
|
267
|
+
const target = reg.profiles.find((p) => p.id === id)
|
|
268
|
+
if (!target) throw deletionError("unknown", `未知のプロファイルです: ${id}`)
|
|
269
|
+
if (reg.activeId === id) throw deletionError("active", "アクティブなプロファイルです。先に別のアカウントへ切り替えてください")
|
|
270
|
+
const configDir = target.configDir
|
|
271
|
+
const expected = `.codex-${id}`
|
|
272
|
+
if (!path.isAbsolute(configDir) || path.dirname(configDir) !== os.homedir()
|
|
273
|
+
|| path.basename(configDir) !== expected || !/^\.codex-\d+$/.test(expected)) {
|
|
274
|
+
throw deletionError("unsafe_path", `安全でない configDir です: ${configDir}`)
|
|
275
|
+
}
|
|
276
|
+
const defaults = new Set([defaultCodexDir()])
|
|
277
|
+
try { defaults.add(await fs.realpath(defaultCodexDir())) } catch { /* 未作成 */ }
|
|
278
|
+
let st = null
|
|
279
|
+
let realpath = null
|
|
280
|
+
try {
|
|
281
|
+
st = await fs.lstat(configDir)
|
|
282
|
+
realpath = await fs.realpath(configDir)
|
|
283
|
+
} catch (err) {
|
|
284
|
+
if (err?.code !== "ENOENT") throw deletionError("io", `configDir の確認に失敗しました: ${err.message}`, err)
|
|
285
|
+
}
|
|
286
|
+
if ((st && (!st.isDirectory() || st.isSymbolicLink())) || defaults.has(configDir)
|
|
287
|
+
|| (realpath !== null && defaults.has(realpath))) {
|
|
288
|
+
throw deletionError("unsafe_path", `安全でない configDir です: ${configDir}`)
|
|
289
|
+
}
|
|
290
|
+
let configDirRemoved = false
|
|
291
|
+
if (st) {
|
|
292
|
+
try { await fs.rm(configDir, { recursive: true, force: true }); configDirRemoved = true }
|
|
293
|
+
catch (err) { throw deletionError("io", `configDir の削除に失敗しました: ${err.message}`, err) }
|
|
294
|
+
}
|
|
295
|
+
const updated = { ...reg, profiles: reg.profiles.filter((p) => p.id !== id) }
|
|
296
|
+
try { await saveRegistry(updated) } catch (err) { throw deletionError("io", `レジストリの保存に失敗しました: ${err.message}`, err) }
|
|
297
|
+
return { id: target.id, label: target.label, configDir, configDirRemoved, realpath }
|
|
298
|
+
}
|
|
299
|
+
|
|
255
300
|
/** 次の連番 ID (既存の数値 id の最大 +1、最低 1)。 */
|
|
256
301
|
function nextNumericId(profiles) {
|
|
302
|
+
// 削除済み id の再利用は安全: Codex の認証は configDir 内の auth.json だけで、
|
|
303
|
+
// deleteProfile はディレクトリ削除が成功した後にしかレジストリから id を外さない
|
|
304
|
+
// (不変条件: 認証を残したまま id が解放されることはない)。
|
|
257
305
|
let max = 0
|
|
258
306
|
for (const p of profiles) {
|
|
259
307
|
const n = Number.parseInt(p.id, 10)
|
|
@@ -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
|
}
|
|
@@ -69,13 +69,14 @@ function chatMode(type) {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
function globalMutationMode(type) {
|
|
72
|
-
if (type === "profile.switch" || type === "profile.add" || type === "profile.rename") {
|
|
72
|
+
if (type === "profile.switch" || type === "profile.add" || type === "profile.rename" || type === "profile.delete") {
|
|
73
73
|
return "claude"
|
|
74
74
|
}
|
|
75
75
|
if (
|
|
76
76
|
type === "codex.profile.switch" ||
|
|
77
77
|
type === "codex.profile.add" ||
|
|
78
|
-
type === "codex.profile.rename"
|
|
78
|
+
type === "codex.profile.rename" ||
|
|
79
|
+
type === "codex.profile.delete"
|
|
79
80
|
) {
|
|
80
81
|
return "codex"
|
|
81
82
|
}
|
package/src/main.mjs
CHANGED
|
@@ -82,6 +82,7 @@ import {
|
|
|
82
82
|
setActiveProfile,
|
|
83
83
|
addProfile,
|
|
84
84
|
renameProfile,
|
|
85
|
+
deleteProfile,
|
|
85
86
|
} from "./profiles.mjs"
|
|
86
87
|
// Codex アカウント・プロファイル (独立切替)。profiles.mjs と同名 export が多いため
|
|
87
88
|
// 名前衝突を避けて namespace import する。
|
|
@@ -91,6 +92,7 @@ import {
|
|
|
91
92
|
hasFileCredentialsCached,
|
|
92
93
|
isKeychainPlatform,
|
|
93
94
|
profileHasKeychainCredentials,
|
|
95
|
+
deleteKeychainCredentials,
|
|
94
96
|
} from "./claude-credentials.mjs"
|
|
95
97
|
import {
|
|
96
98
|
buildClaudeCmd,
|
|
@@ -137,8 +139,10 @@ import {
|
|
|
137
139
|
import {
|
|
138
140
|
clearChatSignal,
|
|
139
141
|
getChatSignal,
|
|
142
|
+
getPromptAcceptedAt,
|
|
140
143
|
recordChatActivity,
|
|
141
144
|
recordChatStreamTick,
|
|
145
|
+
recordPromptAccepted,
|
|
142
146
|
} from "./chat-signals.mjs"
|
|
143
147
|
import { cliKindForSessionName, sessionNameFor } from "./session-names.mjs"
|
|
144
148
|
import {
|
|
@@ -1684,18 +1688,31 @@ export function updateSawUserTurn(name, event) {
|
|
|
1684
1688
|
* @param {number} at epoch ms
|
|
1685
1689
|
* @returns {{ sortAffecting: boolean, sortAffectingAt: number|undefined, stale: boolean }}
|
|
1686
1690
|
*/
|
|
1687
|
-
export function ingestSessionEventForSort(name, event, at) {
|
|
1691
|
+
export function ingestSessionEventForSort(name, event, at, { fresh = false } = {}) {
|
|
1688
1692
|
const prevAt = lastEventAtByName.get(name) ?? 0
|
|
1689
1693
|
// stale (この session で処理済みの at より古い) read は状態を汚さない。arm や status は
|
|
1690
1694
|
// 呼び出し側で従来通り処理してよいが、sort へは一切効かせない。
|
|
1691
|
-
|
|
1695
|
+
// `fresh`: in-process の直接 publish (チャット入力の受付) は out-of-order な file read ではなく
|
|
1696
|
+
// 「いま起きた」event なので、この stale 判定を通さない (受付〜tmux 走査の ms 窓に state loop の
|
|
1697
|
+
// stop が先に取り込まれても、人間の送信を落とさない)。sort 時刻の単調性は下の比較で保つ。
|
|
1698
|
+
if (!fresh && typeof at === "number" && at < prevAt) {
|
|
1692
1699
|
return {
|
|
1693
1700
|
sortAffecting: false,
|
|
1694
1701
|
sortAffectingAt: lastSortAffectingAtByName.get(name),
|
|
1695
1702
|
stale: true,
|
|
1696
1703
|
}
|
|
1697
1704
|
}
|
|
1698
|
-
|
|
1705
|
+
// fresh な prompt_submit でも sort 時刻を前進させないもの (同一行への 2 送信の tmux 走査が逆順に
|
|
1706
|
+
// 完了した後着の古い方、同値の再通知) は、frontend へ sort_affecting=true で流さない。frontend は
|
|
1707
|
+
// 単調マージで弾くが、契約として「sort_affecting=true は sort 時刻が前進した event」に揃える。
|
|
1708
|
+
if (fresh && typeof at === "number" && at <= (lastSortAffectingAtByName.get(name) ?? 0)) {
|
|
1709
|
+
return {
|
|
1710
|
+
sortAffecting: false,
|
|
1711
|
+
sortAffectingAt: lastSortAffectingAtByName.get(name),
|
|
1712
|
+
stale: true,
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
if (typeof at === "number") lastEventAtByName.set(name, Math.max(prevAt, at))
|
|
1699
1716
|
updateSawUserTurn(name, event)
|
|
1700
1717
|
const sortAffecting = computeSortAffecting(event, sawUserTurnByName.get(name) === true)
|
|
1701
1718
|
// sort_affecting=true で前進したときだけ sort 時刻を更新し、sidecar に永続化する (major#1)。
|
|
@@ -1935,50 +1952,8 @@ async function startSessionEventWatcher({ client, logger, readinessTracker }) {
|
|
|
1935
1952
|
const text = await readFile(full, "utf-8")
|
|
1936
1953
|
const data = JSON.parse(text)
|
|
1937
1954
|
if (!data || typeof data.event !== "string") return
|
|
1938
|
-
// ターン境界 (prompt_submit / stop) で readiness tracker を arm し、子プロセスが
|
|
1939
|
-
// baseline へ復帰したら 'ready' を出す土台にする。'ready' 自身では arm しない
|
|
1940
|
-
// (readiness loop の書込み → 再 arm の無限ループを避ける)。prompt_submit は次ターンの
|
|
1941
|
-
// ready を解禁するため emitted をリセットする。
|
|
1942
|
-
if (readinessTracker) {
|
|
1943
|
-
if (data.event === "prompt_submit") {
|
|
1944
|
-
readinessTracker.arm(sessionName, { newTurn: true })
|
|
1945
|
-
} else if (data.event === "stop" || data.event === "idle_hint") {
|
|
1946
|
-
// stop: ターン論理終了。idle_hint: Notification(idle_prompt) フックの裏取り。
|
|
1947
|
-
// どちらも arm するだけで、実際の 'ready' は proc 内省 (子プロセス baseline 復帰)
|
|
1948
|
-
// が確定させる。これにより Notification が Stop フック完了前に発火しても、
|
|
1949
|
-
// proc が clear するまで誤フラッシュしない (idle_hint→即 ready にはしない)。
|
|
1950
|
-
readinessTracker.arm(sessionName)
|
|
1951
|
-
}
|
|
1952
|
-
}
|
|
1953
|
-
// sort_affecting 判定を状態機械へ委譲する: at 単調ガードで out-of-order な stale read を
|
|
1954
|
-
// 弾き (major#2)、prompt_submit/session_start で状態遷移し、sort_affecting=true なら
|
|
1955
|
-
// sort 時刻を更新する (major#1)。arm や status push は従来通り (stale でも status は最新化)。
|
|
1956
1955
|
const at = typeof data.at === "number" ? data.at : Date.now()
|
|
1957
|
-
|
|
1958
|
-
// 計装 (2026-06-19, 既定 OFF=2026-06-20): ターン境界イベントと arm を記録する。常時 info は
|
|
1959
|
-
// ログ肥大の主因のため HUB_AGENT_STATE_TRACE=1 のときだけ出す(障害解析時に有効化)。
|
|
1960
|
-
if (STATE_TRACE) {
|
|
1961
|
-
logger?.info(
|
|
1962
|
-
{
|
|
1963
|
-
session_name: sessionName,
|
|
1964
|
-
event: data.event,
|
|
1965
|
-
armed: readinessTracker ? readinessTracker.isArmed(sessionName) : undefined,
|
|
1966
|
-
sort_affecting: sortAffecting,
|
|
1967
|
-
},
|
|
1968
|
-
"session.event push",
|
|
1969
|
-
)
|
|
1970
|
-
}
|
|
1971
|
-
// 直前に作った一覧 snapshot がこの event より古い場合、次の poll で last_event を
|
|
1972
|
-
// 逆戻りさせない。build 中なら AsyncTtlCache が新世代へ合流し直す。
|
|
1973
|
-
tmuxSessionsSnapshotCache.clear()
|
|
1974
|
-
client.send({
|
|
1975
|
-
type: "session.event",
|
|
1976
|
-
session_name: sessionName,
|
|
1977
|
-
event: data.event,
|
|
1978
|
-
at,
|
|
1979
|
-
// false=ライフサイクル/resume 落ち着き由来 → frontend は status のみ更新し並び順は動かさない。
|
|
1980
|
-
sort_affecting: sortAffecting,
|
|
1981
|
-
})
|
|
1956
|
+
await publishSessionEvent({ client, readinessTracker, logger }, sessionName, data.event, at)
|
|
1982
1957
|
} catch {
|
|
1983
1958
|
// 一時欠如 / parse 失敗等は無視 (次の変更で読み直す)
|
|
1984
1959
|
}
|
|
@@ -2019,6 +1994,46 @@ async function startSessionEventWatcher({ client, logger, readinessTracker }) {
|
|
|
2019
1994
|
}
|
|
2020
1995
|
}
|
|
2021
1996
|
|
|
1997
|
+
/** session-event をプロセス内で取り込み、並び順を更新して WS push する。 */
|
|
1998
|
+
export async function publishSessionEvent(
|
|
1999
|
+
{ client, readinessTracker, logger },
|
|
2000
|
+
sessionName,
|
|
2001
|
+
event,
|
|
2002
|
+
at,
|
|
2003
|
+
{ fresh = false, arm = !fresh } = {},
|
|
2004
|
+
) {
|
|
2005
|
+
// readiness (proc 内省の ready 判定) を動かすのはファイル監視経路だけ。直接 publish (fresh) は
|
|
2006
|
+
// 「人間が送った」事実を並び順へ流すだけで、ターンの開始を意味しない (bridge が拒否すれば
|
|
2007
|
+
// ターンは始まらないし、走査が逆順に完了した後着の古い方は既に終わったターン)。ここで arm すると
|
|
2008
|
+
// 次の stop が来ない限り busy が残る。ターン境界の arm は従来どおり state loop / bundle hook が
|
|
2009
|
+
// 書く event ファイル経由で行う。
|
|
2010
|
+
const { sortAffecting } = ingestSessionEventForSort(sessionName, event, at, { fresh })
|
|
2011
|
+
if (arm) {
|
|
2012
|
+
if (event === "prompt_submit") {
|
|
2013
|
+
readinessTracker?.arm(sessionName, { newTurn: true })
|
|
2014
|
+
} else if (event === "stop" || event === "idle_hint") {
|
|
2015
|
+
readinessTracker?.arm(sessionName)
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
if (STATE_TRACE) {
|
|
2019
|
+
logger?.info?.({
|
|
2020
|
+
session_name: sessionName,
|
|
2021
|
+
event,
|
|
2022
|
+
armed: readinessTracker ? readinessTracker.isArmed(sessionName) : undefined,
|
|
2023
|
+
sort_affecting: sortAffecting,
|
|
2024
|
+
}, "session.event push")
|
|
2025
|
+
}
|
|
2026
|
+
tmuxSessionsSnapshotCache.clear()
|
|
2027
|
+
client.send({
|
|
2028
|
+
type: "session.event",
|
|
2029
|
+
session_name: sessionName,
|
|
2030
|
+
event,
|
|
2031
|
+
at,
|
|
2032
|
+
sort_affecting: sortAffecting,
|
|
2033
|
+
})
|
|
2034
|
+
return { sortAffecting }
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2022
2037
|
|
|
2023
2038
|
/**
|
|
2024
2039
|
* arm 済みセッション (prompt_submit / stop を受けた or busy 観測中) の claude プロセスを
|
|
@@ -2412,6 +2427,71 @@ export async function pushImmediateChatState({
|
|
|
2412
2427
|
}
|
|
2413
2428
|
}
|
|
2414
2429
|
|
|
2430
|
+
/**
|
|
2431
|
+
* チャット入力の受付時刻を、対応する tmux 行の prompt_submit として **in-process で** publish する。
|
|
2432
|
+
*
|
|
2433
|
+
* state loop の 5 秒 tick だけでは、5 秒未満で完了するターンや、終了と次の開始の間に tick が
|
|
2434
|
+
* 入らない連続ターンを取りこぼし、観測できても送信時刻ではなく assistant event の時刻になる。
|
|
2435
|
+
* そこで handler が入力を受け付けた瞬間 (dispatch 前) に、cwd と CLI 種別が一致する tmux 行へ
|
|
2436
|
+
* publishSessionEvent(prompt_submit, 受付時刻, fresh) を流す。
|
|
2437
|
+
*
|
|
2438
|
+
* ファイルは書かない。並び順の正本は ingestSessionEventForSort の sidecar (poll の
|
|
2439
|
+
* sort_affecting_at で復元される) で、event ファイルは status 復元用に state loop が従来どおり
|
|
2440
|
+
* 書く (その prompt_submit の at は chatSessionEventForTick が受付時刻へ揃える)。直接経路が
|
|
2441
|
+
* ファイルを書かないので、fs.watch の読み落とし・並行 rename・kill 中の保留書き込みといった
|
|
2442
|
+
* ファイル由来の競合そのものが無い。
|
|
2443
|
+
*
|
|
2444
|
+
* bridge が入力を拒否した場合 (empty / queue dead 等) も publish 済みのまま取り消さない:
|
|
2445
|
+
* 並び順の定義は「最後に人間が送った順」で、送信操作自体は起きているため (仕様)。
|
|
2446
|
+
*/
|
|
2447
|
+
export async function notifyChatPromptSubmit({
|
|
2448
|
+
cwd,
|
|
2449
|
+
cliKind,
|
|
2450
|
+
at,
|
|
2451
|
+
client,
|
|
2452
|
+
readinessTracker,
|
|
2453
|
+
plugins,
|
|
2454
|
+
logger,
|
|
2455
|
+
listSessionStatesFn = listSessionStates,
|
|
2456
|
+
publishFn = publishSessionEvent,
|
|
2457
|
+
}) {
|
|
2458
|
+
if (!cwd || !client) return
|
|
2459
|
+
try {
|
|
2460
|
+
const states = await listSessionStatesFn({ plugins, logger })
|
|
2461
|
+
for (const s of states) {
|
|
2462
|
+
if (s.cwd !== cwd || cliKindForSessionName(s.session_name) !== cliKind) continue
|
|
2463
|
+
try {
|
|
2464
|
+
await publishFn({ client, readinessTracker, logger }, s.session_name, "prompt_submit", at, { fresh: true })
|
|
2465
|
+
} catch (err) {
|
|
2466
|
+
logger?.warn?.({ err: err?.message, cwd, cliKind, session_name: s.session_name }, "notifyChatPromptSubmit publish failed")
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
} catch (err) {
|
|
2470
|
+
logger?.warn?.({ err: err?.message, cwd, cliKind }, "notifyChatPromptSubmit failed")
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
/**
|
|
2475
|
+
* chat signal から、この tick で書く session-event を導出する。
|
|
2476
|
+
* FIFO で前ターンの stop 後に次ターンの processing を観測しても、prompt_submit の at は
|
|
2477
|
+
* 最後に人間が送った時刻を超えない。直接 publish が失敗 (tmux 走査不能等) しても edge を抑止しないため、
|
|
2478
|
+
* processing 観測時に同じ at で回復できる。同値 at の重複は ingest の単調ガードで無害。
|
|
2479
|
+
*
|
|
2480
|
+
* @returns {{ev: string, at: number}|null}
|
|
2481
|
+
*/
|
|
2482
|
+
export function chatSessionEventForTick({ chat, prevEv, prevTurnAt, directAt }) {
|
|
2483
|
+
const ev = chat.inputPending
|
|
2484
|
+
? "input_required"
|
|
2485
|
+
: chat.status === "processing"
|
|
2486
|
+
? "prompt_submit"
|
|
2487
|
+
: "stop"
|
|
2488
|
+
if (!(chat.turnAt > prevTurnAt && ev !== prevEv)) return null
|
|
2489
|
+
const at = ev === "prompt_submit" && typeof directAt === "number" && directAt > 0
|
|
2490
|
+
? directAt
|
|
2491
|
+
: chat.turnAt
|
|
2492
|
+
return { ev, at }
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2415
2495
|
/**
|
|
2416
2496
|
* turnActiveForCwd / jsonlContextForCwd に渡す bound session_id を解決する純関数。
|
|
2417
2497
|
*
|
|
@@ -2586,17 +2666,19 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
2586
2666
|
//
|
|
2587
2667
|
// 確認待ち (inputPending) は専用 event "input_required"、生成中は "prompt_submit"、
|
|
2588
2668
|
// それ以外は "stop"。並び順に効くのは computeSortAffecting により prompt_submit だけ。
|
|
2589
|
-
const ev = chat.inputPending
|
|
2590
|
-
? "input_required"
|
|
2591
|
-
: chat.status === "processing"
|
|
2592
|
-
? "prompt_submit"
|
|
2593
|
-
: "stop"
|
|
2594
2669
|
const prevTurnAt = lastTurnAtByName.get(s.session_name) || 0
|
|
2595
2670
|
const prevEv = lastChatEventByName.get(s.session_name)
|
|
2596
|
-
|
|
2671
|
+
const eventForTick = chatSessionEventForTick({
|
|
2672
|
+
chat,
|
|
2673
|
+
prevEv,
|
|
2674
|
+
prevTurnAt,
|
|
2675
|
+
directAt: s.cwd ? getPromptAcceptedAt(s.cwd, sessionCliKind) : undefined,
|
|
2676
|
+
})
|
|
2677
|
+
if (eventForTick) {
|
|
2678
|
+
const { ev, at } = eventForTick
|
|
2597
2679
|
lastTurnAtByName.set(s.session_name, chat.turnAt)
|
|
2598
2680
|
lastChatEventByName.set(s.session_name, ev)
|
|
2599
|
-
writeSessionEventFile(s.session_name, ev,
|
|
2681
|
+
writeSessionEventFile(s.session_name, ev, at).catch(() => {})
|
|
2600
2682
|
}
|
|
2601
2683
|
}
|
|
2602
2684
|
// 内容安定で「確実に停止」と判定できたか (frontend が 2 サンプル消灯確認を省いて
|
|
@@ -3124,6 +3206,7 @@ function inputLifecycleRejection(ctx) {
|
|
|
3124
3206
|
* テストから直接呼べるよう dispatch から切り出して export する。
|
|
3125
3207
|
*/
|
|
3126
3208
|
export function handleClaudeChatInput(msg, ctx) {
|
|
3209
|
+
const acceptedAt = Date.now()
|
|
3127
3210
|
const stream_id = msg.stream_id
|
|
3128
3211
|
const input_id = typeof msg.input_id === "string" && msg.input_id ? msg.input_id : null
|
|
3129
3212
|
if (!ctx.claudeBridge) {
|
|
@@ -3172,6 +3255,26 @@ export function handleClaudeChatInput(msg, ctx) {
|
|
|
3172
3255
|
}
|
|
3173
3256
|
return undefined
|
|
3174
3257
|
}
|
|
3258
|
+
// cwd は dispatch 前に確定する (Promise 解決後に引くと、その間に stream が別 cwd へ再 attach
|
|
3259
|
+
// された場合に別行へ記録する)。受付時刻の記録と並び順の publish も dispatch **前** に行う:
|
|
3260
|
+
// Claude の resident reconcile / Codex の turn/start RPC が未解決の間に assistant / turn/started
|
|
3261
|
+
// が先に届いて state loop が processing を観測しても、記録済みなら観測時刻ではなく受付時刻で
|
|
3262
|
+
// 書かれる。bridge が拒否しても取り消さない (仕様。notifyChatPromptSubmit の JSDoc)。
|
|
3263
|
+
const cwdSnapshot = ctx.claudeBridge.getCwd?.(stream_id) ?? null
|
|
3264
|
+
if (cwdSnapshot) {
|
|
3265
|
+
recordPromptAccepted(cwdSnapshot, "claude", acceptedAt)
|
|
3266
|
+
notifyChatPromptSubmit({
|
|
3267
|
+
cwd: cwdSnapshot,
|
|
3268
|
+
cliKind: "claude",
|
|
3269
|
+
at: acceptedAt,
|
|
3270
|
+
plugins: ctx.plugins,
|
|
3271
|
+
logger: ctx.logger,
|
|
3272
|
+
client: ctx.client,
|
|
3273
|
+
readinessTracker: ctx.readinessTracker,
|
|
3274
|
+
listSessionStatesFn: ctx._listSessionStates || listSessionStates,
|
|
3275
|
+
publishFn: ctx._publishSessionEvent || publishSessionEvent,
|
|
3276
|
+
}).catch(() => {})
|
|
3277
|
+
}
|
|
3175
3278
|
const p = ctx.claudeBridge
|
|
3176
3279
|
.input({
|
|
3177
3280
|
stream_id,
|
|
@@ -3396,6 +3499,7 @@ export function buildAttachHistoryPromise({
|
|
|
3396
3499
|
* テストから直接呼べるよう dispatch から切り出して export する。
|
|
3397
3500
|
*/
|
|
3398
3501
|
export function handleCodexChatInput(msg, ctx) {
|
|
3502
|
+
const acceptedAt = Date.now()
|
|
3399
3503
|
const stream_id = msg.stream_id
|
|
3400
3504
|
const input_id = typeof msg.input_id === "string" && msg.input_id ? msg.input_id : null
|
|
3401
3505
|
if (!ctx.codexBridge) {
|
|
@@ -3436,6 +3540,23 @@ export function handleCodexChatInput(msg, ctx) {
|
|
|
3436
3540
|
})
|
|
3437
3541
|
return undefined
|
|
3438
3542
|
}
|
|
3543
|
+
// claude 側と同じ理由で dispatch 前に cwd を確定し、受付時刻の記録と publish を行う
|
|
3544
|
+
// (handleClaudeChatInput のコメント参照)。
|
|
3545
|
+
const cwdSnapshot = ctx.codexBridge.getCwd?.(stream_id) ?? null
|
|
3546
|
+
if (cwdSnapshot) {
|
|
3547
|
+
recordPromptAccepted(cwdSnapshot, "codex", acceptedAt)
|
|
3548
|
+
notifyChatPromptSubmit({
|
|
3549
|
+
cwd: cwdSnapshot,
|
|
3550
|
+
cliKind: "codex",
|
|
3551
|
+
at: acceptedAt,
|
|
3552
|
+
plugins: ctx.plugins,
|
|
3553
|
+
logger: ctx.logger,
|
|
3554
|
+
client: ctx.client,
|
|
3555
|
+
readinessTracker: ctx.readinessTracker,
|
|
3556
|
+
listSessionStatesFn: ctx._listSessionStates || listSessionStates,
|
|
3557
|
+
publishFn: ctx._publishSessionEvent || publishSessionEvent,
|
|
3558
|
+
}).catch(() => {})
|
|
3559
|
+
}
|
|
3439
3560
|
const p = ctx.codexBridge
|
|
3440
3561
|
.input({
|
|
3441
3562
|
stream_id,
|
|
@@ -5046,6 +5167,30 @@ export async function dispatch(msg, ctx) {
|
|
|
5046
5167
|
}
|
|
5047
5168
|
return
|
|
5048
5169
|
}
|
|
5170
|
+
case "profile.delete": {
|
|
5171
|
+
const id = msg.profile_id || ""
|
|
5172
|
+
try {
|
|
5173
|
+
const profileApi = ctx.profiles || { deleteProfile }
|
|
5174
|
+
let keychainDeleted = false
|
|
5175
|
+
const deleted = await profileApi.deleteProfile(id, {
|
|
5176
|
+
beforeRemove: async (target, realpath) => {
|
|
5177
|
+
keychainDeleted = (ctx.isKeychainPlatform || isKeychainPlatform)()
|
|
5178
|
+
? (await (ctx.deleteKeychainCredentials || deleteKeychainCredentials)(target.configDir, { realpath })) > 0
|
|
5179
|
+
: false
|
|
5180
|
+
},
|
|
5181
|
+
})
|
|
5182
|
+
;(ctx.clearCredentialCheckCache || clearCredentialCheckCache)(deleted.configDir)
|
|
5183
|
+
ctx.logger.info({ profile_id: id, config_dir_removed: deleted.configDirRemoved, keychain_deleted: keychainDeleted }, "claude profile deleted")
|
|
5184
|
+
ctx.client.send({
|
|
5185
|
+
type: "profile.delete.result", request_id: msg.request_id, ok: true,
|
|
5186
|
+
profile_id: id, config_dir_removed: deleted.configDirRemoved, keychain_deleted: keychainDeleted,
|
|
5187
|
+
})
|
|
5188
|
+
} catch (err) {
|
|
5189
|
+
ctx.client.send({ type: "profile.delete.result", request_id: msg.request_id, ok: false,
|
|
5190
|
+
profile_id: id, reason: err?.code || "io", error: err?.message || String(err) })
|
|
5191
|
+
}
|
|
5192
|
+
return
|
|
5193
|
+
}
|
|
5049
5194
|
case "codex.profile.list": {
|
|
5050
5195
|
// Codex アカウント・プロファイル一覧と active を返す (Cockpit の Codex 切替UI用)。
|
|
5051
5196
|
// 認証はファイル (<CODEX_HOME>/auth.json) の有無で判定 (Keychain 不要)。
|
|
@@ -5183,6 +5328,20 @@ export async function dispatch(msg, ctx) {
|
|
|
5183
5328
|
}
|
|
5184
5329
|
return
|
|
5185
5330
|
}
|
|
5331
|
+
case "codex.profile.delete": {
|
|
5332
|
+
const id = msg.profile_id || ""
|
|
5333
|
+
try {
|
|
5334
|
+
const profileApi = ctx.codexProfiles || codexProfiles
|
|
5335
|
+
const deleted = await profileApi.deleteProfile(id)
|
|
5336
|
+
ctx.logger.info({ profile_id: id, config_dir_removed: deleted.configDirRemoved }, "codex profile deleted")
|
|
5337
|
+
ctx.client.send({ type: "codex.profile.delete.result", request_id: msg.request_id, ok: true,
|
|
5338
|
+
profile_id: id, config_dir_removed: deleted.configDirRemoved, keychain_deleted: false })
|
|
5339
|
+
} catch (err) {
|
|
5340
|
+
ctx.client.send({ type: "codex.profile.delete.result", request_id: msg.request_id, ok: false,
|
|
5341
|
+
profile_id: id, reason: err?.code || "io", error: err?.message || String(err) })
|
|
5342
|
+
}
|
|
5343
|
+
return
|
|
5344
|
+
}
|
|
5186
5345
|
case "tmux.exec": {
|
|
5187
5346
|
// ロジックは handleTmuxExec へ抽出 (テストから直接呼べる)。
|
|
5188
5347
|
await handleTmuxExec(msg, ctx)
|
package/src/profiles.mjs
CHANGED
|
@@ -114,19 +114,22 @@ export async function loadRegistry() {
|
|
|
114
114
|
|
|
115
115
|
/** レジストリを永続化する。default は profiles[] に含めず、追加分と activeId / defaultLabel だけ保存。 */
|
|
116
116
|
async function saveRegistry(reg) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
117
|
+
try {
|
|
118
|
+
await ensureConfigDir()
|
|
119
|
+
const def = reg.profiles.find((p) => p.id === DEFAULT_PROFILE_ID)
|
|
120
|
+
const payload = {
|
|
121
|
+
activeId: reg.activeId,
|
|
122
|
+
defaultLabel: def ? def.label : undefined,
|
|
123
|
+
profiles: reg.profiles
|
|
124
|
+
.filter((p) => p.id !== DEFAULT_PROFILE_ID)
|
|
125
|
+
.map((p) => ({ id: p.id, label: p.label, configDir: p.configDir })),
|
|
126
|
+
}
|
|
127
|
+
await fs.writeFile(registryPath(), JSON.stringify(payload, null, 2), {
|
|
128
|
+
mode: 0o600,
|
|
129
|
+
})
|
|
130
|
+
} finally {
|
|
131
|
+
_cache = null
|
|
125
132
|
}
|
|
126
|
-
await fs.writeFile(registryPath(), JSON.stringify(payload, null, 2), {
|
|
127
|
-
mode: 0o600,
|
|
128
|
-
})
|
|
129
|
-
_cache = null
|
|
130
133
|
}
|
|
131
134
|
|
|
132
135
|
/** プロファイル一覧と active を返す (UI 表示用)。 */
|
|
@@ -231,8 +234,74 @@ export async function renameProfile(id, label) {
|
|
|
231
234
|
return { id: target.id, label: target.label, configDir: target.configDir }
|
|
232
235
|
}
|
|
233
236
|
|
|
237
|
+
function deletionError(code, message, cause) {
|
|
238
|
+
const err = new Error(message, cause ? { cause } : undefined)
|
|
239
|
+
err.code = code
|
|
240
|
+
return err
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 非既定・非 active プロファイルの実体と登録を安全に削除する。
|
|
245
|
+
* @param {string} id
|
|
246
|
+
* @param {{beforeRemove?: (target: {id:string,label:string,configDir:string}, realpath: string|null) => Promise<void>}} [opts]
|
|
247
|
+
*/
|
|
248
|
+
export async function deleteProfile(id, opts = {}) {
|
|
249
|
+
if (id === DEFAULT_PROFILE_ID) throw deletionError("default", "既定プロファイルは削除できません")
|
|
250
|
+
const reg = await loadRegistry()
|
|
251
|
+
const target = reg.profiles.find((p) => p.id === id)
|
|
252
|
+
if (!target) throw deletionError("unknown", `未知のプロファイルです: ${id}`)
|
|
253
|
+
if (reg.activeId === id) {
|
|
254
|
+
throw deletionError("active", "アクティブなプロファイルです。先に別のアカウントへ切り替えてください")
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const configDir = target.configDir
|
|
258
|
+
const expected = `.claude-${id}`
|
|
259
|
+
if (!path.isAbsolute(configDir) || path.dirname(configDir) !== os.homedir()
|
|
260
|
+
|| path.basename(configDir) !== expected || !/^\.claude-\d+$/.test(expected)) {
|
|
261
|
+
throw deletionError("unsafe_path", `安全でない configDir です: ${configDir}`)
|
|
262
|
+
}
|
|
263
|
+
const defaults = new Set([defaultConfigDir()])
|
|
264
|
+
try { defaults.add(await fs.realpath(defaultConfigDir())) } catch { /* 未作成 */ }
|
|
265
|
+
let realpath = null
|
|
266
|
+
let st = null
|
|
267
|
+
try {
|
|
268
|
+
st = await fs.lstat(configDir)
|
|
269
|
+
realpath = await fs.realpath(configDir)
|
|
270
|
+
} catch (err) {
|
|
271
|
+
if (err?.code !== "ENOENT") throw deletionError("io", `configDir の確認に失敗しました: ${err.message}`, err)
|
|
272
|
+
}
|
|
273
|
+
if ((st && (!st.isDirectory() || st.isSymbolicLink())) || defaults.has(configDir)
|
|
274
|
+
|| (realpath !== null && defaults.has(realpath))) {
|
|
275
|
+
throw deletionError("unsafe_path", `安全でない configDir です: ${configDir}`)
|
|
276
|
+
}
|
|
277
|
+
if (opts.beforeRemove) {
|
|
278
|
+
try {
|
|
279
|
+
await opts.beforeRemove({ id: target.id, label: target.label, configDir }, realpath)
|
|
280
|
+
} catch (err) {
|
|
281
|
+
if (!err.code) err.code = "io"
|
|
282
|
+
throw err
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
let configDirRemoved = false
|
|
286
|
+
if (st) {
|
|
287
|
+
try {
|
|
288
|
+
await fs.rm(configDir, { recursive: true, force: true })
|
|
289
|
+
configDirRemoved = true
|
|
290
|
+
} catch (err) {
|
|
291
|
+
throw deletionError("io", `configDir の削除に失敗しました: ${err.message}`, err)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const updated = { ...reg, profiles: reg.profiles.filter((p) => p.id !== id) }
|
|
295
|
+
try { await saveRegistry(updated) } catch (err) {
|
|
296
|
+
throw deletionError("io", `レジストリの保存に失敗しました: ${err.message}`, err)
|
|
297
|
+
}
|
|
298
|
+
return { id: target.id, label: target.label, configDir, configDirRemoved, realpath }
|
|
299
|
+
}
|
|
300
|
+
|
|
234
301
|
/** 次の連番 ID (既存の数値 id の最大 +1、最低 1)。 */
|
|
235
302
|
function nextNumericId(profiles) {
|
|
303
|
+
// 削除は Keychain → ディレクトリ → レジストリの順で、認証が残ったまま id が
|
|
304
|
+
// 解放されることはない (deleteProfile の beforeRemove)。
|
|
236
305
|
let max = 0
|
|
237
306
|
for (const p of profiles) {
|
|
238
307
|
const n = Number.parseInt(p.id, 10)
|