@cocorograph/hub-agent 0.8.5 → 0.8.7
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/async-ttl-cache.mjs +51 -0
- package/src/codex-stream-bridge.mjs +38 -11
- package/src/main.mjs +83 -40
- package/src/tmux.mjs +16 -4
package/package.json
CHANGED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 読み取り専用スナップショット向けの短期 TTL + single-flight キャッシュ。
|
|
3
|
+
*
|
|
4
|
+
* - TTL 内は直前の成功値を即返す
|
|
5
|
+
* - TTL 切れ後に複数 caller が重なった場合も build は 1 本だけ実行する
|
|
6
|
+
* - build 失敗はキャッシュせず、次回 caller が再試行できる
|
|
7
|
+
* - clear() は進行中 build の呼び出し元も新世代の build へ合流し直す
|
|
8
|
+
*/
|
|
9
|
+
export class AsyncTtlCache {
|
|
10
|
+
constructor({ ttlMs, now = () => Date.now() }) {
|
|
11
|
+
this.ttlMs = Math.max(0, Number(ttlMs) || 0)
|
|
12
|
+
this.now = now
|
|
13
|
+
this.hasValue = false
|
|
14
|
+
this.value = undefined
|
|
15
|
+
this.expiresAt = 0
|
|
16
|
+
this.inFlight = null
|
|
17
|
+
this.generation = 0
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async get(build) {
|
|
21
|
+
if (this.hasValue && this.now() < this.expiresAt) return this.value
|
|
22
|
+
if (this.inFlight) return this.inFlight
|
|
23
|
+
|
|
24
|
+
const generation = this.generation
|
|
25
|
+
const pending = Promise.resolve().then(build)
|
|
26
|
+
this.inFlight = pending
|
|
27
|
+
try {
|
|
28
|
+
const value = await pending
|
|
29
|
+
// build 中に mutation / state push が走った場合、旧値を最初の caller にも返さない。
|
|
30
|
+
// clear() が inFlight を外しているため、新世代の build を開始するか既存の新 build に
|
|
31
|
+
// 合流する。read-only snapshot 専用なので再実行は安全。
|
|
32
|
+
if (this.generation !== generation) return this.get(build)
|
|
33
|
+
this.value = value
|
|
34
|
+
this.hasValue = true
|
|
35
|
+
this.expiresAt = this.now() + this.ttlMs
|
|
36
|
+
return value
|
|
37
|
+
} finally {
|
|
38
|
+
if (this.inFlight === pending) this.inFlight = null
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
clear() {
|
|
43
|
+
this.generation += 1
|
|
44
|
+
this.hasValue = false
|
|
45
|
+
this.value = undefined
|
|
46
|
+
this.expiresAt = 0
|
|
47
|
+
// 旧 build 自体は安全に完走させるが、新 caller はそこへ合流させない。旧 build の
|
|
48
|
+
// finally は identity 比較により、新世代の inFlight を誤って消さない。
|
|
49
|
+
this.inFlight = null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
import { EventEmitter } from "node:events"
|
|
44
44
|
import { randomUUID } from "node:crypto"
|
|
45
45
|
|
|
46
|
+
import { AsyncTtlCache } from "./async-ttl-cache.mjs"
|
|
46
47
|
import { CodexAppServerClient } from "./codex-appserver-client.mjs"
|
|
47
48
|
import {
|
|
48
49
|
snapshotCodexDeliveryAudit,
|
|
@@ -323,6 +324,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
323
324
|
listThreadsFromDisk,
|
|
324
325
|
historyMaxItems,
|
|
325
326
|
historyMaxBytes,
|
|
327
|
+
listThreadsCacheTtlMs,
|
|
326
328
|
} = {}) {
|
|
327
329
|
super()
|
|
328
330
|
this.codexCmd = codexCmd
|
|
@@ -357,10 +359,12 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
357
359
|
/** @type {Map<string, object>} stream_id -> session record */
|
|
358
360
|
this.sessions = new Map()
|
|
359
361
|
this._accountRateLimitsByProfile = new Map()
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
|
|
362
|
+
// 複数 Cockpit タブ + pre-attach/ready の連続 request が、同じ cwd で rollout
|
|
363
|
+
// header 最大 800 件を短時間に何度も走査するのを防ぐ。thread picker の読み取り専用
|
|
364
|
+
// データなので数秒の鮮度差は許容し、実 turn/attach の正本には使わない。各 key の
|
|
365
|
+
// AsyncTtlCache が single-flight と、thread/start 中の世代切替をまとめて担う。
|
|
366
|
+
this._listThreadsCacheTtlMs = listThreadsCacheTtlMs ?? 5_000
|
|
367
|
+
this._listThreadsCache = new Map()
|
|
364
368
|
}
|
|
365
369
|
|
|
366
370
|
/**
|
|
@@ -576,6 +580,9 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
576
580
|
? await client.request("thread/resume", { ...startParams, threadId: resumeThreadId })
|
|
577
581
|
: await client.request("thread/start", startParams)
|
|
578
582
|
session.threadId = result?.thread?.id
|
|
583
|
+
// thread/start は一覧そのものを変更するため、pre-attach で得た短期キャッシュを破棄する。
|
|
584
|
+
// resume/reattach は既存 thread の再閲覧なのでキャッシュを維持し、ready 後の重複走査を防ぐ。
|
|
585
|
+
if (!resuming) this._invalidateListThreadsCacheForCwd(cwd)
|
|
579
586
|
this.logger?.info?.(
|
|
580
587
|
{
|
|
581
588
|
stream_id,
|
|
@@ -836,13 +843,33 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
836
843
|
// sessionsDir 未指定 (旧経路) のときだけ env 由来の値へフォールバックする。
|
|
837
844
|
const readRoot = sessionsDir || process.env.CODEX_HOME || ""
|
|
838
845
|
const key = `${readRoot}::${cwd || ""}::${limit ?? ""}`
|
|
839
|
-
|
|
840
|
-
if (
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
+
let entry = this._listThreadsCache.get(key)
|
|
847
|
+
if (!entry) {
|
|
848
|
+
entry = {
|
|
849
|
+
cwd: cwd || "",
|
|
850
|
+
cache: new AsyncTtlCache({ ttlMs: this._listThreadsCacheTtlMs }),
|
|
851
|
+
}
|
|
852
|
+
this._listThreadsCache.set(key, entry)
|
|
853
|
+
this._pruneListThreadsCache()
|
|
854
|
+
}
|
|
855
|
+
return entry.cache.get(() => this._doListThreads({ cwd, limit, sessionsDir }))
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
_invalidateListThreadsCacheForCwd(cwd) {
|
|
859
|
+
const normalizedCwd = cwd || ""
|
|
860
|
+
for (const entry of this._listThreadsCache.values()) {
|
|
861
|
+
if (entry.cwd === normalizedCwd) entry.cache.clear()
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
_pruneListThreadsCache() {
|
|
866
|
+
if (this._listThreadsCache.size <= 256) return
|
|
867
|
+
for (const [key, entry] of this._listThreadsCache) {
|
|
868
|
+
if (this._listThreadsCache.size <= 256) break
|
|
869
|
+
// 走査中の entry を map から外すと、同じ key の次 caller が別走査を開始して
|
|
870
|
+
// single-flight を破る。完了済みの古い entry だけを先に落とす。
|
|
871
|
+
if (!entry.cache.inFlight) this._listThreadsCache.delete(key)
|
|
872
|
+
}
|
|
846
873
|
}
|
|
847
874
|
|
|
848
875
|
async _doListThreads({ cwd, limit, sessionsDir } = {}) {
|
package/src/main.mjs
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
import { loadPlugins, runHookBroadcast, runHookChain } from "./plugin-loader.mjs"
|
|
49
49
|
import { WsClient } from "./ws-client.mjs"
|
|
50
50
|
import { DispatchScheduler } from "./dispatch-scheduler.mjs"
|
|
51
|
+
import { AsyncTtlCache } from "./async-ttl-cache.mjs"
|
|
51
52
|
import { PtyBridge } from "./pty-bridge.mjs"
|
|
52
53
|
import { ClaudeStreamBridge } from "./claude-stream-bridge.mjs"
|
|
53
54
|
import { CodexMcpControl } from "./codex-mcp-control.mjs"
|
|
@@ -92,6 +93,7 @@ import {
|
|
|
92
93
|
} from "./claude-credentials.mjs"
|
|
93
94
|
import {
|
|
94
95
|
buildClaudeCmd,
|
|
96
|
+
buildWorktreeIndex,
|
|
95
97
|
createSession as createTmuxSession,
|
|
96
98
|
createWorktreeDir,
|
|
97
99
|
execTmux,
|
|
@@ -1717,6 +1719,11 @@ const lastTurnTextsCache = new Map()
|
|
|
1717
1719
|
*/
|
|
1718
1720
|
const lastTurnTextsInFlight = new Map()
|
|
1719
1721
|
|
|
1722
|
+
// 複数 Cockpit タブは各自 8 秒 poll を持つため、位相がずれると agent は同じ
|
|
1723
|
+
// tmux/worktree/jsonl スナップショットを数秒おきに再構築してしまう。5 秒だけ成功値を
|
|
1724
|
+
// 共有し、同時要求は single-flight に畳む。create/kill/worktree 変更時は明示 clear する。
|
|
1725
|
+
const tmuxSessionsSnapshotCache = new AsyncTtlCache({ ttlMs: 5_000 })
|
|
1726
|
+
|
|
1720
1727
|
/** cold cache 時に全セッションを一斉に読まないための同時実行上限。 */
|
|
1721
1728
|
const LAST_TEXTS_CONCURRENCY = 4
|
|
1722
1729
|
|
|
@@ -1779,12 +1786,21 @@ function isSafeSessionIdForPath(id) {
|
|
|
1779
1786
|
*
|
|
1780
1787
|
* @param {Array} sessions listTmuxSessions の素の行 (name / cwd を使う)
|
|
1781
1788
|
* @param {((name: string) => object | null) | undefined} getSnap state loop の getSnapshot
|
|
1789
|
+
* @param {object} [options] I/O と session ID 解決の注入点
|
|
1790
|
+
* @param {((session: object) => string | null) | undefined} [options.resolveFallbackSessionId]
|
|
1791
|
+
* snapshot の context_session_id が再起動後の `unbound:*` のとき、TUI bind / live
|
|
1792
|
+
* Claude bridge から同じ cwd の会話 ID を復元する。戻り値にも containment 検査を行う。
|
|
1782
1793
|
* @returns {Promise<Map<string, {user: string, assistant: string}>>}
|
|
1783
1794
|
*/
|
|
1784
1795
|
export async function readAllSessionLastTexts(
|
|
1785
1796
|
sessions,
|
|
1786
1797
|
getSnap,
|
|
1787
|
-
{
|
|
1798
|
+
{
|
|
1799
|
+
extract = extractLastTurnTexts,
|
|
1800
|
+
statFn = stat,
|
|
1801
|
+
resolvePath = jsonlPath,
|
|
1802
|
+
resolveFallbackSessionId,
|
|
1803
|
+
} = {},
|
|
1788
1804
|
) {
|
|
1789
1805
|
const out = new Map()
|
|
1790
1806
|
if (!getSnap) return out
|
|
@@ -1794,7 +1810,17 @@ export async function readAllSessionLastTexts(
|
|
|
1794
1810
|
try {
|
|
1795
1811
|
const snap = getSnap(s.name)
|
|
1796
1812
|
if (!snap || snap.cli_kind !== "claude") return
|
|
1797
|
-
|
|
1813
|
+
let sessionId = snap.context_session_id
|
|
1814
|
+
// hub-agent 再起動で in-memory の TUI bind が消えると、state loop の snapshot は
|
|
1815
|
+
// `unbound:<session>` へ落ちる。一方、Claude bridge の warm/live session は cwd
|
|
1816
|
+
// から同じ会話 ID を復元できる。ここでフォールバックしないと、再起動後に全行の
|
|
1817
|
+
// 「最後の依頼 / 応答」だけが消え、次に chat signal が fresh になるまで戻らない。
|
|
1818
|
+
//
|
|
1819
|
+
// snapshot が安全な実 ID を持つ場合は必ずそちらを優先し、fallback は placeholder /
|
|
1820
|
+
// 危険値の場合だけ使う。fallback も同じ containment 検査を通してから path 化する。
|
|
1821
|
+
if (!isSafeSessionIdForPath(sessionId) && resolveFallbackSessionId) {
|
|
1822
|
+
sessionId = resolveFallbackSessionId(s)
|
|
1823
|
+
}
|
|
1798
1824
|
// パス要素として安全な形だけを通す (外部由来の値なので containment を局所保証)。
|
|
1799
1825
|
if (!isSafeSessionIdForPath(sessionId)) return
|
|
1800
1826
|
if (!s.cwd) return
|
|
@@ -1950,6 +1976,9 @@ async function startSessionEventWatcher({ client, logger, readinessTracker }) {
|
|
|
1950
1976
|
"session.event push",
|
|
1951
1977
|
)
|
|
1952
1978
|
}
|
|
1979
|
+
// 直前に作った一覧 snapshot がこの event より古い場合、次の poll で last_event を
|
|
1980
|
+
// 逆戻りさせない。build 中なら AsyncTtlCache が新世代へ合流し直す。
|
|
1981
|
+
tmuxSessionsSnapshotCache.clear()
|
|
1953
1982
|
client.send({
|
|
1954
1983
|
type: "session.event",
|
|
1955
1984
|
session_name: sessionName,
|
|
@@ -2274,6 +2303,7 @@ export async function pushImmediateChatState({
|
|
|
2274
2303
|
for (const s of states) {
|
|
2275
2304
|
if (s.cwd !== cwd) continue
|
|
2276
2305
|
if (cliKindForSessionName(s.session_name) !== cliKind) continue
|
|
2306
|
+
tmuxSessionsSnapshotCache.clear()
|
|
2277
2307
|
client.send({
|
|
2278
2308
|
type: "session.state",
|
|
2279
2309
|
session_name: s.session_name,
|
|
@@ -2380,6 +2410,7 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
2380
2410
|
// これで再接続直後の次 tick (≤intervalMs) に短縮する。初回接続時は空 Map の clear で no-op。
|
|
2381
2411
|
const onReopen = () => {
|
|
2382
2412
|
lastByName.clear()
|
|
2413
|
+
tmuxSessionsSnapshotCache.clear()
|
|
2383
2414
|
}
|
|
2384
2415
|
client.on("open", onReopen)
|
|
2385
2416
|
|
|
@@ -2678,6 +2709,9 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
2678
2709
|
"session.state push",
|
|
2679
2710
|
)
|
|
2680
2711
|
}
|
|
2712
|
+
// state push より古い一覧を後続 poll が返すと sidebar 行の status/context が
|
|
2713
|
+
// 逆戻りするため、送信前に snapshot 世代を切り替える。
|
|
2714
|
+
tmuxSessionsSnapshotCache.clear()
|
|
2681
2715
|
client.send({
|
|
2682
2716
|
type: "session.state",
|
|
2683
2717
|
session_name: s.session_name,
|
|
@@ -2715,6 +2749,7 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
2715
2749
|
// の消滅はここが唯一の検知点。frontend が states/lastEvent を忘れてドットを
|
|
2716
2750
|
// 'down' に倒せるよう、ローカル Map を forget する前に通知する。
|
|
2717
2751
|
try {
|
|
2752
|
+
tmuxSessionsSnapshotCache.clear()
|
|
2718
2753
|
client.send({ type: "session.gone", session_name: name })
|
|
2719
2754
|
} catch {
|
|
2720
2755
|
/* ignore */
|
|
@@ -4434,6 +4469,7 @@ async function dispatch(msg, ctx) {
|
|
|
4434
4469
|
ctx.claudeBridge?.shutdown()
|
|
4435
4470
|
// tmux TUI セッションも旧アカウントのまま残さない: kill → 同名・同 cwd で再作成。
|
|
4436
4471
|
const tmuxResult = await restartAllTmuxSessionsForProfileSwitch(ctx)
|
|
4472
|
+
tmuxSessionsSnapshotCache.clear()
|
|
4437
4473
|
// 切替先の config ディレクトリに認証情報が無ければ要ログイン (バッジ/案内用)。
|
|
4438
4474
|
const hasCreds = isKeychainPlatform()
|
|
4439
4475
|
? await profileHasKeychainCredentials(target.configDir)
|
|
@@ -4573,6 +4609,7 @@ async function dispatch(msg, ctx) {
|
|
|
4573
4609
|
// Codex セッションだけ撤去 (Claude セッションは残す = 独立切替)。
|
|
4574
4610
|
const before = ctx.codexBridge?.sessions?.size ?? 0
|
|
4575
4611
|
ctx.codexBridge?.shutdown()
|
|
4612
|
+
tmuxSessionsSnapshotCache.clear()
|
|
4576
4613
|
const hasCreds = await codexProfiles.hasCodexCredentials(target.configDir)
|
|
4577
4614
|
const needsLogin = !hasCreds
|
|
4578
4615
|
const isDefaultTarget = target.id === codexProfiles.DEFAULT_CODEX_PROFILE_ID
|
|
@@ -4706,48 +4743,50 @@ async function dispatch(msg, ctx) {
|
|
|
4706
4743
|
}
|
|
4707
4744
|
case "tmux.list_sessions": {
|
|
4708
4745
|
try {
|
|
4709
|
-
|
|
4710
|
-
|
|
4711
|
-
|
|
4712
|
-
|
|
4713
|
-
|
|
4714
|
-
|
|
4715
|
-
|
|
4716
|
-
|
|
4717
|
-
|
|
4746
|
+
const snapshot = await tmuxSessionsSnapshotCache.get(async () => {
|
|
4747
|
+
// worktree index は listSessions の親判定と stub 列挙で共用する。従来は同じ
|
|
4748
|
+
// 全 workspace + git branch 走査を 1 応答内で 2 回直列実行していた。
|
|
4749
|
+
const worktreeIndex = await buildWorktreeIndex()
|
|
4750
|
+
// boundSessions: 各行の context% (ドーナツ) を bound session の jsonl から引く
|
|
4751
|
+
// (cwd dir 内の別セッション jsonl を最新と取り違える=メタ欠陥#1 の根治)。
|
|
4752
|
+
const sessions = await listTmuxSessions({
|
|
4753
|
+
plugins: ctx.plugins,
|
|
4754
|
+
logger: ctx.logger,
|
|
4755
|
+
boundSessions: ctx.tuiReboundSessions,
|
|
4756
|
+
claudeBridge: ctx.claudeBridge,
|
|
4757
|
+
worktreeIndex,
|
|
4758
|
+
})
|
|
4759
|
+
const [lastEventByName, lastTextsByName] = await Promise.all([
|
|
4760
|
+
readAllSessionEvents(),
|
|
4761
|
+
readAllSessionLastTexts(sessions, ctx.getSessionStateSnapshot, {
|
|
4762
|
+
resolveFallbackSessionId: (session) =>
|
|
4763
|
+
resolveStateLoopBoundSessionId(
|
|
4764
|
+
ctx.tuiReboundSessions,
|
|
4765
|
+
session.name,
|
|
4766
|
+
session.cwd,
|
|
4767
|
+
ctx.claudeBridge,
|
|
4768
|
+
),
|
|
4769
|
+
}),
|
|
4770
|
+
])
|
|
4771
|
+
const enriched = enrichTmuxSessionsForListResponse(
|
|
4772
|
+
sessions,
|
|
4773
|
+
lastEventByName,
|
|
4774
|
+
ctx.getSessionStateSnapshot,
|
|
4775
|
+
lastTextsByName,
|
|
4776
|
+
)
|
|
4777
|
+
const liveNames = new Set(enriched.map((s) => s.name))
|
|
4778
|
+
let worktreeStubs = []
|
|
4779
|
+
try {
|
|
4780
|
+
worktreeStubs = await listWorktreeStubs(liveNames, { worktreeIndex })
|
|
4781
|
+
} catch (err) {
|
|
4782
|
+
ctx.logger?.warn?.({ err: err?.message }, "listWorktreeStubs failed")
|
|
4783
|
+
}
|
|
4784
|
+
return { sessions: enriched, worktree_stubs: worktreeStubs }
|
|
4718
4785
|
})
|
|
4719
|
-
// 各 session の最新 hook event を /tmp/cockpit_session_events/ から読んで
|
|
4720
|
-
// レスポンスに含める (Phase 4: ハードリロード時の lastEvent 復元用)。
|
|
4721
|
-
// fs.watch 由来の session.event push は揮発性のため、frontend が新規
|
|
4722
|
-
// マウントすると過去 event を取れず全グレー表示になる事象を解消する。
|
|
4723
|
-
const lastEventByName = await readAllSessionEvents()
|
|
4724
|
-
// サイドバー行に「何を依頼したか」を出すため、各セッションの jsonl 末尾から
|
|
4725
|
-
// 最新の依頼 / 応答テキストを読む (mtime キャッシュ付き)。失敗は空に落ちる。
|
|
4726
|
-
const lastTextsByName = await readAllSessionLastTexts(
|
|
4727
|
-
sessions,
|
|
4728
|
-
ctx.getSessionStateSnapshot,
|
|
4729
|
-
)
|
|
4730
|
-
const enriched = enrichTmuxSessionsForListResponse(
|
|
4731
|
-
sessions,
|
|
4732
|
-
lastEventByName,
|
|
4733
|
-
ctx.getSessionStateSnapshot,
|
|
4734
|
-
lastTextsByName,
|
|
4735
|
-
)
|
|
4736
|
-
// cockpit (PR 1719) で未起動 worktree をサイドバーに可視化するために
|
|
4737
|
-
// filesystem 上は存在するが tmux session が無い worktree dir のリストを
|
|
4738
|
-
// 同梱する。古い cockpit は worktree_stubs を無視するので互換 OK。
|
|
4739
|
-
const liveNames = new Set(enriched.map((s) => s.name))
|
|
4740
|
-
let worktreeStubs = []
|
|
4741
|
-
try {
|
|
4742
|
-
worktreeStubs = await listWorktreeStubs(liveNames)
|
|
4743
|
-
} catch (err) {
|
|
4744
|
-
ctx.logger?.warn?.({ err: err?.message }, "listWorktreeStubs failed")
|
|
4745
|
-
}
|
|
4746
4786
|
ctx.client.send({
|
|
4747
4787
|
type: "tmux.sessions",
|
|
4748
4788
|
request_id: msg.request_id,
|
|
4749
|
-
|
|
4750
|
-
worktree_stubs: worktreeStubs,
|
|
4789
|
+
...snapshot,
|
|
4751
4790
|
})
|
|
4752
4791
|
} catch (err) {
|
|
4753
4792
|
ctx.client.send({
|
|
@@ -4797,6 +4836,7 @@ async function dispatch(msg, ctx) {
|
|
|
4797
4836
|
typeof msg.initial_prompt === "string" ? msg.initial_prompt : undefined,
|
|
4798
4837
|
logger: ctx.logger,
|
|
4799
4838
|
})
|
|
4839
|
+
tmuxSessionsSnapshotCache.clear()
|
|
4800
4840
|
ctx.client.send({
|
|
4801
4841
|
type: "tmux.create_session.result",
|
|
4802
4842
|
request_id: msg.request_id,
|
|
@@ -4867,6 +4907,7 @@ async function dispatch(msg, ctx) {
|
|
|
4867
4907
|
typeof msg.initial_prompt === "string" ? msg.initial_prompt : undefined,
|
|
4868
4908
|
logger: ctx.logger,
|
|
4869
4909
|
})
|
|
4910
|
+
tmuxSessionsSnapshotCache.clear()
|
|
4870
4911
|
ctx.client.send({
|
|
4871
4912
|
type: "worktree.create.result",
|
|
4872
4913
|
request_id: msg.request_id,
|
|
@@ -4944,6 +4985,7 @@ async function dispatch(msg, ctx) {
|
|
|
4944
4985
|
const force = msg.force === true
|
|
4945
4986
|
try {
|
|
4946
4987
|
const result = await removeWorktreeDir(name, { force })
|
|
4988
|
+
if (result.dirty !== true) tmuxSessionsSnapshotCache.clear()
|
|
4947
4989
|
ctx.client.send({
|
|
4948
4990
|
type: "worktree.remove.result",
|
|
4949
4991
|
request_id: msg.request_id,
|
|
@@ -4966,6 +5008,7 @@ async function dispatch(msg, ctx) {
|
|
|
4966
5008
|
}
|
|
4967
5009
|
case "tmux.kill_session": {
|
|
4968
5010
|
await handleKillSession(msg, ctx)
|
|
5011
|
+
tmuxSessionsSnapshotCache.clear()
|
|
4969
5012
|
return
|
|
4970
5013
|
}
|
|
4971
5014
|
case "skills.request": {
|
package/src/tmux.mjs
CHANGED
|
@@ -174,7 +174,7 @@ export async function createWorktreeDir(parentDir, branch) {
|
|
|
174
174
|
*
|
|
175
175
|
* 移植元: D00000_cockpit/webapp/lib/workspaces.ts (findWorktrees)
|
|
176
176
|
*/
|
|
177
|
-
async function buildWorktreeIndex() {
|
|
177
|
+
export async function buildWorktreeIndex() {
|
|
178
178
|
const out = new Map()
|
|
179
179
|
const projectsBase = hubProjectsBase()
|
|
180
180
|
let projects
|
|
@@ -240,8 +240,11 @@ async function buildWorktreeParentMap() {
|
|
|
240
240
|
* worktree (`<wt>__codex`) が「未起動 stub」としても同時に列挙され、サイドバーに live 行と
|
|
241
241
|
* stub 行が二重表示される事故を防ぐ。
|
|
242
242
|
*/
|
|
243
|
-
export async function listWorktreeStubs(liveSessionNames) {
|
|
244
|
-
const idx =
|
|
243
|
+
export async function listWorktreeStubs(liveSessionNames, opts = {}) {
|
|
244
|
+
const idx =
|
|
245
|
+
opts.worktreeIndex instanceof Map
|
|
246
|
+
? opts.worktreeIndex
|
|
247
|
+
: await buildWorktreeIndex()
|
|
245
248
|
const liveBaseNames = new Set()
|
|
246
249
|
for (const n of liveSessionNames) liveBaseNames.add(baseSessionName(n))
|
|
247
250
|
const stubs = []
|
|
@@ -649,7 +652,16 @@ export async function listSessions(opts = {}) {
|
|
|
649
652
|
// pane 解析 (state.detectSessionState) はフォーマット変動に脆いので、
|
|
650
653
|
// statusLine cache がある cwd では優先採用する。
|
|
651
654
|
const [wtParentMap, sessionUsages] = await Promise.all([
|
|
652
|
-
|
|
655
|
+
opts.worktreeIndex instanceof Map
|
|
656
|
+
? Promise.resolve(
|
|
657
|
+
new Map(
|
|
658
|
+
[...opts.worktreeIndex].map(([name, info]) => [
|
|
659
|
+
name,
|
|
660
|
+
info.parent_session_name,
|
|
661
|
+
]),
|
|
662
|
+
),
|
|
663
|
+
)
|
|
664
|
+
: buildWorktreeParentMap(),
|
|
653
665
|
getSessionUsages().catch(() => []),
|
|
654
666
|
])
|
|
655
667
|
// cwd → context_pct の lookup (同一 cwd で複数 session_id ある場合は最新 mtime 優先)
|