@cocorograph/hub-agent 0.8.18 → 0.8.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/claude-md-sync.mjs +704 -57
- package/src/claude-md.mjs +12 -8
- package/src/main.mjs +27 -10
- package/src/pane-cli-flag.mjs +30 -0
- package/src/pointer-context.mjs +196 -0
- package/src/state.mjs +39 -195
- package/src/tmux.mjs +6 -94
package/src/claude-md.mjs
CHANGED
|
@@ -21,8 +21,7 @@ import os from "node:os"
|
|
|
21
21
|
import path from "node:path"
|
|
22
22
|
import {
|
|
23
23
|
sha256,
|
|
24
|
-
|
|
25
|
-
writeBaseBody,
|
|
24
|
+
writeSyncState,
|
|
26
25
|
nowIso,
|
|
27
26
|
} from "./claude-md-sync.mjs"
|
|
28
27
|
|
|
@@ -381,13 +380,18 @@ export async function ensureClaudeMd({
|
|
|
381
380
|
const hubInitialized = !!hub?.claude_md_updated_at
|
|
382
381
|
if (hubInitialized && hubBody) {
|
|
383
382
|
await fs.writeFile(configMdPath, hubBody, "utf-8")
|
|
384
|
-
await writeMeta(targetDir, {
|
|
385
|
-
lastSyncedHubUpdatedAt: hub.claude_md_updated_at,
|
|
386
|
-
lastSyncedHash: hub.claude_md_hash || sha256(hubBody),
|
|
387
|
-
lastSyncedAt: nowIso(),
|
|
388
|
-
}, configFilename)
|
|
389
383
|
// 初回 pull 直後は base = Hub 本文。以降の session-end 3-way merge の入力。
|
|
390
|
-
|
|
384
|
+
// ⚠️ base → meta の順で書くこと(writeSyncState に集約)。meta は「判定の
|
|
385
|
+
// commit marker」なので先に進めると、base の書込が失敗しても「同期済み」と
|
|
386
|
+
// 見なされ、以降の sync が通常 noop に入って base が壊れたまま固定される。
|
|
387
|
+
await writeSyncState(targetDir, {
|
|
388
|
+
body: hubBody,
|
|
389
|
+
meta: {
|
|
390
|
+
lastSyncedHubUpdatedAt: hub.claude_md_updated_at,
|
|
391
|
+
lastSyncedHash: hub.claude_md_hash || sha256(hubBody),
|
|
392
|
+
lastSyncedAt: nowIso(),
|
|
393
|
+
},
|
|
394
|
+
}, configFilename)
|
|
391
395
|
logger?.info?.(
|
|
392
396
|
{ dirName, configMdPath },
|
|
393
397
|
"wrote project instruction file from Hub project claude_md (initial sync)",
|
package/src/main.mjs
CHANGED
|
@@ -62,9 +62,6 @@ import { listAgents } from "./agents.mjs"
|
|
|
62
62
|
import { listSkills } from "./skills.mjs"
|
|
63
63
|
import { readSidebarMessageSnapshot } from "./sidebar-message-snapshot.mjs"
|
|
64
64
|
import {
|
|
65
|
-
capturePane,
|
|
66
|
-
detectPermissionModeFromText,
|
|
67
|
-
detectSessionState,
|
|
68
65
|
invalidateSessionCache,
|
|
69
66
|
listSessionStates,
|
|
70
67
|
StallTracker,
|
|
@@ -111,6 +108,7 @@ import {
|
|
|
111
108
|
} from "./tmux.mjs"
|
|
112
109
|
import { SiblingInjectBridge } from "./sibling-inject.mjs"
|
|
113
110
|
import { isSessionWarm } from "./live-sessions.mjs"
|
|
111
|
+
import { applyPointerContexts, resolvePointerContexts } from "./pointer-context.mjs"
|
|
114
112
|
import {
|
|
115
113
|
boundSessionId,
|
|
116
114
|
codexContextPercentFromTokenUsage,
|
|
@@ -236,6 +234,10 @@ export async function restartAllTmuxSessionsForProfileSwitch(ctx) {
|
|
|
236
234
|
}
|
|
237
235
|
try {
|
|
238
236
|
await createTmuxSession(s.name, s.cwd, {
|
|
237
|
+
// cliKind はセッション名 (`<dir>__codex` か否か) だけで決まる。渡し忘れると
|
|
238
|
+
// createSession が既定の claude として扱い、codex セッションの再生成でも
|
|
239
|
+
// AGENTS.md ではなく CLAUDE.md を同期対象にしてしまう。
|
|
240
|
+
cliKind: cliKindForSessionName(s.name),
|
|
239
241
|
claudeCmd: claudeCmdFromAgentConfig(ctx.config),
|
|
240
242
|
logger: ctx.logger,
|
|
241
243
|
})
|
|
@@ -2167,7 +2169,6 @@ export async function pushImmediateChatState({
|
|
|
2167
2169
|
session_name: s.session_name,
|
|
2168
2170
|
status: "waiting",
|
|
2169
2171
|
context_pct: typeof contextPct === "number" ? contextPct : 0,
|
|
2170
|
-
permission_mode: s.permission_mode ?? null,
|
|
2171
2172
|
stable: true,
|
|
2172
2173
|
proc_busy: false,
|
|
2173
2174
|
child_busy: false,
|
|
@@ -2311,9 +2312,6 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
2311
2312
|
// (`<dir>` = claude / `<dir>__codex` = codex)。chat 信号の読み出し・turn_active の
|
|
2312
2313
|
// 判定源・session.state に載せる cli_kind はすべてこの 1 値から導く (正本一本化)。
|
|
2313
2314
|
const sessionCliKind = cliKindForSessionName(s.session_name)
|
|
2314
|
-
// 対話 TUI のペインから読んだ権限モード (素のシェル / SDK チャットでは null)。
|
|
2315
|
-
// tmux 上で直接 shift+tab した変更もここで拾い、全ブラウザへ追従させる。
|
|
2316
|
-
const permissionMode = s.permission_mode ?? null
|
|
2317
2315
|
// チャットモード補完: tmux ペインのスクレイプは TUI 前提で効かないため、
|
|
2318
2316
|
// session の cwd に一致する新鮮なチャット信号があれば status/context% を
|
|
2319
2317
|
// 上書きする (Hub ホスト型 Cockpit のステータスドット/各行ドーナツ用)。
|
|
@@ -2533,7 +2531,6 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
2533
2531
|
!prev ||
|
|
2534
2532
|
prev.status !== status ||
|
|
2535
2533
|
prev.context_pct !== contextPct ||
|
|
2536
|
-
prev.permission_mode !== permissionMode ||
|
|
2537
2534
|
prev.stable !== stable ||
|
|
2538
2535
|
prev.proc_busy !== procBusy ||
|
|
2539
2536
|
prev.child_busy !== childBusy ||
|
|
@@ -2545,7 +2542,6 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
2545
2542
|
lastByName.set(s.session_name, {
|
|
2546
2543
|
status,
|
|
2547
2544
|
context_pct: contextPct,
|
|
2548
|
-
permission_mode: permissionMode,
|
|
2549
2545
|
stable,
|
|
2550
2546
|
proc_busy: procBusy,
|
|
2551
2547
|
child_busy: childBusy,
|
|
@@ -2586,7 +2582,6 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
2586
2582
|
session_name: s.session_name,
|
|
2587
2583
|
status,
|
|
2588
2584
|
context_pct: contextPct,
|
|
2589
|
-
permission_mode: permissionMode,
|
|
2590
2585
|
stable,
|
|
2591
2586
|
proc_busy: procBusy,
|
|
2592
2587
|
// child_busy: outputActive を含まない真の子プロセス busy。frontend の shouldQueue が
|
|
@@ -5073,10 +5068,32 @@ async function dispatch(msg, ctx) {
|
|
|
5073
5068
|
}
|
|
5074
5069
|
return { sessions: enriched, worktree_stubs: worktreeStubs }
|
|
5075
5070
|
})
|
|
5071
|
+
// 行ごとの「次に開く会話」の context% (任意)。宛先 (context_targets) は要求ごとに
|
|
5072
|
+
// 違うため、共有される snapshot キャッシュの**外**で解決し、触る行だけ複製して
|
|
5073
|
+
// 載せる (他クライアントの応答を汚さない)。context_targets が無ければ従来どおりの
|
|
5074
|
+
// 応答 (後方互換)。
|
|
5075
|
+
let sessions = snapshot.sessions
|
|
5076
|
+
if (Array.isArray(msg.context_targets) && msg.context_targets.length > 0) {
|
|
5077
|
+
try {
|
|
5078
|
+
const pointerByName = await resolvePointerContexts(
|
|
5079
|
+
msg.context_targets,
|
|
5080
|
+
sessions,
|
|
5081
|
+
{ logger: ctx.logger },
|
|
5082
|
+
)
|
|
5083
|
+
sessions = applyPointerContexts(sessions, pointerByName)
|
|
5084
|
+
} catch (err) {
|
|
5085
|
+
// pointer context はあくまで付加情報。失敗してもセッション一覧は返す。
|
|
5086
|
+
ctx.logger?.warn?.(
|
|
5087
|
+
{ err: err?.message },
|
|
5088
|
+
"resolvePointerContexts failed",
|
|
5089
|
+
)
|
|
5090
|
+
}
|
|
5091
|
+
}
|
|
5076
5092
|
ctx.client.send({
|
|
5077
5093
|
type: "tmux.sessions",
|
|
5078
5094
|
request_id: msg.request_id,
|
|
5079
5095
|
...snapshot,
|
|
5096
|
+
sessions,
|
|
5080
5097
|
})
|
|
5081
5098
|
} catch (err) {
|
|
5082
5099
|
ctx.client.send({
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「tmux pane に CLI (claude / codex) の TUI を起動しているか」の単一判定。
|
|
3
|
+
*
|
|
4
|
+
* この 1 フラグが tmux.mjs (pane で TUI を起動するか) と state.mjs (pane を
|
|
5
|
+
* capture-pane で読む意味があるか) の両方の分岐条件になるため、どちらからも
|
|
6
|
+
* import できる依存の無い最小モジュールに切り出してある。tmux.mjs 側に置いたまま
|
|
7
|
+
* state.mjs から import すると tmux.mjs → state.mjs → tmux.mjs の循環になる。
|
|
8
|
+
*
|
|
9
|
+
* 後方互換のため `tmux.mjs` からも re-export している (既存の import 先を壊さない)。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* tmux pane で CLI (claude / codex) の TUI を起動してよいかを判定する。
|
|
14
|
+
*
|
|
15
|
+
* **既定は false**(pane は素のシェルのまま)。Cockpit のターミナルモードは
|
|
16
|
+
* 2026-07-03 に封印済みで、通常運用は常にチャットモード (Agent SDK / codex
|
|
17
|
+
* app-server) になった。それでも createSession は pane で TUI を起動し続けていたため、
|
|
18
|
+
* **誰も見ていない CLI が常駐**する。実測 (2026-08-01 中村さん端末): tmux セッション
|
|
19
|
+
* 33 個に対して pane の `claude` プロセス 31 個・合計 RSS 1.7 GB。
|
|
20
|
+
*
|
|
21
|
+
* `HUB_AGENT_TMUX_LAUNCH_CLI=1` のときだけ従来どおり起動する(デバッグ・TUI を
|
|
22
|
+
* 意図的に使いたいときの逃げ道)。呼び出し元ごとに挙動を変えないため、判定は
|
|
23
|
+
* この関数 1 箇所に集約し、opts では上書きさせない。
|
|
24
|
+
*
|
|
25
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
26
|
+
* @returns {boolean}
|
|
27
|
+
*/
|
|
28
|
+
export function shouldLaunchPaneCli(env = process.env) {
|
|
29
|
+
return env?.HUB_AGENT_TMUX_LAUNCH_CLI === "1"
|
|
30
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「その行が次に開く会話」の context% (= pointer context) を解決する。
|
|
3
|
+
*
|
|
4
|
+
* ## 背景 (WHY)
|
|
5
|
+
* Cockpit のサイドバーは、コンテキスト使用率を **その行が次に開く会話に帰属させて**表示する
|
|
6
|
+
* ように直した (Hub 側 PR #4534)。しかし供給側 (agent) は「いま動いている会話」の値しか
|
|
7
|
+
* 出しておらず、一度も開いていない行・閉じた行では使用率が出ない。
|
|
8
|
+
*
|
|
9
|
+
* 従来は「その cwd で mtime が一番新しい jsonl」に落として埋めていたが、それが
|
|
10
|
+
* **サブエージェント / headless `claude -p` / 過去セッションの数字が混ざる**原因だったので
|
|
11
|
+
* 撤去した (cross-activity contamination)。よってここでは **frontend が行ごとに指定した
|
|
12
|
+
* 会話 ID (`session_id`) でしか値を引かない**。ID が無い行は推測せず null を返す。
|
|
13
|
+
*
|
|
14
|
+
* ## 不変条件 (ここを崩すと汚染が戻る)
|
|
15
|
+
* 1. `session_id` が渡されていない行では `contextForCwd` を **呼ばない** (mtime 最新への
|
|
16
|
+
* フォールバックが走るため)。
|
|
17
|
+
* 2. `session_id` が渡された行でも、戻り値の `uuid` が要求 ID と**一致するときだけ**採用する。
|
|
18
|
+
* `jsonlContextForCwd` は「その ID の jsonl が存在しない」とき mtime 最新へフォールバック
|
|
19
|
+
* する仕様のため、件数ではなく**実体 (uuid) の同一性**で確認する。
|
|
20
|
+
* 3. 鮮度 (mtime) による足切りをしない。止まっている会話の使用率は古くならない
|
|
21
|
+
* (次に開けばその値から再開する) ので、statusLine 経路の `SESSION_STALE_MS` は適用しない。
|
|
22
|
+
*
|
|
23
|
+
* ## N+1 対策
|
|
24
|
+
* サイドバーは 8 秒周期でポーリングし、複数タブが位相をずらして同時に叩く。行数分の jsonl
|
|
25
|
+
* 読み取りをそのまま毎回走らせないよう、`(cwd, session_id)` をキーにした短期 TTL +
|
|
26
|
+
* single-flight キャッシュを噛ませる (同時要求は 1 本の I/O に畳む)。
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { cliKindForSessionName } from "./session-names.mjs"
|
|
30
|
+
import { jsonlContextForCwd } from "./usage.mjs"
|
|
31
|
+
|
|
32
|
+
/** pointer context のキャッシュ TTL (ms)。tmux スナップショットキャッシュと同じ 5 秒。 */
|
|
33
|
+
const POINTER_CONTEXT_TTL_MS = Number(
|
|
34
|
+
process.env.HUB_AGENT_POINTER_CONTEXT_TTL_MS ?? 5_000,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
/** キャッシュ保持数の上限 (会話 ID は増え続けるため LRU で頭打ちにする)。 */
|
|
38
|
+
const POINTER_CONTEXT_MAX_ENTRIES = 512
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* キー付きの短期 TTL + single-flight キャッシュ。
|
|
42
|
+
*
|
|
43
|
+
* `AsyncTtlCache` (単一値版) と役割は同じだが、こちらは `(cwd, session_id)` ごとに独立した
|
|
44
|
+
* エントリを持つ。build 失敗はキャッシュしない (次の要求で再試行できる)。
|
|
45
|
+
*/
|
|
46
|
+
export class KeyedAsyncTtlCache {
|
|
47
|
+
constructor({ ttlMs, now = () => Date.now(), maxEntries = POINTER_CONTEXT_MAX_ENTRIES } = {}) {
|
|
48
|
+
this.ttlMs = Math.max(0, Number(ttlMs) || 0)
|
|
49
|
+
this.now = now
|
|
50
|
+
this.maxEntries = Math.max(1, Number(maxEntries) || 1)
|
|
51
|
+
this.entries = new Map() // key → {value, expiresAt, inFlight}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async get(key, build) {
|
|
55
|
+
const hit = this.entries.get(key)
|
|
56
|
+
if (hit) {
|
|
57
|
+
if (hit.inFlight) return hit.inFlight // 同時要求は 1 本の I/O に畳む
|
|
58
|
+
if (this.now() < hit.expiresAt) return hit.value
|
|
59
|
+
}
|
|
60
|
+
const pending = Promise.resolve().then(build)
|
|
61
|
+
const entry = { value: undefined, expiresAt: 0, inFlight: pending }
|
|
62
|
+
// delete → set で挿入順 = 最近使った順にし、prune を LRU として使えるようにする。
|
|
63
|
+
this.entries.delete(key)
|
|
64
|
+
this.entries.set(key, entry)
|
|
65
|
+
try {
|
|
66
|
+
const value = await pending
|
|
67
|
+
entry.value = value
|
|
68
|
+
entry.expiresAt = this.now() + this.ttlMs
|
|
69
|
+
entry.inFlight = null
|
|
70
|
+
this.prune()
|
|
71
|
+
return value
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (this.entries.get(key) === entry) this.entries.delete(key)
|
|
74
|
+
throw err
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 期限切れエントリを落とし、上限を超えていれば古い順に捨てる。 */
|
|
79
|
+
prune() {
|
|
80
|
+
const now = this.now()
|
|
81
|
+
for (const [key, e] of this.entries) {
|
|
82
|
+
if (!e.inFlight && now >= e.expiresAt) this.entries.delete(key)
|
|
83
|
+
}
|
|
84
|
+
while (this.entries.size > this.maxEntries) {
|
|
85
|
+
const oldest = this.entries.keys().next()
|
|
86
|
+
if (oldest.done) break
|
|
87
|
+
this.entries.delete(oldest.value)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
clear() {
|
|
92
|
+
this.entries.clear()
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 既定のプロセス共有キャッシュ (tmux.list_sessions ハンドラ用)。 */
|
|
97
|
+
const defaultPointerContextCache = new KeyedAsyncTtlCache({ ttlMs: POINTER_CONTEXT_TTL_MS })
|
|
98
|
+
|
|
99
|
+
/** テスト用: 既定キャッシュを空にする。 */
|
|
100
|
+
export function _resetPointerContextCache() {
|
|
101
|
+
defaultPointerContextCache.clear()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** cwd と会話 ID の連結。区切りは両者に現れ得ない制御文字 (エスケープ表記で書く)。 */
|
|
105
|
+
function cacheKey(cwd, sessionId) {
|
|
106
|
+
return `${cwd}\u0000${sessionId}`
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* `context_targets` (行ごとの「次に開く会話」) を解決し、`session_name → context% | null` の
|
|
111
|
+
* Map を返す。
|
|
112
|
+
*
|
|
113
|
+
* 値が確定しなかった行にも **null を明示的に入れる** (「見に行った上で不明」を表す)。
|
|
114
|
+
* `context_targets` に含まれない行は Map に載らない = 応答にフィールドを付けない。
|
|
115
|
+
*
|
|
116
|
+
* @param {Array<{session_name?: string, session_id?: string|null}>|undefined} targets
|
|
117
|
+
* @param {Array<{name?: string, cwd?: string|null}>} rows tmux.list_sessions の行 (cwd 解決用)
|
|
118
|
+
* @param {{contextForCwd?: Function, cache?: KeyedAsyncTtlCache, logger?: object}} [opts]
|
|
119
|
+
* @returns {Promise<Map<string, number|null>>}
|
|
120
|
+
*/
|
|
121
|
+
export async function resolvePointerContexts(targets, rows, opts = {}) {
|
|
122
|
+
const {
|
|
123
|
+
contextForCwd = jsonlContextForCwd,
|
|
124
|
+
cache = defaultPointerContextCache,
|
|
125
|
+
logger,
|
|
126
|
+
} = opts
|
|
127
|
+
const out = new Map()
|
|
128
|
+
if (!Array.isArray(targets) || targets.length === 0) return out
|
|
129
|
+
|
|
130
|
+
const cwdByName = new Map()
|
|
131
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
132
|
+
if (row && typeof row.name === "string") cwdByName.set(row.name, row.cwd || null)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const jobs = []
|
|
136
|
+
for (const target of targets) {
|
|
137
|
+
const name = typeof target?.session_name === "string" ? target.session_name : ""
|
|
138
|
+
if (!name || out.has(name)) continue // 同名の重複指定は先勝ち
|
|
139
|
+
out.set(name, null)
|
|
140
|
+
|
|
141
|
+
const sessionId = typeof target?.session_id === "string" ? target.session_id.trim() : ""
|
|
142
|
+
// 不変条件 1: ID が無ければ読みに行かない (mtime 最新フォールバックを踏まない)。
|
|
143
|
+
if (!sessionId) continue
|
|
144
|
+
const cwd = cwdByName.get(name)
|
|
145
|
+
if (!cwd) continue
|
|
146
|
+
// codex 行は claude の jsonl を持たない。読んでも uuid 不一致で null になるだけなので、
|
|
147
|
+
// 無駄な readdir/stat を避けるためここで落とす (結果は同じ)。
|
|
148
|
+
if (cliKindForSessionName(name) !== "claude") continue
|
|
149
|
+
jobs.push({ name, cwd, sessionId })
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
await Promise.all(
|
|
153
|
+
jobs.map(async ({ name, cwd, sessionId }) => {
|
|
154
|
+
try {
|
|
155
|
+
const pct = await cache.get(cacheKey(cwd, sessionId), async () => {
|
|
156
|
+
const res = await contextForCwd(cwd, sessionId)
|
|
157
|
+
// 不変条件 2: 実体 (uuid) が一致したときだけ採用する。
|
|
158
|
+
if (!res || res.uuid !== sessionId) return null
|
|
159
|
+
return typeof res.percent === "number" ? res.percent : null
|
|
160
|
+
})
|
|
161
|
+
if (typeof pct === "number") out.set(name, pct)
|
|
162
|
+
} catch (err) {
|
|
163
|
+
logger?.warn?.(
|
|
164
|
+
{ err: err?.message, session: name },
|
|
165
|
+
"pointer context resolve failed",
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
}),
|
|
169
|
+
)
|
|
170
|
+
return out
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 解決済みの pointer context を session 行へ載せる (元配列・元オブジェクトは変更しない)。
|
|
175
|
+
*
|
|
176
|
+
* `tmux.list_sessions` の応答行は TTL スナップショットキャッシュで**複数の要求に共有される**
|
|
177
|
+
* ため、要求ごとに違う値を直接書き込むと他クライアントの応答を汚す。触る行だけ複製する。
|
|
178
|
+
*
|
|
179
|
+
* 既存の `context_pct` (いま動いている会話の値) とは**別フィールド**にする。混ぜると
|
|
180
|
+
* 「どちらの会話の値か」が曖昧になるため。
|
|
181
|
+
*
|
|
182
|
+
* @param {Array<object>} sessions
|
|
183
|
+
* @param {Map<string, number|null>} pointerByName
|
|
184
|
+
* @returns {Array<object>}
|
|
185
|
+
*/
|
|
186
|
+
export function applyPointerContexts(sessions, pointerByName) {
|
|
187
|
+
if (!Array.isArray(sessions)) return []
|
|
188
|
+
if (!(pointerByName instanceof Map) || pointerByName.size === 0) return sessions
|
|
189
|
+
return sessions.map((s) =>
|
|
190
|
+
s && pointerByName.has(s.name)
|
|
191
|
+
? { ...s, pointer_context_pct: pointerByName.get(s.name) }
|
|
192
|
+
: s,
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export default { KeyedAsyncTtlCache, resolvePointerContexts, applyPointerContexts }
|
package/src/state.mjs
CHANGED
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
* 等のパターンを拾う (見つからなければ null)
|
|
9
9
|
* - plugin `transformStatusDetection` hook を chain して結果差し替え可
|
|
10
10
|
*
|
|
11
|
+
* ⚠️ **既定では pane を読まない** (2026-08-01)。Cockpit のターミナルモード封印に伴い
|
|
12
|
+
* pane に CLI の TUI を起動しなくなった (`shouldLaunchPaneCli()` 既定 false) ため、
|
|
13
|
+
* capture-pane は「素のシェルの画面」を読むだけで、判定結果は退化した定値にしかならない。
|
|
14
|
+
* detectSessionState はその設定を見て capture-pane 自体をスキップする。詳細は同関数の JSDoc。
|
|
15
|
+
*
|
|
11
16
|
* 移植元: D00000_cockpit/webapp/lib/tmux.ts (getSessionStatus 部分)
|
|
12
17
|
* 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
|
|
13
18
|
*/
|
|
@@ -15,6 +20,7 @@ import { execFile } from "node:child_process"
|
|
|
15
20
|
import { promisify } from "node:util"
|
|
16
21
|
|
|
17
22
|
import { runHookChain } from "./plugin-loader.mjs"
|
|
23
|
+
import { shouldLaunchPaneCli } from "./pane-cli-flag.mjs"
|
|
18
24
|
|
|
19
25
|
const execFileP = promisify(execFile)
|
|
20
26
|
|
|
@@ -282,29 +288,6 @@ export function detectStatusFromText(text) {
|
|
|
282
288
|
return "idle"
|
|
283
289
|
}
|
|
284
290
|
|
|
285
|
-
/**
|
|
286
|
-
* 【問題1 根治 2026-06-22】bind 時の即時ステータススナップショットで配る status を、
|
|
287
|
-
* プロセス実体の busy を権威として解決する。capture (SPINNER_LINE_RE) の偽陽性に勝たせる。
|
|
288
|
-
*
|
|
289
|
-
* - busy=true (isArmed or pane_pid 子孫数 > baseline = ターン進行中): capture gap で
|
|
290
|
-
* capturedStatus が waiting に化けても processing を強制 (症状C=再マウントで生成中の三点
|
|
291
|
-
* リーダーが消える偽陰性の masking)。
|
|
292
|
-
* - busy=false (プロセス実体 idle): capturedStatus が processing でも、それは静的完了行の
|
|
293
|
-
* SPINNER_LINE_RE 偽陽性であって生成中ではないので processing を配らない (= waiting へ降格)。
|
|
294
|
-
* bind は noCache 単発読みで freeze 履歴が無く偽陽性を弾けないため、ここで busy を権威にする。
|
|
295
|
-
* 万一 no-hook セッションの無音生成中だった場合も、次の state loop tick(≤5s)が outputActive で
|
|
296
|
-
* processing に復帰させるので実害は限定的。
|
|
297
|
-
*
|
|
298
|
-
* @param {boolean} busy プロセス実体の busy (isArmed or 子孫数>baseline)
|
|
299
|
-
* @param {string} capturedStatus detectSessionState の status
|
|
300
|
-
* @returns {string}
|
|
301
|
-
*/
|
|
302
|
-
export function resolveBindSnapshotStatus(busy, capturedStatus) {
|
|
303
|
-
if (busy) return "processing"
|
|
304
|
-
if (capturedStatus === "processing") return "waiting"
|
|
305
|
-
return capturedStatus
|
|
306
|
-
}
|
|
307
|
-
|
|
308
291
|
export function detectContextPctFromText(text) {
|
|
309
292
|
for (const re of CONTEXT_PATTERNS) {
|
|
310
293
|
const m = text.match(re)
|
|
@@ -326,177 +309,6 @@ export function detectContextPctFromText(text) {
|
|
|
326
309
|
return null
|
|
327
310
|
}
|
|
328
311
|
|
|
329
|
-
// 対話 claude TUI のフッターに出る権限モードバナー (claude v2.1 系で実機確認)。
|
|
330
|
-
// shift+tab で循環: default(バナー無し) → accept edits → plan → auto → …
|
|
331
|
-
const PERMISSION_BANNERS = [
|
|
332
|
-
[/⏵⏵\s*accept edits on/i, "acceptEdits"],
|
|
333
|
-
[/⏸\s*plan mode on/i, "plan"],
|
|
334
|
-
[/⏵⏵\s*auto mode on/i, "auto"],
|
|
335
|
-
[/bypass permissions on/i, "bypassPermissions"],
|
|
336
|
-
]
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* 対話 claude TUI のペインテキストから現在の権限モードを判定する。
|
|
340
|
-
*
|
|
341
|
-
* 全端末で「実際に動いているターミナルの権限状態」を同期するための正本。
|
|
342
|
-
* jsonl の permissionMode は送信ターン毎にしか記録されないため、shift+tab した
|
|
343
|
-
* だけ (未送信) の変更や tmux 上で直接切り替えた変更は jsonl に出ない。ペインの
|
|
344
|
-
* フッターバナーは shift+tab 直後に即更新されるので、これを正本にする。
|
|
345
|
-
*
|
|
346
|
-
* - 明示バナー (accept edits / plan / auto / bypass) があればそのモード。
|
|
347
|
-
* - バナーが無い場合、claude TUI のフッターが見えている時だけ "default" と判定する
|
|
348
|
-
* (素のシェルを default と誤検出しないためのガード)。
|
|
349
|
-
* - claude TUI と判別できなければ null (= 不明、呼び出し側は据え置き)。
|
|
350
|
-
*
|
|
351
|
-
* @param {string} text capturePane の結果 (ANSI 除去済み)
|
|
352
|
-
* @returns {string|null}
|
|
353
|
-
*/
|
|
354
|
-
export function detectPermissionModeFromText(text) {
|
|
355
|
-
if (!text) return null
|
|
356
|
-
for (const [re, mode] of PERMISSION_BANNERS) {
|
|
357
|
-
if (re.test(text)) return mode
|
|
358
|
-
}
|
|
359
|
-
// バナー無し = default。ただし claude TUI のフッターが見えている時だけ assert する。
|
|
360
|
-
if (/← for agents|for shortcuts|shift\+tab to cycle/i.test(text)) {
|
|
361
|
-
return "default"
|
|
362
|
-
}
|
|
363
|
-
return null
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* 対話 claude TUI のペインテキストから「入力欄内の本文」を取り出す。
|
|
368
|
-
*
|
|
369
|
-
* 中断キャンセル後の入力欄復旧 (claude.tui.recoverInput) の読込側。claude は
|
|
370
|
-
* 「送信直後の Esc」で直前メッセージの本文を入力欄へ戻すため、これを実測して
|
|
371
|
-
* クリアしないと、次の bracketed paste が後ろへ連結されてメッセージが合体する。
|
|
372
|
-
*
|
|
373
|
-
* 2 形式に対応する (claude v2.1.16x 実機確認 2026-06-08):
|
|
374
|
-
* - **現行 (ルール区切り)**: `───…` / `❯ 本文` (折り返しは 2 スペース字下げで続行) /
|
|
375
|
-
* `───…` / フッター。入力プロンプト `❯` は列 0 から始まる (選択ダイアログのカーソル
|
|
376
|
-
* ` ❯ 1. …` は字下げされるため誤検出しない)。
|
|
377
|
-
* - **旧 (罫線ボックス)**: `╭─…─╮ / │ > 本文 │ / ╰─…─╯`。末尾から最も近い枠の先頭行が
|
|
378
|
-
* プロンプト (`>` / `❯`) で始まる時だけ採用する。
|
|
379
|
-
*
|
|
380
|
-
* 空入力時のプレースホルダ等も文字列として返り得るので、呼び出し側は「期待する本文と
|
|
381
|
-
* 一致するか」で判定すること (本関数は在中テキストの抽出のみを担う)。
|
|
382
|
-
*
|
|
383
|
-
* @param {string} text capturePane の結果 (ANSI 除去済み)
|
|
384
|
-
* @returns {string|null} 入力欄内の本文 (入力欄が見つからない / 空なら null)
|
|
385
|
-
*/
|
|
386
|
-
/**
|
|
387
|
-
* 現行形式 (ルール区切り) の入力ボックス本文を「上下ボーダー間」から抽出する。
|
|
388
|
-
*
|
|
389
|
-
* 末尾から `❯` を逆走する旧方式は、ユーザー評価プロンプト ("How is Claude doing?") や
|
|
390
|
-
* 選択ダイアログの選択肢行 (`❯ Very good` 等) の ❯ に誤マッチし得た。評価プロンプトは
|
|
391
|
-
* 入力欄の「上」に出るため入力欄自体の上下ボーダーはズレない、という知見に基づき、
|
|
392
|
-
* 下ボーダー (ペイン末尾近傍の ────) → 直上の上ボーダー → 間の ❯ プロンプト行、という
|
|
393
|
-
* 範囲ベースで確定する。誤認防止ガード: 下ボーダーはペイン末尾から MAX_FOOTER_LINES 行
|
|
394
|
-
* 以内、上下ボーダー間は 2〜MAX_BOX_LINES 行、間に列 0 の ❯ が在ること。
|
|
395
|
-
* 見つからなければ null を返し、呼び出し側は旧アンカー方式へフォールバックする。
|
|
396
|
-
*
|
|
397
|
-
* @param {string} text capturePane の結果 (ANSI 除去済み)
|
|
398
|
-
* @returns {string|null}
|
|
399
|
-
*/
|
|
400
|
-
function _inputTextFromRuleBox(text) {
|
|
401
|
-
if (!text) return null
|
|
402
|
-
const lines = text.split("\n")
|
|
403
|
-
const isRule = (s) => /^\s*─{4,}\s*$/.test(s)
|
|
404
|
-
const MAX_FOOTER_LINES = 6 // 入力欄の下に出るヒント/ナビ行の想定上限
|
|
405
|
-
const MAX_BOX_LINES = 50 // 上下ボーダー間の許容行数 (折り返し長文入力の上限)
|
|
406
|
-
// 下ボーダー: ペイン末尾近傍 (下に出るヒント行のすぐ上) の ──── を末尾から探す。
|
|
407
|
-
let bottom = -1
|
|
408
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
409
|
-
if (lines.length - 1 - i > MAX_FOOTER_LINES) break
|
|
410
|
-
if (isRule(lines[i])) {
|
|
411
|
-
bottom = i
|
|
412
|
-
break
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
if (bottom < 0) return null
|
|
416
|
-
// 上ボーダー: 下ボーダーの直上にある ──── 。
|
|
417
|
-
let top = -1
|
|
418
|
-
for (let i = bottom - 1; i >= 0 && bottom - i <= MAX_BOX_LINES; i--) {
|
|
419
|
-
if (isRule(lines[i])) {
|
|
420
|
-
top = i
|
|
421
|
-
break
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
if (top < 0 || bottom - top < 2) return null
|
|
425
|
-
// ボーダー間に列 0 の ❯ プロンプト行が在ること (選択ダイアログの字下げ ❯ は対象外)。
|
|
426
|
-
const inner = lines.slice(top + 1, bottom)
|
|
427
|
-
const promptIdx = inner.findIndex((ln) => /^❯/.test(ln))
|
|
428
|
-
if (promptIdx < 0) return null
|
|
429
|
-
const parts = [inner[promptIdx].replace(/^❯\s?/, "").replace(/\s+$/, "")]
|
|
430
|
-
for (let i = promptIdx + 1; i < inner.length; i++) {
|
|
431
|
-
parts.push(inner[i].replace(/\s+$/, ""))
|
|
432
|
-
}
|
|
433
|
-
const joined = parts.join("\n").trim()
|
|
434
|
-
return joined || null
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
export function detectInputBoxText(text) {
|
|
438
|
-
// 第一候補: 上下ボーダー間抽出 (評価プロンプト/選択ダイアログの ❯ 誤マッチに強い)。
|
|
439
|
-
const ruled = _inputTextFromRuleBox(text)
|
|
440
|
-
if (ruled !== null) return ruled
|
|
441
|
-
// フォールバック: 旧 ❯ アンカー / 罫線ボックス方式 (ボーダー未描画版・レイアウト差用)。
|
|
442
|
-
return _inputTextFromPromptAnchor(text)
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
function _inputTextFromPromptAnchor(text) {
|
|
446
|
-
if (!text) return null
|
|
447
|
-
const lines = text.split("\n")
|
|
448
|
-
|
|
449
|
-
// ── 現行形式: 列 0 の `❯` プロンプト行 + 下側ルールまでの続行行 ──
|
|
450
|
-
let promptIdx = -1
|
|
451
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
452
|
-
if (/^❯/.test(lines[i])) {
|
|
453
|
-
promptIdx = i
|
|
454
|
-
break
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
if (promptIdx >= 0) {
|
|
458
|
-
const parts = [lines[promptIdx].replace(/^❯\s?/, "").replace(/\s+$/, "")]
|
|
459
|
-
for (let i = promptIdx + 1; i < lines.length; i++) {
|
|
460
|
-
const ln = lines[i]
|
|
461
|
-
if (/^\s*─{4,}\s*$/.test(ln)) break // 下側ルールで入力欄が終わる
|
|
462
|
-
parts.push(ln.replace(/\s+$/, ""))
|
|
463
|
-
}
|
|
464
|
-
const joined = parts.join("\n").trim()
|
|
465
|
-
return joined || null
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
// ── 旧形式: 罫線ボックス ──
|
|
469
|
-
let bottom = -1
|
|
470
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
471
|
-
if (/^\s*╰/.test(lines[i])) {
|
|
472
|
-
bottom = i
|
|
473
|
-
break
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
if (bottom < 0) return null
|
|
477
|
-
let top = -1
|
|
478
|
-
for (let i = bottom - 1; i >= 0; i--) {
|
|
479
|
-
if (/^\s*╭/.test(lines[i])) {
|
|
480
|
-
top = i
|
|
481
|
-
break
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
if (top < 0 || bottom - top < 2) return null
|
|
485
|
-
const inner = lines.slice(top + 1, bottom)
|
|
486
|
-
const first = inner[0].match(/^\s*│\s*[>❯]\s?(.*)$/)
|
|
487
|
-
if (!first) return null
|
|
488
|
-
// 右罫線と行末空白を落とす (折り返し行は左罫線 + 字下げで続く)。
|
|
489
|
-
const strip = (s) => s.replace(/\s*│\s*$/, "").replace(/\s+$/, "")
|
|
490
|
-
const parts = [strip(first[1])]
|
|
491
|
-
for (let i = 1; i < inner.length; i++) {
|
|
492
|
-
const m = inner[i].match(/^\s*│\s?(.*)$/)
|
|
493
|
-
if (!m) break
|
|
494
|
-
parts.push(strip(m[1]))
|
|
495
|
-
}
|
|
496
|
-
const joined = parts.join("\n").trim()
|
|
497
|
-
return joined || null
|
|
498
|
-
}
|
|
499
|
-
|
|
500
312
|
export async function listSessionNames(opts = {}) {
|
|
501
313
|
const tmuxBin = opts.tmuxBin || "tmux"
|
|
502
314
|
try {
|
|
@@ -622,7 +434,36 @@ function _computeStable(sessionName, status, text) {
|
|
|
622
434
|
return now - prev.since >= STABLE_CONFIRM_MS
|
|
623
435
|
}
|
|
624
436
|
|
|
437
|
+
/**
|
|
438
|
+
* pane に CLI を起動していないときの退化後の定値。
|
|
439
|
+
*
|
|
440
|
+
* pane が素のシェルなら capture-pane を読んでも:
|
|
441
|
+
* status … スピナーも "esc to interrupt" も ❯ プロンプトも出ないので常に 'idle'
|
|
442
|
+
* context_pct … "X% context left" は TUI のフッターにしか出ないので常に null
|
|
443
|
+
* stable … 画面が動かないので常に true
|
|
444
|
+
* spinner_present … 常に false
|
|
445
|
+
* output_sig … 不変なので出力フロー検知の情報量がゼロ
|
|
446
|
+
* にしかならない。読む価値がゼロの subprocess を全セッション × 5s 周期で fork し続ける
|
|
447
|
+
* ことになるため、この場合は capture 自体を行わずこの定値を返す。
|
|
448
|
+
*
|
|
449
|
+
* ⚠️ output_sig は「読んでいない」を表す null。空文字の署名 (`_outputSignature("")`) を
|
|
450
|
+
* 返すと main.mjs の出力フロー検知が「不変な署名を観測し続けている」と誤読するため、
|
|
451
|
+
* 型で区別できる null を返して当該経路をスキップさせる。
|
|
452
|
+
*/
|
|
453
|
+
const PANE_UNREAD_STATE = Object.freeze({
|
|
454
|
+
status: "idle",
|
|
455
|
+
context_pct: null,
|
|
456
|
+
permission_mode: null,
|
|
457
|
+
stable: true,
|
|
458
|
+
spinner_present: false,
|
|
459
|
+
output_sig: null,
|
|
460
|
+
})
|
|
461
|
+
|
|
625
462
|
export async function detectSessionState(sessionName, opts = {}) {
|
|
463
|
+
// ペインに CLI の TUI を起動していない構成 (既定) では、capture-pane は素のシェルの
|
|
464
|
+
// 画面を読むだけで何も供給しない。subprocess を焚く前にここで打ち切る。
|
|
465
|
+
// `HUB_AGENT_TMUX_LAUNCH_CLI=1` の逃げ道 (pane に TUI を起動する) では従来どおり読む。
|
|
466
|
+
if (!shouldLaunchPaneCli()) return { ...PANE_UNREAD_STATE }
|
|
626
467
|
const text = await capturePane(sessionName, opts)
|
|
627
468
|
let defaultStatus = detectStatusFromText(text)
|
|
628
469
|
// 凍結スピナー対策: スピナーが画面に在るが経過タイマーが進んでいない (claude 異常終了/ハング)
|
|
@@ -637,7 +478,10 @@ export async function detectSessionState(sessionName, opts = {}) {
|
|
|
637
478
|
/❯\s/.test(text) || /^>\s/m.test(text) ? "waiting" : "idle"
|
|
638
479
|
}
|
|
639
480
|
const defaultContextPct = detectContextPctFromText(text)
|
|
640
|
-
|
|
481
|
+
// permission_mode はペインのフッターバナーから読んでいたが、消費者がゼロ (frontend の
|
|
482
|
+
// SessionStateRow に該当フィールドが無い) なので判定ごと撤去した。返り値のキーだけは
|
|
483
|
+
// plugin hook (transformStatusDetection) が差し替えられる契約として残す。
|
|
484
|
+
const defaultPermissionMode = null
|
|
641
485
|
// スピナー行 (ライブ or 凍結) が画面に在るか。frozen 降格で status が waiting に落ちても、
|
|
642
486
|
// スピナーが画面に残っている = ターンはクリーンに終わっていない (ハング候補)。StallTracker が
|
|
643
487
|
// 「クリーン idle (スピナー無し) でターンを閉じる」判定に使い、frozen スピナーのハングを拾う。
|